Files

363 lines
20 KiB
JavaScript

(() => {
'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 bridgeHost, bridgeReady = false, stopRevision = 0;
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 (!run || flow.paused) throw new Stopped();
const data = await request(route, { ...body, run });
if (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 (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() {
if (!nextButton) return;
// Only lifecycle flags cross into the page; never credentials or account data.
bridgeHost?.setAttribute('data-auto-news-state', JSON.stringify({
ready: bridgeReady, idle: bridgeReady && !busy && !running && !flow,
paused: !!flow?.paused
}));
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;
flow.paused = true;
flow.reason = reason;
controls();
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() {
if (location.hostname === 'dww11.com') { location.assign(HOME); return; }
const revision = stopRevision;
await recordRestriction();
if (revision !== stopRevision) return;
flow = (await request('/start')).flow;
if (revision !== stopRevision) { await suspend('新闻工作台已终止流程。'); return; }
await drive();
}
async function resume(retry = false) {
if (location.hostname === 'dww11.com') { location.assign(HOME); return; }
if (retry && !confirm('将重新提交本次抽到的账号(不抽新账号)。如上次提交已成功,可能再次登录。确认重试?')) return;
const revision = stopRevision;
flow = (await request('/resume')).flow;
if (revision !== stopRevision) { await suspend('新闻工作台已终止流程。'); return; }
if (retry) {
await save({ phase: 'logout', hops: 0, submittedAt: null, probedAt: null, invalidError: null });
location.assign(HOME);
return;
}
await drive();
}
async function drive() {
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');
bridgeHost = host;
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');
panelVisibility(info.panelHidden);
flow = info.flow;
bridgeReady = true;
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();
}
}
document.addEventListener('dww11:auto-stop', () => {
stopRevision++;
if (flow && !flow.paused) void suspend('新闻工作台已终止流程。').catch(e => show(e.message, true));
});
init().catch(e => { bridgeReady = false; if (message) { panelVisibility(false); show('扩展启动失败:' + e.message, true); } });
})();