feat: publish xianyu hunter hermes skill package
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
|
||||
const baseUrl = process.env.XIANHU_BASE_URL || 'http://127.0.0.1:3000';
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
console.error('提示:Hermes 主控模式优先使用 tools/xianyu_ops.mjs create/run;本脚本保留为兼容入口。');
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
node tools/create_monitor_task.mjs --name <name> --queries <q1,q2> [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求>
|
||||
|
||||
Environment:
|
||||
XIANHU_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
Example:
|
||||
node tools/create_monitor_task.mjs --name 'Mac mini M4 监测' --queries 'Mac mini M4,macmini m4' --price-max 3300 --region 成都 --pages 2 --requirements '自用,国行,成色好,无拆修,预算3300以内,只做筛选分析,不联系卖家,交给 Hermes 分析'
|
||||
`);
|
||||
}
|
||||
|
||||
function take(flag) {
|
||||
const i = args.indexOf(flag);
|
||||
if (i === -1) return undefined;
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
if (args.includes('-h') || args.includes('--help')) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const name = take('--name') || `闲鱼监测-${new Date().toISOString().slice(0, 10)}`;
|
||||
const queriesRaw = take('--queries');
|
||||
const requirements = take('--requirements') || take('--req');
|
||||
if (!queriesRaw || !requirements) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
queries: queriesRaw.split(/[,,]/).map(s => s.trim()).filter(Boolean),
|
||||
priceMin: take('--price-min') ? Number(take('--price-min')) : null,
|
||||
priceMax: take('--price-max') ? Number(take('--price-max')) : null,
|
||||
region: take('--region') || '',
|
||||
personalSeller: args.includes('--personal-seller'),
|
||||
maxPages: take('--pages') ? Number(take('--pages')) : 3,
|
||||
customRequirements: requirements,
|
||||
coarsePrompt: `你是闲鱼商品监测助手。只做标题相关性初筛,目标是帮用户发现值得进一步分析的候选商品。只有标题与需求明确矛盾、明显是配件/维修/求购/租赁/广告时才排除;拿不准必须保留。返回纯JSON数组。`,
|
||||
finePrompt: `你是闲鱼商品风险分析助手。根据商品详情判断是否值得用户人工查看。只做采集后的分析准备,实际深度分析由 Hermes 完成。优先标记:型号/规格不符、价格异常、疑似商家批量、维修/配件/求购、描述矛盾、成色/保修/拆修风险。拿不准可以保留,但理由必须提示待人工确认。`,
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status}: ${text}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
console.log(JSON.stringify({ ok: true, task: { id: data.id, name: data.name, stage: data.stage }, url: `${baseUrl}/` }, null, 2));
|
||||
} catch {
|
||||
console.log(text);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Xianyu Hermes Scheduled Monitor Prompt Template
|
||||
|
||||
Use this as the self-contained prompt when creating a Hermes cron job for 闲鱼 monitoring.
|
||||
|
||||
```
|
||||
你是 Hermes,帮 BOSS 定时执行闲鱼采集和分析。严格分析模式,不联系卖家、不下单、不承诺交易。
|
||||
|
||||
工作目录:/Users/chick/.Hermes/workspace/research/xianyu-hunter
|
||||
|
||||
任务参数:
|
||||
- 名称:<任务名称>
|
||||
- 关键词:<关键词1,关键词2>
|
||||
- 价格上限:<可选>
|
||||
- 地区:<可选>
|
||||
- 最大页数:1-3
|
||||
- 需求:<自然语言需求与风险排除条件>
|
||||
|
||||
执行步骤:
|
||||
1. 在工作目录运行:
|
||||
node tools/xianyu_ops.mjs run --name '<任务名称>' --queries '<关键词1,关键词2>' --price-max <价格上限> --region '<地区>' --pages <页数> --requirements '<需求>' --limit 30
|
||||
2. 如果提示未登录或验证码,需要在最终回复中明确告诉 BOSS 需要手动登录/过验证码;不要尝试绕过。
|
||||
3. 读取生成的 reports/<task_id>-hermes-report.md。
|
||||
4. 用中文给 BOSS 输出简洁排序:推荐优先级、价格、地区、链接、风险点、建议人工确认的问题。
|
||||
5. 不要联系卖家。
|
||||
```
|
||||
|
||||
Example cron creation from chat/tooling context:
|
||||
|
||||
```text
|
||||
Schedule: every 6h
|
||||
Prompt: paste the filled template above
|
||||
Workdir: /Users/chick/.Hermes/workspace/research/xianyu-hunter
|
||||
Enabled toolsets: terminal,file
|
||||
Deliver: origin
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const TASKS_FILE = path.join(ROOT, 'data', 'tasks.json');
|
||||
const REPORT_DIR = path.join(ROOT, 'reports');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { limit: 30 };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--task' || a === '--task-id') args.taskId = argv[++i];
|
||||
else if (a === '--limit') args.limit = Number(argv[++i] || 30);
|
||||
else if (a === '--out') args.out = argv[++i];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function priceNumber(value) {
|
||||
const m = String(value || '').replace(/,/g, '').match(/\d+(?:\.\d+)?/);
|
||||
return m ? Number(m[0]) : null;
|
||||
}
|
||||
|
||||
function riskFlags(item, task) {
|
||||
const text = `${item.title || ''} ${item.description || ''}`.toLowerCase();
|
||||
const flags = [];
|
||||
const badWords = ['维修', '求购', '租', '回收', '展示机', '资源机', '监管机', '扩容', '改装', '仅拆封包装'];
|
||||
for (const w of badWords) if (text.includes(w)) flags.push(`疑似${w}`);
|
||||
if (/配件|零件|外壳|保护壳|贴膜/.test(text) && !/配件齐|原装配件|全套配件/.test(text)) flags.push('疑似配件/零件');
|
||||
if (!item.description) flags.push('详情描述为空');
|
||||
if (item.wantCount && /\d/.test(item.wantCount)) flags.push(`热度: ${item.wantCount}`);
|
||||
const p = priceNumber(item.detailPrice || item.price);
|
||||
if (task.config?.priceMax && p && p > task.config.priceMax) flags.push(`超预算(${p} > ${task.config.priceMax})`);
|
||||
if (item.sellerRating) flags.push(item.sellerRating);
|
||||
if (item.localCoarseReason) flags.push(`粗筛: ${item.localCoarseReason}`);
|
||||
if (item.localFineReason) flags.push(`细筛: ${item.localFineReason}`);
|
||||
return [...new Set(flags)].slice(0, 8);
|
||||
}
|
||||
|
||||
function scoreItem(item, task) {
|
||||
let score = 60;
|
||||
const flags = riskFlags(item, task);
|
||||
score -= flags.filter(f => /疑似|为空|超预算/.test(f)).length * 15;
|
||||
if (item.description && item.description.length > 30) score += 8;
|
||||
if (item.sellerRating && /好评/.test(item.sellerRating)) score += 5;
|
||||
if (item.images?.length) score += Math.min(item.images.length, 5);
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
function renderTask(task, limit) {
|
||||
const items = [...(task.products?.fineFiltered || [])]
|
||||
.map(item => ({ item, score: scoreItem(item, task), flags: riskFlags(item, task) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
const lines = [];
|
||||
lines.push(`# ${task.name || task.id} — Hermes 候选分析报告`);
|
||||
lines.push('');
|
||||
lines.push(`- 任务ID:\`${task.id}\``);
|
||||
lines.push(`- 阶段:${task.stage}`);
|
||||
lines.push(`- 关键词:${(task.config?.queries || []).join('、') || '-'}`);
|
||||
lines.push(`- 需求:${task.config?.customRequirements || '-'}`);
|
||||
lines.push(`- 数量:采集 ${task.products?.raw?.length || 0} / 粗筛 ${task.products?.coarseFiltered?.length || 0} / 候选 ${task.products?.fineFiltered?.length || 0}`);
|
||||
lines.push('');
|
||||
|
||||
if (items.length === 0) {
|
||||
lines.push('暂无可分析候选。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
lines.push('## 优先查看');
|
||||
lines.push('');
|
||||
for (const { item, score, flags } of items) {
|
||||
const price = item.detailPrice || item.price || '-';
|
||||
const loc = item.sellerLocation || '-';
|
||||
const href = item.href || item.url || '';
|
||||
lines.push(`### ${score}分|${item.title || '(无标题)'}`);
|
||||
lines.push(`- 价格:${price}`);
|
||||
lines.push(`- 地区/卖家:${loc}${item.sellerName ? ` / ${item.sellerName}` : ''}`);
|
||||
if (href) lines.push(`- 链接:${href}`);
|
||||
if (item.description) lines.push(`- 描述:${String(item.description).replace(/\s+/g, ' ').slice(0, 220)}`);
|
||||
if (flags.length) lines.push(`- 风险/备注:${flags.join(';')}`);
|
||||
lines.push('- 建议:人工打开链接核对成色、拆修、序列号/保修、配件与交易方式;当前工具不会自动联系卖家。');
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(TASKS_FILE)) {
|
||||
console.error(`未找到 ${TASKS_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const tasks = JSON.parse(fs.readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const task = args.taskId ? tasks.find(t => t.id === args.taskId) : tasks[tasks.length - 1];
|
||||
if (!task) {
|
||||
console.error(`未找到任务: ${args.taskId || '(latest)'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const report = renderTask(task, args.limit);
|
||||
fs.mkdirSync(REPORT_DIR, { recursive: true });
|
||||
const out = args.out || path.join(REPORT_DIR, `${task.id}-hermes-report.md`);
|
||||
fs.writeFileSync(out, report, 'utf-8');
|
||||
console.log(out);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const userDataDir = '/Users/chick/.Hermes/workspace/research/xianyu-hunter/browser-data';
|
||||
const out = '/tmp/xianyu-goofish-login-entry.png';
|
||||
const ctx = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
viewport: { width: 1280, height: 900 },
|
||||
locale: 'zh-CN',
|
||||
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
|
||||
});
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
await page.goto('https://www.goofish.com/', { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
await page.waitForTimeout(5000);
|
||||
console.log('home', page.url(), await page.title().catch(()=>''));
|
||||
const before = (await page.locator('body').innerText({timeout:5000}).catch(()=>''));
|
||||
console.log('body-before', before.slice(0, 800).replace(/\s+/g,' '));
|
||||
for (const selector of [
|
||||
'text=立即登录',
|
||||
'text=登录',
|
||||
'[class*=login]',
|
||||
'button:has-text("登录")',
|
||||
'a:has-text("登录")'
|
||||
]) {
|
||||
const loc = page.locator(selector).first();
|
||||
if (await loc.isVisible({timeout:1500}).catch(()=>false)) {
|
||||
console.log('click', selector);
|
||||
await loc.click({timeout:3000}).catch(e => console.log('click-error', e.message));
|
||||
await page.waitForTimeout(5000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log('after', page.url(), await page.title().catch(()=>''));
|
||||
const after = (await page.locator('body').innerText({timeout:5000}).catch(()=>''));
|
||||
console.log('body-after', after.slice(0, 1200).replace(/\s+/g,' '));
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
console.log(JSON.stringify({ok:true,out,url:page.url(),title:await page.title().catch(()=>''),pid:process.pid}));
|
||||
await page.waitForTimeout(10 * 60 * 1000);
|
||||
await ctx.close().catch(()=>{});
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn } from 'child_process';
|
||||
import { checkLogin } from '../lib/login.mjs';
|
||||
import { closeBrowser } from '../lib/browser.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const RUNTIME_DIR = path.join(ROOT, '.runtime');
|
||||
const SERVER_STATE = path.join(RUNTIME_DIR, 'server.json');
|
||||
const DEFAULT_PORT = Number(process.env.XIANHU_PORT || process.env.PORT || 3000);
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
node tools/xianyu_ops.mjs status [--port 3000]
|
||||
node tools/xianyu_ops.mjs start [--port 3000]
|
||||
node tools/xianyu_ops.mjs stop
|
||||
node tools/xianyu_ops.mjs login
|
||||
node tools/xianyu_ops.mjs create --name <name> --queries <q1,q2> [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求>
|
||||
node tools/xianyu_ops.mjs run --name <name> --queries <q1,q2> [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求> [--limit 30]
|
||||
node tools/xianyu_ops.mjs report [--task <task_id>] [--limit 30]
|
||||
|
||||
Notes:
|
||||
- Web UI is optional/backup: http://127.0.0.1:<port>
|
||||
- login opens a persistent Chromium profile under ./browser-data.
|
||||
- create/run do not require project AI API configuration and never contact sellers.
|
||||
`);
|
||||
}
|
||||
|
||||
function take(args, flag) {
|
||||
const i = args.indexOf(flag);
|
||||
if (i === -1) return undefined;
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
function parseCommon(args) {
|
||||
return {
|
||||
port: Number(take(args, '--port') || DEFAULT_PORT),
|
||||
limit: Number(take(args, '--limit') || 30),
|
||||
};
|
||||
}
|
||||
|
||||
function baseUrl(port) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${typeof data === 'string' ? data : JSON.stringify(data)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function isServerReady(port) {
|
||||
try {
|
||||
await fetchJson(`${baseUrl(port)}/api/tasks`, { signal: AbortSignal.timeout(3000) });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readServerState() {
|
||||
try { return JSON.parse(fs.readFileSync(SERVER_STATE, 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeServerState(state) {
|
||||
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
|
||||
fs.writeFileSync(SERVER_STATE, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function startServer(port) {
|
||||
if (await isServerReady(port)) {
|
||||
console.log(JSON.stringify({ ok: true, status: 'running', port, url: `${baseUrl(port)}/` }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const out = fs.openSync(path.join(ROOT, 'xianyu-server.log'), 'a');
|
||||
const child = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
detached: true,
|
||||
stdio: ['ignore', out, out],
|
||||
});
|
||||
child.unref();
|
||||
writeServerState({ pid: child.pid, port, startedAt: new Date().toISOString(), log: path.join(ROOT, 'xianyu-server.log') });
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (await isServerReady(port)) {
|
||||
console.log(JSON.stringify({ ok: true, status: 'started', pid: child.pid, port, url: `${baseUrl(port)}/` }, null, 2));
|
||||
return;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`server did not become ready on port ${port}; see xianyu-server.log`);
|
||||
}
|
||||
|
||||
async function stopServer() {
|
||||
const state = readServerState();
|
||||
if (!state?.pid) {
|
||||
console.log(JSON.stringify({ ok: true, status: 'no-recorded-server' }, null, 2));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(state.pid, 'SIGTERM');
|
||||
fs.unlinkSync(SERVER_STATE);
|
||||
console.log(JSON.stringify({ ok: true, status: 'stopped', pid: state.pid }, null, 2));
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ ok: false, status: 'stop-failed', pid: state.pid, error: err.message }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async function status(port) {
|
||||
const ready = await isServerReady(port);
|
||||
console.log(JSON.stringify({ ok: true, running: ready, port, url: `${baseUrl(port)}/`, state: readServerState() }, null, 2));
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const emitLog = msg => console.log(msg);
|
||||
const ok = await checkLogin(emitLog);
|
||||
await closeBrowser();
|
||||
console.log(JSON.stringify({ ok, loggedIn: ok }, null, 2));
|
||||
if (!ok) process.exit(1);
|
||||
}
|
||||
|
||||
function buildPayload(args) {
|
||||
const name = take(args, '--name') || `闲鱼监测-${new Date().toISOString().slice(0, 10)}`;
|
||||
const queriesRaw = take(args, '--queries');
|
||||
const requirements = take(args, '--requirements') || take(args, '--req');
|
||||
if (!queriesRaw || !requirements) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
queries: queriesRaw.split(/[,,]/).map(s => s.trim()).filter(Boolean),
|
||||
priceMin: take(args, '--price-min') ? Number(take(args, '--price-min')) : null,
|
||||
priceMax: take(args, '--price-max') ? Number(take(args, '--price-max')) : null,
|
||||
region: take(args, '--region') || '',
|
||||
personalSeller: args.includes('--personal-seller'),
|
||||
maxPages: take(args, '--pages') ? Number(take(args, '--pages')) : 3,
|
||||
customRequirements: requirements,
|
||||
coarsePrompt: '标题只做保守预筛:明显配件/维修/求购/租赁/广告才排除,拿不准保留。',
|
||||
finePrompt: '详情只做采集后的保守预筛:明显冲突才排除,候选后续由 Hermes 深度分析。',
|
||||
analysisMode: 'hermes',
|
||||
};
|
||||
}
|
||||
|
||||
async function createTask(args, port) {
|
||||
await startServer(port);
|
||||
const payload = buildPayload(args);
|
||||
const task = await fetchJson(`${baseUrl(port)}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
console.log(JSON.stringify({ ok: true, task: { id: task.id, name: task.name, stage: task.stage }, url: `${baseUrl(port)}/` }, null, 2));
|
||||
return task;
|
||||
}
|
||||
|
||||
async function waitTask(taskId, port) {
|
||||
for (;;) {
|
||||
const task = await fetchJson(`${baseUrl(port)}/api/tasks/${taskId}`);
|
||||
const counts = task.products || {};
|
||||
console.log(`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${task.name} ${task.stage} raw=${counts.rawCount || 0} coarse=${counts.coarseCount || 0} fine=${counts.fineCount || 0}`);
|
||||
if (!task.running && ['completed', 'stopped'].includes(task.stage)) return task;
|
||||
await sleep(15000);
|
||||
}
|
||||
}
|
||||
|
||||
async function report(args) {
|
||||
const taskId = take(args, '--task') || take(args, '--task-id');
|
||||
const limit = Number(take(args, '--limit') || 30);
|
||||
const child = spawn(process.execPath, ['tools/hermes_report.mjs', ...(taskId ? ['--task', taskId] : []), '--limit', String(limit)], {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`report exited with ${code}`)));
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function run(args, port) {
|
||||
const task = await createTask(args, port);
|
||||
const finished = await waitTask(task.id, port);
|
||||
await report(['--task', finished.id, '--limit', String(take(args, '--limit') || 30)]);
|
||||
}
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
if (!cmd || args.includes('-h') || args.includes('--help')) {
|
||||
usage();
|
||||
process.exit(cmd ? 0 : 2);
|
||||
}
|
||||
const { port } = parseCommon(args);
|
||||
|
||||
try {
|
||||
if (cmd === 'status') await status(port);
|
||||
else if (cmd === 'start') await startServer(port);
|
||||
else if (cmd === 'stop') await stopServer();
|
||||
else if (cmd === 'login') await login();
|
||||
else if (cmd === 'create') await createTask(args, port);
|
||||
else if (cmd === 'run') await run(args, port);
|
||||
else if (cmd === 'report') await report(args);
|
||||
else {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const userDataDir = '/Users/chick/.Hermes/workspace/research/xianyu-hunter/browser-data';
|
||||
const out = '/tmp/xianyu-login-qr.png';
|
||||
const ctx = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
viewport: { width: 1100, height: 900 },
|
||||
locale: 'zh-CN',
|
||||
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
|
||||
});
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
const loginUrl = 'https://login.taobao.com/member/login.jhtml?redirectURL=' + encodeURIComponent('https://www.goofish.com/');
|
||||
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(async () => {
|
||||
await page.goto('https://www.goofish.com/', { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
for (const selector of [
|
||||
'text=扫码登录',
|
||||
'text=二维码登录',
|
||||
'text=使用二维码登录',
|
||||
'.icon-qrcode',
|
||||
'[class*=qrcode]',
|
||||
]) {
|
||||
try {
|
||||
const loc = page.locator(selector).first();
|
||||
if (await loc.isVisible({ timeout: 1200 }).catch(() => false)) {
|
||||
await loc.click({ timeout: 2000 }).catch(() => {});
|
||||
await page.waitForTimeout(2500);
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
console.log(JSON.stringify({ ok: true, out, url: page.url(), title: await page.title().catch(()=>''), pid: process.pid }));
|
||||
await page.waitForTimeout(5 * 60 * 1000);
|
||||
await ctx.close().catch(() => {});
|
||||
Reference in New Issue
Block a user