Commit 11013bd4 authored by 郭阔's avatar 郭阔

登录保温:检测型配方login防御 + 站点保温访问keepalive.js

- login-tools.js: 无 steps 的检测型配方(reuters/wsj)在 login 命令下不再抛
  'recipe.steps is not iterable',改为提示走面板人工登录(exit 6)
- keepalive.js: 用站点共享实例访问一次首页刷新反爬(DataDome)cookie,
  维持常驻访客画像;实例离线则拉起并在结束后回收(有采集锁时不回收);
  检测撞墙写状态文件(NH_KEEPALIVE_STATUS),撞墙 exit 3
parent f85d5ba5
#!/usr/bin/env node
/**
* keepalive.js - 站点保温访问:用该站点的共享浏览器实例访问一次首页,
* 让反爬服务(DataDome 等)的 JS 在真实 profile 里刷新 cookie,维持"常驻访客"画像,
* 降低凌晨采集时突然批量请求触发人机验证的概率。
*
* 行为:
* 1. 实例不在线 → 以站点 profile 无头拉起(ensure-chromium.js),结束后回收;
* 已在线(如正在采集)→ 只开一个标签访问,绝不回收实例。
* 2. 打开首页等待 NH_KEEPALIVE_WAIT_MS(默认 30s),检测是否撞墙并记录状态。
*
* env:
* CDP_PORT_OVERRIDE 必填,站点共享实例端口
* CDP_PROFILE_OVERRIDE 站点 profile 目录(拉起实例用)
* NH_KEEPALIVE_URL 访问地址,默认 https://www.reuters.com/
* NH_KEEPALIVE_WAIT_MS 页面停留时长,默认 30000
* NH_KEEPALIVE_STATUS 可选,状态 JSON 写入路径(面板/诊断用)
*
* 退出码:0 正常;2 实例不可用;3 检测到反爬墙(保温失败,需要人工过一次验证)
*/
const { spawn, spawnSync } = require('child_process');
const http = require('http');
const path = require('path');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const PORT = parseInt(process.env.CDP_PORT_OVERRIDE || '', 10);
const URL_TO_VISIT = process.env.NH_KEEPALIVE_URL || 'https://www.reuters.com/';
const WAIT_MS = parseInt(process.env.NH_KEEPALIVE_WAIT_MS || '30000', 10);
const STATUS_PATH = process.env.NH_KEEPALIVE_STATUS || '';
if (!PORT) { console.error('keepalive: 缺少 CDP_PORT_OVERRIDE'); process.exit(2); }
const cdp = (p, method = 'GET') => new Promise((res, rej) => {
const r = http.request({ host: '127.0.0.1', port: PORT, path: p, method }, (resp) => {
let d = ''; resp.on('data', (c) => (d += c));
resp.on('end', () => { try { res(JSON.parse(d)); } catch { res(d); } });
});
r.on('error', rej); r.end();
});
async function instanceAlive() {
try { await cdp('/json/version'); return true; } catch { return false; }
}
async function main() {
const startedByUs = !(await instanceAlive());
if (startedByUs) {
const ensure = path.join(__dirname, 'ensure-chromium.js');
const r = spawnSync('node', [ensure], { stdio: 'inherit', env: process.env });
if (r.status !== 0 || !(await instanceAlive())) {
console.error('keepalive: 浏览器实例拉起失败');
process.exit(2);
}
}
let tabId = null, verdict = { wall: false };
try {
const tab = await cdp('/json/new', 'PUT');
tabId = tab.id;
const ws = new WebSocket((await cdp('/json')).find((t) => t.id === tabId).webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res) => {
const s = ++seq; pending.set(s, res); ws.send(JSON.stringify({ id: s, method, params }));
});
ws.onmessage = (m) => {
const j = JSON.parse(m.data);
if (pending.has(j.id)) { pending.get(j.id)(j.result); pending.delete(j.id); }
};
// /json/new 不导航:显式 Page.navigate(Chromium 行为差异兜底)
await send('Page.enable');
await send('Page.navigate', { url: URL_TO_VISIT });
await sleep(WAIT_MS);
const r = await send('Runtime.evaluate', {
returnByValue: true,
expression: `JSON.stringify({
url: location.href,
ddIframe: !!document.querySelector('iframe[title*=datadome i], iframe[src*=datadome]'),
ddWidget: !!document.querySelector('#ddv1-captcha, [class*=ddcaptcha]'),
captchaWord: /unusual traffic|verify you|滑动|验证/i.test(document.body.innerText.slice(0, 4000)),
title: document.title.slice(0, 80)
})`,
});
verdict = JSON.parse((r && r.result && r.result.value) || '{}');
verdict.wall = !!(verdict.ddIframe || verdict.ddWidget || verdict.captchaWord);
ws.close();
} finally {
if (tabId) await cdp(`/json/close/${tabId}`).catch(() => {});
}
const line = `${new Date().toISOString()} visit=${URL_TO_VISIT} wall=${verdict.wall} url=${verdict.url || '?'} title=${verdict.title || '?'}`;
if (STATUS_PATH) {
require('fs').writeFileSync(STATUS_PATH, JSON.stringify({
ts: new Date().toISOString(), wall: verdict.wall, url: verdict.url, title: verdict.title,
}, null, 2));
}
if (startedByUs) {
// 我们拉起的实例才回收;期间若有采集任务锁定了该端口,留给对方收尾
const lockDir = process.env.NH_LOCK_DIR;
let busy = false;
if (lockDir) {
try {
busy = require('fs').readdirSync(lockDir).some((f) => f.startsWith(`port-${PORT}.`));
} catch { /* 目录不存在视为空闲 */ }
}
if (!busy) {
const quit = spawnSync('node', [path.join(__dirname, 'instance-quit.js')],
{ stdio: 'inherit', env: process.env });
if (quit.status !== 0) console.error('keepalive: 实例回收失败(不影响保温效果)');
} else {
console.log('keepalive: 采集任务已接管实例,跳过回收');
}
}
console.log(verdict.wall ? `keepalive: ⚠️ 撞墙,需要人工过一次验证 — ${line}` : `keepalive: ✅ ${line}`);
process.exit(verdict.wall ? 3 : 0);
}
main().catch((e) => { console.error(`keepalive: ${e.message}`); process.exit(2); });
......@@ -352,6 +352,12 @@ async function main() {
// ── login:按配方多步执行(discover 成功后落入此处实登验证) ──
if (command === 'login' || command === 'discover') {
// 检测型配方(只有 loggedInSelector/loggedOutSelector,无 steps):
// 只能检测登录态,不支持自动登录——如 reuters/wsj(DataDome 之下自动登录也不可行),人工登录兜底
if (!Array.isArray(recipe.steps) || recipe.steps.length === 0) {
console.error(`检测型配方(无自动登录步骤): logins/${source}.json——请在面板"站点登录→人工登录"完成,该配方仅用于登录态检测`);
process.exit(6);
}
let ownTabId = null; // 全程复用同一个标签(提交导航不换 target id)
if (!await findTab((t) => t.url.includes(new URL(recipe.checkUrl).host))) {
const opened = await openTab(recipe.checkUrl);
......
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