commit ad7a9260b4df0591bf0f1b9c94fe564057811a8b Author: Longtail Date: Sat Oct 31 14:43:15 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89ea1bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.idea/ +.vscode/ +*.log +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..c06c79e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# 广谈大湾网 · 随机换号扩展 3.0.4 + +将提供的 `dww11-acc-switch.user.js` 重写为 Chrome / Edge Manifest V3 扩展。无需运行本机服务,无需持续授权本地文件,也无需安装 npm 依赖。 + +## 安装 + +1. 解压 `dww11-extension-3.0.4.zip` 到一个固定目录,保留整个 `dww11-extension` 文件夹。 +2. Chrome 打开 `chrome://extensions`;Edge 打开 `edge://extensions`。 +3. 开启「开发者模式」,选择「加载已解压的扩展程序」,选中**包含 manifest.json 的 dww11-extension 文件夹**。 +4. 停用原来的油猴脚本,避免两个程序同时换号。 +5. 点击扩展图标打开管理页,选择 CSV、检查预览并点击「确认导入 IndexedDB」。也可在扩展详情页打开「扩展程序选项」。 +6. 打开或刷新 或 。在顶栏「欢迎您的登录!」左侧点击「登陆下一帐号」;未登录时按钮位于「立即登录」左侧。没有顶栏的子页会在卡片内保留该按钮。 + +卡片右上角可点击「隐藏卡片」,顶栏的「显示换号卡片」可以重新打开。显示状态保存在扩展 IndexedDB,刷新或重启后保留;没有顶栏的页面会提供一个小的「显示换号卡片」入口。隐藏卡片不会暂停换号或改变抽取进度。 + +主站与房产站共用同一份扩展账号库、轮次、已抽记录和卡片显示偏好。允许这两个 HTTPS 域名之间的登录、退出跳转,跳转后继续同一流程;仍兼容原来的裸域名入口。不会放开任意子域名或外部网站。登录结果通过当前页面同源首页核对,不假定两个站点必然共享登录 Cookie。 + +需要 Chrome / Edge 120 或更新版本。当前交付不包含 Firefox / Safari 版本。ZIP 用于解压安装,不能直接拖动 ZIP 作为已签名扩展安装。 + +**以后更新:在原目录覆盖代码后,到扩展管理页点击重新加载,再刷新网站。请保持安装目录不变,不要卸载重装。** 不同安装路径可能产生不同扩展 ID,导致旧 IndexedDB 无法被新安装读取。 + +## CSV 格式 + +```csv +username,password +my_account_01,my_password_01 +my_account_02,"password,with,commas" +my_account_03,"password with ""quotes""" +``` + +- 必须包含一行表头。支持 `username,password`;账号列也支持 `account`、`账号`、`帐号`、`用户名`,密码列支持 `密码`。额外列会忽略。 +- 支持 UTF-8、UTF-8 BOM,以及管理页手动选择的 GB18030 / GBK。默认 UTF-8;编码错误会阻止导入。 +- 支持 CRLF / LF、引号内逗号、双引号转义和引号内换行。分隔符固定为英文逗号,不是分号或制表符。 +- 用户名两端空格会去除;密码中的空格、换行和前导零原样保留。请用纯文本方式保存原始凭据,Excel 如果提前把 `00123` 转成 `123`,扩展无法还原。 +- 文件最大 20 MiB。重复用户名、空账号、空密码、列数或引号错误会阻止整批导入,不会留下部分数据。 +- 同名账号只更新密码,保留账号状态、时间、错误及本轮抽取记录。新账号加入本轮剩余池;在同一天也不会重新抽到已经使用过的账号。 +- 存在未完成流程时禁止导入。先继续完成流程,或在管理页点击「结束未完成流程,保留抽取记录」。 +- 模板中的示例不是可登录账号,请替换为你自己的账号后导入。 + +## 随机、轮次和跨天规则 + +1. **日期按电脑本地时区计算**,格式为 `YYYY-MM-DD`,不使用 UTC 日期。 +2. 使用固定版本的伪随机算法和 Fisher–Yates 洗牌。种子包含「本地日期+轮次」;先按用户名进行固定排序,不依赖数据库 ID 或 CSV 行顺序。 +3. **账号集合、日期、轮次相同,生成的顺序相同**。同一天进入下一轮会用新的轮次重新洗牌;这不保证相邻两轮的边界账号必然不同,小账号池可能产生相同排列。 +4. **抽出即记为已用**:账号预留、已抽集合和登录阶段在同一个 IndexedDB 写事务里提交后,才返回给网站脚本。密码错误、暂停、关闭页面、手动结束流程都不会把账号退回本轮。 +5. 本轮所有**非 invalid 的账号**抽完后,下一次需要抽号时自动增加轮次,清空已抽集合,再按当天日期洗牌。只剩一个可用账号时,每一轮只能抽到它;全部失效时暂停并提示,不会无限重试。 +6. **跨天不强制开始新一轮**。保留本轮已抽集合,用新日期重排剩余账号,因此跨天也不在同一轮重复。中途导入新账号、账号失效或手动恢复,会改变剩余池及其排列;历史抽取记录保留。 +7. 「本轮剩余」为仍可抽取的非失效账号数;「已抽」包括本轮抽出后被标记失效、未完成或手动结束的账号。 + +固定顺序是可复现的伪随机顺序,不用于密码学。独立洗牌也可能偶然出现数据库排序或与前一轮相同的顺序;不能为了排除这种可能而声称是真正均匀随机。 + +## 登录、暂停和恢复 + +- 换号流程:随机抽号 → 退出旧账号 → 打开登录表单 → 填写并提交 → 核对实际用户名 → 保存结果并结束。登录成功后按钮立即恢复,可再次点击「登陆下一帐号」。每次点击完成一次成功登录;不会在成功后无休止地自动切换账号。 +- 不再打开、等待或核对任何发布页面,也不要求存在「免费发布信息」入口。保存成功结果与释放流程占用在同一 IndexedDB 事务中完成,页面停留在网站登录后的自然位置。 +- 更新时会自动清理旧版卡在 `publish` / `publishing` 阶段或已保存成功结果的残留流程,账号、轮次和已抽记录保留。覆盖原目录、重新加载扩展并刷新网站即可继续换号,无需在管理页手动结束旧发布流程。 +- 账号密码错误会标记 `invalid` 并记录错误,然后继续抽号。明确的冻结、封禁、禁用提示也会记录为失效;仅检查站点提示容器和原脚本指定的密码安全页面,不扫描帖子正文。 +- 验证码、安全验证、频率限制、网络异常或无法确认登录结果会暂停,保留账号状态及流程,不自动把账号判为失效。需要你在网页人工处理。 +- 点击「暂停换号」保留本次账号;点击「继续未完成流程」继续同一账号,**不算再次抽取**。 +- 如果上次已经记录为「提交登录」,恢复时先核对页面或首页的实际登录用户名,不自动重交密码。30 秒仍无法核对会暂停。确认需要重新提交时,手动点击「重试本次登录」并确认;仍然使用本次预留账号,不抽新账号。 +- 由于数据库写入与网站请求无法组成一个事务,断电可能发生在写入「已提交」之后、实际点击之前。扩展选择保留本次账号并等待核对/人工重试,以避免自动重复提交。 +- 浏览器重启、扩展更新、标签关闭后,账号池、日期、轮次、已抽记录和未完成流程仍保存在 IndexedDB。重新打开/刷新网站并点击「继续未完成流程」。不要依赖原标签的会话存储。 +- 后台 Service Worker 正常休眠再唤醒无需手动恢复;它不使用内存变量保存关键进度。 +- 同时只允许一个网站标签持有流程。另一个标签不能发起并行换号。旧页面或旧流程的异步操作会被拒绝。若原标签仍打开,请在原标签继续;若要改用其他标签,可先在管理页结束流程,再刷新网站开始下一账号。 +- 管理页的「结束未完成流程」会放弃当前流程,但保留抽取记录。网站页面需要刷新以更新按钮。若刚好有请求已发送到网站,结束流程不能撤回网站已经收到的请求。 +- 更新了失效账号的密码后,需点击该账号的「恢复待检测」才重新进入可用池;本轮已抽记录仍然保留。 + +## 数据与权限 + +- 数据保存在**扩展来源**的 IndexedDB,不放在网站 IndexedDB、网站 localStorage 或 sessionStorage 中。清除网站普通数据与清除扩展数据不是同一操作。 +- 密码以明文保存在本地数据库中,不做无法保护本地攻击者的伪加密。管理页不显示密码,列表 API 不返回密码;只有进行登录时才把本次密码发送给隔离内容脚本并填入目标站点表单。 +- 扩展没有外部服务、遥测、远程脚本或 CDN。登录时会访问目标网站,原站点及其登录脚本能读取填入的凭据。 +- 站点权限仅 `https://www.dww11.com/*`、`https://house.dww11.com/*` 和兼容的 `https://dww11.com/*`;`unlimitedStorage` 用于本地持久存储,避免普通存储配额/逐出限制。没有读取任意网站、浏览记录、剪贴板或本地文件系统的权限。 +- **卸载扩展、清除扩展数据、更换浏览器配置、切换安装路径/扩展 ID 可能丢失或隔离本地数据。** Chrome 和 Edge、普通和隐身配置不共享账号库。请妥善保存原 CSV 作为账号备份;CSV 不包含抽取进度。 +- 本版本没有导出进度、删除账号或跨设备同步功能;正常更新代码不需要清空或重新导入数据库。 + +## 验证范围与源码 + +本目录为可直接加载的源码,无编译步骤。算法与 CSV 测试在交付项目的 `tests/core.test.mjs`;真实浏览器测试在 `tests/browser-integration.mjs`、`tests/browser-restart.mjs`,使用独立临时浏览器配置和虚拟账号。具体结果见 ZIP 旁的 `测试说明.md`。 + +真实浏览器中测试了 IndexedDB 事务、消息权限、CSV 导入、并发互斥、后台回收、完整浏览器退出重启和网站流程。网站登录流程使用本地拦截的模拟页面,**未使用真实账号验证 dww11.com 当前网站**。网站改版可能要求调整 DOM 选择器或错误识别规则;这些规则来源于用户提供的原脚本。 + +实现依据:[Chrome 内容脚本隔离](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts)、[扩展消息通信](https://developer.chrome.com/docs/extensions/develop/concepts/messaging)、[Service Worker 生命周期与持久化](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)。 diff --git a/background.js b/background.js new file mode 100644 index 0000000..99b7dfe --- /dev/null +++ b/background.js @@ -0,0 +1,22 @@ +import { operate } from './service.js'; +import { transact } from './db.js'; + +chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.app !== 'dww11-v3') return; + operate(message.route, message.body, sender).then(data => respond({ ok: true, data }), error => respond({ ok: false, error: error.message || '操作失败' })); + return true; +}); +chrome.action.onClicked.addListener(() => chrome.runtime.openOptionsPage()); + +function park(reason) { + return transact((accounts, state) => { + if (state.flow) { state.flow.paused = true; state.flow.tabId = null; state.flow.documentId = null; state.flow.reason = reason; } + }).catch(error => console.error('无法保存流程暂停状态', error.name)); +} +chrome.runtime.onStartup.addListener(() => park('浏览器已重启,点击“继续未完成流程”恢复。')); +chrome.runtime.onInstalled.addListener(() => park('扩展已启动或更新,刷新网站后点击继续。')); +chrome.tabs.onRemoved.addListener(tabId => { + transact((accounts, state) => { + if (state.flow?.tabId === tabId) { state.flow.paused = true; state.flow.tabId = null; state.flow.documentId = null; state.flow.reason = '原标签页已关闭,点击继续恢复。'; } + }).catch(error => console.error('保存标签关闭状态失败', error.name)); +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..640dd16 --- /dev/null +++ b/content.js @@ -0,0 +1,361 @@ +(() => { + 'use strict'; + if (globalThis.__dww11ExtensionLoaded) return; + globalThis.__dww11ExtensionLoaded = true; + const HOME = 'https://www.dww11.com/'; + const PROBE_HOME = `${location.origin}/`; + const SITE_ORIGINS = new Set(['https://www.dww11.com', 'https://house.dww11.com', 'https://dww11.com']); + const isSiteURL = url => SITE_ORIGINS.has(new URL(url, location.href).origin); + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + const visible = el => !!el && !!el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden'; + let flow = null, busy = false, running = false, panel, message, nextButton, resumeButton, retryButton, stopButton; + let automationStopped = false, automationReady = false; + let actions, toolbar, panelToggle, hideButton, restoreButton, panelHidden = false, visibilitySaving = false; + class Stopped extends Error {} + + async function request(route, body = {}) { + let result; + try { result = await chrome.runtime.sendMessage({ app: 'dww11-v3', route, body }); } + catch { throw new Error('扩展连接已断开,请刷新网页;已提交的抽取进度保留。'); } + if (!result?.ok) throw new Error(result?.error || '扩展未返回结果,请刷新后继续。'); + return result.data; + } + async function call(route, body = {}) { + const run = flow?.id; + if (automationStopped || !run || flow.paused) throw new Stopped(); + const data = await request(route, { ...body, run }); + if (automationStopped || flow?.id !== run || flow.paused) throw new Stopped(); + return data; + } + async function save(patch) { + const data = await call('/save', { patch }); + flow = data.flow; + } + const phase = name => save({ phase: name }); + function check(run) { if (automationStopped || flow?.id !== run || flow.paused) throw new Stopped(); } + function show(text, error = false) { + message.textContent = text; + panel.style.borderColor = error ? '#d86d20' : '#d3d9e2'; + controls(); + } + function controls() { + const host = document.getElementById('dww11-extension-panel'); + if (host) host.setAttribute('data-auto-news-state', JSON.stringify({version: 1, ready: automationReady, idle: automationReady && !flow && !busy && !running, paused: !!flow?.paused, phase: flow?.phase || null})); + if (!nextButton) return; + nextButton.disabled = busy || !!flow; + resumeButton.hidden = !flow || (!flow.paused && running); + resumeButton.disabled = busy || running; + retryButton.hidden = !flow?.paused || !['submitted', 'login', 'logout'].includes(flow.phase); + retryButton.disabled = busy || running; + stopButton.disabled = !flow || busy; + } + function panelVisibility(hidden) { + panelHidden = hidden; + panel.hidden = hidden; + if (panelToggle) { + panelToggle.textContent = hidden ? '显示换号卡片' : '隐藏换号卡片'; + panelToggle.setAttribute('aria-expanded', String(!hidden)); + } + // 登录子页可能没有顶栏,仍需保留重新打开卡片的入口。 + restoreButton.hidden = !hidden || !!toolbar?.isConnected; + } + async function changePanelVisibility(hidden) { + if (visibilitySaving) return; + visibilitySaving = true; + panelVisibility(hidden); + for (const b of [panelToggle, hideButton, restoreButton]) if (b) b.disabled = true; + try { await request('/panel', { hidden }); } + catch (e) { panelVisibility(false); show(e.message, true); } + finally { + visibilitySaving = false; + for (const b of [panelToggle, hideButton, restoreButton]) if (b) b.disabled = false; + } + } + function mountTopbar() { + const anchors = [...document.querySelectorAll('.topwp a')]; + const anchor = anchors.find(a => /欢迎您的登[录陆]/.test(a.textContent.replace(/\s/g, ''))) + || anchors.find(a => /立即登[录陆]/.test(a.textContent.replace(/\s/g, ''))); + if (!anchor) { + if (toolbar) { + actions.prepend(nextButton); + nextButton.removeAttribute('style'); + toolbar.remove(); toolbar = null; panelToggle = null; + } + panelVisibility(panelHidden); + return; + } + if (toolbar?.isConnected && toolbar.nextElementSibling === anchor) return; + if (!toolbar) { + toolbar = document.createElement('span'); + toolbar.id = 'dww11-extension-topbar'; + // 与原站点左浮动的链接保持一致,DOM 中在欢迎文字之前即可显示在其左侧。 + toolbar.style.cssText = 'float:left;display:inline-flex;align-items:center;gap:6px;margin:4px 10px 0 0;'; + panelToggle = document.createElement('button'); panelToggle.type = 'button'; + panelToggle.id = 'dww11-extension-panel-toggle'; + panelToggle.style.cssText = 'padding:2px 8px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#666;font:inherit;line-height:24px;cursor:pointer;'; + panelToggle.addEventListener('click', () => changePanelVisibility(!panelHidden)); + toolbar.append(panelToggle, nextButton); + } + nextButton.style.cssText = 'padding:2px 10px;border:1px solid #f28518;border-radius:4px;background:#fff;color:#e66d00;font:inherit;line-height:24px;cursor:pointer;'; + anchor.before(toolbar); + panelVisibility(panelHidden); + } + async function guarded(fn) { + if (busy) return; + busy = true; controls(); + try { await fn(); } + catch (e) { if (!(e instanceof Stopped)) show(e.message, true); } + finally { busy = false; controls(); } + } + async function suspend(reason) { + if (flow && !flow.paused) { + const run = flow.id; + try { await request('/suspend', { run, reason }); } + finally { if (flow?.id === run) { flow.paused = true; flow.reason = reason; } } + } + show(reason + '\n进度已保留,可继续或在管理页结束流程。', true); + } + function signedIn() { return !!document.querySelector('#mumucms_username'); } + function usernameOf(doc = document) { + const el = doc.querySelector('#mumucms_username'); + return el ? [...el.childNodes].filter(n => n.nodeType === Node.TEXT_NODE).map(n => n.textContent).join('').trim() : ''; + } + function restrictionFromText(text) { + const match = String(text || '').match(/(?:您(?:当前的|的)?|你(?:当前的|的)?)?(?:账号|帐号|账户|用户)[^。\n!?]{0,80}?(?:被冻结|已冻结|被封禁|已封禁|被禁止访问|已禁止访问|被禁用|已禁用|被停用|已停用)[^。\n!?]{0,140}/); + if (!match) return null; + const message = match[0].trim(); + return { code: /冻结/.test(message) ? 'account_frozen' : /封禁|禁止访问/.test(message) ? 'account_banned' : 'account_disabled', message }; + } + function restriction(doc = document, url = location.href) { + const nodes = [...doc.querySelectorAll('#messagetext, .layui-layer-content, .alert_error, .alert_info, .alert_warning, [role="alert"]')]; + const parsed = new URL(url, HOME); + if (parsed.pathname === '/home.php' && parsed.searchParams.get('ac') === 'profile' && parsed.searchParams.get('op') === 'password') { + const content = doc.querySelector('#ct'); if (content) nodes.push(content); + } + for (const node of nodes) { + if (doc === document && !visible(node)) continue; + const error = restrictionFromText(node.innerText || node.textContent); + if (error) return error; + } + return null; + } + async function recordRestriction() { + const error = restriction(), username = usernameOf(); + if (error && username) { + await request('/restriction', { username, error }); + show('当前账号被冻结或封禁,已标记失效并保存错误信息。', true); + } + } + function classify(text) { + if (/验证码|安全验证|滑块|拼图|操作.{0,5}频繁|次数.{0,5}(过多|限制|超)|稍后再试|分钟后|访问受限|网络.{0,5}(错误|异常)/.test(text)) return 'pause'; + if (/密码.{0,8}(错误|不正确)|用户名.{0,8}(不存在|错误)|用户不存在|账号.{0,8}(不存在|禁用|冻结)|帐号.{0,8}(不存在|禁用|冻结)|用户.{0,8}禁止登录|账户.{0,8}停用/.test(text)) return 'invalid'; + return null; + } + function messages() { + const texts = [...document.querySelectorAll('.layui-layer-content, #messagetext, .alert_error, .alert_info, [role="alert"]')].filter(visible).map(e => e.textContent); + for (const frame of document.querySelectorAll('iframe[name="submitiframe"]')) { + try { if (frame.contentDocument?.body) texts.push(frame.contentDocument.body.innerText); } catch { /* 跨域验证框不可读。 */ } + } + return texts.join('\n'); + } + function captchaVisible() { + return [...document.querySelectorAll('iframe')].some(f => visible(f) && /captcha|tcaptcha|turing/i.test(f.src)); + } + function fill(el, value) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + async function begin() { + automationStopped = false; + if (location.hostname === 'dww11.com') { location.assign(HOME); return; } + await recordRestriction(); + if (automationStopped) return; + flow = (await request('/start')).flow; + await drive(); + } + async function resume(retry = false) { + automationStopped = false; + if (location.hostname === 'dww11.com') { location.assign(HOME); return; } + if (retry && !confirm('将重新提交本次抽到的账号(不抽新账号)。如上次提交已成功,可能再次登录。确认重试?')) return; + flow = (await request('/resume')).flow; + if (retry) { + await save({ phase: 'logout', hops: 0, submittedAt: null, probedAt: null, invalidError: null }); + location.assign(HOME); + return; + } + await drive(); + } + + async function drive() { + if (automationStopped && flow && !flow.paused) { await suspend('新闻工作台已终止流程。'); return; } + if (running || !flow || flow.paused) return; + running = true; controls(); + const run = flow.id; + const waitingSince = Date.now(); // 重启后给已提交请求留出核对结果的时间。 + try { + while (flow && !flow.paused) { + check(run); + const current = flow.current; + const label = current ? `第 ${current.round} 轮 · 第 ${current.position} 个账号` : ''; + if (['submitted', 'success'].includes(flow.phase)) { + const error = restriction(); + if (error) { + if (signedIn() && usernameOf() !== current.username) throw new Error('限制提示所属账号与本次账号不一致,已暂停。'); + await save({ invalidError: error, phase: 'invalid' }); + continue; + } + } + if (flow.phase === 'select') { + flow = (await call('/next')).flow; + } else if (flow.phase === 'logout') { + show(`准备登录 ${label}……`); + if (signedIn()) { + const link = document.querySelector('#mumucms_username_menu a[href*="action=logout"]'); + if (!link) throw new Error('未找到头像菜单中的退出链接。'); + const url = new URL(link.href); + if (!isSiteURL(url.href)) throw new Error('退出链接不是受支持的站点地址。'); + location.assign(url.href); break; + } + await phase('login'); + } else if (flow.phase === 'login') { + if (signedIn()) throw new Error('退出未完成,请回首页后继续。'); + if (captchaVisible()) throw new Error('需要人工完成验证码。'); + const form = document.querySelector('form#login'); + const username = form?.querySelector('input[name="username"]'), password = form?.querySelector('input[name="password"]'); + const submit = form?.querySelector('button[type="submit"]'); + if (!username || !password || !submit) { + const hops = (flow.hops || 0) + 1; + if (hops > 5) throw new Error('未找到账号密码登录表单,请打开“立即登录”后继续。'); + await save({ hops }); + const link = [...document.querySelectorAll('.topwp a')].find(a => /立即登[录陆]/.test(a.textContent)); + if (link) { + if (!isSiteURL(link.href)) throw new Error('登录链接不是受支持的站点地址。'); + link.click(); + } else location.assign(HOME); + break; + } + const deadline = Date.now() + 15000; + while (document.readyState !== 'complete' && Date.now() < deadline) { await sleep(200); check(run); } + if (document.readyState !== 'complete') throw new Error('登录页仍在加载,请稍后继续。'); + await sleep(600); check(run); + let { account } = await call('/next'); + if (account.username !== current.username) throw new Error('账号位置发生变化。'); + await call('/attempt', { account: current.username }); + check(run); + fill(username, account.username); fill(password, account.password); + account = null; + await save({ phase: 'submitted', submittedAt: Date.now(), hops: 0, oldMessage: messages(), probedAt: null }); + show(`正在登录 ${label}……`); + submit.click(); + // 若在写入 submitted 后、点击前崩溃,恢复时只核对结果,由用户明确选择重试。 + } else if (flow.phase === 'submitted') { + if (signedIn()) { + if (usernameOf() !== current.username) throw new Error('当前用户名与本次账号不一致。'); + await phase('success'); continue; + } + const text = messages(); + const result = text !== flow.oldMessage || Date.now() - flow.submittedAt > 1500 ? classify(text) : null; + if (captchaVisible() || result === 'pause') throw new Error('网站要求验证或暂时限制登录,请人工处理后继续。'); + if (result === 'invalid') { + await save({ phase: 'invalid', invalidError: { code: 'login_failed', message: text.trim().slice(0, 500) } }); continue; + } + if (Date.now() - flow.submittedAt > 1500 && Date.now() - (flow.probedAt || 0) > 2500) { + await save({ probedAt: Date.now() }); + let response, html; + try { + response = await fetch(PROBE_HOME, { credentials: 'include', cache: 'no-store', signal: AbortSignal.timeout(5000) }); + html = response.ok ? await response.text() : null; + } catch { /* 超时保持未确认,不据此标记失效。 */ } + check(run); + if (html && new URL(response.url).origin === location.origin) { + const doc = new DOMParser().parseFromString(html, 'text/html'), name = usernameOf(doc); + const error = restriction(doc, response.url); + if (name && name !== current.username) throw new Error('首页核对到其他账号,已暂停。'); + if (error && name === current.username) { await save({ phase: 'invalid', invalidError: error }); continue; } + if (name === current.username) { location.assign(PROBE_HOME); break; } + } + } + if (Date.now() - Math.max(flow.submittedAt || 0, waitingSince) > 30000) throw new Error('未能确认登录结果,账号未标记失效。可核对网站后继续,或手动重试本次登录。'); + await sleep(250); + } else if (flow.phase === 'invalid') { + await call('/result', { account: current.username, result: 'invalid', error: flow.invalidError }); + show(`${label} 不可用,已保存错误信息,继续下一个……`); + await phase('select'); + location.assign(HOME); break; + } else if (flow.phase === 'success') { + await call('/result', { account: current.username, result: 'available' }); + flow = null; + show(`${label} 登录成功。可继续点击“登陆下一帐号”。`); + } else throw new Error('流程阶段不正确,请在管理页结束后重试。'); + } + } catch (e) { + if (!(e instanceof Stopped) && flow?.id === run) { + try { await suspend(e.message); } catch (error) { show(error.message, true); } + } + } finally { running = false; controls(); } + } + + function mount() { + const host = document.createElement('div'); + host.id = 'dww11-extension-panel'; + const root = host.attachShadow({ mode: 'closed' }); + const style = document.createElement('style'); + style.textContent = ':host{all:initial;position:fixed;right:20px;bottom:24px;z-index:2147483647}section{width:340px;max-width:calc(100vw - 60px);padding:16px;border:1px solid #d3d9e2;border-radius:12px;background:#fff;box-shadow:0 6px 30px #0002;color:#253147;font:14px/1.65 system-ui,sans-serif}strong{font-size:15px}p{white-space:pre-wrap;margin:10px 0}.heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.actions{display:flex;flex-wrap:wrap;gap:8px}button{padding:6px 10px;border:1px solid #ccd3df;border-radius:6px;background:#f7f9fc;color:#253147;font:13px system-ui;cursor:pointer}.actions button:first-child{background:#ed7b16;border-color:#ed7b16;color:#fff}button:disabled{opacity:.5;cursor:default}button[hidden],section[hidden]{display:none}'; + root.append(style); + panel = document.createElement('section'); + panel.hidden = true; + const title = document.createElement('strong'); title.textContent = '广谈大湾网 · 随机换号'; + const heading = document.createElement('div'); heading.className = 'heading'; + hideButton = document.createElement('button'); hideButton.type = 'button'; hideButton.textContent = '隐藏卡片'; + hideButton.addEventListener('click', () => changePanelVisibility(true)); + heading.append(title, hideButton); + message = document.createElement('p'); message.textContent = '正在读取本地进度……'; + actions = document.createElement('div'); actions.className = 'actions'; + const add = (text, fn) => { + const b = document.createElement('button'); b.type = 'button'; b.textContent = text; + b.addEventListener('click', () => guarded(fn)); actions.append(b); return b; + }; + nextButton = add('登陆下一帐号', begin); + nextButton.id = 'dww11-extension-next'; + resumeButton = add('继续未完成流程', () => resume()); + retryButton = add('重试本次登录', () => resume(true)); + add('管理 / 导入 CSV', () => request('/manager')); + stopButton = add('暂停换号', () => suspend('自动换号已暂停。')); + restoreButton = document.createElement('button'); restoreButton.type = 'button'; restoreButton.textContent = '显示换号卡片'; restoreButton.hidden = true; + restoreButton.addEventListener('click', () => changePanelVisibility(false)); + panel.append(heading, message, actions); root.append(panel, restoreButton); document.body.append(host); + mountTopbar(); + new MutationObserver(mountTopbar).observe(document.body, { childList: true, subtree: true }); + controls(); + } + + async function init() { + mount(); + const info = await request('/hello'); + automationReady = true; + panelVisibility(info.panelHidden); + flow = info.flow; + if (flow && !info.owned) { + flow.paused = true; + show('存在未完成流程。可在原标签页继续;原标签关闭后可在此接续。', true); + } else if (flow?.paused) show(flow.reason || '存在未完成流程,点击继续恢复。', true); + else if (flow) await drive(); + else { + show(info.total ? `本地 ${info.total} 个账号,可用 ${info.active} 个。\n第 ${info.round} 轮,剩余 ${info.remaining} 个。` : '请先点击“管理 / 导入 CSV”导入账号。'); + await recordRestriction(); + } + } + // Public bridge exposes no credentials and only permits stopping, never starting login. + document.addEventListener('dww11:auto-stop', () => { + automationStopped = true; + const current = flow; + if (!current || current.paused) return; + current.paused = true; // Synchronously invalidate in-flight continuations. + controls(); + request('/suspend', {run: current.id, reason: '新闻工作台已终止流程。'}) + .then(() => show('新闻工作台已终止流程;如需恢复,请手动继续。', true)) + .catch(error => show(error.message, true)); + }); + init().catch(e => { if (message) { panelVisibility(false); show('扩展启动失败:' + e.message, true); } }); +})(); diff --git a/core.js b/core.js new file mode 100644 index 0000000..0517114 --- /dev/null +++ b/core.js @@ -0,0 +1,98 @@ +// 算法版本固定;不得改成 Math.random() 或依赖数据库自增 ID。 +export function localDay(date = new Date()) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function seed32(text) { + let h = 2166136261; + for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619); + return h >>> 0; +} + +export function shuffled(usernames, day, round) { + const items = [...usernames].sort(); // UTF-16 固定排序,独立于 CSV 行顺序、ID 和系统语言。 + let seed = seed32(`dww11-v1|${day}|${round}`); + function random() { + seed = (seed + 0x6D2B79F5) >>> 0; + let t = seed; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + return items; +} + +export function newSchedule() { return { version: 1, day: '', round: 1, used: [], totalDraws: 0 }; } + +export function remaining(accounts, schedule, day) { + const used = new Set(schedule.used); + return shuffled(accounts.filter(a => a.status !== 'invalid').map(a => a.username), day, schedule.round) + .filter(username => !used.has(username)); +} + +// 在同一个 readwrite 事务里调用,先持久化 used,再把账号返回给页面。 +export function draw(accounts, schedule, day) { + if (!accounts.some(a => a.status !== 'invalid')) throw new Error('没有可用账号,请导入账号或在管理页恢复失效账号。'); + let choices = remaining(accounts, schedule, day); + if (!choices.length) { + schedule.round++; + schedule.used = []; + choices = remaining(accounts, schedule, day); + } + schedule.day = day; + const username = choices[0]; + schedule.used.push(username); + schedule.totalDraws++; + return { account: accounts.find(a => a.username === username), round: schedule.round, position: schedule.used.length }; +} + +export function parseCSV(input) { + if (typeof input !== 'string') throw new Error('CSV 内容必须是文本。'); + const text = input.replace(/^\uFEFF/, ''); + const rows = []; + let row = [], cell = '', quoted = false, endedQuote = false; + const pushRow = () => { row.push(cell); if (row.some(v => v !== '')) rows.push(row); row = []; cell = ''; endedQuote = false; }; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"') { + if (text[i + 1] === '"') { cell += '"'; i++; } + else { quoted = false; endedQuote = true; } + } else cell += c; + } else if (c === ',') { row.push(cell); cell = ''; endedQuote = false; } + else if (c === '\r' || c === '\n') { if (c === '\r' && text[i + 1] === '\n') i++; pushRow(); } + else if (c === '"' && !cell && !endedQuote) quoted = true; + else { + if (endedQuote || c === '"') throw new Error(`CSV 第 ${rows.length + 1} 条记录的引号格式错误。`); + cell += c; + } + } + if (quoted) throw new Error('CSV 存在未闭合的双引号。'); + if (cell || row.length || endedQuote) pushRow(); + if (rows.length < 2) throw new Error('CSV 必须包含表头及至少一个账号。'); + const aliases = { username: 'username', account: 'username', '账号': 'username', '帐号': 'username', '用户名': 'username', password: 'password', '密码': 'password' }; + const headers = rows.shift().map(h => aliases[h.trim().toLowerCase()] || h.trim().toLowerCase()); + for (const name of ['username', 'password']) if (headers.filter(h => h === name).length !== 1) throw new Error('表头必须各包含一列 username,password(或 账号,密码)。'); + const users = new Set(); + return rows.map((r, i) => { + if (r.length !== headers.length) throw new Error(`CSV 第 ${i + 2} 条记录的列数不匹配。`); + const username = r[headers.indexOf('username')].trim(); + const password = r[headers.indexOf('password')]; // 密码中的空格有意义,不 trim。 + if (!username || !password) throw new Error(`CSV 第 ${i + 2} 条记录的账号或密码为空。`); + if (username.length > 500 || password.length > 4096) throw new Error(`CSV 第 ${i + 2} 条记录的字段过长。`); + if (users.has(username)) throw new Error(`CSV 第 ${i + 2} 条记录的账号重复,整批未导入。`); + users.add(username); + return { username, password }; + }); +} + +export const isRestriction = error => ['account_frozen', 'account_banned', 'account_disabled'].includes(error?.code); +export function errorRecord(error) { + if (!error) return { code: 'login_failed', message: '账号或密码错误', at: new Date().toISOString() }; + if (!['login_failed', 'account_frozen', 'account_banned', 'account_disabled'].includes(error.code) || typeof error.message !== 'string' || !error.message.trim()) throw new Error('账号错误信息不正确。'); + return { code: error.code, message: error.message.trim().slice(0, 500), at: new Date().toISOString() }; +} diff --git a/db.js b/db.js new file mode 100644 index 0000000..c9fce12 --- /dev/null +++ b/db.js @@ -0,0 +1,43 @@ +const DB_NAME = 'dww11-account-switch-v3'; +let opening; +export function openDB() { + if (!opening) opening = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore('accounts', { keyPath: 'username' }); + req.result.createObjectStore('meta'); + }; + req.onsuccess = () => { + const db = req.result; + db.onversionchange = () => { db.close(); opening = null; }; + db.onclose = () => { opening = null; }; + resolve(db); + }; + req.onerror = () => { opening = null; reject(new Error('无法打开 IndexedDB:' + req.error?.message)); }; + req.onblocked = () => { opening = null; reject(new Error('数据库升级被阻塞,请关闭扩展管理页后重试。')); }; + }); + return opening; +} + +// 所有状态变更跨 accounts/meta 使用同一事务。回调必须同步,避免 await 使事务自动提交。 +export async function transact(reducer, write = true) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(['accounts', 'meta'], write ? 'readwrite' : 'readonly', write ? { durability: 'strict' } : undefined); + const accountsStore = tx.objectStore('accounts'), metaStore = tx.objectStore('meta'); + const a = accountsStore.getAll(), m = metaStore.get('state'); + let output, failure, ready = 0; + const loaded = () => { + if (++ready !== 2) return; + try { + const state = m.result || { schedule: null, flow: null }; + output = reducer(a.result, state, accountsStore); + if (write) metaStore.put(state, 'state'); + } catch (e) { failure = e; tx.abort(); } + }; + a.onsuccess = loaded; m.onsuccess = loaded; + tx.oncomplete = () => resolve(output); + tx.onabort = () => reject(failure || new Error('数据库事务未提交:' + (tx.error?.message || '已中止'))); + tx.onerror = () => {}; // 由 onabort 统一处理,不在提交之前返回成功。 + }); +} diff --git a/example.csv b/example.csv new file mode 100644 index 0000000..68b7d9d --- /dev/null +++ b/example.csv @@ -0,0 +1,3 @@ +username,password +demo_user_01,replace_with_your_password +demo_user_02,"example password with comma, and space" diff --git a/manager.css b/manager.css new file mode 100644 index 0000000..5f1d670 --- /dev/null +++ b/manager.css @@ -0,0 +1 @@ +:root{font:15px/1.65 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#233247;background:#f4f6fa;color-scheme:light}*{box-sizing:border-box}body{margin:0}main{max-width:1180px;margin:auto;padding:36px 28px 48px}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:22px}h1{font-size:34px;line-height:1.3;margin:0;font-weight:700;letter-spacing:-1px}h2{font-size:19px;margin:0}.eyebrow{margin:0 0 7px;font-size:12px;font-weight:700;letter-spacing:2px;color:#a35515}.sub{margin:8px 0 0;color:#637188}a{color:#a85513;text-decoration:none}a:hover{text-decoration:underline}#status{min-height:48px;padding:11px 16px;border-radius:8px;background:#e9f0e9;color:#31553b;white-space:pre-wrap;margin-bottom:20px}#status.error{background:#fff0df;color:#994308}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}.stats article{background:#fff;border:1px solid #e1e6ee;border-radius:12px;padding:18px 22px}.stats span{display:block;color:#67758a;font-size:13px}.stats strong{font-size:32px;line-height:1.6}.card{background:#fff;border:1px solid #e1e6ee;border-radius:12px;padding:22px 24px;margin:18px 0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:16px}p{margin:12px 0}.muted,footer{color:#68768a;font-size:13px}.import-line{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin:18px 0}button,.file-label{border:1px solid #ba5b12;background:#ba5b12;color:#fff;border-radius:7px;padding:9px 15px;font:inherit;cursor:pointer}button:disabled{opacity:.45;cursor:default}.secondary{background:#fff;color:#34445c;border-color:#d3dae4;padding:6px 12px}input,select{border:1px solid #d3dae4;border-radius:6px;padding:7px 10px;font:inherit;background:#fff;color:#233247}input[type=file]{display:block;max-width:280px;font-size:12px;padding:0;border:none;margin-top:5px;background:transparent;color:inherit}.file-label{background:#f8f9fb;border-color:#d3dae4;color:#34445c;padding:6px 12px;font-size:13px}code{padding:2px 6px;background:#f2f4f7;border-radius:4px}.table-wrap{overflow:auto;margin-top:18px}table{width:100%;border-collapse:collapse;font-size:13px;text-align:left}th{color:#65748a;background:#f7f9fc;font-weight:600}td,th{padding:12px 10px;border-bottom:1px solid #edf0f5;vertical-align:top}td:first-child{word-break:break-all;max-width:180px}td:nth-child(5){max-width:280px;overflow-wrap:anywhere}.badge{display:inline-block;padding:1px 8px;border-radius:20px;background:#eaf3ec;color:#346343;white-space:nowrap}.badge.invalid{background:#fff0e4;color:#a6541d}.badge.unknown{background:#edf0f5;color:#65748a}.pagination{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:16px;font-size:13px}#count{margin-right:auto}footer{margin-top:25px;line-height:1.8}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #dca568;outline-offset:3px}@media(max-width:760px){main{padding:22px 16px}.stats{grid-template-columns:repeat(2,1fr)}header,.section-head{align-items:flex-start;flex-direction:column}.card{padding:18px}.stats article{padding:12px 16px}} diff --git a/manager.html b/manager.html new file mode 100644 index 0000000..1fafeae --- /dev/null +++ b/manager.html @@ -0,0 +1,35 @@ + + +广谈大湾网 · 账号管理 + +
+

