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 @@
发送方式是「一次性交接码」:凭据不进地址栏,短码 5 分钟内有效、取一次即作废。
\n\n)。\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 点击的 /