diff --git a/public/ext-src/manifest.json b/public/ext-src/manifest.json index 417e208..fa3db65 100644 --- a/public/ext-src/manifest.json +++ b/public/ext-src/manifest.json @@ -33,5 +33,5 @@ "alarms", "tabs" ], - "version": "2.11" + "version": "2.13" } diff --git a/public/ext-src/popup.html b/public/ext-src/popup.html index 38c726a..70cc595 100644 --- a/public/ext-src/popup.html +++ b/public/ext-src/popup.html @@ -4,34 +4,83 @@

🍪 签到面板助手

+
+
正在读取当前网站…
diff --git a/public/ext-src/popup.js b/public/ext-src/popup.js index 5436bdc..66ed71b 100644 --- a/public/ext-src/popup.js +++ b/public/ext-src/popup.js @@ -286,6 +286,74 @@ $('api-key').onchange = saveCfgNow; fillVersion(); +// ---------- 扩展更新检查 ---------- +// Chrome 不允许未上架的扩展自己静默更新,所以这里做「检查 + 一键下载」: +// 有新版时显示下载按钮,用户解压覆盖后去 chrome://extensions 点「重新加载」即可。 +// 版本号比对:按点分割逐段比数字("2.9" < "2.10" < "2.11")。 +function cmpVer(a, b) { + const pa = String(a || '').split('.').map((x) => parseInt(x, 10) || 0); + const pb = String(b || '').split('.').map((x) => parseInt(x, 10) || 0); + const n = Math.max(pa.length, pb.length); + for (let i = 0; i < n; i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d; + } + return 0; +} + +async function checkExtUpdate() { + let localVer = ''; + try { localVer = String((chrome.runtime.getManifest() || {}).version || ''); } catch { /* 忽略 */ } + const btn = $('btn-update'); + const tip = $('update-tip'); + if (!localVer) return; + // 面板地址:优先用输入框里已填的,没有就用注入的默认值 + let panelUrl = ''; + try { + panelUrl = normalizePanelUrl($('panel-url').value); + if (!panelUrl && typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL.startsWith('http')) { + panelUrl = normalizePanelUrl(DEFAULT_PANEL_URL); + } + } catch { /* 忽略 */ } + if (!panelUrl) return; // 没填面板地址就不检查 + let remoteVer = ''; + try { + const resp = await fetch(panelUrl + '/api/external/ext-version'); + if (resp.ok) { + const data = await resp.json().catch(() => null); + remoteVer = String((data && data.version) || ''); + } + } catch { /* 面板不可达就静默跳过 */ } + if (!remoteVer || cmpVer(remoteVer, localVer) <= 0) return; // 没有新版 + // 有新版:显示更新按钮和提示 + btn.style.display = 'inline-block'; + tip.style.display = 'block'; + tip.innerHTML = ''; + const t1 = document.createElement('div'); + t1.textContent = `发现新版本 v${remoteVer}(当前 v${localVer})`; + tip.appendChild(t1); + const dl = document.createElement('button'); + dl.className = 'dl-btn'; + dl.textContent = '⬇️ 下载新版扩展'; + dl.onclick = () => { + chrome.tabs.create({ url: panelUrl + '/cookie-helper-extension.zip' }); + const t2 = document.createElement('div'); + t2.style.marginTop = '6px'; + t2.textContent = '下载后解压覆盖原文件夹,再去 chrome://extensions 点「重新加载」即可。'; + // 只追加一次 + if (!tip.querySelector('.done-tip')) { + t2.className = 'done-tip'; + tip.appendChild(t2); + } + }; + tip.appendChild(dl); + btn.onclick = () => { + tip.style.display = tip.style.display === 'none' ? 'block' : 'none'; + }; +} + +checkExtUpdate(); + // 打开弹窗时顺手叫醒一次中继(后台常驻长轮询,本来就会自己跑)。 // 以前这里还有个「立即执行待办签到」按钮——它会挂住弹窗、也让用户以为要手动点才干活,已删掉: // 面板点「执行」时后台会在 1 秒内接到单,不需要在弹窗上再点一次。 diff --git a/src/ext-files.js b/src/ext-files.js index e825960..fede76e 100644 --- a/src/ext-files.js +++ b/src/ext-files.js @@ -1,9 +1,9 @@ // 自动生成:扩展源文件嵌入(运行 node tools/gen-ext-files.mjs 更新) // 源文件在 public/ext-src/;下载时由 src/ext-zip.js 注入面板地址。 export const EXT_FILES = { - "manifest.json": "{\n \"action\": {\n \"default_popup\": \"popup.html\",\n \"default_title\": \"签到面板助手:一键取 Cookie、本地网络中继、浏览器签到\",\n \"default_icon\": {\n \"16\": \"icon16.png\",\n \"32\": \"icon32.png\",\n \"48\": \"icon48.png\",\n \"128\": \"icon128.png\"\n }\n },\n \"background\": {\n \"service_worker\": \"background.js\"\n },\n \"description\": \"签到面板助手:一键取 Cookie、本地网络中继、需要时自动完成浏览器签到\",\n \"minimum_chrome_version\": \"110\",\n \"host_permissions\": [\n \"\"\n ],\n \"icons\": {\n \"16\": \"icon16.png\",\n \"32\": \"icon32.png\",\n \"48\": \"icon48.png\",\n \"128\": \"icon128.png\"\n },\n \"manifest_version\": 3,\n \"name\": \"签到面板助手\",\n \"permissions\": [\n \"cookies\",\n \"activeTab\",\n \"scripting\",\n \"storage\",\n \"alarms\",\n \"tabs\"\n ],\n \"version\": \"2.11\"\n}\n", - "popup.html": "\n\n\n\n\n\n\n
\n

🍪 签到面板助手

\n \n
\n
正在读取当前网站…
\n\n \n \n

发送方式是「一次性交接码」:凭据不进地址栏,短码 5 分钟内有效、取一次即作废。

\n\n
\n
面板连接
\n \n \n \n \n \n
\n \n\n\n", - "popup.js": "// 签到面板 Cookie 助手 - popup.js\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://your-panel.your-name.workers.dev 占位符),\n// 扩展首次打开时自动带出,用户仍可手动修改。\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n// API Key 不从下载包里注入:由用户在面板「设置」页复制后手动填入,避免 Key 随文件传播、也方便随时更换。\nconst $ = (id) => document.getElementById(id);\nconst status = (msg, cls = '') => { const s = $('status'); s.textContent = msg; s.className = cls; };\n\nlet cookies = [];\nlet domain = '';\n\n// 标题里的版本号从 manifest 读:以前 popup 里写死「2.2」而 manifest 已经是 2.3,\n// 面板上显示「扩展在线 · v2.3」、扩展里写 2.2,看着像两个东西。\nfunction fillVersion() {\n try {\n const el = $('title-ver');\n if (el) el.textContent = 'v' + ((chrome.runtime.getManifest() || {}).version || '');\n } catch { /* 忽略 */ }\n}\n\n// ---------- 配置读写 ----------\n// API Key 属于凭据:存 storage.local(只在本机,不会跟着浏览器账号同步上云)。\n// 老版本(≤ 2.3)把它写在 sync:这里读不到本地就回退读一次 sync,并搬过来、删掉云端那份。\nfunction localArea() {\n try {\n const l = chrome.storage && chrome.storage.local;\n if (l && typeof l.get === 'function' && typeof l.set === 'function') return l;\n } catch { /* 忽略 */ }\n return chrome.storage.sync;\n}\nasync function readCfg() {\n let got = {};\n try { got = (await localArea().get(['panelUrl', 'apiKey'])) || {}; } catch { got = {}; }\n if (!got.apiKey) {\n try {\n const s = await chrome.storage.sync.get(['apiKey']);\n if (s && s.apiKey) {\n got.apiKey = s.apiKey;\n try { await localArea().set({ apiKey: s.apiKey }); } catch { /* 忽略 */ }\n try { if (typeof chrome.storage.sync.remove === 'function') await chrome.storage.sync.remove(['apiKey']); } catch { /* 忽略 */ }\n }\n } catch { /* 忽略 */ }\n }\n return got;\n}\nasync function writeCfg(obj) {\n try { await localArea().set(obj); } catch { /* 忽略 */ }\n}\n\n// 面板地址规范化 + 校验。Cookie / API Key 都要发到这里,\n// 所以除本机回环调试外一律要求 https(http 会让凭据在网络上明文传输)。\nfunction normalizePanelUrl(raw) {\n const s = String(raw || '').trim().replace(/\\/+$/, '');\n if (!s) return '';\n try {\n const u = new URL(s);\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';\n const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\\/+$/, '') : '';\n return u.origin + path;\n } catch { return ''; }\n}\nfunction panelUrlProblem(url) {\n let u;\n try { u = new URL(url); } catch { return '面板地址不是合法网址(示例:https://xxx.workers.dev)'; }\n const loop = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1' || u.hostname === '[::1]';\n if (u.protocol !== 'https:' && !loop) return '面板地址必须是 https —— 否则 Cookie 和 API Key 会明文上网';\n return '';\n}\n// 读取输入框里的面板地址并校验;不合格时把提示写进状态栏并返回空串\nasync function panelUrlFromInput(showErr = status) {\n let url = normalizePanelUrl($('panel-url').value);\n if (!url) { showErr('请先填写签到面板地址(示例:https://xxx.workers.dev)', 'err'); return ''; }\n const problem = panelUrlProblem(url);\n if (problem) { showErr(problem, 'err'); return ''; }\n await writeCfg({ panelUrl: url });\n return url;\n}\n\n// 采集当前标签页的 Cookie,力求“精准而全面”:\n// ① 主源 getAll({ url }) —— 与浏览器真正会发给该地址的 Cookie 完全一致\n// (自动考虑 domain 匹配含父域、path、secure、sameSite、是否过期),对任意网站都准。\n// ② host-only 兜底 getAll({ domain: hostname })。\n// ③ 尝试分区 Cookie(CHIPS),失败则跳过。\n// 合并去重(name|domain|path),并按“路径长→域名→名称”排序,尽量贴近浏览器发送顺序。\n// 刻意不猜父域(避免把 co.uk / com.cn 这种当成域名,反而混入无关 Cookie)。\nasync function collectCookies(tab) {\n const bag = new Map();\n const put = (list) => { for (const c of list || []) bag.set(`${c.name}|${c.domain}|${c.path}`, c); };\n let host = '';\n let origin = '';\n try { const u = new URL(tab.url); host = u.hostname; origin = u.origin; } catch { /* 忽略 */ }\n\n const queries = [];\n if (tab.url) queries.push({ url: tab.url });\n if (host) queries.push({ domain: host });\n if (origin) queries.push({ url: tab.url, partitionKey: { topLevelSite: origin } });\n\n for (const q of queries) {\n try { put(await chrome.cookies.getAll(q)); } catch { /* 忽略不支持的查询 */ }\n }\n\n const list = Array.from(bag.values());\n list.sort((a, b) =>\n (b.path || '').length - (a.path || '').length ||\n String(a.domain).localeCompare(String(b.domain)) ||\n String(a.name).localeCompare(String(b.name)));\n return list;\n}\n\n// Cookie 概况:Cookie 数 / 域数 / HttpOnly 数 / 会话与持久数 / 最近过期时间\nfunction describeCookies(list) {\n const domains = new Set();\n let httpOnly = 0;\n let session = 0;\n let persistent = 0;\n for (const c of list) {\n domains.add(c.domain || '');\n if (c.httpOnly) httpOnly++;\n if (c.session) session++; else persistent++;\n }\n return { count: list.length, domains: domains.size, httpOnly, session, persistent };\n}\n\nasync function init() {\n // 读取保存的面板地址和 API Key;没有保存过则用下载时注入的默认地址(面板动态生成 zip 时填入)\n const { panelUrl, apiKey } = await readCfg();\n const hasDefault = typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http');\n if (panelUrl) {\n $('panel-url').value = panelUrl;\n } else if (hasDefault) {\n $('panel-url').value = DEFAULT_PANEL_URL;\n // 自动保存默认地址,避免下次为空\n writeCfg({ panelUrl: normalizePanelUrl(DEFAULT_PANEL_URL) || DEFAULT_PANEL_URL });\n }\n if (apiKey) $('api-key').value = apiKey;\n\n // 获取当前标签页\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab || !tab.url || !tab.url.startsWith('http')) {\n status('请在目标网站页面使用', 'err');\n return;\n }\n const url = new URL(tab.url);\n domain = url.hostname;\n $('domain').textContent = '当前网站:' + domain;\n\n // 获取页面真实的 User-Agent(吾爱等站点要求 UA 与 Cookie 配对)\n let pageUA = '';\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => navigator.userAgent,\n });\n pageUA = result || '';\n } catch { /* 忽略,用默认 */ }\n\n // 精准而全面地抓取 Cookie(与浏览器实际发送的登录态一致)\n cookies = await collectCookies(tab);\n const stat = describeCookies(cookies);\n\n // 顺带抓 localStorage(部分站点把 token 放这里,如 akile)\n let localStore = {};\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => {\n const o = {};\n try { for (let i = 0; i < localStorage.length && i < 200; i++) { const k = localStorage.key(i); if (k) o[k] = String(localStorage.getItem(k) || '').slice(0, 2000); } } catch { /* 忽略 */ }\n return o;\n },\n });\n localStore = result || {};\n } catch { /* 忽略 */ }\n\n window._pageUA = pageUA;\n window._localStore = localStore;\n const lsCount = Object.keys(localStore).length;\n const countEl = $('count');\n if (cookies.length) {\n countEl.textContent = cookies.length + ' 个 Cookie';\n countEl.style.display = 'inline-block';\n } else {\n countEl.style.display = 'none';\n }\n status(\n cookies.length\n ? `已读取 ${cookies.length} 个 Cookie(${stat.domains} 个域 / ${stat.httpOnly} 个 HttpOnly)` + (lsCount ? ` + ${lsCount} 项 localStorage` : '')\n : '该网站没有 Cookie',\n cookies.length ? 'ok' : 'err'\n );\n}\n\nfunction cookieString() {\n return cookies.map((c) => `${c.name}=${c.value}`).join('; ');\n}\n\nfunction payloadObj() {\n return {\n domain,\n userAgent: window._pageUA || '',\n cookies: cookieString(),\n // 附带可读的分解信息,面板据此清晰展示:每个 Cookie 的名称/域/是否 HttpOnly\n cookieList: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain,\n path: c.path,\n secure: !!c.secure,\n httpOnly: !!c.httpOnly,\n sameSite: c.sameSite || '',\n session: !!c.session,\n expirationDate: c.expirationDate || null,\n })),\n localStorage: window._localStore || {},\n stats: describeCookies(cookies),\n ts: Date.now(),\n };\n}\n\nfunction fullPayload() {\n return JSON.stringify(payloadObj());\n}\n\n$('btn-copy').onclick = async () => {\n if (!cookies.length) return status('没有可复制的 Cookie', 'err');\n // 复制完整 JSON:面板粘贴后自动解析出域名、UA、Cookie\n await navigator.clipboard.writeText(fullPayload());\n status(`已复制 ${cookies.length} 个 Cookie + UA,去面板「粘贴扩展内容」粘贴即可`, 'ok');\n};\n\n$('btn-send').onclick = async () => {\n if (!cookies.length) return status('没有可发送的 Cookie', 'err');\n const panelUrl = await panelUrlFromInput();\n if (!panelUrl) return;\n const apiKey = $('api-key').value.trim();\n const payload = payloadObj();\n\n // ① 首选「一次性交接码」:先把内容 POST 给面板,面板打开的地址里只带一个 16 位短码。\n // 好处:整包 Cookie / localStorage 不再进入地址栏,也不会写进浏览历史;\n // 短码 5 分钟有效、取一次即作废(面板端 /api/handoff/)。\n if (apiKey) {\n try {\n const resp = await fetch(panelUrl + '/api/external/handoff', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify(payload),\n });\n if (resp.status === 401) return status('API Key 无效:请去面板「设置」页重新复制一次', 'err');\n if (resp.ok) {\n const data = await resp.json().catch(() => null);\n if (data && data.code) {\n chrome.tabs.create({ url: panelUrl + '#handoff=' + data.code });\n return status('已打开面板,请在面板中确认保存(交接码 5 分钟内有效)', 'ok');\n }\n }\n } catch { /* 面板不可达 / 老面板没有这个接口 → 走下面的回退 */ }\n }\n\n // ② 回退:老面板(没有交接码接口)或没填 API Key 时,把内容编码进 URL hash。\n // 面板端 #ext-cookies= 分支还在,旧组合照旧能用。\n // 用 base64url 编码(+/= 替换为 -_.),避免特殊字符在地址栏被转义或截断。\n const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));\n const enc = b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n chrome.tabs.create({ url: panelUrl + '#ext-cookies=' + enc });\n status(apiKey ? '面板没有交接接口,已用旧方式打开(凭据随链接传递)' : '未填 API Key,已用旧方式打开(凭据随链接传递)', 'ok');\n};\n\n// 输入时不再「每敲一个键就写一次存储」:去抖 400ms,失焦时立刻落盘。\n// (以前每敲一下都写盘,Key 输到一半的残值也会被存进去)\nlet saveTimer = null;\nfunction scheduleSave() {\n if (saveTimer) clearTimeout(saveTimer);\n saveTimer = setTimeout(saveCfgNow, 400);\n}\nfunction saveCfgNow() {\n if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }\n writeCfg({\n panelUrl: $('panel-url').value.trim().replace(/\\/$/, ''),\n apiKey: $('api-key').value.trim(),\n });\n}\n$('panel-url').oninput = scheduleSave;\n$('api-key').oninput = scheduleSave;\n$('panel-url').onchange = saveCfgNow;\n$('api-key').onchange = saveCfgNow;\n\nfillVersion();\n\n// 打开弹窗时顺手叫醒一次中继(后台常驻长轮询,本来就会自己跑)。\n// 以前这里还有个「立即执行待办签到」按钮——它会挂住弹窗、也让用户以为要手动点才干活,已删掉:\n// 面板点「执行」时后台会在 1 秒内接到单,不需要在弹窗上再点一次。\n// 延到下一个 tick:先把弹窗自己的 UI 初始化完,再去叫醒后台,\n// 不抢在主界面之前干活(后台不在时静默忽略,不影响取 Cookie)。\nsetTimeout(() => {\n try {\n const p = chrome.runtime.sendMessage({ action: 'runRelayNow' });\n if (p && typeof p.catch === 'function') p.catch(() => {});\n } catch { /* 忽略 */ }\n}, 0);\n\n// 面板连接检查:验证面板地址和 API Key 是否可用\n$('btn-check-conn').onclick = async () => {\n status('正在检查面板连接…', '');\n try {\n const panelUrl = await panelUrlFromInput();\n if (!panelUrl) return; // 提示已经在状态栏里\n const apiKey = $('api-key').value.trim();\n if (!apiKey) return status('请先填写 API Key(面板设置页获取)', 'err');\n\n // ① 检查面板是否可访问(用专用 ping 接口,不消费任务)\n let resp;\n try {\n resp = await fetch(panelUrl + '/api/external/ping', {\n headers: { 'X-Api-Key': apiKey },\n });\n } catch (e) {\n return status('连接失败:面板地址无法访问(' + (e.message || '网络错误') + ')', 'err');\n }\n // ② 检查 API Key 是否有效\n if (resp.status === 401) return status('连接失败:API Key 无效,请去面板设置页重新生成', 'err');\n if (!resp.ok) return status('连接失败:面板返回 HTTP ' + resp.status, 'err');\n // ③ 解析任务列表\n let data;\n try { data = await resp.json(); } catch { return status('连接失败:面板返回数据格式错误', 'err'); }\n status(`连接正常 ✅ API Key 有效(面板版本 ${data.version || '未知'})`, 'ok');\n } catch (e) {\n status('检查失败:' + (e.message || e), 'err');\n }\n};\n\ninit();\n", + "manifest.json": "{\n \"action\": {\n \"default_popup\": \"popup.html\",\n \"default_title\": \"签到面板助手:一键取 Cookie、本地网络中继、浏览器签到\",\n \"default_icon\": {\n \"16\": \"icon16.png\",\n \"32\": \"icon32.png\",\n \"48\": \"icon48.png\",\n \"128\": \"icon128.png\"\n }\n },\n \"background\": {\n \"service_worker\": \"background.js\"\n },\n \"description\": \"签到面板助手:一键取 Cookie、本地网络中继、需要时自动完成浏览器签到\",\n \"minimum_chrome_version\": \"110\",\n \"host_permissions\": [\n \"\"\n ],\n \"icons\": {\n \"16\": \"icon16.png\",\n \"32\": \"icon32.png\",\n \"48\": \"icon48.png\",\n \"128\": \"icon128.png\"\n },\n \"manifest_version\": 3,\n \"name\": \"签到面板助手\",\n \"permissions\": [\n \"cookies\",\n \"activeTab\",\n \"scripting\",\n \"storage\",\n \"alarms\",\n \"tabs\"\n ],\n \"version\": \"2.13\"\n}\n", + "popup.html": "\n\n\n\n\n\n\n
\n

