Commit c5612a4c authored by 郭阔's avatar 郭阔

feat: login recipe auto-discovery (discover command)

- discover <source>: open homepage → find login entry by visible text
  (fallback to common login paths) → click/open → detect main-frame vs
  iframe form (captcha/challenge iframes excluded) → enumerate fields →
  generate recipe (single-page / two-step / password-only) with
  remember-me checkbox ticking → write logins/<source>.json → optional
  live login verification (--no-login to skip)
- loggedOutText: login-state check by visible button/link text
  (regex), for sites whose login control has no stable class
- honest boundaries: CAPTCHA/OTP/perimeter protection exits with a
  clear human-required message and writes nothing (validated against
  wsj's DataDome layer); validated green path against nikkei Piano ID
parent 84c54a30
...@@ -22,7 +22,14 @@ ...@@ -22,7 +22,14 @@
* *
* 浏览器实例:尊重 CDP_PORT_OVERRIDE / CDP_PROFILE_OVERRIDE(与 ensure-chromium 一致), * 浏览器实例:尊重 CDP_PORT_OVERRIDE / CDP_PROFILE_OVERRIDE(与 ensure-chromium 一致),
* 未设置时用 config.chromium.cdpPort(默认 9222)。 * 未设置时用 config.chromium.cdpPort(默认 9222)。
* 退出码:0 成功;1 环境/配方错误;2 浏览器不可用;3 未登录;4 无配方;5 登录失败。 * discover 子命令(自动配置配方):
* node scripts/login-tools.js discover <source> [--no-login]
* 打开站点主页 → 按文本找登录入口 → 识别弹层/跳转/iframe → 枚举表单生成配方
* → 写入 logins/<source>.json → (默认)立即用凭据实登验证。
* 站点主页取 NH_LOGIN_URL 环境变量,缺省读技能 config 的 sources[].homepageUrl。
* 识别不了的形态(CAPTCHA/OTP/OAuth)会如实报告,不写配方。
*
* 退出码:0 成功;1 环境/配方错误;2 浏览器不可用;3 未登录;4 无配方;5 登录失败;6 自动发现失败。
*/ */
const fs = require('fs'); const fs = require('fs');
...@@ -143,18 +150,143 @@ async function closeTab(id) { ...@@ -143,18 +150,143 @@ async function closeTab(id) {
await fetch(`http://127.0.0.1:${cdpPort()}/json/close/${id}`, { method: 'PUT' }).catch(() => {}); await fetch(`http://127.0.0.1:${cdpPort()}/json/close/${id}`, { method: 'PUT' }).catch(() => {});
} }
async function homepageOf(source) {
if (process.env.NH_LOGIN_URL) return process.env.NH_LOGIN_URL;
try {
const cfg = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8'));
const s = (cfg.sources || []).find((x) => x.id === source);
if (s && s.homepageUrl) return s.homepageUrl;
} catch (e) { /* fallthrough */ }
return null;
}
const LOGIN_ENTRY_RE = 'log\\s*in|sign\\s*in|登\\s*入|登录|登陸';
// ── discover:自动发现登录流程并生成配方 ──────────────────────
async function discover(source, doLogin) {
const home = await homepageOf(source);
if (!home) { console.error('找不到站点主页:设 NH_LOGIN_URL 或在技能 config 配置 homepageUrl'); process.exit(6); }
console.log(`① 打开主页: ${home}`);
const opened = await openTab(home);
if (!opened) { console.error('无法打开主页'); process.exit(6); }
await sleep(6000);
let tab = await tabById(opened.id);
if (!tab) { console.error('主页标签丢失'); process.exit(6); }
// ② 找登录入口并点击(按可见文本)
console.log('② 查找登录入口…');
let entry = await evalIn(tab, `(() => {
const els = [...document.querySelectorAll('button,[role=button],a')];
const el = els.find(e => new RegExp(${JSON.stringify(LOGIN_ENTRY_RE)}, 'i').test((e.innerText || '').trim()) && e.offsetParent);
if (!el) return null;
const text = (el.innerText || '').trim();
el.click();
return { text, tag: el.tagName };
})()`);
if (!entry) {
// 回退:常见登录路径探测
for (const p of ['/login', '/signin', '/register/signin.php', '/user/login']) {
const probe = await openTab(new URL(p, new URL(home).origin).href);
if (!probe) continue;
await sleep(4000);
const hasForm = await evalIn(await tabById(probe.id) || probe,
`!!document.querySelector('input[type=password], input[type=email], input[name*=mail i]')`);
if (hasForm) { tab = await tabById(probe.id); entry = { text: '', tag: 'A', via: p }; break; }
await closeTab(probe.id).catch(() => {});
}
}
if (!entry) { console.error('未找到登录入口(可能已登录/需人工识别)'); process.exit(6); }
console.log(` 入口: ${entry.text || entry.via}(${entry.tag})`);
await sleep(5000);
tab = (await tabById(opened.id)) || tab;
// ③ 判断表单上下文:主框架 or iframe(排除反爬/验证码 iframe)
let frameSub = null;
const iframeGuess = await evalIn(tab, `(() => {
const bad = /captcha|challenge|delivery|turnstile|recaptcha|hcaptcha|perimeter|px-|datadome/i;
const good = /auth|login|signin|sign-in|account|sso/i;
const frames = [...document.querySelectorAll('iframe')].map(f => f.src || '').filter(s => s);
const hit = frames.find(s => good.test(s) && !bad.test(s)) || null;
return hit ? { src: hit.slice(0, 110), blocked: frames.some(s => bad.test(s)) && !hit } : { src: null, blocked: frames.some(s => bad.test(s)) };
})()`);
if (iframeGuess && iframeGuess.src) {
frameSub = new URL(iframeGuess.src).host;
console.log(` 登录表单在 iframe: ${frameSub}`);
} else if (iframeGuess && iframeGuess.blocked) {
console.error('检测到反爬/验证码层(captcha/challenge iframe),无法自动配置,需人工登录一次');
process.exit(6);
} else {
console.log(' 登录表单在主框架');
}
// ④ 枚举表单(在对应上下文里执行)
const dumpExpr = `JSON.stringify({
email: (() => { const e = document.querySelector('input[type=email]') || document.querySelector('input[name*=mail i]') || document.querySelector('input[name=email i]'); return e ? (e.id ? '#' + e.id : (e.name ? '[name=' + JSON.stringify(e.name) + ']' : 'input[type=email]')) : null; })(),
emailVisible: (() => { const e = document.querySelector('input[type=email],input[name*=mail i],input[name=email i]'); return e ? e.offsetParent !== null : false; })(),
pw: !!document.querySelector('input[type=password]'),
pwVisible: (() => { const e = document.querySelector('input[type=password]'); return e ? e.offsetParent !== null : false; })(),
pwSel: (() => { const e = document.querySelector('input[type=password]'); return e ? 'input[type=password]' : null; })(),
submit: (() => { const b = document.querySelector('form button[type=submit], form input[type=submit], button[type=submit]'); return b ? 'button[type=submit], form input[type=submit]' : null; })(),
boxes: [...document.querySelectorAll('input[type=checkbox]')].map(c => ((c.closest('label') || {}).innerText || '').replace(/\\s+/g, ' ').slice(0, 30)).filter(Boolean),
captcha: !!document.querySelector('.g-recaptcha, iframe[src*=recaptcha], iframe[src*=hcaptcha], iframe[src*=turnstile]')
})`;
let info = {};
try {
info = JSON.parse((await evalIn(tab, dumpExpr, frameSub)) || '{}');
} catch (e) {
if (frameSub) {
console.log(` iframe(${frameSub})无执行上下文,回退主框架…`);
info = JSON.parse((await evalIn(tab, dumpExpr)) || '{}');
} else { throw e; }
}
if (info.captcha) { console.error('检测到 CAPTCHA,无法自动配置(需人工)'); process.exit(6); }
if (!info.email && !info.pw) { console.error('未发现登录表单字段(可能是 OAuth/二维码登录)'); process.exit(6); }
// ⑤ 生成配方
const steps = [];
if (entry.text && !entry.via) steps.push({ click: { text: entry.text } });
if (info.email && (!info.pwVisible)) {
steps.push({ ...(frameSub ? { frame: frameSub } : {}), fill: { [info.email]: '$USER' }, submit: info.submit });
steps.push({ ...(frameSub ? { frame: frameSub } : {}), fill: { 'input[type=password]': '$PASS' }, submit: info.submit, waitMs: 8000 });
console.log('⑤ 识别为两步式(邮箱 → 密码)');
} else if (info.email && info.pw) {
const fill = { [info.email]: '$USER', 'input[type=password]': '$PASS' };
steps.push({ ...(frameSub ? { frame: frameSub } : {}), fill, submit: info.submit, waitMs: 8000 });
console.log('⑤ 识别为单页式(邮箱+密码)');
} else if (info.pw) {
steps.push({ ...(frameSub ? { frame: frameSub } : {}), fill: { 'input[type=password]': '$PASS' }, submit: info.submit, waitMs: 8000 });
console.log('⑤ 仅见密码框(账号可能已被站点记住)');
}
if (info.boxes.length) steps[steps.length - 1].checks = [{ selector: 'input[type=checkbox]', where: 'save|keep|remember|signed|记住|保持' }];
const recipe = {
checkUrl: home,
...(entry.text ? { loggedOutText: entry.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } : {}),
steps,
};
fs.mkdirSync(path.join(ROOT, 'logins'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'logins', `${source}.json`), JSON.stringify(recipe, null, 2) + '\n');
console.log(`⑥ 配方已写入 logins/${source}.json`);
if (!doLogin) { console.log('⑦ --no-login:跳过实登验证'); process.exit(0); }
}
async function main() { async function main() {
const [command, source] = process.argv.slice(2); const [command, source, ...rest] = process.argv.slice(2);
if (!command || !source) { if (!command || !source) {
console.error('用法: node scripts/login-tools.js check|login <source>'); console.error('用法: node scripts/login-tools.js check|login|discover <source>');
process.exit(1); process.exit(1);
} }
if (command === 'discover') {
if (!(await browserAlive())) { console.error(`浏览器实例不可用(端口 ${cdpPort()})`); process.exit(2); }
if (!LOGIN_USER || !LOGIN_PASS) { console.error('缺少凭据: 需要环境变量 NH_LOGIN_USER / NH_LOGIN_PASS'); process.exit(1); }
await discover(source, !rest.includes('--no-login'));
// discover 写好配方后,直接走 login 分支完成实登验证
}
const recipe = loadRecipe(source); const recipe = loadRecipe(source);
if (!recipe) { if (!recipe) {
console.error(`无登录配方: logins/${source}.json(该源无需登录或未配置)`); console.error(`无登录配方: logins/${source}.json(该源无需登录或未配置)`);
process.exit(4); process.exit(4);
} }
if (command === 'login' && (!LOGIN_USER || !LOGIN_PASS)) { if ((command === 'login' || command === 'discover') && (!LOGIN_USER || !LOGIN_PASS)) {
console.error('缺少凭据: 需要环境变量 NH_LOGIN_USER / NH_LOGIN_PASS'); console.error('缺少凭据: 需要环境变量 NH_LOGIN_USER / NH_LOGIN_PASS');
process.exit(1); process.exit(1);
} }
...@@ -171,6 +303,10 @@ async function main() { ...@@ -171,6 +303,10 @@ async function main() {
if (recipe.loggedOutSelector) { if (recipe.loggedOutSelector) {
return !(await evalIn(tab, `!!document.querySelector(${JSON.stringify(recipe.loggedOutSelector)})`)); return !(await evalIn(tab, `!!document.querySelector(${JSON.stringify(recipe.loggedOutSelector)})`));
} }
if (recipe.loggedOutText) {
const re = recipe.loggedOutText;
return !(await evalIn(tab, `!![...document.querySelectorAll('button,[role=button],a')].find(e => new RegExp(${JSON.stringify(re)}, 'i').test((e.innerText || '').trim()) && e.offsetParent)`));
}
return false; return false;
}; };
...@@ -186,8 +322,8 @@ async function main() { ...@@ -186,8 +322,8 @@ async function main() {
process.exit(logged ? 0 : 3); process.exit(logged ? 0 : 3);
} }
// ── login:按配方多步执行 ── // ── login:按配方多步执行(discover 成功后落入此处实登验证) ──
if (command === 'login') { if (command === 'login' || command === 'discover') {
let ownTabId = null; // 全程复用同一个标签(提交导航不换 target id) let ownTabId = null; // 全程复用同一个标签(提交导航不换 target id)
for (const step of recipe.steps) { for (const step of recipe.steps) {
if (step.url) { if (step.url) {
......
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