From 230d6c19185071f8b7a575dd9305d0af07f7d61f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 5 Sep 2026 17:55:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=99=8E=E7=89=99=E4=BA=BA=E5=B7=A5=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E6=94=B9=E7=94=A8=E6=9C=AC=E6=9C=BA=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=B9=B6=E6=96=B0=E5=A2=9E=E6=89=A9=E5=B1=95=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=B3=A8=E5=85=A5=E7=99=BB=E5=BD=95=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除服务器浏览器方案(容器/Xvfb/noVNC/CDP 会话管理),后端仅下发 兑换材料;新增内置 Chrome/Edge 扩展一键写入 huya.com 登录态, 未安装扩展时回退控制台脚本手动注入,兑换始终由用户手动完成。 --- core/huya/cookie_utils.py | 25 ++++ docs/HUYA_BROWSER_EXCHANGE.md | 53 ++++++++ web/backend/routers/huya.py | 53 +++++++- web/backend/schemas.py | 2 + .../huya-exchange-helper/background.js | 78 ++++++++++++ .../huya-exchange-helper/generate_icons.py | 69 +++++++++++ .../huya-exchange-helper/icon128.png | Bin 0 -> 4814 bytes web/extension/huya-exchange-helper/icon16.png | Bin 0 -> 653 bytes web/extension/huya-exchange-helper/icon48.png | Bin 0 -> 1902 bytes .../huya-exchange-helper/manifest.json | 26 ++++ web/extension/huya-exchange-helper/relay.js | 17 +++ web/frontend/src/api/huya.ts | 4 + web/frontend/src/api/types.ts | 10 ++ web/frontend/src/pages/HuyaAccountsPage.tsx | 117 +++++++++++++++++- web/frontend/src/utils/clipboard.ts | 27 ++++ 15 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 docs/HUYA_BROWSER_EXCHANGE.md create mode 100644 web/extension/huya-exchange-helper/background.js create mode 100644 web/extension/huya-exchange-helper/generate_icons.py create mode 100644 web/extension/huya-exchange-helper/icon128.png create mode 100644 web/extension/huya-exchange-helper/icon16.png create mode 100644 web/extension/huya-exchange-helper/icon48.png create mode 100644 web/extension/huya-exchange-helper/manifest.json create mode 100644 web/extension/huya-exchange-helper/relay.js create mode 100644 web/frontend/src/utils/clipboard.ts diff --git a/core/huya/cookie_utils.py b/core/huya/cookie_utils.py index af1cb92..a42f836 100644 --- a/core/huya/cookie_utils.py +++ b/core/huya/cookie_utils.py @@ -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`),但只响应本后台页面派发的固定事件,且只写 `huya.com` 域的 Cookie。 +- 请勿把脚本、Cookie 或扩展转给无关人员;如需共享操作,建议使用系统的账号授权机制而非转发凭证。 diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 4256781..ba3297b 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -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 ] diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 25def1f..4beced9 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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, diff --git a/web/extension/huya-exchange-helper/background.js b/web/extension/huya-exchange-helper/background.js new file mode 100644 index 0000000..f90267e --- /dev/null +++ b/web/extension/huya-exchange-helper/background.js @@ -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, '失败'); + } +}); diff --git a/web/extension/huya-exchange-helper/generate_icons.py b/web/extension/huya-exchange-helper/generate_icons.py new file mode 100644 index 0000000..ef4fcf8 --- /dev/null +++ b/web/extension/huya-exchange-helper/generate_icons.py @@ -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') diff --git a/web/extension/huya-exchange-helper/icon128.png b/web/extension/huya-exchange-helper/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..9332cdc43fae3ff6c1b7f10aeb63a21c07413363 GIT binary patch literal 4814 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 literal 0 HcmV?d00001 diff --git a/web/extension/huya-exchange-helper/icon16.png b/web/extension/huya-exchange-helper/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..fc60ab10b2e584f6c11ae70ba72a7d86dd729ac9 GIT binary patch literal 653 zcmV;80&@L{P)p#YZETV%iem2%u&}TZ%Rmrj{g2RQqyE}8{%*v@*VOkB3`}*sCE5kox2Z5MWFO2%!lFlq|!c%bnH&#)-&3xYQ>@ zEeRlo5Qau`unJ_amjb!dMZ8TQ{a!p#VrV32;X_-6LHj$v%ysb9`2-+;eqm>R4(c6_ zJ$wywVj=;^>SN&TGm_yhkpx(APRSTTU4go1fuU}u-QI;d5A_}>I>^ibj7>o63TSyq z1c3LXv3TbIUk}=s1DJCIhei(0eGQGi&I1uZy^E&+7YOg4L&|Ls8^~S>4L(gGyjTXh zW6%(wUtU6~09=*sIMH+t3JdxA0kZxEG&KjBoCU?~@td3hObW`CygzP-l_tI6~ts3%1wU; nd-Wm0Nnz06{Yy_bB`o{{)L2i+^DF3j00000NkvXXu0mjf`e7W{ literal 0 HcmV?d00001 diff --git a/web/extension/huya-exchange-helper/icon48.png b/web/extension/huya-exchange-helper/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..92160c8acc84a1b498947335ea887c8d8649683a GIT binary patch literal 1902 zcmV-!2a))RP)fG6Oc0I|R zx$}PA=e+0hJ$DEh$?n~T_UvJM|JX;iATalUWfCkK0F)sh43kKKPz#>?8$<`e{3F`& zo5S<5Qg4GiJUNcl=3ypmH@;Uw!a&M^3>A8p6rE=P-vCJ(gXsx0lP}T(2j9psQ-0I@ z#vgXKp=bcQ^g^wnt61bRW+|4!kUv&ioG|a!jlW z18%hUdiNjt6v;PlxrzPY2t4yYhPnA36Tl@sxF5f3VZ6+xZ8T>zxh#>S0 zC%DfiPCty;vSpUf-4rCn8l|dElfJO5mpIi%QpN_S9_6#kNWuO>L>yZWb)`>`67v#w zKG(c7v8%%Pu1!6$?e_pMkR;X+ZNG9OHWm#;l5L3(=B0#6z<5CWJirMsGU32A>p;>5 z%B$SaRvqvHpmG_&DKLB1%?n4|?|iKR$RAv7#O-pc0a|-K%*{JNSH8;vmHI1?qfdg5 z|H?|l+;}_a-FJZ|)>}s9)xUtB`99?2^FV2INs{m}sQRcXM6;kZo8W!&2S918+ckPQ zXx&YapbW$sV0p@MDWfik;s50t=e7=F&jxWH!ek%~QA@!G>>n&H|+_*qaJPSGX535#1?4h6y zFyNOE-2ENU<=0$z(Q7{lv-c55d)7*fMn>RGJ_xhssteAMnb#11>yD0O`wa{Idm-KJ zBnDPZfU0YpXUUCsfr_;j&eyh%4b7Vaa;~>hgEXfBD)sGF`?mporjAwMY3%e>+j<%3 zJan-&d)nqOtwb&j(Y5U}G7r}W`O>ch#D#lO8}Lg`%=0PtG^*S4N!zslLK(YXf~b8c zZY!yaEdi?@9%SY;k^>LHZ2nkR8hI{bRmy6BHgVwBbMr$RI&G7=8Kk;)1 zAoVF{#9Bz`lc@Ejjm0BK)P$r7s5}OmxH`j`0a6B9Ei?`vodcx0bu-LZCG$GnAFofTD6?Ehski>Z*RkvW*6Y$=uC8oI~XI@0~ z*ry@&la8l?IZ^RQtk0O8hS~If2fjBQ&+Tb3yIQP|Q;^1#8z$|VofPBJMPiGfyGs_# zQVv7XT_g8C1@ZLng*wmF(gy2!bndBZP$FGJAPaJ>{%9}>6!6piEh&Rbpl}|p@nFfS zGk;B4B-_1Y=K*o*v(!Ss0{k*837Cs70W5ajxTH4Ff2i+^IM1_&B7tm|_+lF|57!3l ztXvYExL`x+1*DU;=VN+-LviykB_OVFWi!&A?9x`?^vtW3w|Qi%b<`bW#|7?ND;h(y z0ZBmND3YVUhX1!iFdH^!XOhglYG7(Mx@Uzv@oY69tv9=4Y~~8^%f}HP`8i0>#(8P7 z08wLSttba>*VD)Xr12}}FKMGQ&z$Xo(W7%UD?8>@%{&{OIpu%{rNRe#r2%WBX$Op) zJ-O%z^0jo*{Vny^7k9m0;I1@JNs8w}kpYE;eM-Jtz4f3@U*;A&V6uud{g)_=;xC4D zrb^%?-=mG~)Fuf9S1E9syJ%~{0uKx*X1wML<6y~`jco>l(BmRjG;!I$i(`3;&fhz- z1x0gC&I^T6}q?HLhKQOmHMWPoN2}5WdzeLezNwH_)e1D zFw=iU%?tcLj{0=%bcEOtR^*Fr7R1#MK`X&Y;dsl~+i}{eOl?2THdAw3E8&0DN)iZ* zQmWsb@V`u#^kAul62u9=9L2b;`cu5#^Jfnj+5?{YC{~Zx=<5W!JM`eSQ`rA|ET>8Z z1}GVz&6>#5zEiX5=hy!dQ#t1D`-iMS?`J-a_j#c0Ecg%$uF~?;a)8%&@kTIZ2)|>I o$E*ACTW#NWC%n7%YcvJ^14Pt$wT_#1ivR!s07*qoM6N<$f~NhWJ^%m! literal 0 HcmV?d00001 diff --git a/web/extension/huya-exchange-helper/manifest.json b/web/extension/huya-exchange-helper/manifest.json new file mode 100644 index 0000000..6e1521a --- /dev/null +++ b/web/extension/huya-exchange-helper/manifest.json @@ -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": [""], + "js": ["relay.js"], + "run_at": "document_start" + } + ], + "action": { + "default_title": "虎牙兑换助手" + }, + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } +} diff --git a/web/extension/huya-exchange-helper/relay.js b/web/extension/huya-exchange-helper/relay.js new file mode 100644 index 0000000..afb17f2 --- /dev/null +++ b/web/extension/huya-exchange-helper/relay.js @@ -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); diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 3076eb4..48ccf33 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -29,6 +29,7 @@ import type { HuyaTaskBatchRequest, HuyaTaskBatchResult, HuyaTaskItem, + HuyaBrowserExchangeKit, MessageCountResponse, MessageDeletedResponse, MessageResponse, @@ -142,4 +143,7 @@ export const huyaApi = { api.get(`/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(`/huya/accounts/${accountId}/browser-exchange`, {}), }; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 29ac640..be9f5b5 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/HuyaAccountsPage.tsx b/web/frontend/src/pages/HuyaAccountsPage.tsx index c2d76cd..4dc0394 100644 --- a/web/frontend/src/pages/HuyaAccountsPage.tsx +++ b/web/frontend/src/pages/HuyaAccountsPage.tsx @@ -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 = { 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([]); const [users, setUsers] = useState([]); @@ -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(null); + const [exchangeTitle, setExchangeTitle] = useState(''); + const [exchangeLoading, setExchangeLoading] = useState(false); const [searchText, setSearchText] = useState(''); const [tagFilter, setTagFilter] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -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() { )} + {canImport && record.has_cookie && ( + + , + , + ] : null} + > + {exchangeKit && ( + + {exchangeHelperReady ? ( + + ) : ( + + )} + + 手动步骤 1: + 点击下方「打开兑换页面」,在新标签页打开兑换活动页。 + + + 手动步骤 2: + 点击「复制登录脚本」,在兑换页面按 F12 打开控制台,粘贴脚本并回车执行。 + Chrome/Edge 首次粘贴需先手动输入 allow pasting 再回车。脚本会写入该账号的登录态并自动跳转。 + + + + + + + 备选:安装 Cookie-editor 类扩展后,在 .huya.com 域下导入 Cookie 字符串。脚本和 Cookie 等同账号凭证,请勿泄露给他人。 + + + 手动步骤 3: + 确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。 + + + )} + { + 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); + } + }); +}