DWW11 · 本地账号池

账号管理

CSV 导入 · 每日固定随机顺序 · 每轮不重复

打开广谈大湾网 ↗
+
正在读取 IndexedDB……
+
+
全部账号—
+
可用 / 未检测—
+
当前轮次—
+
本轮剩余可用—
+
+
+

导入账号

下载 CSV 模板
+

表头使用 username,password 或 账号,密码。同名账号更新密码,保留历史状态和抽取记录;新增账号加入本轮剩余池。

+
+

支持引号、逗号、换行及 UTF-8 BOM。单个文件最大 20 MiB。

+
+
+

抽取与恢复

+

—

—

+ +

抽出即记为本轮已用;暂停、失败或关闭浏览器不会退回池中。未完成流程可在网站面板接续同一个账号;面板隐藏时,可从顶栏「显示换号卡片」重新打开。全部可用账号抽完后,下次抽取自动进入新一轮。

+
+
+

账号状态

+
用户名状态最近检测最近登录错误信息操作
+ +
+
账号密码以明文保存在此浏览器配置的扩展 IndexedDB 中,管理页不显示密码。扩展没有遥测;账号密码仅在登录该网站时填入。卸载扩展、清除扩展数据或更换浏览器配置会丢失本地数据,请保留原始 CSV。请将模板中的示例替换为自己的账号后再导入。
+
+ + + diff --git a/manager.js b/manager.js new file mode 100644 index 0000000..c483c53 --- /dev/null +++ b/manager.js @@ -0,0 +1,96 @@ +import { parseCSV } from './core.js'; +const $ = id => document.getElementById(id); +let data, csvText = null, accounts = [], page = 0, pending = false, reading = 0; +async function request(route, body = {}) { + const result = await chrome.runtime.sendMessage({ app: 'dww11-v3', route, body }); + if (!result?.ok) throw new Error(result?.error || '无法连接扩展后台。'); + return result.data; +} +function status(text, error = false) { $('status').textContent = text; $('status').classList.toggle('error', error); } +function buttons() { + $('import').disabled = pending || !csvText || !!data?.flow; + $('end').disabled = pending || !data?.flow; + $('file').disabled = pending; $('encoding').disabled = pending; +} +async function act(fn) { + if (pending) return; + pending = true; buttons(); + try { await fn(); } catch (e) { status(e.message || '操作失败', true); } + finally { pending = false; buttons(); } +} +const dateText = value => value ? new Date(value).toLocaleString() : '—'; +function renderTable() { + const query = $('search').value.trim().toLowerCase(); + const filtered = accounts.filter(a => a.username.toLowerCase().includes(query)); + const pages = Math.max(1, Math.ceil(filtered.length / 50)); + page = Math.min(page, pages - 1); + const body = $('accounts'); body.replaceChildren(); + for (const a of filtered.slice(page * 50, page * 50 + 50)) { + const tr = document.createElement('tr'); + const cell = text => { const td = document.createElement('td'); td.textContent = text; tr.append(td); return td; }; + cell(a.username); + const badge = document.createElement('span'); badge.className = `badge ${a.status}`; + badge.textContent = ({ unknown: '未检测', available: '可用', invalid: '失效' })[a.status]; + cell('').append(badge); + cell(dateText(a.lastchecked)); cell(dateText(a.lastlogin)); + const error = cell(a.lastError?.message || '—'); + if (a.lastError) error.title = `${a.lastError.code} · ${dateText(a.lastError.at)}`; + const action = cell(''); + if (a.status === 'invalid') { + const button = document.createElement('button'); button.className = 'secondary'; button.textContent = '恢复待检测'; + button.disabled = !!data.flow; + button.addEventListener('click', () => act(async () => { await request('/restore', { username: a.username }); await refresh(); status('账号已恢复待检测;本轮已抽记录保留。'); })); + action.append(button); + } else action.textContent = '—'; + body.append(tr); + } + if (!filtered.length) { const tr = document.createElement('tr'), td = document.createElement('td'); td.colSpan = 6; td.textContent = '暂无匹配的账号'; tr.append(td); body.append(tr); } + $('count').textContent = `共 ${filtered.length} 个账号,每页 50 个`; + $('page').textContent = `${page + 1} / ${pages}`; + $('prev').disabled = page === 0; $('next').disabled = page >= pages - 1; +} +async function refresh() { + data = await request('/list'); accounts = data.accounts; + for (const key of ['total', 'active', 'round', 'remaining']) $(key).textContent = data[key]; + $('progress').textContent = `今天 ${data.day}(电脑本地日期) · 第 ${data.round} 轮已抽 ${data.drawn} 个 · 累计抽取 ${data.totalDraws} 次。跨天保留已抽记录,只重排剩余账号。`; + const f = data.flow; + $('flow').textContent = f ? `${f.paused ? '已暂停' : '未完成'} · ${f.current?.username || '尚未抽取'} · 阶段:${({ select: '抽取账号', logout: '退出原账号', login: '准备登录', submitted: '核对登录结果', invalid: '保存失效结果', success: '保存登录结果' })[f.phase] || f.phase}${f.reason ? ' · ' + f.reason : ''}。打开网站换号卡片,点击继续可恢复。` : '当前没有未完成流程。在网站顶栏点击“登陆下一帐号”开始。'; + renderTable(); buttons(); +} +async function readFile() { + const generation = ++reading; + csvText = null; buttons(); + const file = $('file').files[0]; + if (!file) { $('preview').textContent = '尚未选择 CSV。'; return; } + try { + if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MiB,请拆分后分批导入。'); + const text = new TextDecoder($('encoding').value, { fatal: true }).decode(await file.arrayBuffer()); + const parsed = parseCSV(text); + if (generation !== reading) return; + csvText = text; + $('preview').textContent = `${file.name}:已校验 ${parsed.length} 个账号。密码中的空格会原样保留。确认后整批写入 IndexedDB。`; + status('CSV 校验通过,等待确认导入。'); + } catch (e) { + if (generation !== reading) return; + $('preview').textContent = '未导入任何数据。'; + status(e instanceof TypeError ? '无法按所选编码解码,请切换 UTF-8 或 GB18030 后重试。' : e.message, true); + } + buttons(); +} +$('file').addEventListener('change', readFile); +$('encoding').addEventListener('change', readFile); +$('import').addEventListener('click', () => act(async () => { + const result = await request('/import', { text: csvText }); + csvText = null; $('file').value = ''; $('preview').textContent = '已保存到扩展 IndexedDB,无需保持 CSV 文件打开。'; + await refresh(); status(`导入完成:新增 ${result.added} 个,更新 ${result.updated} 个。历史状态及本轮已抽记录保留。`); +})); +$('refresh').addEventListener('click', () => act(async () => { await refresh(); status('状态已刷新。'); })); +$('end').addEventListener('click', () => act(async () => { + if (!confirm('结束当前流程?已抽账号仍计入本轮已用,下一次会抽取新账号。')) return; + await request('/pause'); await refresh(); status('流程已结束,抽取进度保留。请刷新网站页面。'); +})); +$('search').addEventListener('input', () => { page = 0; renderTable(); }); +$('prev').addEventListener('click', () => { page--; renderTable(); }); +$('next').addEventListener('click', () => { page++; renderTable(); }); +window.addEventListener('focus', () => act(refresh)); +act(async () => { await refresh(); status('本地 IndexedDB 已连接。请在网站页面操作换号。'); }); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4feea2c --- /dev/null +++ b/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 3, + "name": "广谈大湾网 · 随机换号", + "version": "3.0.4", + "description": "CSV 导入 IndexedDB;按日期确定随机顺序,每轮不重复,自动续轮并保存进度。", + "minimum_chrome_version": "120", + "permissions": ["unlimitedStorage"], + "host_permissions": ["https://www.dww11.com/*", "https://house.dww11.com/*", "https://dww11.com/*"], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { "default_title": "管理账号 / 导入 CSV" }, + "options_page": "manager.html", + "content_scripts": [{ + "matches": ["https://www.dww11.com/*", "https://house.dww11.com/*", "https://dww11.com/*"], + "js": ["content.js"], + "run_at": "document_idle", + "all_frames": false + }] +} diff --git a/service.js b/service.js new file mode 100644 index 0000000..14c0952 --- /dev/null +++ b/service.js @@ -0,0 +1,166 @@ +import { transact as databaseTransaction } from './db.js'; +import { draw, errorRecord, isRestriction, localDay, newSchedule, parseCSV, remaining } from './core.js'; + +const SITE = /^https:\/\/(?:(?:www|house)\.)?dww11\.com\//; +const PHASES = new Set(['select', 'logout', 'login', 'submitted', 'invalid', 'success']); +const managerURL = chrome.runtime.getURL('manager.html'); +const publicFlow = f => f ? structuredClone(f) : null; +const stamp = () => new Date().toISOString(); + +function transact(reducer, write = true) { + return databaseTransaction((accounts, state, store) => { + // 旧版登录已成功却卡在发布阶段的流程自动释放,不改账号或抽取记录。 + if (state.flow && (['publish', 'publishing'].includes(state.flow.phase) || state.flow.current?.result === 'available')) state.flow = null; + return reducer(accounts, state, store); + }, write); +} + +function summary(accounts, state) { + const s = state.schedule || newSchedule(); + const active = accounts.filter(a => a.status !== 'invalid').length; + return { + total: accounts.length, active, invalid: accounts.length - active, + day: localDay(), seedDay: s.day || localDay(), round: s.round, + drawn: s.used.length, remaining: remaining(accounts, s, localDay()).length, + totalDraws: s.totalDraws, flow: publicFlow(state.flow), panelHidden: !!state.panelHidden + }; +} + +function owned(state, body, sender, allowPaused = false) { + const f = state.flow; + if (!f || f.id !== body.run || f.tabId !== sender.tab?.id || (f.paused && !allowPaused)) throw new Error('流程已暂停、转移或结束,请刷新页面后继续。'); + if (f.documentId && sender.documentId && f.documentId !== sender.documentId) throw new Error('旧页面的流程已失效。'); + return f; +} + +export async function operate(route, body = {}, sender = {}) { + const isManager = sender.id === chrome.runtime.id && sender.url === managerURL; + const isContent = sender.frameId === 0 && Number.isInteger(sender.tab?.id) && SITE.test(sender.url || ''); + if (!isManager && !isContent) throw new Error('不允许的消息来源。'); + const managerRoutes = new Set(['/list', '/import', '/pause', '/restore']); + if (managerRoutes.has(route) && !isManager) throw new Error('此操作只能在扩展管理页进行。'); + if (!isContent && !managerRoutes.has(route) && !['/status', '/manager'].includes(route)) throw new Error('此操作只能在网站顶层页面进行。'); + if (route === '/manager') { await chrome.runtime.openOptionsPage(); return { ok: true }; } + if (route === '/import') { + if (typeof body.text !== 'string' || body.text.length > 20 * 1024 * 1024) throw new Error('CSV 超过 20 MiB 字符上限。'); + const rows = parseCSV(body.text); // 校验整批后再开启写事务。 + return transact((accounts, state, store) => { + if (state.flow) throw new Error('存在未完成流程,请先在管理页结束流程,再导入。'); + const old = new Map(accounts.map(a => [a.username, a])); + let added = 0, updated = 0; + for (const row of rows) { + const previous = old.get(row.username); + if (previous) updated++; else added++; + store.put(previous ? { ...previous, password: row.password } : { + ...row, status: 'unknown', lastchecked: null, lastlogin: null, lastError: null, createdAt: stamp() + }); + } + state.schedule ||= newSchedule(); + return { added, updated }; + }); + } + if (route === '/resume') { + const snapshot = await transact((a, s) => publicFlow(s.flow), false); + if (snapshot?.tabId != null && snapshot.tabId !== sender.tab.id) { + let tab; + try { tab = await chrome.tabs.get(snapshot.tabId); } catch { /* 原标签已关闭,可接续。 */ } + if (tab && SITE.test(tab.url || tab.pendingUrl || '')) throw new Error('另一标签页持有流程,请在原标签页继续,或在管理页结束流程。'); + } + return transact((accounts, state) => { + const f = state.flow; + if (!f || f.id !== snapshot?.id) throw new Error('流程已改变,请刷新后重试。'); + f.id = crypto.randomUUID(); // 使旧页面的异步操作立即失效。 + f.tabId = sender.tab.id; f.documentId = sender.documentId; f.paused = false; + f.reason = ''; f.hops = 0; + return { flow: publicFlow(f) }; + }); + } + return transact((accounts, state, store) => { + state.schedule ||= newSchedule(); + if (route === '/status') return summary(accounts, state); + if (route === '/panel') { + if (typeof body.hidden !== 'boolean') throw new Error('卡片显示设置无效。'); + state.panelHidden = body.hidden; + return { ok: true }; + } + if (route === '/list') return { ...summary(accounts, state), accounts: accounts.map(({ password, ...a }) => a) }; + if (route === '/hello') { + const f = state.flow; + if (f?.tabId === sender.tab.id) f.documentId = sender.documentId; + return { ...summary(accounts, state), owned: f?.tabId === sender.tab.id }; + } + if (route === '/pause') { + // 管理页明确结束流程;抽取记录保留,下一次选择新的账号。 + state.flow = null; + return { ok: true }; + } + if (route === '/restore') { + if (state.flow) throw new Error('请先结束未完成流程,再恢复账号。'); + const a = accounts.find(a => a.username === body.username); + if (!a) throw new Error('账号不存在。'); + a.status = 'unknown'; a.lastError = null; store.put(a); + return { ok: true }; + } + if (route === '/restriction') { + if (state.flow) throw new Error('换号流程进行中,请先暂停并结束该流程,再记录当前账号。'); + const a = accounts.find(a => a.username === body.username); + const error = errorRecord(body.error); + if (!a) throw new Error('当前账号不在数据库中。'); + if (!isRestriction(error)) throw new Error('不是有效的冻结或封禁信息。'); + if (a.status !== 'invalid' || a.lastError?.code !== error.code || a.lastError?.message !== error.message) { + a.status = 'invalid'; a.lastError = error; store.put(a); + } + return { ok: true }; + } + if (route === '/start') { + if (state.flow) throw new Error('存在未完成流程,请先继续或在管理页结束。'); + if (!accounts.some(a => a.status !== 'invalid')) throw new Error('没有可用账号,请先在管理页导入 CSV。'); + state.flow = { id: crypto.randomUUID(), tabId: sender.tab.id, documentId: sender.documentId, phase: 'select', paused: false, startedAt: stamp() }; + return { flow: publicFlow(state.flow) }; + } + const f = owned(state, body, sender); + if (route === '/save') { + const patch = body.patch || {}; + if (patch.phase && !PHASES.has(patch.phase)) throw new Error('无效的流程阶段。'); + for (const key of ['phase', 'hops', 'submittedAt', 'oldMessage', 'probedAt', 'invalidError']) { + if (Object.hasOwn(patch, key)) f[key] = patch[key]; + } + return { flow: publicFlow(f) }; + } + if (route === '/suspend') { + f.paused = true; f.reason = String(body.reason || '已暂停').slice(0, 500); + return { ok: true }; + } + if (route === '/finish' || route === '/cancel') { state.flow = null; return { ok: true }; } + if (route === '/next') { + if (!f.current || (f.phase === 'select' && f.current.result === 'invalid')) { + const selected = draw(accounts, state.schedule, localDay()); + f.current = { username: selected.account.username, attempted: false, result: null, round: selected.round, position: selected.position }; + f.phase = 'logout'; // 预留账号和流程阶段同时提交,崩溃后不会再次抽取。 + f.hops = 0; f.submittedAt = null; f.probedAt = null; f.invalidError = null; + } + const a = accounts.find(a => a.username === f.current.username); + if (!a) throw new Error('本次账号已不存在。'); + return { account: a, flow: publicFlow(f) }; + } + const current = f.current; + if (!current || current.username !== body.account) throw new Error('当前账号与流程不匹配。'); + const a = accounts.find(a => a.username === current.username); + if (!a) throw new Error('当前账号不存在。'); + if (route === '/attempt') { + if (!current.attempted) { a.lastchecked = stamp(); current.attempted = true; store.put(a); } + } else if (route === '/result') { + if (!current.attempted || !['available', 'invalid'].includes(body.result)) throw new Error('登录结果无效。'); + const error = body.result === 'invalid' ? errorRecord(body.error) : null; + if (current.result && current.result !== body.result) throw new Error('不能更改已确认的结果。'); + if (!current.result) { + a.status = body.result; a.lastError = error; + if (body.result === 'available') a.lastlogin = stamp(); + current.result = body.result; store.put(a); + } + // 登录成功和释放换号占用在同一事务提交,之后即可直接切换下一账号。 + if (body.result === 'available') state.flow = null; + } else throw new Error('未知操作。'); + return { ok: true }; + }); +}