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 0000000..9332cdc Binary files /dev/null and b/web/extension/huya-exchange-helper/icon128.png differ diff --git a/web/extension/huya-exchange-helper/icon16.png b/web/extension/huya-exchange-helper/icon16.png new file mode 100644 index 0000000..fc60ab1 Binary files /dev/null and b/web/extension/huya-exchange-helper/icon16.png differ diff --git a/web/extension/huya-exchange-helper/icon48.png b/web/extension/huya-exchange-helper/icon48.png new file mode 100644 index 0000000..92160c8 Binary files /dev/null and b/web/extension/huya-exchange-helper/icon48.png differ 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); + } + }); +}