Commit f85d5ba5 authored by 郭阔's avatar 郭阔

feat(news-harvester): login-capture.js — manual login wizard detector

Polls site login state for the panel's 人工登录 flow: opens the login page
(PUT /json/new with Page.navigate fallback — this Chromium build ignores
the ?url= param), probes via existing recipe or heuristics (visible
sign-in elements absent + account-testid element present), and on first
successful login can auto-generate a detection-only recipe (--write,
unique data-testid candidates only). JSON line output; exit 0 logged-in,
3 timeout.
parent cd834d14
#!/usr/bin/env node
/**
* login-capture.js — 人工登录向导的检测端:轮询站点登录态,首次登录成功时
* 可自动生成"检测型配方"(无配方站点)。
*
* 面板"人工登录"按钮调用:拉起站点实例窗口后运行本脚本,直到用户在窗口里
* 登录成功(或超时)。登录态本身落在站点共享 profile,无需脚本做任何写入;
* "--write" 仅在没有配方时按启发式探测结果生成 logins/<site>.json。
*
* Usage:
* CDP_PORT_OVERRIDE=<port> node login-capture.js <source> [--url <登录页>] \
* [--write] [--timeout-min 15] [--poll-sec 15]
*
* 输出(成功或超时)单行 JSON:
* {site, logged_in, recipe_exists, recipe_written, selector, strategy, elapsed_sec}
* 退出码:0 已登录(或已登录且配方已写);3 超时仍未登录;1 参数/实例错误
*/
const fs = require('fs');
const path = require('path');
const WebSocket = require(path.join(__dirname, '..', 'node_modules', 'ws'));
const ROOT = path.join(__dirname, '..');
const CONFIG_PATH = path.join(ROOT, 'config.json');
const LOGINS_DIR = path.join(ROOT, 'logins');
function parseArgs(argv) {
const a = { _: [] };
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) { a[argv[i].slice(2)] = argv[i + 1] || true; i++; }
else a._.push(argv[i]);
}
return a;
}
const args = parseArgs(process.argv.slice(2));
const source = args._[0];
if (!source) { console.error('用法: node login-capture.js <source> [--url U] [--write]'); process.exit(1); }
const site = source.split('-')[0];
const TIMEOUT_MIN = parseInt(args['timeout-min'], 10) || 15;
const POLL_SEC = parseInt(args['poll-sec'], 10) || 15;
const WANT_WRITE = args.write === true;
function cdpPort() {
if (process.env.CDP_PORT_OVERRIDE) return parseInt(process.env.CDP_PORT_OVERRIDE, 10);
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
if (cfg.chromium && cfg.chromium.cdpPort) return parseInt(cfg.chromium.cdpPort, 10);
} catch (e) { /* ignore */ }
return 9222;
}
const PORT = cdpPort();
function loadConfig() { try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch (e) { return {}; } }
function loadRecipe() {
for (const name of [source, site]) {
const p = path.join(LOGINS_DIR, `${name}.json`);
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'));
}
return null;
}
function checkUrlOf() {
const recipe = loadRecipe();
if (recipe && recipe.checkUrl) return recipe.checkUrl;
if (typeof args.url === 'string' && args.url) return args.url;
const cfg = loadConfig();
const src = (cfg.sources || []).find(s => s.id === source) || (cfg.sources || []).find(s => (s.id || '').split('-')[0] === site);
if (src && src.homepageUrl) return src.homepageUrl;
return null;
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function api(pathname, method = 'GET') {
const res = await fetch(`http://127.0.0.1:${PORT}${pathname}`, { method });
return res.json();
}
async function openTab(url) {
try {
const t = await api(`/json/new?url=${encodeURIComponent(url)}`, 'PUT');
// 该 Chromium 构建会忽略 ?url= 参数(建出 about:blank):用 Page.navigate 兜底
if (t && t.webSocketDebuggerUrl && (!t.url || t.url === 'about:blank')) {
await new Promise((resolve) => {
const ws = new WebSocket(t.webSocketDebuggerUrl);
ws.on('open', () => {
ws.send(JSON.stringify({ id: 1, method: 'Page.navigate', params: { url } }));
setTimeout(() => { try { ws.close(); } catch (e) { /* ignore */ } resolve(); }, 3000);
});
ws.on('error', () => resolve());
setTimeout(() => resolve(), 6000);
});
}
return t;
} catch (e) { return null; }
}
async function findTab(urlPart) {
const tabs = await api('/json');
return tabs.find(t => t.type === 'page' && t.url.includes(urlPart)) || null;
}
function evalIn(tab, expr) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(tab.webSocketDebuggerUrl);
const done = (v) => { try { ws.close(); } catch (e) { /* ignore */ } resolve(v); };
ws.on('open', () => {
ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate',
params: { expression: expr, returnByValue: true } }));
});
ws.on('message', (raw) => {
const m = JSON.parse(raw);
if (m.id === 1) done(m.result && m.result.result ? m.result.result.value : undefined);
});
ws.on('error', (e) => done(undefined));
setTimeout(() => done(undefined), 8000);
});
}
// ── 登录态判定 ──────────────────────────────────────────────
// 1) 有配方:按配方(loggedInSelector / loggedOutSelector)
// 2) 无配方:启发式 —— 可见 Sign In/Log In 元素或账号登录域链接 = 未登录;
// 无上述且存在账户类元素(data-testid/class 含 account/user) = 已登录
async function probe(tab) {
const recipe = loadRecipe();
if (recipe) {
if (recipe.loggedInSelector) {
const present = await evalIn(tab, `!!document.querySelector(${JSON.stringify(recipe.loggedInSelector)})`);
if (present) return { logged_in: true, strategy: 'recipe:loggedInSelector', selector: recipe.loggedInSelector };
}
if (recipe.loggedOutSelector) {
const present = await evalIn(tab, `!!document.querySelector(${JSON.stringify(recipe.loggedOutSelector)})`);
if (!present) return { logged_in: true, strategy: 'recipe:loggedOutSelector-absent', selector: recipe.loggedOutSelector };
}
return { logged_in: false, strategy: 'recipe:not-matched' };
}
const signedOut = await evalIn(tab, `
[...document.querySelectorAll('a,button')]
.filter(e => e.offsetParent && /^(sign in|log in|登录)$/i.test((e.innerText||'').trim()))
.length`);
const accountCandidates = await evalIn(tab, `
[...document.querySelectorAll('[data-testid],[class]')]
.filter(e => e.offsetParent && e.children.length <= 3
&& /account|usermenu|user-menu|avatar/i.test(
(e.getAttribute('data-testid')||'') + ' ' + (e.getAttribute('class')||'')))
.slice(0, 6)
.map(e => e.tagName.toLowerCase()
+ (e.getAttribute('data-testid')
? '[data-testid=' + JSON.stringify(e.getAttribute('data-testid')) + ']'
: ''))
.filter(s => s.includes('[data-testid='))`);
const nOut = Number(signedOut || 0);
const cands = Array.isArray(accountCandidates) ? accountCandidates : [];
if (nOut === 0 && cands.length > 0) {
// 唯一的 data-testid 候选才自动写配方,避免误选
const unique = [...new Set(cands)];
return { logged_in: true, strategy: 'heuristic', selector: unique.length === 1 ? unique[0] : unique[0], candidates: unique };
}
return { logged_in: false, strategy: 'heuristic', signed_out_count: nOut };
}
function writeRecipe(selector, checkUrl) {
const p = path.join(LOGINS_DIR, `${site}.json`);
if (fs.existsSync(p)) return false;
const recipe = {
note: `自动生成检测配方(login-capture.js,人工登录向导):已登录标志 ${selector}(实测于登录会话)。仅检测,不含 steps;如需自动登录请补登录步骤。`,
checkUrl,
loggedInSelector: selector,
};
fs.writeFileSync(p, JSON.stringify(recipe, null, 2) + '\n');
return true;
}
(async () => {
const checkUrl = checkUrlOf();
if (!checkUrl) { console.log(JSON.stringify({ site, error: '无 checkUrl(配方/参数/配置均未提供)' })); process.exit(1); }
const t0 = Date.now();
const deadline = t0 + TIMEOUT_MIN * 60 * 1000;
let opened = false;
while (Date.now() < deadline) {
// 确保实例上有 checkUrl 页签(首次打开;之后复用)
if (!opened) {
const host = new URL(checkUrl).host;
let tab = await findTab(host).catch(() => null);
if (!tab) { await openTab(checkUrl); await sleep(6000); }
opened = true;
}
const host = new URL(checkUrl).host;
const tab = await findTab(host).catch(() => null);
if (tab) {
const r = await probe(tab);
if (r.logged_in) {
let recipe_written = false;
const recipeExisted = !!loadRecipe();
if (!recipeExisted && WANT_WRITE && r.selector && r.strategy === 'heuristic') {
recipe_written = writeRecipe(r.selector, checkUrl);
}
console.log(JSON.stringify({ site, logged_in: true, recipe_exists: recipeExisted || recipe_written,
recipe_written, selector: r.selector || '', strategy: r.strategy,
elapsed_sec: Math.round((Date.now() - t0) / 1000) }));
process.exit(0);
}
}
await sleep(POLL_SEC * 1000);
}
console.log(JSON.stringify({ site, logged_in: false, recipe_exists: !!loadRecipe(),
recipe_written: false, strategy: 'timeout', elapsed_sec: Math.round((Date.now() - t0) / 1000) }));
process.exit(3);
})().catch(e => { console.log(JSON.stringify({ site, error: e.message })); process.exit(1); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment