Commit 84c54a30 authored by 郭阔's avatar 郭阔

feat: iframe login support + nikkei-world recipe

- engine: step.frame executes fill/submit in the matching iframe's
  execution context (cross-origin auth widgets, e.g. Piano ID)
- engine: step.click {text} clicks a main-frame button by visible text
  (opens login modals); loggedOutSelector = logged-in when absent
- logins/nikkei-world.json: Piano two-step form (email -> password in
  auth.asia.nikkei.com iframe), validated with a real login
parent a567b691
{
"checkUrl": "https://asia.nikkei.com/business/technology",
"loggedOutSelector": "button[class*=login i], a[class*=login i]",
"steps": [
{
"click": { "text": "Log In" }
},
{
"frame": "auth.asia.nikkei.com",
"fill": { "#email-field": "$USER" },
"submit": "button[type=submit]"
},
{
"frame": "auth.asia.nikkei.com",
"fill": { "input[type=password]": "$PASS" },
"submit": "button[type=submit]",
"waitMs": 8000
}
]
}
......@@ -66,9 +66,11 @@ function connect(tab) {
const ws = new WebSocket(tab.webSocketDebuggerUrl, { perMessageDeflate: false });
let id = 0;
const pending = new Map();
const contexts = []; // Runtime.enable 后各 frame 的执行上下文
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); }
if (m.method === 'Runtime.executionContextCreated') contexts.push(m.params.context);
});
ws.on('error', reject);
ws.on('open', () => {
......@@ -78,16 +80,29 @@ function connect(tab) {
pending.set(i, (m) => { clearTimeout(timer); res(m); });
ws.send(JSON.stringify({ id: i, method, params }));
});
resolve({ ws, send });
resolve({ ws, send, contexts });
});
});
}
async function evalIn(tab, expression) {
const { ws, send } = await connect(tab);
async function evalIn(tab, expression, frameUrlSub) {
const { ws, send, contexts } = await connect(tab);
try {
await send('Page.enable').catch(() => {});
await send('Runtime.enable');
const r = await send('Runtime.evaluate', { expression, returnByValue: true });
await sleep(300); // 等 executionContextCreated 事件收集
let contextId;
if (frameUrlSub) {
// 在 url 匹配的 iframe 里执行(跨域登录表单,如 Piano ID)
const tree = await send('Page.getFrameTree');
const frames = [];
(function walk(n) { if (n.frame) frames.push(n.frame); (n.childFrames || []).forEach(walk); })(tree.result.frameTree);
const frame = frames.find((f) => f.url.includes(frameUrlSub));
const ctx = frame && contexts.find((c) => c.auxData && c.auxData.frameId === frame.id);
if (!ctx) throw new Error('未找到 iframe 执行上下文: ' + frameUrlSub);
contextId = ctx.id;
}
const r = await send('Runtime.evaluate', { expression, returnByValue: true, ...(contextId ? { contextId } : {}) });
if (r.result && r.result.exceptionDetails) {
throw new Error('页面执行异常: ' + (r.result.exceptionDetails.text || ''));
}
......@@ -149,14 +164,23 @@ async function main() {
}
// ── check:打开检查页,看登录态选择器 ──
const isLoggedOn = async (tab) => {
if (recipe.loggedInSelector) {
return !!(await evalIn(tab, `!!document.querySelector(${JSON.stringify(recipe.loggedInSelector)})`));
}
if (recipe.loggedOutSelector) {
return !(await evalIn(tab, `!!document.querySelector(${JSON.stringify(recipe.loggedOutSelector)})`));
}
return false;
};
if (command === 'check') {
const opened = await openTab(recipe.checkUrl);
if (!opened) { console.error('无法打开检查页'); process.exit(2); }
await sleep(5000);
const tab = (await tabById(opened.id)) || (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)})`);
const logged = await isLoggedOn(tab);
await closeTab(opened.id).catch(() => {});
console.log(logged ? `已登录: ${source}` : `未登录: ${source}`);
process.exit(logged ? 0 : 3);
......@@ -183,7 +207,12 @@ async function main() {
}
if (!tab) { console.error('登录标签丢失,中止'); process.exit(5); }
if (ownTabId) ownTabId = tab.id;
// 填充→勾选→提交 合并为单次页面内执行:
// 前置动作:主框架按可见文本点按钮(用于打开登录弹层,如 Piano ID)
if (step.click && step.click.text) {
await evalIn(tab, `(() => { const b = [...document.querySelectorAll('button,[role=button],a')].find(e => (e.innerText || '').trim().toLowerCase() === ${JSON.stringify(step.click.text.toLowerCase())}); if (b) { b.click(); return 'CLICKED'; } return 'NOT_FOUND'; })()`);
await sleep(5000);
}
// 填充→勾选→提交 合并为单次页面内执行(可指定 frame = 跨域 iframe 内执行):
// 提交按钮优先在「所填字段所在的表单」内查找,避免命中页首搜索表单的
// 同类按钮(全文档查询的教训:点到了搜索按钮而不是 Next)。
const fillPairs = Object.entries(step.fill || {})
......@@ -227,7 +256,7 @@ async function main() {
}
return JSON.stringify(log);
})()`;
const result = await evalIn(tab, stepExpr);
const result = await evalIn(tab, stepExpr, step.frame);
const results = JSON.parse(result || '[]');
if (results.some((r) => String(r).startsWith('MISSING'))) {
console.error('表单字段未找到: ' + results.join('; '));
......@@ -241,10 +270,9 @@ async function main() {
}
// 验证(独立新标签,按 id 定位,用完关闭;顺带清理登录流程标签)
const vTab = await openTab(recipe.checkUrl);
await sleep(5000);
await sleep(6000);
const tab = vTab ? await tabById(vTab.id) : null;
const logged = tab && await evalIn(tab,
`!!document.querySelector(${JSON.stringify(recipe.loggedInSelector)})`);
const logged = tab && await isLoggedOn(tab);
if (vTab) await closeTab(vTab.id).catch(() => {});
if (ownTabId) await closeTab(ownTabId).catch(() => {});
console.log(logged ? `✅ 自动登录成功: ${source}` : `❌ 登录后未检测到登录态: ${source}`);
......
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