虎牙人工兑换改用本机浏览器并新增扩展自动注入登录态

移除服务器浏览器方案(容器/Xvfb/noVNC/CDP 会话管理),后端仅下发
兑换材料;新增内置 Chrome/Edge 扩展一键写入 huya.com 登录态,
未安装扩展时回退控制台脚本手动注入,兑换始终由用户手动完成。
This commit is contained in:
yml2213
2026-09-05 17:55:31 +08:00
parent 95ad900ef0
commit 230d6c1918
15 changed files with 477 additions and 4 deletions
+4
View File
@@ -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`, {}),
};
+10
View File
@@ -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;
+116 -1
View File
@@ -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="批量设置标签"
+27
View File
@@ -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);
}
});
}