🍪 签到面板助手

\n \n \n
\n
\n
正在读取当前网站…
\n\n \n \n

发送方式是「一次性交接码」:凭据不进地址栏,短码 5 分钟内有效、取一次即作废。

\n\n
\n
面板连接
\n \n \n \n \n \n
\n \n\n\n", + "popup.js": "// 签到面板 Cookie 助手 - popup.js\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://your-panel.your-name.workers.dev 占位符),\n// 扩展首次打开时自动带出,用户仍可手动修改。\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n// API Key 不从下载包里注入:由用户在面板「设置」页复制后手动填入,避免 Key 随文件传播、也方便随时更换。\nconst $ = (id) => document.getElementById(id);\nconst status = (msg, cls = '') => { const s = $('status'); s.textContent = msg; s.className = cls; };\n\nlet cookies = [];\nlet domain = '';\n\n// 标题里的版本号从 manifest 读:以前 popup 里写死「2.2」而 manifest 已经是 2.3,\n// 面板上显示「扩展在线 · v2.3」、扩展里写 2.2,看着像两个东西。\nfunction fillVersion() {\n try {\n const el = $('title-ver');\n if (el) el.textContent = 'v' + ((chrome.runtime.getManifest() || {}).version || '');\n } catch { /* 忽略 */ }\n}\n\n// ---------- 配置读写 ----------\n// API Key 属于凭据:存 storage.local(只在本机,不会跟着浏览器账号同步上云)。\n// 老版本(≤ 2.3)把它写在 sync:这里读不到本地就回退读一次 sync,并搬过来、删掉云端那份。\nfunction localArea() {\n try {\n const l = chrome.storage && chrome.storage.local;\n if (l && typeof l.get === 'function' && typeof l.set === 'function') return l;\n } catch { /* 忽略 */ }\n return chrome.storage.sync;\n}\nasync function readCfg() {\n let got = {};\n try { got = (await localArea().get(['panelUrl', 'apiKey'])) || {}; } catch { got = {}; }\n if (!got.apiKey) {\n try {\n const s = await chrome.storage.sync.get(['apiKey']);\n if (s && s.apiKey) {\n got.apiKey = s.apiKey;\n try { await localArea().set({ apiKey: s.apiKey }); } catch { /* 忽略 */ }\n try { if (typeof chrome.storage.sync.remove === 'function') await chrome.storage.sync.remove(['apiKey']); } catch { /* 忽略 */ }\n }\n } catch { /* 忽略 */ }\n }\n return got;\n}\nasync function writeCfg(obj) {\n try { await localArea().set(obj); } catch { /* 忽略 */ }\n}\n\n// 面板地址规范化 + 校验。Cookie / API Key 都要发到这里,\n// 所以除本机回环调试外一律要求 https(http 会让凭据在网络上明文传输)。\nfunction normalizePanelUrl(raw) {\n const s = String(raw || '').trim().replace(/\\/+$/, '');\n if (!s) return '';\n try {\n const u = new URL(s);\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';\n const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\\/+$/, '') : '';\n return u.origin + path;\n } catch { return ''; }\n}\nfunction panelUrlProblem(url) {\n let u;\n try { u = new URL(url); } catch { return '面板地址不是合法网址(示例:https://xxx.workers.dev)'; }\n const loop = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1' || u.hostname === '[::1]';\n if (u.protocol !== 'https:' && !loop) return '面板地址必须是 https —— 否则 Cookie 和 API Key 会明文上网';\n return '';\n}\n// 读取输入框里的面板地址并校验;不合格时把提示写进状态栏并返回空串\nasync function panelUrlFromInput(showErr = status) {\n let url = normalizePanelUrl($('panel-url').value);\n if (!url) { showErr('请先填写签到面板地址(示例:https://xxx.workers.dev)', 'err'); return ''; }\n const problem = panelUrlProblem(url);\n if (problem) { showErr(problem, 'err'); return ''; }\n await writeCfg({ panelUrl: url });\n return url;\n}\n\n// 采集当前标签页的 Cookie,力求“精准而全面”:\n// ① 主源 getAll({ url }) —— 与浏览器真正会发给该地址的 Cookie 完全一致\n// (自动考虑 domain 匹配含父域、path、secure、sameSite、是否过期),对任意网站都准。\n// ② host-only 兜底 getAll({ domain: hostname })。\n// ③ 尝试分区 Cookie(CHIPS),失败则跳过。\n// 合并去重(name|domain|path),并按“路径长→域名→名称”排序,尽量贴近浏览器发送顺序。\n// 刻意不猜父域(避免把 co.uk / com.cn 这种当成域名,反而混入无关 Cookie)。\nasync function collectCookies(tab) {\n const bag = new Map();\n const put = (list) => { for (const c of list || []) bag.set(`${c.name}|${c.domain}|${c.path}`, c); };\n let host = '';\n let origin = '';\n try { const u = new URL(tab.url); host = u.hostname; origin = u.origin; } catch { /* 忽略 */ }\n\n const queries = [];\n if (tab.url) queries.push({ url: tab.url });\n if (host) queries.push({ domain: host });\n if (origin) queries.push({ url: tab.url, partitionKey: { topLevelSite: origin } });\n\n for (const q of queries) {\n try { put(await chrome.cookies.getAll(q)); } catch { /* 忽略不支持的查询 */ }\n }\n\n const list = Array.from(bag.values());\n list.sort((a, b) =>\n (b.path || '').length - (a.path || '').length ||\n String(a.domain).localeCompare(String(b.domain)) ||\n String(a.name).localeCompare(String(b.name)));\n return list;\n}\n\n// Cookie 概况:Cookie 数 / 域数 / HttpOnly 数 / 会话与持久数 / 最近过期时间\nfunction describeCookies(list) {\n const domains = new Set();\n let httpOnly = 0;\n let session = 0;\n let persistent = 0;\n for (const c of list) {\n domains.add(c.domain || '');\n if (c.httpOnly) httpOnly++;\n if (c.session) session++; else persistent++;\n }\n return { count: list.length, domains: domains.size, httpOnly, session, persistent };\n}\n\nasync function init() {\n // 读取保存的面板地址和 API Key;没有保存过则用下载时注入的默认地址(面板动态生成 zip 时填入)\n const { panelUrl, apiKey } = await readCfg();\n const hasDefault = typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http');\n if (panelUrl) {\n $('panel-url').value = panelUrl;\n } else if (hasDefault) {\n $('panel-url').value = DEFAULT_PANEL_URL;\n // 自动保存默认地址,避免下次为空\n writeCfg({ panelUrl: normalizePanelUrl(DEFAULT_PANEL_URL) || DEFAULT_PANEL_URL });\n }\n if (apiKey) $('api-key').value = apiKey;\n\n // 获取当前标签页\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab || !tab.url || !tab.url.startsWith('http')) {\n status('请在目标网站页面使用', 'err');\n return;\n }\n const url = new URL(tab.url);\n domain = url.hostname;\n $('domain').textContent = '当前网站:' + domain;\n\n // 获取页面真实的 User-Agent(吾爱等站点要求 UA 与 Cookie 配对)\n let pageUA = '';\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => navigator.userAgent,\n });\n pageUA = result || '';\n } catch { /* 忽略,用默认 */ }\n\n // 精准而全面地抓取 Cookie(与浏览器实际发送的登录态一致)\n cookies = await collectCookies(tab);\n const stat = describeCookies(cookies);\n\n // 顺带抓 localStorage(部分站点把 token 放这里,如 akile)\n let localStore = {};\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => {\n const o = {};\n try { for (let i = 0; i < localStorage.length && i < 200; i++) { const k = localStorage.key(i); if (k) o[k] = String(localStorage.getItem(k) || '').slice(0, 2000); } } catch { /* 忽略 */ }\n return o;\n },\n });\n localStore = result || {};\n } catch { /* 忽略 */ }\n\n window._pageUA = pageUA;\n window._localStore = localStore;\n const lsCount = Object.keys(localStore).length;\n const countEl = $('count');\n if (cookies.length) {\n countEl.textContent = cookies.length + ' 个 Cookie';\n countEl.style.display = 'inline-block';\n } else {\n countEl.style.display = 'none';\n }\n status(\n cookies.length\n ? `已读取 ${cookies.length} 个 Cookie(${stat.domains} 个域 / ${stat.httpOnly} 个 HttpOnly)` + (lsCount ? ` + ${lsCount} 项 localStorage` : '')\n : '该网站没有 Cookie',\n cookies.length ? 'ok' : 'err'\n );\n}\n\nfunction cookieString() {\n return cookies.map((c) => `${c.name}=${c.value}`).join('; ');\n}\n\nfunction payloadObj() {\n return {\n domain,\n userAgent: window._pageUA || '',\n cookies: cookieString(),\n // 附带可读的分解信息,面板据此清晰展示:每个 Cookie 的名称/域/是否 HttpOnly\n cookieList: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain,\n path: c.path,\n secure: !!c.secure,\n httpOnly: !!c.httpOnly,\n sameSite: c.sameSite || '',\n session: !!c.session,\n expirationDate: c.expirationDate || null,\n })),\n localStorage: window._localStore || {},\n stats: describeCookies(cookies),\n ts: Date.now(),\n };\n}\n\nfunction fullPayload() {\n return JSON.stringify(payloadObj());\n}\n\n$('btn-copy').onclick = async () => {\n if (!cookies.length) return status('没有可复制的 Cookie', 'err');\n // 复制完整 JSON:面板粘贴后自动解析出域名、UA、Cookie\n await navigator.clipboard.writeText(fullPayload());\n status(`已复制 ${cookies.length} 个 Cookie + UA,去面板「粘贴扩展内容」粘贴即可`, 'ok');\n};\n\n$('btn-send').onclick = async () => {\n if (!cookies.length) return status('没有可发送的 Cookie', 'err');\n const panelUrl = await panelUrlFromInput();\n if (!panelUrl) return;\n const apiKey = $('api-key').value.trim();\n const payload = payloadObj();\n\n // ① 首选「一次性交接码」:先把内容 POST 给面板,面板打开的地址里只带一个 16 位短码。\n // 好处:整包 Cookie / localStorage 不再进入地址栏,也不会写进浏览历史;\n // 短码 5 分钟有效、取一次即作废(面板端 /api/handoff/)。\n if (apiKey) {\n try {\n const resp = await fetch(panelUrl + '/api/external/handoff', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify(payload),\n });\n if (resp.status === 401) return status('API Key 无效:请去面板「设置」页重新复制一次', 'err');\n if (resp.ok) {\n const data = await resp.json().catch(() => null);\n if (data && data.code) {\n chrome.tabs.create({ url: panelUrl + '#handoff=' + data.code });\n return status('已打开面板,请在面板中确认保存(交接码 5 分钟内有效)', 'ok');\n }\n }\n } catch { /* 面板不可达 / 老面板没有这个接口 → 走下面的回退 */ }\n }\n\n // ② 回退:老面板(没有交接码接口)或没填 API Key 时,把内容编码进 URL hash。\n // 面板端 #ext-cookies= 分支还在,旧组合照旧能用。\n // 用 base64url 编码(+/= 替换为 -_.),避免特殊字符在地址栏被转义或截断。\n const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));\n const enc = b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n chrome.tabs.create({ url: panelUrl + '#ext-cookies=' + enc });\n status(apiKey ? '面板没有交接接口,已用旧方式打开(凭据随链接传递)' : '未填 API Key,已用旧方式打开(凭据随链接传递)', 'ok');\n};\n\n// 输入时不再「每敲一个键就写一次存储」:去抖 400ms,失焦时立刻落盘。\n// (以前每敲一下都写盘,Key 输到一半的残值也会被存进去)\nlet saveTimer = null;\nfunction scheduleSave() {\n if (saveTimer) clearTimeout(saveTimer);\n saveTimer = setTimeout(saveCfgNow, 400);\n}\nfunction saveCfgNow() {\n if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }\n writeCfg({\n panelUrl: $('panel-url').value.trim().replace(/\\/$/, ''),\n apiKey: $('api-key').value.trim(),\n });\n}\n$('panel-url').oninput = scheduleSave;\n$('api-key').oninput = scheduleSave;\n$('panel-url').onchange = saveCfgNow;\n$('api-key').onchange = saveCfgNow;\n\nfillVersion();\n\n// ---------- 扩展更新检查 ----------\n// Chrome 不允许未上架的扩展自己静默更新,所以这里做「检查 + 一键下载」:\n// 有新版时显示下载按钮,用户解压覆盖后去 chrome://extensions 点「重新加载」即可。\n// 版本号比对:按点分割逐段比数字(\"2.9\" < \"2.10\" < \"2.11\")。\nfunction cmpVer(a, b) {\n const pa = String(a || '').split('.').map((x) => parseInt(x, 10) || 0);\n const pb = String(b || '').split('.').map((x) => parseInt(x, 10) || 0);\n const n = Math.max(pa.length, pb.length);\n for (let i = 0; i < n; i++) {\n const d = (pa[i] || 0) - (pb[i] || 0);\n if (d) return d;\n }\n return 0;\n}\n\nasync function checkExtUpdate() {\n let localVer = '';\n try { localVer = String((chrome.runtime.getManifest() || {}).version || ''); } catch { /* 忽略 */ }\n const btn = $('btn-update');\n const tip = $('update-tip');\n if (!localVer) return;\n // 面板地址:优先用输入框里已填的,没有就用注入的默认值\n let panelUrl = '';\n try {\n panelUrl = normalizePanelUrl($('panel-url').value);\n if (!panelUrl && typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL.startsWith('http')) {\n panelUrl = normalizePanelUrl(DEFAULT_PANEL_URL);\n }\n } catch { /* 忽略 */ }\n if (!panelUrl) return; // 没填面板地址就不检查\n let remoteVer = '';\n try {\n const resp = await fetch(panelUrl + '/api/external/ext-version');\n if (resp.ok) {\n const data = await resp.json().catch(() => null);\n remoteVer = String((data && data.version) || '');\n }\n } catch { /* 面板不可达就静默跳过 */ }\n if (!remoteVer || cmpVer(remoteVer, localVer) <= 0) return; // 没有新版\n // 有新版:显示更新按钮和提示\n btn.style.display = 'inline-block';\n tip.style.display = 'block';\n tip.innerHTML = '';\n const t1 = document.createElement('div');\n t1.textContent = `发现新版本 v${remoteVer}(当前 v${localVer})`;\n tip.appendChild(t1);\n const dl = document.createElement('button');\n dl.className = 'dl-btn';\n dl.textContent = '⬇️ 下载新版扩展';\n dl.onclick = () => {\n chrome.tabs.create({ url: panelUrl + '/cookie-helper-extension.zip' });\n const t2 = document.createElement('div');\n t2.style.marginTop = '6px';\n t2.textContent = '下载后解压覆盖原文件夹,再去 chrome://extensions 点「重新加载」即可。';\n // 只追加一次\n if (!tip.querySelector('.done-tip')) {\n t2.className = 'done-tip';\n tip.appendChild(t2);\n }\n };\n tip.appendChild(dl);\n btn.onclick = () => {\n tip.style.display = tip.style.display === 'none' ? 'block' : 'none';\n };\n}\n\ncheckExtUpdate();\n\n// 打开弹窗时顺手叫醒一次中继(后台常驻长轮询,本来就会自己跑)。\n// 以前这里还有个「立即执行待办签到」按钮——它会挂住弹窗、也让用户以为要手动点才干活,已删掉:\n// 面板点「执行」时后台会在 1 秒内接到单,不需要在弹窗上再点一次。\n// 延到下一个 tick:先把弹窗自己的 UI 初始化完,再去叫醒后台,\n// 不抢在主界面之前干活(后台不在时静默忽略,不影响取 Cookie)。\nsetTimeout(() => {\n try {\n const p = chrome.runtime.sendMessage({ action: 'runRelayNow' });\n if (p && typeof p.catch === 'function') p.catch(() => {});\n } catch { /* 忽略 */ }\n}, 0);\n\n// 面板连接检查:验证面板地址和 API Key 是否可用\n$('btn-check-conn').onclick = async () => {\n status('正在检查面板连接…', '');\n try {\n const panelUrl = await panelUrlFromInput();\n if (!panelUrl) return; // 提示已经在状态栏里\n const apiKey = $('api-key').value.trim();\n if (!apiKey) return status('请先填写 API Key(面板设置页获取)', 'err');\n\n // ① 检查面板是否可访问(用专用 ping 接口,不消费任务)\n let resp;\n try {\n resp = await fetch(panelUrl + '/api/external/ping', {\n headers: { 'X-Api-Key': apiKey },\n });\n } catch (e) {\n return status('连接失败:面板地址无法访问(' + (e.message || '网络错误') + ')', 'err');\n }\n // ② 检查 API Key 是否有效\n if (resp.status === 401) return status('连接失败:API Key 无效,请去面板设置页重新生成', 'err');\n if (!resp.ok) return status('连接失败:面板返回 HTTP ' + resp.status, 'err');\n // ③ 解析任务列表\n let data;\n try { data = await resp.json(); } catch { return status('连接失败:面板返回数据格式错误', 'err'); }\n status(`连接正常 ✅ API Key 有效(面板版本 ${data.version || '未知'})`, 'ok');\n } catch (e) {\n status('检查失败:' + (e.message || e), 'err');\n }\n};\n\ninit();\n", "background.js": "// 签到面板 Cookie 助手 - 后台执行引擎\n// 每小时从面板获取 browser 模式的待执行任务,在用户浏览器中完成签到(使用用户网络),上报结果。\n\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://your-panel.your-name.workers.dev 占位符)\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n\nconst ALARM_NAME = 'checkin-jobs';\nconst CHECK_INTERVAL_MIN = 1; // 每分钟领一次「浏览器导航签到」任务(面板点「浏览器签到」后很快就会被领走)\nconst RELAY_ALARM = 'relay-poll';\n// 注意:Chrome 会把 alarms 的周期压到最小 0.5 分钟,靠短轮询做不到「秒级响应」。\n// 所以中继改成「长轮询」:请求挂起最多 20 秒,一有任务 Worker 立刻返回;\n// 一轮结束马上接下一轮(RELAY_GAP_MS),延迟从 15~30 秒降到 1 秒内。\nconst RELAY_INTERVAL_MIN = 0.5; // 兜底 alarm(Service Worker 被回收后由它重新拉起长轮询循环)\nconst RELAY_LONGPOLL_MS = 15000; // 单次长轮询挂起时长(Worker 端上限 15 秒)\nconst RELAY_GAP_MS = 200; // 两轮长轮询之间的间隔\nconst RELAY_BURST_ROUNDS = 24; // 单次突发最多跑几轮长轮询(防止无限循环/异常时死循环)\nconst RELAY_BURST_IDLE = 6; // 连续几轮没接到任务就结束本次突发(6×15s≈90s 空转覆盖)\nconst RELAY_MAX_JOBS_PER_ROUND = 2; // 单轮最多执行几个中继任务(单飞执行,多了会集体超时)\nconst RELAY_FETCH_TIMEOUT_MS = 30000; // 页面内单次 fetch 的超时(52pojie / 糊涂鳄这类慢站需要更久)\n// 第一次尝试用较短超时:普通站点几百毫秒就回来了,只有「后台标签里反爬挑战跑不动」的站点会超时。\n// 超时后把标签激活到前台再试一次(见 executeRelayJob)。\nconst RELAY_FIRST_TRY_MS = 12000;\nconst RELAY_ACTIVATE_WAIT_MS = 1200; // 激活前台后等页面里的挑战/定时脚本醒过来\n// 任务级硬超时:超时也要回传,避免任务永久卡在 pending。\n// 【必须能装下「首次试探 + 激活前台 + 前台重试」】—— 否则第二次明明快要拿到响应了,\n// 也会被硬超时掐掉。线上就是这么坏的:45 秒的硬超时把 12 + 1.2 + 30 的重试链条掐死,\n// 面板上永远只看到「中继执行超时」,而那个 POST 其实已经发出去了(站点可能已签到)。\n// 所以下面的值用公式算,改上面任何一个常量都不会再踩这个坑。\nconst RELAY_JOB_TIMEOUT_MS = RELAY_FIRST_TRY_MS + RELAY_ACTIVATE_WAIT_MS + RELAY_FETCH_TIMEOUT_MS + 15000; // = 58.2s\n// 单轮「浏览器签到任务」的总预算:Service Worker 随时可能被浏览器回收,\n// 串着做太久会做到一半被掐掉;用完了剩下的留给下一分钟(面板每分钟都会再下发)。\nconst JOBS_BUDGET_MS = 90 * 1000;\n\n// ---------- 配置:面板地址 + API Key ----------\n//\n// 【为什么 Key 不放 storage.sync】sync 里的内容会跟着浏览器账号同步到云端,\n// 而这把 API Key 等于「让面板远程指挥你这台机器开标签页、用你的 Cookie 发请求」的钥匙,\n// 属于凭据,只应该留在本机(storage.local)。\n// 老版本(≤ 2.3)把它写在 sync:这里读不到本地就回退读一次 sync,\n// 并把它搬到 local、再把云端那份删掉(迁移只做一次)。\nfunction localArea() {\n try {\n const l = chrome.storage && chrome.storage.local;\n if (l && typeof l.get === 'function' && typeof l.set === 'function') return l;\n } catch { /* 忽略:受限环境(测试台架)没有 local,回退 sync */ }\n return chrome.storage.sync;\n}\n\nasync function readStore(keys) {\n try { return (await localArea().get(keys)) || {}; } catch { return {}; }\n}\n\nasync function writeStore(obj) {\n try { await localArea().set(obj); return true; } catch { return false; }\n}\n\n// 把 Key 从 sync 迁到 local(本地已经有就不用管)\nasync function migrateApiKeyFromSync(got) {\n const have = got || {};\n if (have.apiKey) return have;\n try {\n const s = await chrome.storage.sync.get(['apiKey']);\n if (s && s.apiKey) {\n await writeStore({ apiKey: s.apiKey });\n try { if (typeof chrome.storage.sync.remove === 'function') await chrome.storage.sync.remove(['apiKey']); } catch { /* 忽略 */ }\n return { ...have, apiKey: s.apiKey };\n }\n } catch { /* 忽略 */ }\n return have;\n}\n\n// 面板地址必须是合法的 http(s) 地址。\n// 凭据(Cookie / API Key)都要发到这儿,地址不合法就当作「没配置」——\n// 否则等于把一个未知地址当成面板,把用户 Cookie 全送过去。\nfunction normalizePanelUrl(raw) {\n const s = String(raw || '').trim().replace(/\\/+$/, '');\n if (!s) return '';\n try {\n const u = new URL(s);\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';\n const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\\/+$/, '') : '';\n return u.origin + path;\n } catch {\n return '';\n }\n}\n\n// 获取面板地址和 API Key\nasync function getConfig() {\n const got = await migrateApiKeyFromSync(await readStore(['panelUrl', 'apiKey']));\n // 面板地址:已保存 > 下载时注入的默认值(两者都要过合法性校验)\n let url = normalizePanelUrl(got.panelUrl);\n if (!url && typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http')) {\n url = normalizePanelUrl(DEFAULT_PANEL_URL);\n }\n return { panelUrl: url, apiKey: String(got.apiKey || '').trim() };\n}\n\n// 从面板获取待执行任务\nasync function fetchJobs(panelUrl, apiKey) {\n const resp = await fetch(panelUrl + '/api/external/browser-jobs', {\n headers: { 'X-Api-Key': apiKey },\n });\n if (!resp.ok) throw new Error('获取任务失败:HTTP ' + resp.status);\n const data = await resp.json();\n return data.jobs || [];\n}\n\n// ---------- 汇报:带重试 + 失败暂存 ----------\n//\n// 为什么必须重试:签到是**写操作**,一次网络抖动就丢掉汇报,\n// 面板上那一行会永远停在「待确认」,用户根本不知道到底签上没有。\n// 4xx(Key 错 / 参数错)不重试 —— 重试多少次结果都一样;5xx 和网络错误共试 3 次。\nconst PENDING_REPORTS_KEY = 'pendingReports';\nconst PENDING_REPORTS_MAX = 20;\n\nasync function postWithRetry(panelUrl, apiKey, path, body, attempts = 3) {\n let last = '';\n for (let i = 0; i < attempts; i++) {\n try {\n const resp = await fetch(panelUrl + path, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify(body),\n });\n if (resp.ok) return { ok: true };\n last = 'HTTP ' + resp.status;\n if (resp.status >= 400 && resp.status < 500) break;\n } catch (e) {\n last = String((e && e.message) || e);\n }\n if (i < attempts - 1) await sleep(600 * (i + 1));\n }\n return { ok: false, error: last };\n}\n\n// 送不出去的结果先攒着,下一轮连上就补报(最多 20 条,避免无限堆积)\nasync function queuePendingReport(body) {\n try {\n const got = await readStore([PENDING_REPORTS_KEY]);\n const list = Array.isArray(got[PENDING_REPORTS_KEY]) ? got[PENDING_REPORTS_KEY] : [];\n list.push({ ...body, at: Date.now() });\n await writeStore({ [PENDING_REPORTS_KEY]: list.slice(-PENDING_REPORTS_MAX) });\n } catch { /* 忽略 */ }\n}\n\n// 补报上一轮没送出去的结果(每条只补一次,还失败就继续留着)\nasync function flushPendingReports(panelUrl, apiKey) {\n let list = [];\n try {\n const got = await readStore([PENDING_REPORTS_KEY]);\n list = Array.isArray(got[PENDING_REPORTS_KEY]) ? got[PENDING_REPORTS_KEY] : [];\n } catch { list = []; }\n if (!list.length) return 0;\n const left = [];\n let sent = 0;\n for (const item of list) {\n const body = {\n account_id: item.account_id,\n status: item.status,\n message: item.message,\n detail: item.detail || '',\n duration_ms: item.duration_ms || 0,\n };\n const r = await postWithRetry(panelUrl, apiKey, '/api/external/report', body, 1);\n if (r.ok) sent++; else left.push(item);\n }\n await writeStore({ [PENDING_REPORTS_KEY]: left.slice(-PENDING_REPORTS_MAX) });\n if (sent) console.log('[签到面板] 补报了 ' + sent + ' 条之前没送出去的结果');\n return sent;\n}\n\n// 上报结果到面板(失败会重试,仍失败则暂存等下一轮补报)\nasync function reportResult(panelUrl, apiKey, accountId, status, message, durationMs) {\n const body = { account_id: accountId, status, message, detail: '', duration_ms: durationMs || 0 };\n const r = await postWithRetry(panelUrl, apiKey, '/api/external/report', body);\n if (!r.ok) {\n console.log('[签到面板] 上报失败(已暂存,稍后补报):' + r.error);\n await queuePendingReport(body);\n }\n return r.ok;\n}\n\n// 原生页面巡检:不依赖 eval / new Function(MV3 已禁止),用于读取网站真实反馈。\n// 返回 { ok, message, unsigned?, href? } 或 null。\n//\n// 判定顺序很关键(都是踩过的坑):\n// ① 验证码/WAF 优先 —— 这些页面里也夹带「签到」字样(但**只认真实挑战证据**,见下面)\n// ② 再有「已验证签到」的正信号(Discuz 任务:「签到完毕 / 您已完成过此任务 / 恭喜…获得 N 吾爱币」)\n// ③ 才看「去签到链接」还在不在:\n// 论坛(尤其吾爱破解)把当天状态画在图片里(qds.png),文本节点是空的,\n// 但**链接还在 = 服务端认为今天还没签**;签完之后链接就没了。\n// 注意:必须**限定在链接自己所在的那一行**找「任务/签到」字样 —— 任务列表页里\n// 还有其他任务(验证邮箱等)也带 apply 链接,拿整页搜会搞错。\nasync function inspectPage(tabId) {\n const results = await chrome.scripting.executeScript({\n target: { tabId },\n func: () => {\n const title = document.title || '';\n const bodyText = (document.body && document.body.innerText) || '';\n const html = document.documentElement ? document.documentElement.innerHTML : '';\n const all = bodyText + '\\n' + html;\n const compact = bodyText.replace(/\\s+/g, ' ').trim();\n\n // 1) 人机验证 / 验证码 / WAF:优先判定——这些页面里往往也夹带「签到」字样。\n //\n // ★ 2026-09-28 线上踩坑(「吾爱明明已登录、今天还没签到,面板却报人机验证」的唯一原因):\n // 以前是拿「整页 HTML」去搜一批中文词,其中包含光秃秃的「验证码」。而 52pojie\n // **每一个页面**顶栏都有那条公告链接:\n // title=\"修复各种访问不了论坛、访问论坛异常、验证码异常等各种问题的大杀器!\"\n // 这几个字只存在于 title 属性里、人眼根本看不见,却让浏览器签到 100% 误判成\n // 「遇到人机验证/安全验证」,于是面板上永远只有一句「需人工验证后重试」。\n // 现在的规矩:**中文词只搜可见文本(innerText),技术指纹才搜 HTML。**\n const visibleText = bodyText.replace(/\\s+/g, ' ');\n // ① 只有真正的挑战页才会出现的**可见**文案(正常论坛页不会在正文里说这些)\n const CHALLENGE_TEXT = ['请完成安全验证', '完成安全验证', '人机验证', '滑动验证', '拖动滑块', '请拖动滑块', '智能验证', '请完成验证', '安全验证失败', '正在检查您的浏览器', '检查浏览器中', '请输入验证码', '请填写验证码', '验证码不正确', '验证码错误'];\n // ② 技术指纹:挑战脚本 / 挂载点(不算「验证码」这种正文里也可能出现的词)\n const CHALLENGE_SIG = ['wzws-waf-cgi', 'slidercaptcha', 'geetest', 'grecaptcha', 'hcaptcha', 'turnstile', 'challenge-platform', 'cf_chl', 'Just a moment', 'checking your browser', 'turing.captcha.qcloud.com', 'TCaptcha', 'CaptchaAId'];\n // ③ 挑战控件:真的有验证码输入框 / 滑块 iframe 才算(52pojie 正常页面上一个都查不到,已实测)\n const CHALLENGE_DOM = '[class*=geetest],[id*=geetest],[class*=slidercaptcha],[id*=slidercaptcha],[class*=turnstile],[id*=turnstile],iframe[src*=captcha],iframe[src*=geetest],#captcha,[id*=captcha],.verifyimg,input[name*=seccode],input[id*=seccode]';\n // ⓪ 网宿 WZWS 挑战页(实测 2026-09-28:吾爱破解的 /home.php* 一律先回这一页):\n // 它是**纯 JS 自动过**的 —— 浏览器把页内那段混淆脚本跑完、POST /waf_zw_verify\n // 拿到新的 wzws_sid 就自动继续,**不需要人点任何东西**。\n // 所以不能归到「请人工过验证」那一类:那样只会把人引向一个根本不存在的滑块。\n // 如实说明,并指出去读首页就能拿到真状态。\n const wzwsHit = (html.match(/wzws-waf-cgi|wzwsquestion|WZWS_CONFIRM_PREFIX_LABEL|\\/waf_zw_verify/) || [''])[0];\n if (wzwsHit) {\n return { ok: false, waf: 'wzws',\n message: '这一页是网宿 WAF(WZWS)的 JS 挑战页,浏览器会自动过掉,不需要人工操作|命中:' + wzwsHit\n + '|说明:/home.php 这类动态地址首次访问都会先回它,跑完 JS 就继续(别把它当成签到结果)|想直接看当天状态可以读首页|页面:' + title };\n }\n const challengeText = CHALLENGE_TEXT.find((m) => visibleText.includes(m)) || '';\n const challengeSig = CHALLENGE_SIG.find((m) => html.includes(m)) || '';\n let challengeDom = '';\n try { challengeDom = document.querySelector(CHALLENGE_DOM) ? CHALLENGE_DOM : ''; } catch { challengeDom = ''; }\n // 挑战页还有一个共同点:正文几乎空白(只有一句提示 + 一个转圈)。\n // 用它兜住「没有明显文案的挑战页」(如网宿 WZWS),同时不会误伤信息量正常的论坛页。\n const blankPage = visibleText.replace(/\\s+/g, '').length < 200;\n const sigFired = !!(challengeSig && blankPage);\n const challengeHit = challengeDom || challengeText || (sigFired ? challengeSig : '');\n if (challengeHit) {\n const why = [challengeDom && '验证控件', challengeText && '挑战文案', sigFired && '挑战脚本'].filter(Boolean).join('+');\n return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|命中:' + why + '(' + challengeHit.slice(0, 48) + ')|页面:' + title };\n }\n const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'Access Denied', '403 Forbidden', '请求被拦截'];\n if (WAF.some((m) => all.includes(m))) {\n return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };\n }\n\n // ---- 找「去签到」入口(Discuz 系论坛通用)----\n // 已签到的视觉特征:文字或图片里直接写着已签/已打卡/连续签到\n const ALREADY = /已签到|已经签到|已打卡|签到完毕|签到完成|连续签到|今日已签|明天再来|下期再来/;\n // 未签到的视觉特征:文字\n const SIGN_TEXT = /打卡签到|立即签到|点击签到|点击打卡|签到领奖|每日签到|每日打卡|签个到|去签到|未打卡|没有签到|还没有签到|今天还没有签到|今天还没有|立即打卡/;\n // 未签到的视觉特征:图片(各 Discuz 签到插件用的图)\n const SIGN_IMG = /qds\\.png|signin_no\\.png|pperwb\\.gif|wb\\.png|qiandao|qdbg|sign_?in/i;\n // 已知的签到按钮 id / class(来自各社区签到脚本:discuz 任务、dsu_paulsign、k_misign、zqlj_sign 等)\n const SIGN_SEL = '#kx,#JD_sign,#addsign,#sg_sign,#dcsignin_tips,#tt_sign,.punch_btn,.go-user-qiandao,a.initiate-checkin,#my_amupper,#pper_a,#sign_title,#setsign,.click-qiandao,.zzhuti_qd_1,.user-index-qd,.taskbtn';\n\n const attrOf = (el) => ((el.getAttribute && el.getAttribute('href') || '') + ' ' +\n (el.id || '') + ' ' + (el.className || '') + ' ' + (el.getAttribute && el.getAttribute('title') || '')).replace(/\\s+/g, ' ');\n const textOf = (el) => String(el.innerText || el.textContent || '').replace(/\\s+/g, '');\n const imgsOf = (el) => Array.from(el.querySelectorAll ? el.querySelectorAll('img') : [])\n .concat(el.tagName === 'IMG' ? [el] : [])\n .map((i) => (i.getAttribute('src') || '') + ' ' + (i.getAttribute('alt') || '')).join(' ');\n\n // 找到入口元素时**当场给它打个标记**,并把选择器随结果一起返回。\n // 为什么不在外面再 querySelector 一次:入口往往是「图片包在 里」这种奇怪结构\n // (吾爱首页:``),\n // 外面按通用选择器重找很可能落到另一个元素上 —— 点了等于没点。\n const PANEL_SIGN_SEL = '[data-panel-sign=\"1\"]';\n const markEl = (el) => {\n try { if (el && el.setAttribute) { el.setAttribute('data-panel-sign', '1'); return PANEL_SIGN_SEL; } } catch (e) { /* 注入环境可能没有 DOM */ }\n return '';\n };\n const findSignLink = () => {\n // ⓪ 站点自己的「打卡签到」入口优先:Discuz 顶部用户菜单里的\n // \n // 社区脚本用的就是这个选择器(XIU2 的「吾爱破解论坛增强」:\n // `#um a[href^=\"home.php?mod=task&do=apply&id=2\"]`)。\n // 为什么先看它:这个入口在**每个页面**都有(首页/版块页/任务页),签完就消失,\n // 所以「它还在不在」是**页面无关**的服务端状态 —— 也才能拿首页当复查页。\n let umLink = null;\n try {\n umLink = document.querySelector('#um a[href*=\"do=apply\"], #um a[href*=\"do=draw\"], #um .click-qiandao, #um .qq_bind');\n } catch { umLink = null; }\n if (umLink) {\n const anchor = (umLink.tagName === 'IMG' && umLink.closest) ? (umLink.closest('a') || umLink) : umLink;\n const own = textOf(anchor) + ' ' + attrOf(anchor) + ' ' + imgsOf(anchor);\n const href = (anchor.getAttribute && anchor.getAttribute('href')) || '';\n if (!ALREADY.test(textOf(anchor)) && !/wbs\\.png/i.test(own)) {\n const row = (anchor.closest && (anchor.closest('li') || anchor.closest('tr'))) || null;\n const rowTxt = ((row && row.innerText) || '').replace(/\\s+/g, ' ').slice(0, 200);\n const rowDone = /已完成|已申请|已领取|已打卡/.test(rowTxt) && !/立即申请|马上申请|申请任务/.test(rowTxt);\n if (rowDone) return { href, label: (textOf(anchor) || '打卡签到'), done: true };\n if (href) return { href, clickSel: markEl(anchor), label: (textOf(anchor) || '打卡签到'), done: false };\n }\n }\n\n // ① Discuz 任务申请链接(吾爱破解这类:mod=task&do=apply&id=N)\n const applies = Array.from(document.querySelectorAll('a[href*=\"do=apply\"], a[href*=\"do=draw\"]'))\n .filter((a) => /mod=task/i.test(a.getAttribute('href') || ''));\n for (const a of applies) {\n const own = textOf(a) + ' ' + attrOf(a) + ' ' + imgsOf(a);\n if (ALREADY.test(textOf(a))) continue;\n const row = a.closest('tr') || a.closest('li') || a.closest('table') || a.parentElement;\n const rowTxt = ((row && row.innerText) || '').replace(/\\s+/g, ' ').slice(0, 200);\n // 任务列表里那个「已完成」是隔壁任务的,必须限定在链接所在行\n const rowDone = /已完成|已申请|已领取|已打卡/.test(rowTxt) && !/立即申请|马上申请|申请任务/.test(rowTxt);\n if (rowDone) return { href: a.getAttribute('href') || '', label: (textOf(a) || '签到'), done: true };\n if (/qds\\.png|打卡|每日|签到/i.test(own + ' ' + rowTxt) || applies.length === 1) {\n return { href: a.getAttribute('href') || '', clickSel: markEl(a), label: (textOf(a) || '打卡签到'), done: false };\n }\n }\n // ② 已知的签到按钮(可能是 ,也可能是 JS 点击的 /
/)\n const known = Array.from(document.querySelectorAll(SIGN_SEL));\n for (const el of known) {\n const own = textOf(el) + ' ' + attrOf(el) + ' ' + imgsOf(el);\n if (ALREADY.test(own)) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n const usable = href && !/^javascript:void/i.test(href) ? href : '';\n return { href: usable, clickSel: true, label: (textOf(el) || '签到'), done: false };\n }\n // ③ 兜底:页面上写着「打卡签到 / 每日签到」的可点元素\n const cands = Array.from(document.querySelectorAll('a,button,[onclick],img'));\n for (const el of cands) {\n const t = textOf(el);\n const im = imgsOf(el);\n if (ALREADY.test(t + ' ' + im)) continue;\n const hit = SIGN_TEXT.test(t) || SIGN_IMG.test(im);\n if (!hit) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n if (!href && !el.onclick && !el.getAttribute('onclick')) continue; // 纯展示元素,点了也没用\n return { href: /^javascript:/i.test(href) ? '' : href, clickSel: true, label: (t || '签到'), done: false };\n }\n return null;\n };\n const markForClick = (res) => {\n // findSignLink 已经在入口元素上打过标记了(图片版入口那种),直接用那一个\n try { if (document.querySelector(PANEL_SIGN_SEL)) return PANEL_SIGN_SEL; } catch (e) { /* 无 DOM */ }\n if (!res || !res.clickSel) return '';\n const el = document.querySelector(SIGN_SEL) || (SIGN_TEXT.test(compact) ? document.querySelector('a[href*=\"do=apply\"]') : null);\n if (!el) return '';\n el.setAttribute('data-panel-sign', '1');\n return '[data-panel-sign=\"1\"]';\n };\n\n // 2) 「已完成」的强信号(Discuz:您已完成过此任务 / 今日已签到 / 恭喜…获得…)\n const SIGNED = /签到完毕|签到完成|您已完成过此任务|您已经完成此任务|您今日已经签到|您今天已经签到|今天已经签到|已经签到过|已经完成签到|今日任务已完成|今日已签到|今天已完成签到|已连续签到|下期再来|明天再来|无需重复|恭喜.{0,14}(完成|获得|领到|签到)/;\n // 成功信号:任务页直接告诉用户拿到了什么\n const SUCCESS = /任务已完成|签到成功|打卡成功|领取成功|成功领取|获得.{0,10}(鸡腿|积分|金币|铜币|银币|吾爱币|AK|币|空间|MB|GB)/;\n // 注入环境可能没有 DOM API(部分页面/测试台架):退回到纯文本 + 正则,\n // 只认「do=apply 链接 + 签到图/签到字样」这个最硬的组合。\n let link = null;\n try { link = findSignLink(); } catch (e) { link = null; }\n if (!link) {\n const am = all.match(/]*href=[\"']([^\"']*mod=task[^\"']*do=(?:apply|draw)[^\"']*)[\"'][^>]*>/i);\n if (am && /qds\\.png|打卡签到|每日签到|每日打卡|签到完毕/i.test(all) && !/已完成过此任务|您已经完成此任务/.test(all)) {\n link = { href: am[1].replace(/&/g, '&'), label: '打卡签到', done: false };\n }\n }\n if (SUCCESS.test(all)) {\n return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };\n }\n // 强信号优先于「还挂着链接」:例如任务列表里签到那条已「已完成」,\n // 而顶栏还挂着别的任务的 apply 链接\n if (SIGNED.test(all)) return { ok: true, message: '今日已签到,无需重复' };\n if (link && link.done) return { ok: true, message: '今日已签到(任务页显示:已完成)' };\n // 3.5) 「签到完毕」图标 wbs.png —— 社区脚本(XIU2 的吾爱增强、lyc8503 的签到脚本)\n // 都拿它当「今天已经签过」的判据:站点靠换图标表示状态(qds.png 未签 / wbs.png 已签),\n // 文字节点是空的。图标名很特殊,不会在正文里乱出现;\n // 放在「还挂着入口」之前判断,避免因为入口链接还在而误判为未签。\n if (/wbs\\.png/i.test(all)) return { ok: true, message: '今日已签到,无需重复(网站图标:签到完毕 wbs.png)' };\n // 4) 未登录(Discuz 游客页会写「注册[Register]」)—— 比「未签到」更优先:\n // 登录失效时页面导航里也有「每日签到」这类字样,不能把它当成“还没签到”。\n // ★ 2026-09-28 实测抓到的第二个「拿 HTML 搜中文」的坑,和上次那个「验证码异常」公告一模一样:\n // Discuz 每个页面(**包括登录态首页**)都带一段隐藏的快捷登录菜单:\n //
请 登录 后使用快捷导航…注册[Register]
\n // 它只存在于 HTML 里(innerText 里根本没有,因为它 display:none)。\n // 以前这条判定搜的是 `all = 可见文本 + 整页 HTML`,于是**登录得好好的首页也会被判成\n // 「登录已失效,请重新获取 Cookie」**。改用可见文本(innerText):\n // 真掉登录态时,游客页顶部那个登录表单是**看得见**的,仍然能判出来。\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\\[Register\\]|立即注册|登录后才能/.test(visibleText)) {\n return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n }\n // 页面上写着「还没签到」却找不到可点的入口(插件式签到页、被遮挡的按钮):\n // 如实报未签到,并说清要人工处理,不要吐一句“未识别到成功标识”。\n if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡|qds\\.png/.test(all)) {\n return { ok: false, unsigned: true, href: '', clickSel: '',\n message: '网站显示还没签到(打卡入口/图标 qds.png 还在),但页面上找不到可点的签到入口' };\n }\n if (link) {\n let clickSel = '';\n try { clickSel = markForClick(link); } catch (e) { clickSel = ''; }\n return {\n ok: false, unsigned: true, href: link.href || '', clickSel,\n message: '网站还挂着「' + (link.label || '签到') + '」入口 —— 今天还没签上',\n };\n }\n return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };\n },\n });\n return (results && results[0] && results[0].result) || null;\n}\n\n// 校验一个「站点自己给的绝对地址」(navigate_url / sign_url / verify_urls 都走它):\n// 只允许 http(s),且必须落在目标任务域上(本域、子域,或反过来域名是它的子域)。\n// 这些地址全部来自面板下发的配置,同样是不可信输入 —— 不能拿用户的标签页去跳任意站点。\n// 把面板给的 Cookie 写进浏览器、并让「浏览器签到」这条路真的能走通。\n//\n// 为什么需要它:吾爱破解这类站点,签到动作必须由浏览器亲自发出(WAF 只放行真实页面导航),\n// 而服务端认的是**浏览器 cookie jar 里的那个登录态**。浏览器没登录时,这条路一步都走不下去;\n// 面板里又恰好存着这份登录态(当初就是扩展从浏览器读出去交给面板的),所以由面板把它交回来最顺。\n// 于是「面板点一下 → 自动签到」才是完整的闭环,不需要人先去浏览器登录一遍。\n// ============ 「自动过的挑战」要等它过完,别急着下结论 ============\n//\n// 实测(2026-09-28,吾爱破解):/home.php?mod=task 与 /home.php?mod=task&do=apply&id=2\n// 无论带不带 Cookie、带不带 Referer,都先回一张网宿 WZWS 的 JS 挑战页。\n// 这张页子是**纯 JS 自动过**的:浏览器跑完脚本(POST /waf_zw_verify 换一张新票)会自动\n// 继续到真页面,全程不需要人点任何东西。\n//\n// 所以「点完入口立刻看一眼」看到的往往还是挑战页 —— 在那里就报结论,等于把一次\n// 已经成功的签到判成失败(或者反过来让人去找一个根本不存在的滑块,以前就这么干过)。\n// 这里的做法:看到这种页子就等一会儿再看,最多等 AUTO_PASS_TRIES 轮。\n// 真的等不到才如实说「等了 N 秒仍未跳转」,并把耗时写进结论里。\nconst AUTO_PASS_WAIT_MS = 2000;\nconst AUTO_PASS_TRIES = 5; // 最长约 10 秒\n\nasync function waitAutoPassedChallenge(tabId, first) {\n let cur = first;\n let waited = 0;\n for (let i = 0; i < AUTO_PASS_TRIES; i++) {\n if (!cur || cur.ok || cur.waf !== 'wzws') break;\n await new Promise((r) => setTimeout(r, AUTO_PASS_WAIT_MS));\n waited += AUTO_PASS_WAIT_MS;\n const again = await inspectPage(tabId).catch(() => null);\n if (!again) break;\n cur = again;\n }\n if (cur && cur.waf === 'wzws' && waited) {\n cur = Object.assign({}, cur, {\n message: String(cur.message || '') + `(已等 ${Math.round(waited / 1000)} 秒仍未跳转,说明这次挑战没能自动过掉)`,\n });\n }\n return cur;\n}\n\n//\n// 这段能力很重(写 cookie = 改这个站的登录身份),所以自己再守一道:\n// ① 只写给任务声明的那个域(job.domain,已经过 sameSiteHttpUrl 同域校验);\n// ② 只写面板给的那些名值,不读回、不上报、**日志里不打值**;\n// ③ 明确跳过 WAF/CF 自己的会话票(wzws_sid / cf_clearance …):\n// 那张票由浏览器现场过一遍挑战拿新的才对,灌一个旧的进去反而自讨苦吃;\n// ④ 单条写失败不影响其他,整体失败也不阻断签到流程(就当没注入,如实记日志)。\n// 写之前先看一眼浏览器里已有什么。三种情况:\n// none —— 没登录过/已清干净 → 该写(这正是我们要修的主场景)\n// same —— 已经有同名同值的凭据(同一个会话)→ **不用写**(浏览器自己那份就是对的)\n// diff —— 有同名的但值不一样:浏览器登录的是**另一个会话**,很可能是另一个账号。\n// 这时候绝对不能硬写:写下去会把人家在浏览器里的登录顶掉;更坏的是,\n// 如果不写而直接用浏览器那个会话去点签到,就会「用 B 的登录态签上,面板上记成 A 成功」\n// —— 那就成了假状态。宁可不签,也要把话说清楚。\nasync function existingAuthState(host, cookieStr) {\n const SENSITIVE = /(_auth$|_auth\\b|saltkey|^session|sessionid|passport|pwd)/i;\n if (!chrome.cookies || typeof chrome.cookies.get !== 'function' || !host || !cookieStr) return 'none';\n let same = false;\n for (const part of String(cookieStr).split(';')) {\n const i = part.indexOf('=');\n if (i <= 0) continue;\n const name = part.slice(0, i).trim();\n const value = part.slice(i + 1).trim();\n if (!SENSITIVE.test(name)) continue;\n let cur = null;\n try { cur = await chrome.cookies.get({ url: 'https://' + host + '/', name }); } catch { cur = null; }\n if (!cur || typeof cur.value !== 'string') continue;\n if (cur.value !== value) return 'diff';\n same = true;\n }\n return same ? 'same' : 'none';\n}\n\n// ============ 会话续命(alist 式):把站点轮换后的新 Cookie 回写面板 ============\n//\n// alist 对网盘 token 的做法:请求过程中令牌被服务端轮换(refresh token rotation),\n// 就把新令牌当场写回存储,而不是等它过期后人工重新填。我们这里同理:\n// 签到过程本身会让站点下发新会话(Set-Cookie 换发登录名、WAF 换票),\n// 签完还把旧值留在面板里,等于让凭据「计划性报废」——过几天必然「登录已失效」。\n//\n// 规矩(和服务端 /api/external/creds-rotation 配套):\n// ① 只在「任务确实注入过凭据」的站点上做(浏览器里那份就是面板给的同一会话,才谈得上轮换);\n// ② 只把「登录名类」Cookie 的**值有变化**的条目报上去,整包不变就不发请求(省 D1 配额);\n// ③ 面板还会再做同域 + 内容两道校验,这里被拒就打日志,绝不重试(不是网络问题)。\nconst ROTATION_SENSITIVE = /(_auth$|_auth\b|saltkey|^session|sessionid|passport|pwd|token)/i;\n\n// 采集某域名下浏览器 cookie jar 里的「登录名类」条目(名 -> 值)\nasync function collectAuthCookies(host) {\n const out = new Map();\n if (!chrome.cookies || typeof chrome.cookies.getAll !== 'function' || !host) return out;\n let list = [];\n try { list = await chrome.cookies.getAll({ domain: host }) || []; } catch { list = []; }\n // 与 collectCookies(popup)同一套兜底:url 查询能带上父域 Cookie\n try {\n const more = await chrome.cookies.getAll({ url: 'https://' + host + '/' }) || [];\n for (const c of more) if (c && c.name && !out.has(c.name)) out.set(c.name, c);\n } catch { /* 忽略 */ }\n for (const c of list) if (c && c.name) out.set(c.name, c);\n for (const [name, c] of Array.from(out)) {\n if (!ROTATION_SENSITIVE.test(name)) out.delete(name);\n else out.set(name, String(c.value || ''));\n }\n return out;\n}\n\n// 与面板库里的旧值比对:只有「同名不同值」才算轮换。返回 null 或 { changed: 'a=..; b=..' }\nfunction diffRotated(newMap, oldCookieStr) {\n const oldMap = new Map();\n for (const part of String(oldCookieStr || '').split(';')) {\n const i = part.indexOf('=');\n if (i > 0) oldMap.set(part.slice(0, i).trim(), part.slice(i + 1).trim());\n }\n const changed = [];\n for (const [name, val] of newMap) {\n const old = oldMap.get(name);\n if (old !== undefined && old !== val && val) changed.push(name + '=' + val);\n }\n return changed.length ? { changed: changed.join('; ') } : null;\n}\n\n// 签到结束后调用:发现轮换就回写面板(失败只打日志,不影响签到结果本身)\nasync function reportCookieRotation(panelUrl, apiKey, job, host) {\n try {\n if (!panelUrl || !apiKey || !job || !job.inject_cookies || !job.cookie) return;\n const fresh = await collectAuthCookies(host);\n if (!fresh.size) return;\n const d = diffRotated(fresh, job.cookie);\n if (!d) return; // 没变化,一个请求都不发\n const resp = await fetch(panelUrl + '/api/external/creds-rotation', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify({ account_id: job.account_id, domain: 'https://' + host + '/', cookies: d.changed }),\n });\n if (resp.ok) {\n const n = d.changed.split(';').length;\n console.log('[签到面板] 检测到站点轮换了登录 Cookie,已把 ' + n + ' 条新值回写面板(' + host + ')');\n } else {\n console.log('[签到面板] Cookie 回写被面板拒绝(HTTP ' + resp.status + '),不影响签到结果');\n }\n } catch (e) {\n console.log('[签到面板] Cookie 回写失败(不影响签到结果):', String((e && e.message) || e).slice(0, 120));\n }\n}\n\nasync function injectCookies(domain, cookieStr) {\n const host = String(domain || '').replace(/^\\./, '').trim().toLowerCase();\n if (!host || !cookieStr) return 0;\n if (!chrome.cookies || typeof chrome.cookies.set !== 'function') return 0; // 老浏览器没有这个能力\n const SKIP = /^(wzws_sid|__jsl|cf_clearance|__cf)/i;\n // 登录凭据类写成 HttpOnly(站点自己也是这么下的);其余保持可读,免得站点 JS 读不到自己的值\n const SENSITIVE = /(_auth$|_auth\\b|saltkey|^session|sessionid|passport|pwd)/i;\n let n = 0;\n for (const part of String(cookieStr).split(';')) {\n const i = part.indexOf('=');\n if (i <= 0) continue;\n const name = part.slice(0, i).trim();\n const value = part.slice(i + 1).trim();\n if (!name || SKIP.test(name)) continue;\n try {\n await chrome.cookies.set({\n url: 'https://' + host + '/', name, value, path: '/',\n secure: true, sameSite: 'lax', httpOnly: SENSITIVE.test(name),\n });\n n++;\n } catch (e) { /* 单个 cookie 写失败(如名字非法)不影响其它 */ }\n }\n return n;\n}\n\nfunction sameSiteHttpUrl(raw, domain) {\n try {\n const u = new URL(String(raw || ''));\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';\n const d = String(domain || '').trim().toLowerCase();\n if (!d) return u.href; // 站点没声明的(如自定义站点)不额外限制\n const h = u.hostname.toLowerCase();\n if (h === d || h.endsWith('.' + d) || d.endsWith('.' + h)) return u.href;\n return '';\n } catch {\n return '';\n }\n}\n\n// 把页面里找到的「去签到」链接解析成绝对地址,并做同源校验。\n// 页面内容不可信:只允许跳到当前站点的同源地址(http/https + 同 host)。\nfunction resolveSignHref(result, tab) {\n const raw = result && result.href;\n if (!raw) return '';\n try {\n const base = (tab && tab.url) || ('https://' + (tab && tab.pendingUrl ? '' : ''));\n const u = new URL(raw, base || undefined);\n const cur = tab && tab.url ? new URL(tab.url) : null;\n if (cur && u.host !== cur.host) return '';\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';\n return u.href;\n } catch {\n return '';\n }\n}\n\n// 等待标签页加载完成(带超时),用于导航后复查\nfunction waitTabComplete(tabId, timeoutMs) {\n return new Promise((resolve) => {\n const listener = (id, info) => {\n if (id === tabId && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs || 20000);\n });\n}\n\n// ============ 「只动我们自己的标签页」 ============\n//\n// 浏览器签到需要目标站点的一个标签页来发请求/点按钮。旧做法是「优先复用同域名下已存在的标签页」,\n// 于是:用户正在看 52pojie 的那个标签页被扩展拿去导航来导航去(任务页 → 签到链接 → 再读一遍),\n// 用户看到的就是「浏览器签到乱跳」,甚至自己正在读的页面被改掉。\n//\n// 现在的规矩:**用户的标签页一律不碰**(不看、不导航、不关闭),只用我们自己开的标签页;\n// 同一个域名只开**一个**,后续请求复用,空闲一段时间后自动关掉(见 RELAY_TAB_IDLE_MS)。\n// 新建的标签页用 active:false(后台打开),不抢焦点。\nconst ownTabs = new Map(); // tabId -> { domain, at }\nconst OWN_TAB_TTL_MS = 10 * 60 * 1000;\n// 中继专用标签页的空闲回收时间:比上面短得多。\n// 一次签到(比如糊涂鳄)要打好几个请求(首页 → 主题 JS → 签到接口),\n// 以前**每个请求**都开一个标签页、用完立刻关 —— 用户看到的就是「浏览器一直在新建页面」。\n// 现在改成:一个域名只开一个后台标签页,后续请求都复用它;\n// 连续 45 秒没用到才关掉(够覆盖一次签到的全部请求,又不会一直挂着)。\nconst RELAY_TAB_IDLE_MS = 45 * 1000;\n\nfunction sameDomain(url, domain) {\n try {\n const h = new URL(String(url)).hostname;\n const d = String(domain || '');\n return !!d && (h === d || h.endsWith('.' + d));\n } catch {\n return false;\n }\n}\n\nfunction rememberOwnTab(id, domain) {\n if (id == null) return;\n ownTabs.set(id, { domain: String(domain || ''), at: Date.now() });\n // 清掉过期记录。\n // 【以前只删记录、不关标签页】——一旦某个域名超过 10 分钟没再用到,它的记录会被这里悄悄丢掉,\n // 之后 sweepOwnTabs 就再也不认识那个标签页了(回收是照着 ownTabs 关的),\n // 于是我们在用户浏览器里留了一个永远没人管的标签页。现在顺手一起关掉。\n for (const [tid, info] of Array.from(ownTabs)) {\n if (tid === id || Date.now() - info.at <= OWN_TAB_TTL_MS) continue;\n ownTabs.delete(tid);\n try { chrome.tabs.remove(tid).catch(() => {}); } catch { /* 已经关了就算了 */ }\n }\n}\n\nfunction forgetOwnTab(id) {\n ownTabs.delete(id);\n}\n\n// 找一个「我们自己开的、还是这个域名」的标签页(没有就返回 null)。\n// 复用时刷新使用时间:有它在,sweepOwnTabs 才知道这个标签页还在干活。\nasync function findOwnTab(domain) {\n if (!ownTabs.size) return null;\n let tabs = [];\n try { tabs = await chrome.tabs.query({}); } catch { return null; }\n for (const t of tabs || []) {\n if (!ownTabs.has(t.id)) continue;\n if (!sameDomain(t.url || t.pendingUrl, domain)) continue;\n const info = ownTabs.get(t.id);\n if (info) info.at = Date.now();\n return t;\n }\n return null;\n}\n\n// 开一个我们自己的后台标签页(不抢焦点),并等页面加载完\nasync function openOwnTab(domain, url) {\n const tab = await chrome.tabs.create({ url: url || `https://${domain}/`, active: false });\n rememberOwnTab(tab.id, domain);\n await waitTabComplete(tab.id, 15000);\n return tab;\n}\n\n// 在目标域名的标签页中执行签到\n//\n// 【2026-09-28 安全整改:删掉了 runPanelScript】\n// 以前这里有个「执行面板下发脚本字符串」的路径(new Function(scriptStr))。\n// 两个理由把它整个删掉:\n// ① MV3 里 new Function / eval 一律被禁 —— 这条路径在真实浏览器里 100% 失败,纯死代码;\n// ② 万一哪天限制放宽,它就变成一个现成的「远程代码执行」入口:\n// 面板(或面板数据库里的站点配置)能往用户浏览器里塞任意脚本。\n// 页内判定一律走下面的原生巡检(inspectPage),不再执行任何字符串代码。\nasync function executeJob(job) {\n const startMs = Date.now();\n let tab = null;\n let created = false;\n try {\n // 与 popup 保持一致:缺域名时直接报错,避免误开 https://undefined/ 标签页\n if (!job.domain) throw new Error('任务缺少目标域名');\n // 【不碰用户的标签页】只复用我们自己开的,没有就开一个新的后台标签页。\n // 以前的「复用同域名已有标签页」会把用户正在看的页面导航走(「浏览器签到乱跳」的来源)。\n tab = await findOwnTab(job.domain);\n if (!tab) {\n tab = await openOwnTab(job.domain);\n created = true;\n }\n\n // 站点声明「需要浏览器处于登录态」时,先把面板里的登录态写回浏览器(见 injectCookies)。\n // 顺序很重要:必须在导航之前写,否则第一次巡检读到的还是未登录页。\n if (job.inject_cookies && job.cookie) {\n // 关键:写入目标取「我们真的要去访问的那个主机名」,而不是面板自称的 job.domain。\n // job.domain 同样是面板给的输入,只拿它自己跟自己比毫无意义(它说自己是哪个域都行);\n // navigate_url 过完同站校验后剩下的主机名,才是这次真实要落的域。\n const navForHost = sameSiteHttpUrl(job.navigate_url || '', job.domain);\n let host = '';\n try { host = navForHost ? new URL(navForHost).hostname.toLowerCase() : ''; } catch { host = ''; }\n const declared = String(job.domain || '').replace(/^www\\./, '').toLowerCase();\n const hostOk = host && declared && (host === declared || host.endsWith('.' + declared) || declared.endsWith('.' + host));\n if (!hostOk) {\n console.log('[签到面板] 拒绝把登录态写到非本域:要访问的是 ' + (host || '(无)') + ',声明的是 ' + (job.domain || '(无)').slice(0, 60));\n } else if ((await existingAuthState(host, job.cookie)) === 'diff') {\n // 浏览器里是另一个登录会话:不写、也不签 —— 直接把话说清楚\n // (否则很容易出现「浏览器里是 B、面板里记的是 A」的假状态)\n const msg = '浏览器里已经登录了这个网站的另一个账号或会话,为避免签错账号(以及把你在浏览器里的登录顶掉),'\n + '本次没有写回面板里的登录态、也没有执行签到。请在浏览器里退出该网站的登录(或清掉这个网站的 Cookie)后重试,'\n + '面板下次会自动把登录态写回去。';\n console.log('[签到面板] 浏览器已有另一个登录会话,跳过本次执行(不写凭据、不签到)');\n return { status: 'fail', message: msg, durationMs: Date.now() - startMs };\n } else if ((await existingAuthState(host, job.cookie)) === 'same') {\n // 浏览器里就是同一个会话(同名同值):不用写,直接用人家自己那份\n console.log('[签到面板] 浏览器里已经是同一个登录会话,不需要写回凭据');\n } else {\n const n = await injectCookies(host, job.cookie).catch(() => 0);\n console.log(n\n ? '[签到面板] 已把 ' + n + ' 条登录 Cookie 写回浏览器(' + host + ',值不记录)'\n : '[签到面板] 没有写入任何登录 Cookie(浏览器可能不支持,或面板没给)');\n }\n }\n\n // 如果任务指定了导航 URL(如吾爱破解的任务页),先导航过去(模拟手动点击)再巡检。\n // 两个要点:\n // ① navigate_url 也要过同站校验 —— 它同样来自面板下发的配置,不是我们自己写死的常量;\n // ② 用 waitTabComplete,而不是就地再抄一遍监听器 —— 老写法在「20 秒超时」那一支忘了摘监听器\n // (只 resolve、不 removeListener),任务做得越多监听器积得越多。\n if (job.navigate_url) {\n const navUrl = sameSiteHttpUrl(job.navigate_url, job.domain);\n if (navUrl) {\n await chrome.tabs.update(tab.id, { url: navUrl });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n } else {\n console.log('[签到面板] 忽略跨站/非法的导航地址:', String(job.navigate_url).slice(0, 120));\n }\n }\n\n // 执行签到:页内判定一律走原生巡检(不执行面板下发的任何脚本字符串,见上面的安全说明)。\n let result = await inspectPage(tab.id);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n // 落在 WZWS 这类「JS 自动过」的挑战页上时不能立刻下结论(见 waitAutoPassedChallenge 的说明)\n result = await waitAutoPassedChallenge(tab.id, result);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n\n // 页面还挂着「打卡签到」入口 = 服务端认为今天没签。\n // ① 是链接(吾爱破解这类 Discuz 任务)→ 导航过去,等同人手点一下;\n // ② 是 JS 按钮(dsu_paulsign / k_misign 等插件)→ 在页面里点一下。\n // 两条路都是真实用户行为,之前实测过:脚本化 fetch 会被 WAF 吊死,而导航/点击能过。\n if (!result.ok && result.unsigned) {\n const signHref = resolveSignHref(result, tab);\n try {\n // 顺序很重要:**先点页面上的真入口,点不动再退回直接导航**。\n // 点了才是「人手点一下」的样子:同源 GET、带 Referer、复用本页已经过掉的 WAF 会话。\n // 直接 chrome.tabs.update 过去等于发起一次全新的顶层请求 ——\n // 吾爱这类站点的 /home.php* 一律先回一张网宿 WZWS 挑战页(实测:带不带 Cookie、\n // 带不带 Referer 都一样),浏览器得先把挑战跑完才能落到真页面,又慢又容易被误判。\n const urlBefore = ((await chrome.tabs.get(tab.id).catch(() => null)) || {}).url || '';\n let clicked = false;\n if (result.clickSel) {\n try {\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: (sel) => { const el = document.querySelector(sel); if (el) el.click(); },\n args: [result.clickSel],\n });\n clicked = true;\n await waitTabComplete(tab.id, 15000);\n await new Promise((r) => setTimeout(r, 2000));\n } catch (e) {\n console.log('[签到面板] 页内点击失败,改为直接导航入口地址:', e.message || e);\n }\n }\n const urlAfterClick = ((await chrome.tabs.get(tab.id).catch(() => null)) || {}).url || '';\n const moved = clicked && urlAfterClick && urlAfterClick !== urlBefore;\n if (signHref && !moved) {\n // 没有可点元素,或者点了没跳(站点用 AJAX 结算时也可能不跳)—— 再按入口地址跳一次;\n // Discuz 对重复的 apply 是幂等的(第二次只会告诉你「已经领取过」)。\n await chrome.tabs.update(tab.id, { url: signHref });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n }\n const after = (signHref || result.clickSel)\n ? await waitAutoPassedChallenge(tab.id, await inspectPage(tab.id))\n : null;\n if (after) result = after;\n } catch (e) {\n console.log('[签到面板] 触发签到失败:', e.message || e);\n }\n }\n\n // 连入口都没认出来(站点改版/插件页),但面板给了「签到地址」——那是站点模块自己知道的,\n // 再试一次,别白自放弃(同源校验后仍然只跳本域)。\n if (!result.ok && result.unsigned && !result.href && !result.clickSel && job.sign_url) {\n const signUrl = sameSiteHttpUrl(job.sign_url, job.domain);\n if (!signUrl) {\n console.log('[签到面板] 忽略跨站/非法的兜底签到地址:', String(job.sign_url).slice(0, 120));\n } else {\n try {\n await chrome.tabs.update(tab.id, { url: signUrl });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n const after = await waitAutoPassedChallenge(tab.id, await inspectPage(tab.id));\n if (after) result = after;\n } catch (e) {\n console.log('[签到面板] 兜底签到地址不可用:', e.message || e);\n }\n }\n }\n\n // ============ 触发完之后:回「任务页」复查服务端真实状态 ============\n //\n // ★ 为什么必须有这一步(2026-09-28 吾爱破解实测):\n // 点完「去签到」之后,Discuz 的任务页往往只是刷新/跳回列表——\n // 既没有「任务已完成」,也没有「打卡签到」,扩展只能含糊地报「未识别到成功标识」,\n // 于是面板上这一行永远显示失败:签没签上都看不出来,用户只能自己去网站手点一遍。\n // 而任务页上那条 `do=apply` 链接是**服务端状态**:\n // · 链接还在 = 今天真的没签上\n // · 链接没了 / 本行写着「已完成」= 今天已经签上了\n // 以它为准,比在结果页里猜文案硬得多。\n //\n // 复查地址由站点自己声明(browserJob 里的 verify_urls / verify_url);没声明就不动 ——\n // 不给自己找活干,也不去别的站点乱跳。\n // 安全:页面内容不可信,复查只允许同域(http/https);导航只在我们自己的后台标签页里发生。\n //\n // 为什么可以给多个地址(社区两边都在用,互为补充):\n // · 任务页(home.php?mod=task)—— 那条 do=apply 链接就是服务端状态;\n // · 站点的任意页面(含首页)—— Discuz 顶部用户菜单 `#um` 里也挂着同一个入口,\n // 签完就消失;站点还要把图标换成 wbs.png(「签到完毕」)。\n // 一个地址说不清或说「还没签」时,再看下一个;只要有一个说已签就报成功。\n // ============ 「报成功之前先跟站点核对」 ============\n // 结果页说成功,有可能只是页面文案;而首页上那个入口 / 图标是**服务端状态**。\n // 站点声明 confirm_before_report 时,结果页说成功也要再看一眼,两个证据对上了才敢报成功。\n // 三种结局一个都不含糊(“状态真实无误”就靠这一段):\n // ① 首页也说已签 → ok,并在反馈里写明是复查确认过的;\n // ② 首页仍挂着入口(等几秒再读一次仍如此)→ fail,并写明“结果页说成功、首页说没签”的矛盾;\n // ③ 复查页读不到/说不清 → 保留结果页结论,但注明“未能复查”,不假装核实过。\n let reconciled = false; // 已经被「核对」环节给出结论了,后面的老复查就不要把它覆盖掉\n if (result.ok && job.confirm_before_report) {\n const confirmTargets = [];\n if (Array.isArray(job.verify_urls)) confirmTargets.push(...job.verify_urls);\n if (job.verify_url) confirmTargets.push(job.verify_url);\n let confirmed = false;\n let contradicted = false;\n let checked = 0;\n for (const t of confirmTargets) {\n const v = sameSiteHttpUrl(t, job.domain); // 只回本站核对,跨站一律跳过\n if (!v) continue;\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n await chrome.tabs.update(tab.id, { url: v });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, attempt === 0 ? 1200 : 1800));\n const back = await inspectPage(tab.id);\n if (!back) break;\n checked++;\n if (back.ok) { confirmed = true; break; }\n if (back.unsigned) { contradicted = true; continue; } // 服务端可能要一小会儿才更新,再读一次\n break; // 页面上说不清,不强求\n } catch (e) {\n console.log('[签到面板] 复查失败:', e.message || e);\n break;\n }\n }\n // 只有「确认已签」才能提前收工;「说没签」要继续看下一个复查页\n // (首页说没签、任务页写「已完成」这种情况要以后者为准,不能因为第一个说法就下结论)\n if (confirmed) break;\n }\n if (confirmed) {\n result = { ok: true, message: String(result.message || '') + '(已回站点复查确认:今日已签到)' };\n reconciled = true;\n } else if (contradicted && checked) {\n result = { ok: false,\n message: '结果页显示「' + String(result.message || '').slice(0, 40)\n + '」,但回站点复查时首页仍挂着打卡入口 —— 两边对不上,本次按“没签上”记,稍后会自动重试' };\n reconciled = true;\n } else if (checked) {\n result = { ok: true, message: String(result.message || '') + '(未能复查确认,按结果页记)' };\n reconciled = true;\n }\n }\n\n if (!result.ok && !reconciled) {\n const rawTargets = [];\n if (Array.isArray(job.verify_urls)) rawTargets.push(...job.verify_urls);\n if (job.verify_url) rawTargets.push(job.verify_url);\n const targets = [];\n for (const t of rawTargets) {\n const v = sameSiteHttpUrl(t, job.domain); // 只复查本站地址,跨站一律跳过\n if (v && !targets.includes(v)) targets.push(v);\n }\n let sawAmbiguous = false;\n for (const verifyUrl of targets) {\n try {\n await chrome.tabs.update(tab.id, { url: verifyUrl });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 1500));\n const after = await inspectPage(tab.id);\n if (!after) continue;\n if (after.ok) {\n result = { ok: true, message: '签到成功(已回站点复查确认):' + String(after.message || '') };\n break;\n }\n if (after.unsigned) {\n result = { ...after, message: '复查(' + verifyUrl.replace(/^https?:\\/\\/[^/]+/, '') + '):' + String(after.message || '今天还没有签上') };\n continue;\n }\n sawAmbiguous = true;\n } catch (e) {\n console.log('[签到面板] 复查失败:', e.message || e);\n }\n }\n // 复查地址都说不清(既非成功也非未签):保留原结论,但注明「已复查过」\n if (!result.ok && sawAmbiguous && !result.unsigned) {\n result = { ...result, message: String(result.message || '未识别到成功标识') + '(已回站点复查,仍未识别到已签到标识)' };\n }\n }\n\n // 明确未签到、且始终找不到可点的入口/地址 → 把「接下来怎么办」说清楚,\n // 不要只丢一句含糊的“未识别到成功标识”。\n if (!result.ok && result.unsigned && !result.href && !result.clickSel) {\n result = { ...result, message: String(result.message || '今天还没签上') + ';页面上找不到可点的签到入口,请在浏览器手动签一次' };\n }\n\n return {\n status: result.ok ? 'ok' : 'fail',\n message: String(result.message || (result.ok ? '签到成功' : '签到失败')),\n durationMs: Date.now() - startMs,\n };\n } catch (e) {\n return {\n status: 'fail',\n message: '浏览器执行失败:' + (e.message || e),\n durationMs: Date.now() - startMs,\n };\n } finally {\n // 只关闭「我们自己开的」标签页(用户的标签页一律不动),避免累积一堆后台标签页\n if (created && tab && tab.id != null) {\n forgetOwnTab(tab.id);\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 任务互斥:定时 alarm、popup 手动触发、安装时的一次都可能同时进来,\n// 两套循环并行 = 同时开两个标签页各自导航(看起来就是“乱跳”),所以同一时刻只允许一套。\nlet jobsBusy = false;\n\n// 主流程:获取任务 → 逐个执行 → 上报\nasync function runJobs() {\n if (jobsBusy) {\n console.log('[签到面板] 浏览器任务正在执行中,本轮跳过');\n return;\n }\n jobsBusy = true;\n try {\n await runJobsOnce();\n } finally {\n jobsBusy = false;\n }\n}\n\nasync function runJobsOnce() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) {\n console.log('[签到面板] 未配置面板地址或 API Key,跳过');\n return;\n }\n // 先把上一轮没送出去的汇报补掉\n await flushPendingReports(panelUrl, apiKey);\n\n let jobs;\n try {\n jobs = await fetchJobs(panelUrl, apiKey);\n } catch (e) {\n console.log('[签到面板] 获取任务失败:', e.message);\n return;\n }\n\n if (!jobs.length) {\n console.log('[签到面板] 暂无待执行任务');\n return;\n }\n\n console.log(`[签到面板] 获取到 ${jobs.length} 个任务,开始执行`);\n // 单轮总预算:Service Worker 随时可能被回收,不能无限期串着做;\n // 用完了剩下的留给下一分钟(面板每分钟都会重新下发一次,不会丢)。\n const startedAt = Date.now();\n for (const job of jobs) {\n if (Date.now() - startedAt > JOBS_BUDGET_MS) {\n console.log('[签到面板] 本轮预算用尽,剩余任务留到下一分钟(从账号 ' + job.account_id + ' 开始)');\n break;\n }\n console.log(`[签到面板] 执行:${job.site_name}(账号 ${job.account_id})`);\n const r = await executeJob(job);\n console.log(`[签到面板] 结果:${r.status} - ${r.message}`);\n await reportResult(panelUrl, apiKey, job.account_id, r.status, r.message, r.durationMs);\n // 会话续命:签到过程里站点可能已经换了新会话(Set-Cookie),把新值回写面板,\n // 免得过几天「登录已失效」又要人工重抓(见 reportCookieRotation 顶部说明)。\n // 只对「这次确实注入过凭据」的任务做 —— 浏览器里那份才确定是面板的同一会话。\n if (job.inject_cookies && job.cookie) {\n let rotHost = '';\n try { rotHost = new URL(job.navigate_url || 'https://' + job.domain + '/').hostname.toLowerCase(); } catch { rotHost = ''; }\n if (rotHost) await reportCookieRotation(panelUrl, apiKey, job, rotHost);\n }\n // 任务之间稍作间隔\n await new Promise((r) => setTimeout(r, 2000));\n }\n}\n\n// ============ 本地网络中继代理 ============\n// Worker 把 HTTP 请求存入面板队列,扩展用用户本地网络执行后回传响应。\n// 这样 Worker 端的站点逻辑可以使用用户本地 IP,绕过 CF/Worker IP 限制。\n\n// base64 ↔ Uint8Array\nfunction b64ToBytes(b64) {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n}\nfunction bytesToB64(bytes) {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n}\n\n// 获取待执行的中继任务(长轮询:没有任务时挂起,一有任务立刻返回)\nasync function fetchRelayJobs(panelUrl, apiKey, waitMs = RELAY_LONGPOLL_MS) {\n // 带上扩展版本(?v=),面板可以显示「扩展在线 · v2.2」并提醒版本陈旧\n let ver = '';\n try { ver = (chrome.runtime.getManifest() || {}).version || ''; } catch { /* 忽略 */ }\n const resp = await fetch(panelUrl + '/api/external/relay-pending?wait=' + waitMs + (ver ? '&v=' + encodeURIComponent(ver) : ''), {\n headers: { 'X-Api-Key': apiKey },\n });\n if (!resp.ok) throw new Error('HTTP ' + resp.status);\n const data = await resp.json();\n return data.jobs || [];\n}\n\n// 回传中继结果\n//\n// 【两道自保,都是线上踩过的坑(2026-09-28)】\n// ① **绝不能回传空 body**:`JSON.stringify(undefined)` 返回 undefined,fetch 会把它当成\n// 「没有请求体」,面板那边解析成空对象,最后存成一条「完成了、但没有状态码也没有正文」的结果。\n// 站点模块拿到空响应就会得出「站点不认这个接口」这类**错误结论** —— 用户看到的是\n// 「本地网络签到失败」,而站点其实一直回答得好好的。所以先把 payload 归一化。\n// ② **必须看 HTTP 状态并重试**:以前 `.catch(() => {})` 把一切都吞掉,包括「面板返回 500 / 401」,\n// 于是任务永远停在「租约中」,两分钟后被面板判成「扩展没有回传(可能被关闭或休眠)」——\n// 而扩展早就答完了。现在 5xx / 网络错误重试 3 次,4xx 立即停(Key 错重试无意义)。\nasync function submitRelayResult(panelUrl, apiKey, jobId, result) {\n const obj = (result && typeof result === 'object' && !Array.isArray(result)) ? { ...result } : {};\n // 既没有状态码也没有错误说明 → 当作「扩展内部错误」如实回传,不要发空 body\n if (obj.status == null && !obj.error) {\n obj.error = '中继执行没有返回结果(扩展内部错误:' + String(result === undefined ? 'undefined' : result).slice(0, 120) + ')';\n }\n const body = JSON.stringify(obj);\n let last = '';\n for (let i = 0; i < 3; i++) {\n try {\n const resp = await fetch(panelUrl + '/api/external/relay/' + jobId + '/result', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body,\n });\n if (resp.ok) return true;\n last = 'HTTP ' + resp.status;\n if (resp.status >= 400 && resp.status < 500) break; // Key/参数错,重试无意义\n } catch (e) {\n last = String((e && e.message) || e);\n }\n if (i < 2) await sleep(600 * (i + 1));\n }\n console.error(`[签到面板] 中继结果回传失败(${last}):${jobId}`);\n return false;\n}\n\n// ============ 中继请求的三道闸门 ============\n//\n// 中继是「用你的浏览器 + 你的登录态发请求,再把响应原文回传给面板」——\n// 这是整个扩展权限最大的能力。面板本身可信,但任务里的 url / headers 来自面板数据库里的\n// 站点配置(社区导入的配置也进这里),所以扩展这边必须自己再收一道口子:\n// ① 只发 http(s),且不碰内网 / 回环 / 链路本地地址(否则一条恶意配置就能拿用户的浏览器\n// 当内网探针:读路由器后台、扫描内网服务);\n// ② 方法白名单、请求头清洗(不允许 Host/Connection/Content-Length 这类由浏览器自己管的头,\n// 也不允许 CRLF 注入);\n// ③ 响应体上限(把几百 MB 的内容 base64 回来会把整个 Service Worker 拖死)。\nconst RELAY_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];\nconst RELAY_MAX_BYTES = 4 * 1024 * 1024;\nconst RELAY_BLOCKED_HEADERS = ['host', 'connection', 'content-length', 'transfer-encoding', 'upgrade', 'proxy-authorization', 'proxy-connection'];\n\n// 内网 / 回环 / 链路本地(含云元数据 169.254.169.254)一律拒绝\nfunction isPrivateHost(host) {\n const h = String(host || '').trim().toLowerCase().replace(/^\\[|\\]$/g, '');\n if (!h) return true;\n if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true;\n // IPv6 字面量(URL 里是带中括号的,上面已经去过括号)。\n // 注意必须限定在「含冒号」的情况下判断 fc/fd —— 否则像 fc2.com 这种普通域名会被误拦。\n if (h.includes(':')) {\n if (h === '::1' || h === '::' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;\n if (/^::ffff:(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|169\\.254\\.)/.test(h)) return true; // IPv4-mapped\n }\n const m = /^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/.exec(h);\n if (!m) return false;\n const a = Number(m[1]);\n const b = Number(m[2]);\n if (a === 0 || a === 10 || a === 127) return true;\n if (a === 192 && b === 168) return true;\n if (a === 172 && b >= 16 && b <= 31) return true;\n if (a === 169 && b === 254) return true;\n if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT\n return false;\n}\n\n// 校验一条中继任务;不合法就返回原因(由调用方回传给面板,不静默丢)\nfunction checkRelayJob(job) {\n let u = null;\n try { u = new URL(String((job && job.url) || '')); } catch { return '地址不是合法 URL'; }\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '只允许 http/https 地址';\n if (isPrivateHost(u.hostname)) return '拒绝访问内网/本机地址(' + u.hostname + ')';\n const method = String((job && job.method) || 'GET').toUpperCase();\n if (!RELAY_METHODS.includes(method)) return '不支持的方法:' + method;\n if (job && job.body_base64 && String(job.body_base64).length > 8 * 1024 * 1024) return '请求体过大';\n return '';\n}\n\n// 只保留合法、且不会破坏请求语义的头\nfunction sanitizeHeaders(raw) {\n const out = {};\n if (!raw || typeof raw !== 'object') return out;\n for (const k of Object.keys(raw).slice(0, 60)) {\n const name = String(k).trim();\n if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) continue; // 头名必须是合法 token\n if (RELAY_BLOCKED_HEADERS.includes(name.toLowerCase())) continue; // 交给浏览器自己算\n const v = String(raw[k] == null ? '' : raw[k]);\n if (/[\\r\\n]/.test(v)) continue; // 防头注入\n out[name] = v.slice(0, 8192);\n }\n return out;\n}\n\n// 在目标域名的页面上下文中执行单个 HTTP 请求(携带用户 Cookie)\n//\n// 为什么要「先短超时、超时就激活标签重试」:\n// 实测(2026-09-28)发现,对 www.52pojie.cn 这类**带反爬挑战的主站**,从**后台标签**发\n// fetch 会一直挂到硬超时(同一时刻同一浏览器:example.com 与 static.52pojie.cn 都正常返回 200,\n// 只有主站的 robots.txt / portal.php / home.php 全部超时)。\n// 原因是挑战/验证脚本在后台标签里被节流,服务端一直等不到「验证完成」,连接就不回包。\n// 所以:第一次用短超时快速试探;若超时,就把标签**激活到前台**再试一次,跑完把焦点还给用户。\n//\n// 【2026-09-29】绝大多数站点只是普通的 Cookie API 请求,不需要标签页。\n// 改成:优先在 service worker 里直接 fetch( 权限 + credentials:include 会自动带 Cookie,\n// 不开任何标签页);只有直接 fetch 超时/失败、或任务显式要求前台时,才回退到标签页路径。\nasync function executeRelayJob(job) {\n // 三道闸门(见上面说明):不合法的任务直接回传错误,不照做\n const bad = checkRelayJob(job);\n if (bad) return { error: '中继任务被拒绝:' + bad };\n const url = new URL(job.url);\n const domain = url.hostname;\n const method = String(job.method || 'GET').toUpperCase();\n const reqHeaders = sanitizeHeaders(job.headers);\n const needsForeground = !!(job.options && job.options.foreground);\n\n // 纯 fetch 实现:service worker 里直接跑,不依赖任何标签页/DOM。\n // (原来是包在 executeScript 里丢进标签页执行的,逻辑一字未动,只是换了个运行位置。)\n const directFetch = async (url, method, headers, bodyB64, options, timeoutMs, maxBytes) => {\n const b64ToBytes = (b64) => {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n };\n const bytesToB64 = (bytes) => {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n };\n const init = {\n method,\n headers,\n credentials: 'include', // 始终携带用户 Cookie\n redirect: options.redirect === 'manual' ? 'follow' : (options.redirect || 'follow'),\n };\n if (bodyB64) init.body = b64ToBytes(bodyB64);\n const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;\n if (ctrl) init.signal = ctrl.signal;\n let timer = null;\n const timeout = new Promise((resolve) => {\n timer = setTimeout(() => {\n try { if (ctrl) ctrl.abort(); } catch { /* 忽略 */ }\n resolve({ timeout: true });\n }, timeoutMs || 18000);\n });\n const call = (async () => {\n const resp = await fetch(url, init);\n const buf = new Uint8Array(await resp.arrayBuffer());\n if (maxBytes && buf.length > maxBytes) return { tooLarge: buf.length, status: resp.status };\n const h = {};\n resp.headers.forEach((v, k) => { h[k] = v; });\n return {\n status: resp.status,\n headers: h,\n body_base64: bytesToB64(buf),\n url: resp.url,\n };\n })();\n try {\n return await Promise.race([call, timeout]);\n } finally {\n clearTimeout(timer);\n call.catch(() => {});\n }\n };\n\n // 第一步:不开标签页,直接在 service worker 里试一次(短超时快速试探)。\n // 任务显式要求前台(反爬挑战站)时跳过这步,直接走标签页。\n if (!needsForeground) {\n try {\n const r = await directFetch(url.href, method, reqHeaders, job.body_base64 || null, job.options || {}, RELAY_FIRST_TRY_MS, RELAY_MAX_BYTES);\n if (r && !r.timeout) {\n if (r.tooLarge) {\n return { error: `响应体过大(${Math.round(r.tooLarge / 1048576)}MB,上限 ${Math.round(RELAY_MAX_BYTES / 1048576)}MB),已放弃回传` };\n }\n const respHeaders = { ...(r.headers || {}), 'x-relay-url': r.url || job.url };\n console.log('[签到面板] 中继请求直接完成(无标签页):', method, url.hostname);\n return { status: r.status, headers: respHeaders, body_base64: r.body_base64 };\n }\n console.log('[签到面板] 直接请求超时,回退到标签页重试:', url.hostname);\n } catch (e) {\n console.log('[签到面板] 直接请求失败,回退到标签页重试:', (e && e.message) || e);\n }\n }\n\n // 第二步(回退):走标签页执行(反爬挑战站需要真实页面环境)。\n let tab = null;\n let created = false;\n let prevActiveId = null;\n let activated = false;\n try {\n // 同样只用我们自己的标签页(Cookie 是按域名走的,新开的标签页一样带着登录态)。\n // 以前会把用户正在看的那个标签页激活到前台去反爬,焦点被抢来抢去。\n tab = await findOwnTab(domain);\n if (!tab) {\n tab = await openOwnTab(domain);\n created = true;\n }\n\n const injectFetch = async (url, method, headers, bodyB64, options, timeoutMs, maxBytes) => {\n const b64ToBytes = (b64) => {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n };\n const bytesToB64 = (bytes) => {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n };\n const init = {\n method,\n headers,\n credentials: 'include', // 始终携带用户 Cookie\n // 页面里拿不到 opaqueredirect 的响应头,manual 等于白跑一轮,统一 follow\n redirect: options.redirect === 'manual' ? 'follow' : (options.redirect || 'follow'),\n };\n if (bodyB64) init.body = b64ToBytes(bodyB64);\n const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;\n if (ctrl) init.signal = ctrl.signal;\n let timer = null;\n // 不依赖 AbortController:某些受限环境里它不存在,那样整个任务会挂到硬超时。\n // 用 Promise.race 做兜底,保证无论如何都在 timeoutMs 内**返回**(返回超时标记,不抛出)。\n const timeout = new Promise((resolve) => {\n timer = setTimeout(() => {\n try { if (ctrl) ctrl.abort(); } catch { /* 忽略 */ }\n resolve({ timeout: true });\n }, timeoutMs || 18000);\n });\n const call = (async () => {\n const resp = await fetch(url, init);\n const buf = new Uint8Array(await resp.arrayBuffer());\n // 超大响应直接放弃:base64 后回传会把 Service Worker 的内存/消息通道拖垮\n if (maxBytes && buf.length > maxBytes) return { tooLarge: buf.length, status: resp.status };\n const h = {};\n resp.headers.forEach((v, k) => { h[k] = v; });\n return {\n status: resp.status,\n headers: h,\n body_base64: bytesToB64(buf),\n url: resp.url, // 最终 URL(跟随重定向后)\n };\n })();\n try {\n return await Promise.race([call, timeout]);\n } finally {\n clearTimeout(timer);\n call.catch(() => {}); // 超时后迟到的失败不要变成未处理拒绝\n }\n };\n\n const runOnce = async (timeoutMs) => {\n const results = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: injectFetch,\n args: [url.href, method, reqHeaders, job.body_base64 || null, job.options || {}, timeoutMs, RELAY_MAX_BYTES],\n });\n return (results && results[0] && results[0].result) || null;\n };\n\n let r = await runOnce(RELAY_FIRST_TRY_MS);\n if (r && r.tooLarge) {\n return { error: `响应体过大(${Math.round(r.tooLarge / 1048576)}MB,上限 ${Math.round(RELAY_MAX_BYTES / 1048576)}MB),已放弃回传` };\n }\n if (r && r.timeout) {\n // 超时后的第二次尝试。\n // 【默认不抢焦点】把标签页拿到前台确实能让某些反爬挑战跑起来,但那会打断用户正在看的页面\n // (用户看到的就是“浏览器乱跳”),所以改成**站点显式声明**才启用:\n // relayFetch(..., { foreground: true }) → 任务里带 options.foreground\n // 没声明的站点就在后台标签页里再试一次(超时放宽到 RELAY_FETCH_TIMEOUT_MS)。\n if (job.options && job.options.foreground) {\n try {\n const [cur] = await chrome.tabs.query({ active: true, currentWindow: true });\n prevActiveId = cur && cur.id != null ? cur.id : null;\n await chrome.tabs.update(tab.id, { active: true });\n activated = true;\n await new Promise((res) => setTimeout(res, RELAY_ACTIVATE_WAIT_MS)); // 给页面里的定时/挑战脚本一点时间\n } catch { /* 激活失败也得继续试 */ }\n }\n r = await runOnce(RELAY_FETCH_TIMEOUT_MS);\n }\n if (!r) throw new Error('无返回结果');\n if (r.timeout) {\n // 把话说清楚:请求**已经发出去了**,只是没等到响应。\n // 签到这类写操作可能已经生效,面板不能当成「失败」(面板会记为「结果未知」并稍后自动复核)。\n return {\n error: activated\n ? `中继请求超时(前台标签也没等到响应,${Math.round(RELAY_FETCH_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包。该站点的反爬可能要求人工验证,或站点暂时不可达 —— 若站点其实处理了,签到可能已生效`\n : `中继请求超时(${Math.round(RELAY_FETCH_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包,站点可能已记录(结果未知)`,\n };\n }\n // 把最终 URL 放入特殊头,Worker 端可读取\n const respHeaders = { ...(r.headers || {}), 'x-relay-url': r.url || job.url };\n return { status: r.status, headers: respHeaders, body_base64: r.body_base64 };\n } catch (e) {\n return { error: String(e.message || e).slice(0, 500) };\n } finally {\n // 把焦点还给用户原来的标签(重试时我们只能激活自己的标签页,激活完还回去)\n if (activated && prevActiveId != null) {\n chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});\n }\n // 【这里刻意不再立刻关标签页】\n // 一次签到要打好几个请求(首页 → 主题 JS → 签到接口),以前每个请求都\n // 「开一个 → 用完关掉」,用户看到的就是「浏览器总在新建页面」。\n // 现在这个标签页留给本域名后续的请求复用,由 runRelayRound 末尾的\n // sweepOwnTabs(RELAY_TAB_IDLE_MS) 在空闲 45 秒后统一关掉。\n // 它是后台标签页(active:false),不会抢焦点、也不会打断用户正在看的页面。\n // (created 仅用于日志,说明这一轮是不是新开的)\n if (created && tab && tab.id != null) rememberOwnTab(tab.id, domain);\n }\n}\n\n// 中继主循环(单轮):取任务 → 执行 → 回传\n// 若正好有一轮在跑,先等它(避免「刚排队的任务被这轮跳过」),最多等 ~600ms。\nasync function runRelayOnce() {\n for (let i = 0; i < 3 && relayBusy; i++) await sleep(200);\n if (relayBusy) return { ok: true, count: 0, skipped: true };\n relayBusy = true;\n try {\n return await runRelayRound();\n } finally {\n relayBusy = false;\n }\n}\n\nasync function runRelayRound() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) return { ok: false, reason: 'noconfig' };\n // 上一轮没送出去的汇报:连上了就补报(面板否则永远停在「待确认」)\n await flushPendingReports(panelUrl, apiKey);\n let jobs;\n try {\n jobs = await fetchRelayJobs(panelUrl, apiKey);\n } catch (e) {\n return { ok: false, error: String((e && e.message) || e) }; // 面板不可达时静默重试\n }\n // 一次只做少量:扩展是**单飞**执行(每个请求最长约 RELAY_FETCH_TIMEOUT_MS 秒),\n // 一口气领一堆,后面的会在 Worker 那边等到 90 秒超时(线上 2026-09-28 的「扩展没有在 90 秒内回传」)。\n // Worker 端也已经只下发 2 个,这里再兜一道,两边都不会把队列堵死。\n const batch = (jobs || []).slice(0, RELAY_MAX_JOBS_PER_ROUND);\n for (const job of batch) {\n try {\n console.log(`[签到面板] 中继请求:${job.method} ${job.url}`);\n // 硬超时:即使页面 fetch 或注入挂起,也必须回传结果,否则任务会永久卡在队列\n const result = await Promise.race([\n executeRelayJob(job),\n new Promise((resolve) => setTimeout(() => resolve({ error: `中继执行超时(${Math.round(RELAY_JOB_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包,站点可能已记录(结果未知)` }), RELAY_JOB_TIMEOUT_MS)),\n ]);\n await submitRelayResult(panelUrl, apiKey, job.id, result);\n } catch (e) {\n console.error(`[签到面板] 中继任务 ${job.id} 失败:`, e);\n // 回传错误,避免任务卡在 pending\n try {\n await submitRelayResult(panelUrl, apiKey, job.id, { error: String(e.message || e) });\n } catch { /* 忽略 */ }\n }\n }\n // 顺手回收我们自己开的后台标签页:中继标签页按「空闲 45 秒」回收 ——\n // 一次签到的各个请求之间会有几秒停顿,45 秒足够让它们复用同一个页面,\n // 而签到结束后最多 45 秒它自己就消失了(用户不会看到一堆残留标签页)。\n await sweepOwnTabs(RELAY_TAB_IDLE_MS);\n // count 只算「这一轮真的处理了几条」:算上没领回来的那些会让突发误以为一直有活干\n return { ok: true, count: batch.length, total: (jobs || []).length };\n}\n\n// 中继长轮询「突发」:一轮结束立刻接下一轮,让面板点「执行」后 1 秒内就被领走。\n//\n// 为什么不用 while(true) 常驻循环:① 浏览器可能随时回收 Service Worker,常驻循环早晚会断;\n// ② 若面板侧不支持长轮询(旧版本),常驻循环会变成毫秒级空转打网络。\n// 所以改成「有界突发」:最多 RELAY_BURST_ROUNDS 轮,连续 RELAY_BURST_IDLE 轮没任务就结束;\n// 由 RELAY_ALARM(0.5 分钟)和 popup 的「立即执行」反复拉起,接得上就与常驻无异。\nlet relayGen = 0; // 代际令牌:新的突发会作废旧的,避免多份循环并发抢任务\nlet relayBusy = false; // 正在处理一轮长轮询(避免并发取同一批任务)\n\nfunction sleep(ms) {\n return new Promise((res) => setTimeout(res, ms));\n}\n\nfunction startRelayBurst(opts = {}) {\n const rounds = opts.rounds || RELAY_BURST_ROUNDS;\n const idleStop = opts.idleStop || RELAY_BURST_IDLE;\n const gen = ++relayGen;\n let idle = 0;\n (async () => {\n for (let i = 0; i < rounds; i++) {\n if (gen !== relayGen) return; // 已被更新的突发取代/已停止\n let r;\n try {\n r = await runRelayOnce();\n } catch (e) {\n r = { ok: false, error: String((e && e.message) || e) };\n }\n if (gen !== relayGen) return;\n if (r && r.reason === 'noconfig') return; // 未配置面板地址/Key,不空转\n // 被别的一轮顶掉(busy)时既不算「有活」也不算「空转」:\n // 否则一次并发就能让突发提前收工\n if (r && r.skipped) { await sleep(RELAY_GAP_MS); continue; }\n if (r && r.count) idle = 0; else idle++;\n if (idle >= idleStop) return; // 一连几轮都没任务,先歇着,交给下次 alarm\n await sleep(r && r.ok ? RELAY_GAP_MS : 5000);\n }\n })();\n}\n\n// 供 popup / 测试使用:立刻停掉当前突发(不打断正在执行的那一轮)\nfunction stopRelayBurst() {\n relayGen++;\n}\n\n// 回收我们自己开的后台标签页:空闲超过 idleMs 没再被用到的就关掉。\n// (只动 ownTabs 里记着的 —— 那都是我们开的;用户的标签页一律不碰)\nasync function sweepOwnTabs(idleMs = OWN_TAB_TTL_MS) {\n if (!ownTabs.size) return 0;\n const now = Date.now();\n let tabs = [];\n try { tabs = await chrome.tabs.query({}); } catch { return 0; }\n const live = new Set((tabs || []).map((t) => t.id));\n let closed = 0;\n for (const [tid, info] of Array.from(ownTabs)) {\n if (now - info.at <= idleMs) continue;\n ownTabs.delete(tid);\n if (!live.has(tid)) continue;\n try { await chrome.tabs.remove(tid); closed++; } catch { /* 已经关了就算了 */ }\n }\n if (closed) console.log('[签到面板] 回收了 ' + closed + ' 个自己开的陈旧标签页');\n return closed;\n}\n\n// 定时器\n// 我们自己开的标签页被关掉(用户手动关/浏览器回收)后,别再记着它。\n// 受限环境(测试台架/阉割实现)里可能没有 onRemoved:不能因此让整个后台挂掉。\nif (chrome.tabs && chrome.tabs.onRemoved && typeof chrome.tabs.onRemoved.addListener === 'function') {\n chrome.tabs.onRemoved.addListener((tabId) => forgetOwnTab(tabId));\n}\n\nchrome.alarms.onAlarm.addListener((alarm) => {\n if (alarm.name === ALARM_NAME) runJobs();\n if (alarm.name === RELAY_ALARM) startRelayBurst();\n});\n\n// 扩展安装/启动时设置定时器\nchrome.runtime.onInstalled.addListener(() => {\n chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n // 安装后立即跑一次(方便验证),并起一次中继突发\n setTimeout(runJobs, 5000);\n startRelayBurst();\n});\n\nchrome.runtime.onStartup.addListener(() => {\n chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n startRelayBurst();\n});\n\n// Service Worker 每次启动时确保定时器存在(覆盖\"重新加载\"场景:onInstalled/onStartup 都不触发)\nchrome.alarms.get(ALARM_NAME, (a) => {\n if (!a) chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n});\nchrome.alarms.get(RELAY_ALARM, (a) => {\n if (!a) chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n});\n\n// popup 手动触发。\n// 【安全】只接受本扩展自己的页面发来的消息(sender.id 就是本扩展的 id):\n// 否则任何能发消息的一方都能指挥这里开标签页、发请求、用用户的登录态干活。\nchrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {\n const fromSelf = !sender || !sender.id || !chrome.runtime.id || sender.id === chrome.runtime.id;\n if (!fromSelf) {\n sendResponse({ ok: false, error: '来源不受信任' });\n return false;\n }\n if (!msg || typeof msg.action !== 'string') {\n sendResponse({ ok: false, error: '未知指令' });\n return false;\n }\n if (msg.action === 'runJobsNow') {\n runJobs().then(() => sendResponse({ ok: true })).catch((e) => sendResponse({ ok: false, error: String(e) }));\n return true; // 异步响应\n }\n if (msg && msg.action === 'runRelayNow') {\n // 先同步跑完一轮(已排队的任务立即执行),响应后再转入长轮询突发\n runRelayOnce()\n .then((r) => {\n sendResponse({ ok: true, count: (r && r.count) || 0 });\n startRelayBurst();\n })\n .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));\n return true;\n }\n if (msg && msg.action === 'stopRelay') {\n stopRelayBurst();\n sendResponse({ ok: true });\n return true;\n }\n});\n", "@b64:icon16.png": "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAZlJREFUOE+Vk0svQ0EUx8/UQqOVku4kvoQPYWFD4hmNVyteYYFF6QJBidJGU6GJhehCWViLRT+B+B5Cqvqmbe6RM3NnOtU2OIt7M/ee///8zpkZBmYsRSuIiGAgAhpN3vTfQKC8G6+DkZQ//iuWJne+TsYaiS+XW8EdLiqS6zUbPD6XIZYoKgJpwhYvyqhjk1iPyWAOYuv2mm8j/hRvg0zY/HkJZc+6eCqU50kkXohkIbrSDmOHH/UEc2dfisBmBcjkDT7IUhkh7hWVh/1pYIAAIAYo8SmPzUY+FYEkoZ4pXIEMT45vOITR/nuNmMyYJ1xUBFerbTW9NlsM7LxVZzBzWqgS6HhmZTmw3h4rPDwVoMUCUKoYgoSGOB3KCwJTHHDboMtp4QO73ezgEIO7SbjfcoJeWbQLwCaDOXECzRMme9a3isQU/duvqjKJOcHESRal+Nhj59V/iz7fizpkzBXI1BH83Cq1NrHVXaFdGD9KKwK9DXWxZHsNxPwkEu7oQareRBusHJhemcSJYDfjBhRDe8mqyR/FpPsG9KUzUWvclUUAAAAASUVORK5CYII=", "@b64:icon32.png": "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABAFJREFUWEe9V1tPE0EUPkMtqIB4e+HFd/UXEOKjD8ZLDN64CYIIxVvEK8RL1CiVggKCSLwQIYBG0QcTHzT6aPgJ/gWNiAUKpS3tmHNmZzu7O7sWQfrQZna3853zne98Z5aB7dM0xHlsLgkAHDgH4JwDx2fwl9Z0B7/Me/iMdq3sgfdX5eTD4DnGVEhzcXaA8+RCEniK0JYdHIOWCWVl+WH0Si5h09dKggvyOLAsP7xqzhN0nHqywFcicwkumXhzbSNj/7PmKu12cEzcl50P7GT/giDFQ3BPT+eYuqntjmoFN3gh13ymMjRDNMs9deAkbg4YQEKI2EPtT88oAXRFHepPcg7DF/MogMWAIyZrfIwBuLeaCm5vWVxXtEdg5JIAd/uUBcOQTFEzi+CNhKkLAn1xXGv7PMcP0BtIZ++J4nGztDUMKQK2ghMDDX1xuqozmWdnV5vbjn1NwMFiP62r789SjYcuWDMvb5uG0SvrzP8caQ0TqC5zyTpreBQzNOB0uOdGADWdc+SGL5rW0maB3lnoP50WHV5DcNzoZXOBGcDhu789wYmB+l4jAI29xhIcsn0kepGJYSL2zCViWXCKmHnVsh4yAcf92ImemJBgBt5evG0V1O9Ka6IihFmDhfbAwymYnCE/19bcngyr65kX+EYnuA0Wtc9J/Qa4fP5lS5p6vH/ozqRF7Y79kasUAKt7OG+2oQ588Ly11ovthJJbv8Rws5URwWkmHO+Okk7t4AW5AN31QnTy8+5rDEqKvdty/FscirZmW/73fjwKAx8jpoYkOGmgFgNwmeeS9sbeCMzMkbkC+Ymmn1Vzwf2qd+bCviLRxvtv/tSCUxfUdNHOTg3YvHz4cr7Zavi8WnNUvDSZsesbCbTk1gTRnkpxmvkUvkG7isdqOuecGtCA61pNXpOCQ5ix65tM+r0yN4gEduzBrFUDmikW2L0GdmwXLnjobhiymGDs9dUNdA39ojw4CW9vLA4cS8mqMQDFZNxGqM7hMIADtyeJ4Pk4hw93NlNAZa0TEI0JzehoNzIW47jqfsTwIef8Hrmc9vXFtp/6/N5rPxSNKSLGNqzqiJAF6TJXB8tSAthz9bvhA1ZwYuBox4x5ILGfXFTalxLAbgxAbV8jYdJAZTsGkB40mRyjHCNWzpIMai7ZJi/BA0lFaNpVA3Zzcaxd7NXqK07aJTgZUUXbtKsGPA8T6hvSP2RuHkqruziPz4vDhNfp1RLMMmSOcL7sPPFmVBoMO8uQ4Tz/W5/bay4zxzJ8ebBFvBmVh2Z5ciHunN/K6dVtnjsPM941l90APj98aS9Mv6mW3otQEJmeZJaSOfP54XN7YfrlVO3x0lCCJ6QmZBmWseafghssr+d/AJMFI9vo7StdAAAAAElFTkSuQmCC", diff --git a/src/index.js b/src/index.js index 79eef4e..4744aba 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { ensureSchema, getSetting, setSetting } from './db.js'; import { hashPassword, verifyPassword, encryptJSON, decryptJSON, decryptJSONWith, accountKey, randomHex } from './crypto.js'; import { handleExtZip } from './ext-zip.js'; +import { EXT_FILES } from './ext-files.js'; import { runAll, runAccount } from './runner.js'; import { getSite, siteMeta, getBrowserScript } from './sites/index.js'; import { listCommunitySites, importSiteConfig, deleteCommunitySite, validateSiteConfig, makeCommunitySite, exportAccountConfig } from './community.js'; @@ -428,6 +429,17 @@ async function handleApi(req, env, url) { return json({ ok: true, version: PANEL_VERSION, time: Date.now() }); } + // ---- 扩展最新版本号(弹窗里的更新按钮用:比对本地版本,有新版就提示下载)---- + // 不需要 API Key:版本号不是敏感信息 + if (path === '/api/external/ext-version' && method === 'GET') { + let ver = ''; + try { + const m = JSON.parse(EXT_FILES['manifest.json'] || '{}'); + ver = String(m.version || ''); + } catch { /* 忽略 */ } + return json({ ok: true, version: ver }); + } + // ---- 浏览器扩展:任务已并入「本地网络中继」,这里不再下发脚本 ---- // 浏览器模式(含站点默认 browser 的吾爱破解 / NodeSeek / V2EX / 看雪 / Discuz)统一走中继: // 站点逻辑仍在 Worker,HTTP 请求由扩展在用户本地网络中执行(见 runner.js),