虎牙人工兑换改用本机浏览器并新增扩展自动注入登录态
移除服务器浏览器方案(容器/Xvfb/noVNC/CDP 会话管理),后端仅下发 兑换材料;新增内置 Chrome/Edge 扩展一键写入 huya.com 登录态, 未安装扩展时回退控制台脚本手动注入,兑换始终由用户手动完成。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from requests.cookies import RequestsCookieJar
|
||||
@@ -67,3 +68,27 @@ def cookie_value(
|
||||
if item_key == key:
|
||||
value = "" if item_value is None else str(item_value).strip()
|
||||
return value
|
||||
|
||||
|
||||
def browser_login_script(
|
||||
pairs: Mapping[str, str],
|
||||
target_url: str,
|
||||
*,
|
||||
domain: str = ".huya.com",
|
||||
ttl_days: int = 180,
|
||||
) -> str:
|
||||
"""生成在用户自己浏览器控制台执行的登录脚本。
|
||||
|
||||
脚本把 Cookie 写入指定域后跳转目标页;Cookie 值以 JSON 数组内联,
|
||||
保证引号、反斜杠等字符安全转义。
|
||||
"""
|
||||
payload = json.dumps([[key, value] for key, value in pairs.items()], ensure_ascii=False)
|
||||
target = json.dumps(target_url, ensure_ascii=False)
|
||||
ttl_ms = max(1, ttl_days) * 86_400_000
|
||||
return (
|
||||
f"(function(){{var c={payload};"
|
||||
f"var e=new Date(Date.now()+{ttl_ms}).toUTCString();"
|
||||
f"for(var i=0;i<c.length;i++){{"
|
||||
f"document.cookie=c[i][0]+'='+c[i][1]+';domain={domain};path=/;expires='+e;"
|
||||
f"}}location.href={target};}})();"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 虎牙浏览器兑换(本机浏览器)
|
||||
|
||||
人工兑换在操作者自己的 Chrome/Edge 中完成:后端下发目标页地址和登录材料,登录态自动(扩展)或手动(控制台脚本)写入本机浏览器,兑换本身始终由人工点击,服务端不托管任何浏览器会话。
|
||||
|
||||
## 一键自动注入(推荐):安装内置扩展
|
||||
|
||||
项目自带 Chrome/Edge 扩展 `web/extension/huya-exchange-helper/`,安装一次后即可自动注入:
|
||||
|
||||
1. 打开扩展管理页:Chrome 为 `chrome://extensions`,Edge 为 `edge://extensions`。
|
||||
2. 开启「开发者模式」,点击「加载已解压的扩展程序」,选择仓库目录 `web/extension/huya-exchange-helper`。
|
||||
3. 刷新后台页面(扩展的内容脚本需要对当前页生效)。
|
||||
|
||||
之后的流程:
|
||||
|
||||
1. 点击账号行的浏览器图标 → 后端返回兑换材料 → 扩展自动把该账号 Cookie 写入 `.huya.com` 域并在新标签页打开已登录的兑换页。
|
||||
2. 确认页面右上角账号无误后,手动完成兑换。
|
||||
3. 回到后台点击「完成」。
|
||||
|
||||
扩展只在注入时覆盖与登录态同名的旧 Cookie,其余虎牙 Cookie 不动;扩展图标点击可作为兜底(读取当前后台页签留存的材料);未检测到材料时图标会显示「无任务」角标。
|
||||
|
||||
## 手动方式(未装扩展时的兜底)
|
||||
|
||||
1. 点击账号行的浏览器图标,弹出「浏览器兑换」窗口。
|
||||
2. 点击「打开兑换页面」在新标签页打开兑换活动页(地址来自配置的 `room_pid`,未配置时回落到活动页 `https://zt.huya.com/b02faae1/pc/index.html`)。
|
||||
3. 点击「复制登录脚本」,在兑换页面按 `F12` 打开控制台,粘贴脚本并回车执行。Chrome/Edge 首次粘贴需先手动输入 `allow pasting` 再回车。脚本把该账号 Cookie 写入 `.huya.com` 域并自动跳转。
|
||||
4. 确认登录态正确后完成兑换,回到系统点击「完成」。
|
||||
|
||||
没有控制台操作习惯时,可安装 Cookie-editor 类扩展,使用「复制 Cookie 字符串」在 `.huya.com` 域下导入。
|
||||
|
||||
## 接口
|
||||
|
||||
`POST /api/huya/accounts/{account_id}/browser-exchange`(权限 `huya:task`)返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"account_id": 1,
|
||||
"username": "xxx",
|
||||
"target_url": "https://...",
|
||||
"cookie_string": "yyuid=...; ...",
|
||||
"cookie_script": "(function(){...})();"
|
||||
}
|
||||
```
|
||||
|
||||
- 脚本由 `core.huya.cookie_utils.browser_login_script` 生成:Cookie 以 JSON 数组内联(自动转义),逐条 `document.cookie` 写入 `domain=.huya.com; path=/`,有效期 180 天,最后跳转目标页。
|
||||
- 扩展注入走同一条接口:前端解析 `cookie_string` 后通过 DOM 事件交给扩展,由 `chrome.cookies` API 写入。
|
||||
- 服务端不保存任何会话状态。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 接口受 `huya:task` 权限与账号可见范围约束;脚本、Cookie 字符串与扩展注入等同账号凭证,仅在授权用户点击时下发。
|
||||
- 登录态只写入操作者自己的浏览器,不会在服务器留存浏览器进程或 Profile。
|
||||
- 扩展为内部工具,通过开发者模式加载;其内容脚本在所有页面注入通信桥(manifest `<all_urls>`),但只响应本后台页面派发的固定事件,且只写 `huya.com` 域的 Cookie。
|
||||
- 请勿把脚本、Cookie 或扩展转给无关人员;如需共享操作,建议使用系统的账号授权机制而非转发凭证。
|
||||
@@ -30,7 +30,7 @@ from core.huya import (
|
||||
login_huya_sms,
|
||||
send_huya_sms_code,
|
||||
)
|
||||
from core.huya.cookie_utils import normalize_huya_cookie
|
||||
from core.huya.cookie_utils import browser_login_script, cookie_pairs, normalize_huya_cookie
|
||||
from core.huya.device_fingerprint import account_state_dir as _fp_state_dir
|
||||
from core.huya.device_profile import (
|
||||
_load_db as _load_profile_db,
|
||||
@@ -90,6 +90,7 @@ from ..services.huya_register_runner import (
|
||||
list_success_logs,
|
||||
)
|
||||
from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry
|
||||
from ..services.huya_runner_bind import BindMixin
|
||||
from ..services.huya_service import (
|
||||
HUYA_CONFIG_FIELDS,
|
||||
SUPPORTED_TASK_TYPES,
|
||||
@@ -107,6 +108,36 @@ from ..services.huya_service import (
|
||||
router = APIRouter(prefix="/api/huya", tags=["虎牙"])
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/browser-exchange")
|
||||
def create_browser_exchange_kit(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""生成指定账号的本机浏览器人工兑换材料。
|
||||
|
||||
登录态由前端引导用户在自己的 Chrome/Edge 中写入,服务端不托管浏览器会话。
|
||||
"""
|
||||
_require_huya_perm(current, "huya:task")
|
||||
account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id).first()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作")
|
||||
if not account.cookie:
|
||||
raise HTTPException(status_code=400, detail="该账号没有可用 Cookie,请先登录")
|
||||
config = ensure_huya_config(db)
|
||||
target_url = BindMixin._bind_redirect_url({
|
||||
"room_pid": huya_config_value("room_pid", config.room_pid),
|
||||
}) or "https://zt.huya.com/b02faae1/pc/index.html"
|
||||
pairs = dict(cookie_pairs(normalize_huya_cookie(account.cookie)))
|
||||
return {
|
||||
"account_id": account.id,
|
||||
"username": account.username or account.yyuid or "",
|
||||
"target_url": target_url,
|
||||
"cookie_string": "; ".join(f"{key}={value}" for key, value in pairs.items()),
|
||||
"cookie_script": browser_login_script(pairs, target_url),
|
||||
}
|
||||
|
||||
|
||||
def _has_huya_perm(user: User, permission: str) -> bool:
|
||||
"""虎牙新权限兼容旧的 huya:account 大权限。"""
|
||||
return user_has_permission(user, permission) or user_has_permission(
|
||||
@@ -323,6 +354,7 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou
|
||||
yyuid=account.yyuid or "",
|
||||
username=account.username or "",
|
||||
has_password=bool(account.account_password),
|
||||
has_cookie=bool(account.cookie),
|
||||
nickname=account.nickname or "",
|
||||
cookie=cookie if include_cookie else "",
|
||||
cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***",
|
||||
@@ -344,7 +376,7 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou
|
||||
|
||||
|
||||
def _account_out_light(
|
||||
account: HuyaAccount, *, has_password: bool = False
|
||||
account: HuyaAccount, *, has_password: bool = False, has_cookie: bool = False
|
||||
) -> HuyaAccountOut:
|
||||
"""虎牙账号列表轻量输出,不读取加密字段。"""
|
||||
return HuyaAccountOut(
|
||||
@@ -353,6 +385,7 @@ def _account_out_light(
|
||||
yyuid=account.yyuid or "",
|
||||
username=account.username or "",
|
||||
has_password=has_password,
|
||||
has_cookie=has_cookie,
|
||||
nickname=account.nickname or "",
|
||||
cookie="",
|
||||
cookie_preview="***",
|
||||
@@ -480,6 +513,17 @@ def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, b
|
||||
return {account_id: bool(has_password) for account_id, has_password in rows}
|
||||
|
||||
|
||||
def _huya_cookie_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]:
|
||||
if not account_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(HuyaAccount.id, (HuyaAccount.cookie != "").label("has_cookie"))
|
||||
.filter(HuyaAccount.id.in_(account_ids))
|
||||
.all()
|
||||
)
|
||||
return {account_id: bool(has_cookie) for account_id, has_cookie in rows}
|
||||
|
||||
|
||||
@router.get("/task-types")
|
||||
def task_types(current: User = Depends(require_permission("huya:task"))):
|
||||
"""返回当前规划的虎牙任务类型。"""
|
||||
@@ -531,9 +575,12 @@ def list_accounts(
|
||||
password_map = _huya_password_state_map(
|
||||
db, [account.id for account in accounts]
|
||||
)
|
||||
cookie_map = _huya_cookie_state_map(db, [account.id for account in accounts])
|
||||
result = [
|
||||
_account_out_light(
|
||||
account, has_password=password_map.get(account.id, False)
|
||||
account,
|
||||
has_password=password_map.get(account.id, False),
|
||||
has_cookie=cookie_map.get(account.id, False),
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
@@ -428,6 +428,7 @@ class HuyaAccountOut(BaseModel):
|
||||
yyuid: str = ""
|
||||
username: str = ""
|
||||
has_password: bool = False
|
||||
has_cookie: bool = False
|
||||
nickname: str = ""
|
||||
cookie: str = ""
|
||||
cookie_preview: str = ""
|
||||
@@ -455,6 +456,7 @@ class HuyaAccountOut(BaseModel):
|
||||
"yyuid": self.yyuid,
|
||||
"username": self.username,
|
||||
"has_password": self.has_password,
|
||||
"has_cookie": self.has_cookie,
|
||||
"nickname": self.nickname,
|
||||
"cookie": self.cookie,
|
||||
"cookie_preview": self.cookie_preview,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 虎牙兑换助手后台:接收后台页面 relay 转发的登录材料,
|
||||
// 覆盖写入 huya.com 同名 Cookie 后打开已登录的兑换页。
|
||||
const HUYA_COOKIE_URL = 'https://www.huya.com/';
|
||||
const TTL_SECONDS = 180 * 86400;
|
||||
|
||||
function badge(tabId, text) {
|
||||
if (!tabId) return;
|
||||
chrome.action.setBadgeText({ tabId, text });
|
||||
setTimeout(() => {
|
||||
chrome.action.setBadgeText({ tabId, text: '' });
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function injectKit(kit) {
|
||||
const cookies = Array.isArray(kit && kit.cookies) ? kit.cookies : [];
|
||||
const wanted = new Set(cookies.map(([name]) => String(name)).filter(Boolean));
|
||||
// 只清理与登录态同名的旧 Cookie(覆盖所有域/路径变体),其余 huya Cookie 保持不动,
|
||||
// 行为与控制台脚本方案一致,避免误伤浏览器里已有的其他虎牙状态。
|
||||
const existing = await chrome.cookies.getAll({ domain: 'huya.com' });
|
||||
for (const cookie of existing) {
|
||||
if (!wanted.has(cookie.name)) continue;
|
||||
const url = 'https://' + cookie.domain.replace(/^\./, '') + (cookie.path || '/');
|
||||
try {
|
||||
await chrome.cookies.remove({ url, name: cookie.name });
|
||||
} catch (e) { /* 单个失败不阻断 */ }
|
||||
}
|
||||
const expirationDate = Math.floor(Date.now() / 1000) + TTL_SECONDS;
|
||||
for (const [name, value] of cookies) {
|
||||
if (!name) continue;
|
||||
try {
|
||||
await chrome.cookies.set({
|
||||
url: HUYA_COOKIE_URL,
|
||||
domain: '.huya.com',
|
||||
path: '/',
|
||||
name: String(name),
|
||||
value: value == null ? '' : String(value),
|
||||
secure: true,
|
||||
expirationDate,
|
||||
});
|
||||
} catch (e) { /* 单个失败不阻断 */ }
|
||||
}
|
||||
if (kit && kit.target_url) {
|
||||
chrome.tabs.create({ url: kit.target_url });
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg && msg.type === 'inject' && msg.kit) {
|
||||
injectKit(msg.kit).then(() => badge(sender.tab && sender.tab.id, 'OK'));
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// 兜底:后台页面把材料存到 window.__huyaExchangeKit,点击扩展图标时读取。
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (!tab || !tab.id) return;
|
||||
let payload = null;
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.__huyaExchangeKit || null,
|
||||
});
|
||||
payload = result ? result.result : null;
|
||||
} catch (e) {
|
||||
return; // 无权访问当前页面
|
||||
}
|
||||
if (!payload) {
|
||||
badge(tab.id, '无任务');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await injectKit(JSON.parse(payload));
|
||||
badge(tab.id, 'OK');
|
||||
} catch (e) {
|
||||
badge(tab.id, '失败');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"""生成虎牙兑换助手扩展图标:橙色渐变圆角底 + 白色礼盒。
|
||||
|
||||
128/48 用完整图案;16px 细节会糊,改用去掉蝴蝶结的加粗简化版。
|
||||
用法: uv run python web/extension/huya-exchange-helper/generate_icons.py
|
||||
"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
S = 1024 # 超采样画布,最后缩到目标尺寸
|
||||
RADIUS = 200
|
||||
|
||||
TOP = (255, 156, 71) # 渐变顶部
|
||||
BOTTOM = (240, 84, 0) # 渐变底部
|
||||
BOX = (255, 255, 255) # 礼盒
|
||||
RIBBON = (234, 74, 8) # 丝带(比背景深,压在白色上可见)
|
||||
|
||||
|
||||
def gradient_rounded(size: int, radius: int) -> Image.Image:
|
||||
grad = Image.new('RGBA', (size, size))
|
||||
g = ImageDraw.Draw(grad)
|
||||
for y in range(size):
|
||||
t = y / (size - 1)
|
||||
color = tuple(round(TOP[i] + (BOTTOM[i] - TOP[i]) * t) for i in range(3)) + (255,)
|
||||
g.line([(0, y), (size, y)], fill=color)
|
||||
mask = Image.new('L', (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
|
||||
out = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||
out.paste(grad, (0, 0), mask)
|
||||
return out
|
||||
|
||||
|
||||
def draw_gift(img: Image.Image) -> None:
|
||||
d = ImageDraw.Draw(img)
|
||||
cx = S // 2
|
||||
# 蝴蝶结:左右两个椭圆
|
||||
d.ellipse([348, 235, 512, 365], fill=BOX)
|
||||
d.ellipse([512, 235, 676, 365], fill=BOX)
|
||||
# 盒盖(比盒身宽)
|
||||
d.rounded_rectangle([cx - 235, 375, cx + 235, 485], radius=30, fill=BOX)
|
||||
# 盒身
|
||||
d.rounded_rectangle([cx - 200, 509, cx + 200, 789], radius=36, fill=BOX)
|
||||
# 竖向丝带贯穿盖与盒身
|
||||
d.rectangle([cx - 44, 375, cx + 44, 789], fill=RIBBON)
|
||||
# 结口圆点,压在蝴蝶结与盒盖交界
|
||||
d.ellipse([cx - 46, 330, cx + 46, 422], fill=RIBBON)
|
||||
|
||||
|
||||
def draw_gift_simple(img: Image.Image) -> None:
|
||||
"""小尺寸版:去掉蝴蝶结,盒体加粗。"""
|
||||
d = ImageDraw.Draw(img)
|
||||
cx = img.width // 2
|
||||
scale = img.width / 512
|
||||
def rect(x1, y1, x2, y2, radius):
|
||||
d.rounded_rectangle([x1 * scale, y1 * scale, x2 * scale, y2 * scale],
|
||||
radius=radius * scale, fill=BOX)
|
||||
rect(cx / scale - 144, 96, cx / scale + 144, 206, 24) # 盖
|
||||
rect(cx / scale - 112, 230, cx / scale + 112, 420, 28) # 盒身
|
||||
d.rectangle([cx - 34 * scale, 96 * scale, cx + 34 * scale, 420 * scale], fill=RIBBON)
|
||||
|
||||
|
||||
full = gradient_rounded(S, RADIUS)
|
||||
draw_gift(full)
|
||||
base = 'web/extension/huya-exchange-helper/'
|
||||
for size in (128, 48):
|
||||
full.resize((size, size), Image.LANCZOS).save(f'{base}icon{size}.png')
|
||||
|
||||
simple = gradient_rounded(512, 100)
|
||||
draw_gift_simple(simple)
|
||||
simple.resize((16, 16), Image.LANCZOS).save(f'{base}icon16.png')
|
||||
print('icons regenerated')
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 653 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "虎牙兑换助手",
|
||||
"version": "1.0.0",
|
||||
"description": "配合直播后台使用:一键把所选虎牙账号的登录态写入本机浏览器(仅 huya.com 域),兑换操作仍由用户手动完成。",
|
||||
"permissions": ["cookies", "scripting", "activeTab"],
|
||||
"host_permissions": ["*://*.huya.com/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["relay.js"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_title": "虎牙兑换助手"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 内容脚本:在所有页面标记扩展存在,并把后台页面派发的兑换材料转发给 service worker。
|
||||
// 后台页面通过 documentElement.dataset.huyaExchangeHelper === '1' 检测扩展是否可用。
|
||||
document.documentElement.dataset.huyaExchangeHelper = '1';
|
||||
|
||||
window.addEventListener('huya-exchange-kit', (event) => {
|
||||
const detail = event.detail;
|
||||
if (typeof detail !== 'string') return;
|
||||
try {
|
||||
const kit = JSON.parse(detail);
|
||||
if (kit && kit.target_url && Array.isArray(kit.cookies)) {
|
||||
const sending = chrome.runtime.sendMessage({ type: 'inject', kit });
|
||||
if (sending && typeof sending.catch === 'function') {
|
||||
sending.catch(() => { /* 扩展上下文失效等场景忽略 */ });
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 忽略非法消息 */ }
|
||||
}, true);
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
HuyaTaskBatchRequest,
|
||||
HuyaTaskBatchResult,
|
||||
HuyaTaskItem,
|
||||
HuyaBrowserExchangeKit,
|
||||
MessageCountResponse,
|
||||
MessageDeletedResponse,
|
||||
MessageResponse,
|
||||
@@ -142,4 +143,7 @@ export const huyaApi = {
|
||||
api.get<HuyaTaskItem, HuyaTaskItem>(`/huya/tasks/${taskId}`),
|
||||
cleanupOrphans: () =>
|
||||
api.post<{ message: string; cleaned: number; success: boolean }, { message: string; cleaned: number; success: boolean }>('/huya/tasks/cleanup-orphans'),
|
||||
/** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 */
|
||||
createBrowserExchange: (accountId: number) =>
|
||||
api.post<HuyaBrowserExchangeKit, HuyaBrowserExchangeKit>(`/huya/accounts/${accountId}/browser-exchange`, {}),
|
||||
};
|
||||
|
||||
@@ -445,6 +445,7 @@ export interface HuyaAccountItem {
|
||||
yyuid: string;
|
||||
username: string;
|
||||
has_password: boolean;
|
||||
has_cookie?: boolean;
|
||||
nickname: string;
|
||||
cookie: string;
|
||||
cookie_preview: string;
|
||||
@@ -463,6 +464,15 @@ export interface HuyaAccountItem {
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** 本机浏览器人工兑换材料 (登录脚本在用户自己的 Chrome/Edge 控制台执行) */
|
||||
export interface HuyaBrowserExchangeKit {
|
||||
account_id: number;
|
||||
username: string;
|
||||
target_url: string;
|
||||
cookie_string: string;
|
||||
cookie_script: string;
|
||||
}
|
||||
|
||||
/** 账号 ↔ 设备环境绑定 (一号一环境, 后端 data/huya_device_profiles.json + huya_fp_states) */
|
||||
export interface HuyaDeviceBindingItem {
|
||||
account: string;
|
||||
|
||||
@@ -4,17 +4,19 @@ import {
|
||||
} from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import type { TableProps } from 'antd';
|
||||
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined, GlobalOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
huyaApi,
|
||||
type HuyaAccountItem,
|
||||
type HuyaAccountSummary,
|
||||
type HuyaBrowserExchangeKit,
|
||||
type HuyaPasswordLoginBatchItem,
|
||||
type SupportUserItem,
|
||||
} from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -48,6 +50,30 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
invalid: 'error',
|
||||
};
|
||||
|
||||
/** 解析后端 Cookie 字符串为 [name, value] 数组,供兑换扩展注入 */
|
||||
function parseCookieString(cookieString: string): [string, string][] {
|
||||
return cookieString
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.includes('='))
|
||||
.map((part) => {
|
||||
const idx = part.indexOf('=');
|
||||
return [part.slice(0, idx), part.slice(idx + 1)];
|
||||
});
|
||||
}
|
||||
|
||||
/** 内置扩展的内容脚本会在所有页面打上此标记 */
|
||||
function huyaExchangeHelperReady(): boolean {
|
||||
return document.documentElement.dataset.huyaExchangeHelper === '1';
|
||||
}
|
||||
|
||||
/** 请求内置扩展自动注入登录态;同时留存到 window 供扩展图标点击兜底读取 */
|
||||
function requestExtensionInject(kit: HuyaBrowserExchangeKit): void {
|
||||
const payload = JSON.stringify({ target_url: kit.target_url, cookies: parseCookieString(kit.cookie_string) });
|
||||
(window as unknown as { __huyaExchangeKit?: string }).__huyaExchangeKit = payload;
|
||||
window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload }));
|
||||
}
|
||||
|
||||
export default function HuyaAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||
const [users, setUsers] = useState<SupportUserItem[]>([]);
|
||||
@@ -73,6 +99,9 @@ export default function HuyaAccountsPage() {
|
||||
const [smsState, setSmsState] = useState('');
|
||||
const [smsSending, setSmsSending] = useState(false);
|
||||
const [smsLogging, setSmsLogging] = useState(false);
|
||||
const [exchangeKit, setExchangeKit] = useState<HuyaBrowserExchangeKit | null>(null);
|
||||
const [exchangeTitle, setExchangeTitle] = useState('');
|
||||
const [exchangeLoading, setExchangeLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
@@ -351,6 +380,34 @@ export default function HuyaAccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowserExchange = async (record: HuyaAccountItem) => {
|
||||
setExchangeLoading(true);
|
||||
try {
|
||||
const kit = await huyaApi.createBrowserExchange(record.id);
|
||||
requestExtensionInject(kit);
|
||||
setExchangeKit(kit);
|
||||
setExchangeTitle(record.nickname || record.username || record.yyuid || `账号#${record.id}`);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setExchangeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openExchangePage = () => {
|
||||
if (!exchangeKit) return;
|
||||
window.open(exchangeKit.target_url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const copyExchangeText = async (text: string, tip: string) => {
|
||||
try {
|
||||
await copyTextToClipboard(text);
|
||||
message.success(tip);
|
||||
} catch {
|
||||
message.error('复制失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebLoginSelected = async () => {
|
||||
if (selectedCount === 0) {
|
||||
message.warning('请先选择虎牙账号');
|
||||
@@ -626,6 +683,11 @@ export default function HuyaAccountsPage() {
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{canImport && record.has_cookie && (
|
||||
<Tooltip title="在浏览器中打开兑换页面(用你自己的 Chrome/Edge 完成)">
|
||||
<Button size="small" icon={<GlobalOutlined />} loading={exchangeLoading} onClick={() => void handleBrowserExchange(record)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
@@ -636,6 +698,8 @@ export default function HuyaAccountsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const exchangeHelperReady = huyaExchangeHelperReady();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
@@ -858,6 +922,57 @@ export default function HuyaAccountsPage() {
|
||||
</Paragraph>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`浏览器兑换 · ${exchangeTitle}`}
|
||||
open={exchangeKit !== null}
|
||||
width={720}
|
||||
centered
|
||||
onCancel={() => setExchangeKit(null)}
|
||||
footer={exchangeKit ? [
|
||||
<Button key="open" type="primary" icon={<GlobalOutlined />} onClick={openExchangePage}>打开兑换页面</Button>,
|
||||
<Button key="done" onClick={() => { setExchangeKit(null); message.success('兑换完成后可在精英宝典任务中刷新积分'); }}>完成</Button>,
|
||||
] : null}
|
||||
>
|
||||
{exchangeKit && (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size={12}>
|
||||
{exchangeHelperReady ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message="已检测到「虎牙兑换助手」扩展"
|
||||
description="点击兑换按钮时已自动写入该账号登录态并在新标签页打开兑换页面。请确认页面右上角账号无误后手动完成兑换;若页面未自动打开,按下方手动步骤操作。"
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="未检测到「虎牙兑换助手」扩展"
|
||||
description="安装后可一键自动写入登录态(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页)。当前请按下方手动步骤操作。"
|
||||
/>
|
||||
)}
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
<Text strong>手动步骤 1:</Text>
|
||||
点击下方「打开兑换页面」,在新标签页打开兑换活动页。
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
<Text strong>手动步骤 2:</Text>
|
||||
点击「复制登录脚本」,在兑换页面按 <Text code>F12</Text> 打开控制台,粘贴脚本并回车执行。
|
||||
Chrome/Edge 首次粘贴需先手动输入 <Text code>allow pasting</Text> 再回车。脚本会写入该账号的登录态并自动跳转。
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本</Button>
|
||||
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_string, 'Cookie 字符串已复制')}>复制 Cookie 字符串</Button>
|
||||
</Space>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
备选:安装 Cookie-editor 类扩展后,在 .huya.com 域下导入 Cookie 字符串。脚本和 Cookie 等同账号凭证,请勿泄露给他人。
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
<Text strong>手动步骤 3:</Text>
|
||||
确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
|
||||
</Paragraph>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量设置标签"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 复制文本到剪贴板,兼容非安全上下文(http:// 非 localhost) */
|
||||
export function copyTextToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// fallback:利用 textarea + execCommand,兼容 http 远程访问
|
||||
return new Promise((resolve, reject) => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
if (ok) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('copy failed'));
|
||||
}
|
||||
} catch (err) {
|
||||
document.body.removeChild(ta);
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user