Commit 3a520b32 authored by 郭阔's avatar 郭阔

feat: login-tools.js — per-source login check & auto-login

- check <source>: open checkUrl, assert loggedInSelector (exit 3 when
  logged out); login <source>: recipe-driven multi-step form flow with
  $USER/$PASS substitution from NH_LOGIN_* env, tab-relocation hardening
  against concurrent harvests, post-login verification
- recipes live in logins/<source>.json; ships digitimes-feed.json
- honors CDP_PORT_OVERRIDE / CDP_PROFILE_OVERRIDE (per-source instances)
parent bc521a36
{
"checkUrl": "https://www.digitimes.com/",
"loggedInSelector": "a[href*=logout], a[href*=signout]",
"steps": [
{
"url": "https://www.digitimes.com/register/signin.php",
"fill": { "input[name=signInEMail]": "$USER" },
"submit": "form button[type=submit], form input[type=submit]"
},
{
"marker": "input[type=password]",
"fill": { "input[type=password]": "$PASS" },
"submit": "form button[type=submit], form input[type=submit]",
"waitMs": 8000
}
]
}
#!/usr/bin/env node
/**
* login-tools.js — 采集前登录预检/自动登录(按站点配方驱动)
*
* 用法:
* node scripts/login-tools.js check <source> 检查登录态;已登录 exit 0,未登录 exit 3
* node scripts/login-tools.js login <source> 自动登录;凭据来自环境变量
* NH_LOGIN_USER / NH_LOGIN_PASS
*
* 配方:logins/<source>.json
* {
* "checkUrl": "https://example.com/",
* "loggedInSelector": "a[href*=logout]",
* "steps": [
* {"url": "https://example.com/signin", "fill": {"#email": "$USER"},
* "submit": "form button[type=submit]"},
* {"fill": {"input[type=password]": "$PASS"},
* "submit": "form button[type=submit]", "waitMs": 8000}
* ]
* }
* fill 值中的 $USER / $PASS 会被凭据替换;多步登录按序执行。
*
* 浏览器实例:尊重 CDP_PORT_OVERRIDE / CDP_PROFILE_OVERRIDE(与 ensure-chromium 一致),
* 未设置时用 config.chromium.cdpPort(默认 9222)。
* 退出码:0 成功;1 环境/配方错误;2 浏览器不可用;3 未登录;4 无配方;5 登录失败。
*/
const fs = require('fs');
const http = require('http');
const path = require('path');
const WebSocket = require(path.join(__dirname, '..', 'node_modules', 'ws'));
const ROOT = path.join(__dirname, '..');
const LOGIN_USER = process.env.NH_LOGIN_USER || '';
const LOGIN_PASS = process.env.NH_LOGIN_PASS || '';
function cdpPort() {
if (process.env.CDP_PORT_OVERRIDE) return parseInt(process.env.CDP_PORT_OVERRIDE, 10);
try {
const cfg = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8'));
if (cfg.chromium && cfg.chromium.cdpPort) return parseInt(cfg.chromium.cdpPort, 10);
} catch (e) { /* default */ }
return 9222;
}
function loadRecipe(source) {
const p = path.join(ROOT, 'logins', `${source}.json`);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(pathname, method = 'GET') {
const res = await fetch(`http://127.0.0.1:${cdpPort()}${pathname}`, { method });
return res.json();
}
async function findTab(pred) {
const tabs = await api('/json');
return tabs.find((t) => t.type === 'page' && pred(t));
}
function connect(tab) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(tab.webSocketDebuggerUrl, { perMessageDeflate: false });
let id = 0;
const pending = new Map();
ws.on('message', (raw) => {
const m = JSON.parse(raw);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
});
ws.on('error', reject);
ws.on('open', () => {
const send = (method, params = {}) => new Promise((res, rej) => {
const i = ++id;
const timer = setTimeout(() => rej(new Error('CDP 调用超时: ' + method)), 20000);
pending.set(i, (m) => { clearTimeout(timer); res(m); });
ws.send(JSON.stringify({ id: i, method, params }));
});
resolve({ ws, send });
});
});
}
async function evalIn(tab, expression) {
const { ws, send } = await connect(tab);
try {
await send('Runtime.enable');
const r = await send('Runtime.evaluate', { expression, returnByValue: true });
if (r.result && r.result.exceptionDetails) {
throw new Error('页面执行异常: ' + (r.result.exceptionDetails.text || ''));
}
return r.result ? r.result.result && r.result.result.value : undefined;
} finally {
ws.close();
}
}
async function browserAlive() {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${cdpPort()}/json/version`, (res) => {
res.resume();
resolve(true);
});
req.on('error', () => resolve(false));
req.setTimeout(1500, () => { req.destroy(); resolve(false); });
});
}
function substitute(value) {
return String(value).replace(/\$USER/g, LOGIN_USER).replace(/\$PASS/g, LOGIN_PASS);
}
async function main() {
const [command, source] = process.argv.slice(2);
if (!command || !source) {
console.error('用法: node scripts/login-tools.js check|login <source>');
process.exit(1);
}
const recipe = loadRecipe(source);
if (!recipe) {
console.error(`无登录配方: logins/${source}.json(该源无需登录或未配置)`);
process.exit(4);
}
if (command === 'login' && (!LOGIN_USER || !LOGIN_PASS)) {
console.error('缺少凭据: 需要环境变量 NH_LOGIN_USER / NH_LOGIN_PASS');
process.exit(1);
}
if (!(await browserAlive())) {
console.error(`浏览器实例不可用(端口 ${cdpPort()}),请先运行 ensure-chromium.js`);
process.exit(2);
}
// ── check:打开检查页,看登录态选择器 ──
if (command === 'check') {
await api(`/json/new?${encodeURIComponent(recipe.checkUrl)}`, 'PUT').catch(() => {});
await sleep(5000);
const tab = await findTab((t) => t.url.includes(new URL(recipe.checkUrl).host));
if (!tab) { console.error('检查页标签丢失(可能被并发采集关闭)'); process.exit(3); }
const logged = await evalIn(tab,
`!!document.querySelector(${JSON.stringify(recipe.loggedInSelector)})`);
console.log(logged ? `已登录: ${source}` : `未登录: ${source}`);
process.exit(logged ? 0 : 3);
}
// ── login:按配方多步执行 ──
if (command === 'login') {
for (const step of recipe.steps) {
if (step.url) {
await api(`/json/new?${encodeURIComponent(step.url)}`, 'PUT').catch(() => {});
await sleep(5000);
}
// 重新定位自己的标签(并发采集可能关掉闲置标签)
const marker = step.url ? new URL(step.url).pathname : (step.marker || 'input');
let tab = null;
for (let i = 0; i < 4 && !tab; i++) {
tab = await findTab((t) => (step.url ? t.url.includes(marker) : true));
if (!tab) await sleep(3000);
}
if (!tab) { console.error('登录标签丢失,中止'); process.exit(5); }
const fills = Object.entries(step.fill || {}).map(([sel, val]) =>
`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (!el) return 'MISSING:${sel}'; el.focus(); el.value = ${JSON.stringify(substitute(val))}; el.dispatchEvent(new Event('input', {bubbles:true})); el.dispatchEvent(new Event('change', {bubbles:true})); return 'OK'; })()`).join(',\n');
const result = await evalIn(tab, `JSON.stringify([${fills}])`);
const results = JSON.parse(result || '[]');
if (results.some((r) => String(r).startsWith('MISSING'))) {
console.error('表单字段未找到: ' + results.join('; '));
process.exit(5);
}
if (step.submit) {
await evalIn(tab, `(() => { const b = document.querySelector(${JSON.stringify(step.submit)}); const f = b ? b.closest('form') : null; if (b) b.click(); else if (f) f.requestSubmit(); return 'OK'; })()`);
}
await sleep(step.waitMs || 6000);
}
// 验证
await api(`/json/new?${encodeURIComponent(recipe.checkUrl)}`, 'PUT').catch(() => {});
await sleep(5000);
const tab = await findTab((t) => t.url.includes(new URL(recipe.checkUrl).host));
const logged = tab && await evalIn(tab,
`!!document.querySelector(${JSON.stringify(recipe.loggedInSelector)})`);
console.log(logged ? `✅ 自动登录成功: ${source}` : `❌ 登录后未检测到登录态: ${source}`);
process.exit(logged ? 0 : 5);
}
console.error('未知命令: ' + command);
process.exit(1);
}
main().catch((e) => { console.error('ERR', 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