363 lines
20 KiB
JavaScript
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); } });
|
|
})();
|