13 lines
76 KiB
JavaScript
13 lines
76 KiB
JavaScript
// 自动生成:扩展源文件嵌入(运行 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插件2.2:一键取 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 \"host_permissions\": [\n \"<all_urls>\"\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\": \"cookie插件2.2\",\n \"permissions\": [\n \"cookies\",\n \"activeTab\",\n \"scripting\",\n \"storage\",\n \"alarms\",\n \"tabs\"\n ],\n \"version\": \"2.3\"\n}\n",
|
||
"popup.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<style>\n body { width: 320px; padding: 16px; font-family: -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif; margin: 0; background: #0f1420; color: #e8edf7; }\n h3 { margin: 0 0 12px; font-size: 15px; }\n .muted { color: #8b98b3; font-size: 12px; line-height: 1.6; }\n button { width: 100%; padding: 11px; margin: 6px 0; border: 0; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }\n .primary { background: linear-gradient(135deg, #5b8cff, #4a7de8); color: #fff; }\n .ghost { background: transparent; border: 1px solid #26314a; color: #e8edf7; }\n button:hover { opacity: .9; }\n button:disabled { opacity: .5; cursor: wait; }\n #status { font-size: 12px; color: #8b98b3; margin-top: 8px; min-height: 18px; }\n #status.ok { color: #3ddc84; }\n #status.err { color: #ff6b6b; }\n label { font-size: 12px; color: #8b98b3; display: block; margin: 10px 0 4px; }\n input { width: 100%; box-sizing: border-box; background: #0d1322; border: 1px solid #26314a; color: #e8edf7; border-radius: 8px; padding: 9px 12px; font-size: 13px; }\n .count { display: inline-block; background: rgba(91,140,255,.15); color: #7aa5ff; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin-left: 6px; }\n</style>\n</head>\n<body>\n <h3>🖊️ cookie插件2.2 <span class=\"count\" id=\"count\"></span></h3>\n <div class=\"muted\" id=\"domain\">正在读取当前网站…</div>\n <button class=\"primary\" id=\"btn-send\">一键发送到签到面板</button>\n <button class=\"ghost\" id=\"btn-copy\">📋 一键复制全部信息(含 UA)</button>\n <button class=\"ghost\" id=\"btn-check-conn\">🔌 面板连接检查</button>\n <label>签到面板地址</label>\n <input id=\"panel-url\" placeholder=\"https://daily-checkin-panel.xxx.workers.dev\">\n <label>API Key(面板设置页获取)</label>\n <input id=\"api-key\" type=\"password\" placeholder=\"填入后面板点「执行」即用你的本机网络发请求\">\n <div id=\"status\"></div>\n <script src=\"popup.js\"></script>\n</body>\n</html>\n",
|
||
"popup.js": "// 签到面板 Cookie 助手 - popup.js\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://daily-checkin-panel.guo527029137.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// 采集当前标签页的 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 chrome.storage.sync.get(['panelUrl', 'apiKey']);\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 chrome.storage.sync.set({ panelUrl: DEFAULT_PANEL_URL }).catch(() => {});\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 $('count').textContent = cookies.length + ' 个';\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 fullPayload() {\n return JSON.stringify({\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\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 let panelUrl = $('panel-url').value.trim().replace(/\\/$/, '');\n if (!panelUrl) return status('请先填写签到面板地址', 'err');\n if (!panelUrl.startsWith('http')) panelUrl = 'https://' + panelUrl;\n await chrome.storage.sync.set({ panelUrl });\n\n // 把 Cookie + UA + 域名编码进 URL hash,面板 JS 读取后自动弹出确认框\n // 用 base64url 编码(+/= 替换为 -_.),避免特殊字符在地址栏被转义或截断\n const b64 = btoa(unescape(encodeURIComponent(fullPayload())));\n const payload = b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n chrome.tabs.create({ url: panelUrl + '#ext-cookies=' + payload });\n status('已打开面板,请在面板中确认保存', 'ok');\n};\n\n$('panel-url').oninput = () => {\n chrome.storage.sync.set({ panelUrl: $('panel-url').value.trim().replace(/\\/$/, '') });\n};\n\n$('api-key').oninput = () => {\n chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });\n};\n\n// 兼容旧的 onchange(保留)\n$('panel-url').onchange = () => {\n chrome.storage.sync.set({ panelUrl: $('panel-url').value.trim().replace(/\\/$/, '') });\n};\n\n$('api-key').onchange = () => {\n chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });\n};\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// 原生页面巡检:不依赖 eval / new Function(MV3 禁止),用于读取网站真实反馈。\nasync function inspectPageInPopup(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 // 1) 人机验证 / 验证码 / WAF:优先判定——这些页面里往往也夹带「签到」字样\n const CAPTCHA = ['验证码', '人机验证', '安全验证', '请完成验证', '滑动验证', '滑块', '智能验证', 'slidercaptcha', 'geetest', '极验', 'recaptcha', 'hcaptcha', 'turnstile', 'cf_chl', 'challenge-platform', 'Just a moment', 'checking your browser', 'turing.captcha.qcloud.com', 'TCaptcha', 'CaptchaAId', 'ibex'];\n if (CAPTCHA.some((m) => all.includes(m))) return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|页面:' + title };\n const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'Access Denied', '403 Forbidden', '请求被拦截'];\n if (WAF.some((m) => all.includes(m))) return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };\n // ---- 找「去签到」入口(Discuz 系论坛通用)----\n const ALREADY = /已签到|已经签到|已打卡|签到完毕|签到完成|连续签到|今日已签|明天再来|下期再来/;\n const SIGN_TEXT = /打卡签到|立即签到|点击签到|点击打卡|签到领奖|每日签到|每日打卡|签个到|去签到|未打卡|还没有签到|今天还没有/;\n const SIGN_IMG = /qds\\.png|signin_no\\.png|pperwb\\.gif|wb\\.png|qiandao|qdbg|sign_?in/i;\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 const attrOf = (el) => ((el.getAttribute && el.getAttribute('href') || '') + ' ' + (el.id || '') + ' ' +\n (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 const findSignLink = () => {\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 if (ALREADY.test(textOf(a))) continue;\n const own = textOf(a) + ' ' + attrOf(a) + ' ' + imgsOf(a);\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 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') || '', label: (textOf(a) || '打卡签到'), done: false };\n }\n }\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 el.setAttribute('data-panel-sign', '1');\n return { href: usable, clickSel: '[data-panel-sign=\"1\"]', label: (textOf(el) || '签到'), done: false };\n }\n for (const el of Array.from(document.querySelectorAll('a,button,[onclick],img'))) {\n const t = textOf(el);\n const im = imgsOf(el);\n if (ALREADY.test(t + ' ' + im)) continue;\n if (!(SIGN_TEXT.test(t) || SIGN_IMG.test(im))) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n if (!href && !el.onclick && !el.getAttribute('onclick')) continue;\n el.setAttribute('data-panel-sign', '1');\n return { href: /^javascript:/i.test(href) ? '' : href, clickSel: '[data-panel-sign=\"1\"]', label: (t || '签到'), done: false };\n }\n return null;\n };\n const SIGNED = /签到完毕|签到完成|您已完成过此任务|您已经完成此任务|您今日已经签到|您今天已经签到|今天已经签到|已经签到过|已经完成签到|今日任务已完成|今日已签到|今天已完成签到|已连续签到|下期再来|明天再来|无需重复|恭喜.{0,14}(完成|获得|领到|签到)/;\n const SUCCESS = /任务已完成|签到成功|打卡成功|领取成功|成功领取|获得.{0,10}(鸡腿|积分|金币|铜币|银币|吾爱币|AK|币|空间|MB|GB)/;\n let link = null;\n try { link = findSignLink(); } catch (e) { link = null; }\n if (!link) {\n const am = all.match(/<a[^>]*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)) return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };\n if (SIGNED.test(all)) return { ok: true, message: '今日已签到,无需重复' };\n if (link && link.done) return { ok: true, message: '今日已签到(任务页显示:已完成)' };\n // 4) 未登录(Discuz 游客页会写「注册[Register]」)——比「未签到」更优先:\n // 登录失效时页面导航里也有「每日签到」这类字样。\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\\[Register\\]|立即注册|登录后才能/.test(all)) return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡/.test(all)) {\n return { ok: false, unsigned: true, href: '', clickSel: '', message: '网站显示还没签到,但页面上找不到可点的签到入口' };\n }\n if (link) return { ok: false, unsigned: true, href: link.href || '', clickSel: link.clickSel || '', message: '网站还挂着「' + (link.label || '签到') + '」入口 —— 今天还没签上' };\n return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };\n },\n });\n return (results && results[0] && results[0].result) || null;\n}\n\n// 尝试执行面板下发的脚本字符串(MV3 下通常失效,失败时回退原生巡检)。\nasync function runPanelScriptInPopup(tabId, job) {\n const execPromise = chrome.scripting.executeScript({\n target: { tabId },\n func: (scriptStr, params) => {\n const fn = new Function('params', `return (${scriptStr})(params)`);\n return fn(params);\n },\n args: [job.script, job.params || {}],\n });\n const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('脚本执行超时(60秒)')), 60000));\n const results = await Promise.race([execPromise, timeoutPromise]);\n return (results && results[0] && results[0].result) || null;\n}\n\n// 在弹窗上下文中执行单个签到任务(复用目标域名标签页,携带用户 Cookie)\n// onStep: 步骤回调,用于显示详细进度\nasync function executeJobInPopup(job, onStep) {\n const startMs = Date.now();\n const step = (msg) => { if (onStep) onStep(msg); };\n let tab = null;\n let created = false;\n try {\n if (!job.domain) throw new Error('任务缺少目标域名');\n step('查找标签页…');\n const tabs = await chrome.tabs.query({ url: `*://${job.domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n step('复用已有标签页…');\n } else {\n step('打开新标签页…');\n tab = await chrome.tabs.create({ url: `https://${job.domain}/`, active: false });\n created = true;\n step('等待页面加载…');\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 15000);\n });\n }\n // 如果任务指定了导航 URL(如吾爱破解),先导航到该页面(模拟手动点击),再执行检查脚本\n if (job.navigate_url) {\n step('导航到签到页面…');\n await chrome.tabs.update(tab.id, { url: job.navigate_url });\n step('等待签到页面加载…');\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n // 多等 2 秒,让页面内跳转完成\n await new Promise((r) => setTimeout(r, 2000));\n }\n // MV3 禁止 new Function / eval:有 navigate_url 直接原生巡检,否则先试面板脚本再回退。\n step('读取页面结果…');\n let result = null;\n if (!job.navigate_url && job.script) {\n try { result = await runPanelScriptInPopup(tab.id, job); } catch { result = null; }\n }\n if (!result) result = await inspectPageInPopup(tab.id);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n\n // 页面还挂着「打卡签到」入口 = 今天还没签:链接→导航过去,JS 按钮→在页面里点一下\n if (!result.ok && result.unsigned && (result.href || result.clickSel)) {\n try {\n let navigated = false;\n if (result.href) {\n const base = tab.url ? new URL(tab.url) : null;\n const u = new URL(result.href, tab.url || undefined);\n if ((u.protocol === 'http:' || u.protocol === 'https:') && (!base || u.host === base.host)) {\n step('网站显示还没签到,正在执行签到…');\n await chrome.tabs.update(tab.id, { url: u.href });\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n await new Promise((r) => setTimeout(r, 2000));\n navigated = true;\n }\n }\n if (!navigated && result.clickSel) {\n step('网站显示还没签到,正在点击签到按钮…');\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 await new Promise((r) => setTimeout(r, 3000));\n }\n const after = await inspectPageInPopup(tab.id);\n if (after) result = after;\n } catch (e) {\n console.log('[签到面板] 触发签到失败:', e.message || e);\n }\n }\n\n step('解析结果…');\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 { status: 'fail', message: '浏览器执行失败:' + (e.message || e), durationMs: Date.now() - startMs };\n } finally {\n // 只关闭自己新建的标签页(复用的保留)\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 面板连接检查:验证面板地址和 API Key 是否可用\n$('btn-check-conn').onclick = async () => {\n status('正在检查面板连接…', '');\n try {\n let panelUrl = $('panel-url').value.trim().replace(/\\/$/, '');\n if (!panelUrl) return status('请先填写签到面板地址', 'err');\n if (!panelUrl.startsWith('http')) panelUrl = 'https://' + panelUrl;\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://daily-checkin-panel.guo527029137.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_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\n// 获取面板地址和 API Key\nasync function getConfig() {\n const { panelUrl, apiKey } = await chrome.storage.sync.get(['panelUrl', 'apiKey']);\n // 面板地址:已保存 > 下载时注入的默认值\n let url = (panelUrl || '').trim().replace(/\\/$/, '');\n if (!url && typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http')) {\n url = DEFAULT_PANEL_URL.replace(/\\/$/, '');\n }\n return { panelUrl: url, apiKey: (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// 上报结果到面板\nasync function reportResult(panelUrl, apiKey, accountId, status, message, durationMs) {\n await fetch(panelUrl + '/api/external/report', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify({\n account_id: accountId,\n status,\n message,\n detail: '',\n duration_ms: durationMs || 0,\n }),\n }).catch(() => { /* 上报失败不阻塞 */ });\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 const CAPTCHA = ['验证码', '人机验证', '安全验证', '请完成验证', '滑动验证', '滑块', '智能验证', 'slidercaptcha', 'geetest', '极验', 'recaptcha', 'hcaptcha', 'turnstile', 'cf_chl', 'challenge-platform', 'Just a moment', 'checking your browser', 'turing.captcha.qcloud.com', 'TCaptcha', 'CaptchaAId', 'ibex'];\n if (CAPTCHA.some((m) => all.includes(m))) {\n return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|页面:' + 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 const findSignLink = () => {\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') || '', label: (textOf(a) || '打卡签到'), done: false };\n }\n }\n // ② 已知的签到按钮(可能是 <a href>,也可能是 JS 点击的 <span>/<div>/<img>)\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 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(/<a[^>]*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 // 4) 未登录(Discuz 游客页会写「注册[Register]」)—— 比「未签到」更优先:\n // 登录失效时页面导航里也有「每日签到」这类字样,不能把它当成“还没签到”。\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\\[Register\\]|立即注册|登录后才能/.test(all)) {\n return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n }\n // 页面上写着「还没签到」却找不到可点的入口(插件式签到页、被遮挡的按钮):\n // 如实报未签到,并说清要人工处理,不要吐一句“未识别到成功标识”。\n if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡/.test(all)) {\n return { ok: false, unsigned: true, href: '', clickSel: '',\n message: '网站显示还没签到,但页面上找不到可点的签到入口' };\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// 把页面里找到的「去签到」链接解析成绝对地址,并做同源校验。\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// 注意:MV3 禁止 new Function / eval,多数情况下这里会失败——失败时由调用方回退到原生巡检。\nasync function runPanelScript(tabId, job) {\n const execPromise = chrome.scripting.executeScript({\n target: { tabId },\n func: (scriptStr, params) => {\n const fn = new Function('params', `return (${scriptStr})(params)`);\n return fn(params);\n },\n args: [job.script, job.params || {}],\n });\n const timeoutPromise = new Promise((_, reject) =>\n setTimeout(() => reject(new Error('脚本执行超时(60秒)')), 60000)\n );\n const results = await Promise.race([execPromise, timeoutPromise]);\n return (results && results[0] && results[0].result) || null;\n}\n\n// 在目标域名的标签页中执行签到脚本\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 const tabs = await chrome.tabs.query({ url: `*://${job.domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n } else {\n tab = await chrome.tabs.create({ url: `https://${job.domain}/`, active: false });\n created = true;\n // 等待页面加载完成\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 15000); // 超时保护\n });\n }\n\n // 如果任务指定了导航 URL(如吾爱破解),先导航到该页面(模拟手动点击),再执行检查脚本\n if (job.navigate_url) {\n await chrome.tabs.update(tab.id, { url: job.navigate_url });\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n await new Promise((r) => setTimeout(r, 2000));\n }\n\n // 执行签到。\n // MV3 禁止 new Function / eval,面板下发的脚本字符串通常无法执行;\n // 因此:有 navigate_url 的任务直接对目标页做原生巡检;否则先试面板脚本,拿不到结果就回退原生巡检。\n let result = null;\n if (!job.navigate_url && job.script) {\n try {\n result = await runPanelScript(tab.id, job);\n } catch (e) {\n console.log('[签到面板] 面板脚本执行失败,回退原生巡检:', e.message || e);\n result = null;\n }\n }\n if (!result) result = await inspectPage(tab.id);\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 if (signHref) {\n await chrome.tabs.update(tab.id, { url: signHref });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n } else if (result.clickSel) {\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 await new Promise((r) => setTimeout(r, 3000));\n }\n const after = (signHref || result.clickSel) ? await inspectPage(tab.id) : 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 try {\n const u = new URL(job.sign_url);\n const sameHost = !job.domain || u.hostname === job.domain || u.hostname.endsWith('.' + job.domain);\n if ((u.protocol === 'http:' || u.protocol === 'https:') && sameHost) {\n await chrome.tabs.update(tab.id, { url: u.href });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n const after = await inspectPage(tab.id);\n if (after) result = after;\n }\n } catch (e) {\n console.log('[签到面板] 兑底签到地址不可用:', e.message || e);\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) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 主流程:获取任务 → 逐个执行 → 上报\nasync function runJobs() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) {\n console.log('[签到面板] 未配置面板地址或 API Key,跳过');\n return;\n }\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 for (const job of jobs) {\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 // 任务之间稍作间隔\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// 回传中继结果\nasync function submitRelayResult(panelUrl, apiKey, jobId, result) {\n await fetch(panelUrl + '/api/external/relay/' + jobId + '/result', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify(result),\n }).catch(() => { /* 忽略 */ });\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// 所以:第一次用短超时快速试探;若超时,就把标签**激活到前台**再试一次,跑完把焦点还给用户。\nasync function executeRelayJob(job) {\n const url = new URL(job.url);\n const domain = url.hostname;\n let tab = null;\n let created = false;\n let prevActiveId = null;\n let activated = false;\n try {\n const tabs = await chrome.tabs.query({ url: `*://${domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n } else {\n tab = await chrome.tabs.create({ url: `https://${domain}/`, active: false });\n created = true;\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 8000); // 建标签页只为拿登录态,页面没加载完也照样能发请求,不必等满\n });\n }\n\n const injectFetch = async (url, method, headers, bodyB64, options, timeoutMs) => {\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 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: [job.url, job.method, job.headers || {}, job.body_base64 || null, job.options || {}, timeoutMs],\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.timeout) {\n // 后台标签里反爬挑战跑不动 → 把标签拿到前台重试一次\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 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 headers = { ...(r.headers || {}), 'x-relay-url': r.url || job.url };\n return { status: r.status, headers, 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 if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\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 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 for (const job of jobs) {\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 return { ok: true, count: 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 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// 定时器\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 手动触发\nchrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {\n if (msg && 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",
|
||
"@b64:icon48.png": "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",
|
||
"@b64:icon128.png": "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",
|
||
};
|