feat: publish xianyu hunter hermes skill package

This commit is contained in:
2026-05-06 19:26:45 +08:00
commit 6384156097
59 changed files with 9627 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Runtime data
browser-data/
data/config.json
data/tasks.json
# OS files
.DS_Store
Thumbs.db
Desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
+275
View File
@@ -0,0 +1,275 @@
<div align="center">
# 🐟 闲鱼扫货助手
**告别手动翻页、逐个砍价的低效扫货方式**
AI 帮你搜、帮你筛、帮你聊 —— 你只需要告诉它"你想买什么"
[![Node.js](https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![Playwright](https://img.shields.io/badge/Playwright-Chromium-2EAD33?logo=playwright&logoColor=white)](https://playwright.dev/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
</div>
---
## 痛点
在闲鱼买一台二手 MacBook,你会遇到这些问题:
**搜索结果质量差** — 搜"MacBook Air M2 16G",出来一堆手机壳、贴膜、维修服务、求购帖。参数越长尾、越具体,搜索结果里的噪声越多,翻5页才能挑出几个靠谱的。
**标题党泛滥** — 标题写着"99新 无划痕",点进详情页一看:"轻微磕碰、电池健康82%、屏幕有一条细划痕"。不打开详情页,光看标题根本判断不了真实情况。
**平台筛选形同虚设** — 闲鱼的"几成新"完全靠卖家自己勾,8成新和99新混在一起。想知道真实成色?只能一个个点进去看图和描述。
**砍价是体力活** — 好不容易筛出几个,还得一个个打招呼、问成色、问电池、问能不能包邮、试探底价……十几个卖家,每个都重复同样的对话流程。
> **一个下午过去了,你只聊了3个卖家,还没一个谈拢。**
这个工具就是来解决这个问题的 —— **让 AI 帮你海量扫、逐个筛、自动聊。**
---
## 看看实际效果
### 一个任务搜 123 件商品,AI 自动筛到 8 件,同时跟 5 个卖家聊
![商品列表](docs/product-list.png)
### AI 逐个打开详情页,像老买家一样判断每件商品
> "电池健康87%不满足90%以上要求" / "价格3999在范围内,屏幕完美无划痕" / "商品是MacBook Pro不是Air,不符合需求"
![细筛日志](docs/fine-filter.png)
### 自动跟卖家聊天、询价、砍价 —— 全程日志可追踪
![聊天日志](docs/auto-chat.png)
### 手机上打开闲鱼:已经聊开了
<div align="center">
<img src="docs/xianyu-chat-1.png" width="270">&nbsp;&nbsp;<img src="docs/xianyu-chat-2.png" width="270">&nbsp;&nbsp;<img src="docs/xianyu-chat-3.png" width="270">
</div>
<br>
> 上面是真实运行结果。一个任务下来,十几个卖家已经在跟"你"热聊了 —— "可以再少点嘛"、"好的~"、"电池健康多少呀"、"能少点嘛~我急着用嘞"…… 卖家完全以为在跟真人说话。
---
## 为什么卖家愿意聊?—— 因为有「人设」
这不是那种发一句"最低多少"就完事的脚本。
**内置的 AI 人设是一个高情商女大学生买家**,说话有温度、有策略、有节奏:
- 开场先夸商品、拉近距离:"哇这个成色好好呀~还在吗"
- 中场自然地了解信息:"电池健康怎么样呢~有没有磕碰呀"
- 砍价时卖萌示弱而不是冷冰冰丢数字:"有点超预算了耶🥺 能再少一丢丢嘛~"
- 每条消息控制在 15 字以内,像真人一样分条发送
- 语气词、表情、波浪号自然穿插,不会被识别为机器人
**卖家的真实反应**(来自上面的截图):
- "好的~"、"哇 那挺新的~"、"可以嘛~" —— 卖家在配合聊天
- "可以发快递麻~"、"算了 妹妹 m4的得在现场" —— 卖家在主动推进交易
- "底价了"、"实价了~" —— 砍价流程自然推进到底价
**人设完全可自定义**:换成男大学生、数码发烧友、精打细算的宝妈……任何你想要的沟通风格,在新建任务时直接改就行。
---
## 省钱设计 & 聊天记忆
### 💰 两轮筛选,极致省 Token
不是每件商品都调用大模型。两轮筛选的设计大幅减少 API 消耗:
| 阶段 | 做什么 | API 调用量 |
|------|--------|-----------|
| **粗筛** | 只看标题,15个一批送给AI判断 | 123件商品 ≈ 9次调用 |
| **细筛** | 只有粗筛通过的才打开详情页深度判断 | 27件通过 = 27次调用 |
| **聊天** | 只有细筛通过的才发起聊天 | 8件通过 = 8个会话 |
> 123 件商品,全流程下来大约 **40+ 次 API 调用**,用 DeepSeek V3 大约 ¥0.05 ~ ¥0.3。如果全部都做细筛再聊天,费用要翻 10 倍。
### 🧠 聊天会话记忆
- 每个卖家的完整对话历史都保存在本地
- **任务中断后重启,自动从上次位置继续聊**,不会重复发消息、不会丢上下文
- 等待卖家回复期间零消耗,有新消息才触发 AI 生成回复
- 自动判断目标是否达成(谈妥/被拒/商品下架),不在死单上浪费时间
---
## 全部可自定义
<img src="docs/create-task.png" width="420" align="right">
不只是填关键词这么简单。每个任务都可以精细配置:
**搜索条件**
- 多关键词(逗号分隔)
- 价格区间、地区、仅个人闲置
**AI 筛选规则**
- 用自然语言描述你的需求
- 粗筛 / 细筛 Prompt 都可以自定义
**砍价策略**
- 心理价位、砍价节奏、底线
- "先夸再砍"还是"直接报价"
**聊天人设**
- 默认高情商女大学生
- 可改为任何风格
<br clear="right">
---
## 它能做什么?
> "我想买一台 MacBook Air M2,成色9新以上,电池健康90%+,预算4000以内"
你只需要像这样描述需求,然后:
| 步骤 | 做什么 | 谁来做 |
|:---:|--------|:---:|
| 1 | 在闲鱼搜索多个关键词,自动翻页采集商品 | 🤖 |
| 2 | 根据标题批量粗筛,排除明显不相关的 | 🤖 |
| 3 | 逐个打开详情页,深度判断是否符合需求 | 🤖 |
| 4 | 用高情商人设跟卖家聊天、询价、砍价 | 🤖 |
| 5 | 看看结果,挑一个满意的下单 | **你** |
整个过程你可以在实时看板上全程围观。
## 30 秒上手
```bash
git clone https://github.com/Disrush/xianyu-shopping-assistant.git
cd xianyu-shopping-assistant
npm install
npx playwright install chromium
npm start
```
打开 http://localhost:3000 → 点「设置」配置 API Key → 点「新建任务」开始扫货
> **Windows 用户** 更简单:双击 `start.bat`,全自动安装启动
## 支持的 AI 服务商
开箱预设了 **8 大主流服务商**,也可以填任何 OpenAI 兼容的自定义端点:
| 服务商 | 推荐模型 | 说明 |
|--------|---------|------|
| **DeepSeek** | DeepSeek V3 | 性价比之王,中文能力强 |
| **OpenRouter** | 自选 | 聚合平台,一个 Key 用几百个模型 |
| **OpenAI** | GPT-4o Mini | 经典选择 |
| **通义千问** | Qwen Plus | 阿里出品,国内直连快 |
| **SiliconFlow** | DeepSeek V3 | 硅基流动,国内平台免翻墙 |
| **智谱 AI** | GLM-4 Flash | 免费额度多 |
| **Moonshot** | Kimi 128K | 长上下文 |
| **Groq** | Llama 3.3 70B | 推理最快 |
| **自定义** | 任意 | 填入你自己的 Base URL |
> 💡 **推荐**:用 DeepSeek V3 或 GPT-4o Mini,性价比最高。
## 更多截图
<details>
<summary><b>搜索采集 & 粗筛日志</b></summary>
<br>
![搜索与粗筛日志](docs/search-logs.png)
5页搜索采集 123 个商品 → AI 标题粗筛只保留 27 个相关的
</details>
<details>
<summary><b>细筛日志 —— AI 逐个分析给理由</b></summary>
<br>
![细筛日志](docs/fine-filter.png)
每个商品都有明确的通过/不通过理由,比人工筛选更一致
</details>
<details>
<summary><b>自动聊天日志</b></summary>
<br>
![自动聊天](docs/auto-chat.png)
逐个发起聊天 → 发送开场白 → 等待回复 → 持续监控跟进
</details>
## 项目结构
```
├── server.mjs # Express + WebSocket 主服务
├── start.bat # Windows 一键启动
├── public/
│ ├── index.html # 看板界面
│ ├── app.js # 前端逻辑(任务管理、配置、实时更新)
│ └── style.css # 暗色主题样式
├── lib/
│ ├── config.mjs # API 配置管理(多供应商支持)
│ ├── ai.mjs # LLM 调用、Prompt 工程
│ ├── task.mjs # 任务流水线编排
│ ├── search.mjs # 闲鱼搜索与商品采集
│ ├── filter.mjs # AI 粗筛 / 细筛
│ ├── chat.mjs # 自动聊天引擎
│ ├── login.mjs # 登录状态检测
│ ├── browser.mjs # Playwright 浏览器管理
│ └── utils.mjs # 工具函数
└── data/ # 运行时数据(gitignore
```
## 常见问题
<details>
<summary><b>首次运行要登录吗?</b></summary>
是的。首次启动时会弹出 Chromium 浏览器,你需要在闲鱼网页端手动登录一次。之后登录状态会自动保持。
</details>
<details>
<summary><b>遇到滑块验证怎么办?</b></summary>
在弹出的浏览器窗口中手动完成验证即可,系统会自动检测并等待你完成,之后继续执行。
</details>
<details>
<summary><b>任务中断了数据会丢吗?</b></summary>
不会。所有进度实时保存,重新启动任务会从上次中断的阶段恢复。聊天会话的完整上下文也会保留,续聊时不会重复发消息。
</details>
<details>
<summary><b>API Key 安全吗?</b></summary>
API Key 仅保存在你本地的 <code>data/config.json</code> 中,该文件已加入 .gitignore,不会被提交或上传。
</details>
<details>
<summary><b>一次任务大概花多少 API 费用?</b></summary>
取决于商品数量和聊天轮次。典型场景(搜3页、筛选50个、聊天5个卖家)大约消耗 ¥0.05 ~ ¥0.3,两轮筛选设计把调用量压到了最低。
</details>
<details>
<summary><b>会不会被闲鱼封号?</b></summary>
本工具使用真实浏览器(非接口调用),消息频率有随机延迟控制,模拟真人操作。但任何自动化工具都有风险,建议不要在主力账号上高频使用。
</details>
## 环境要求
- **Node.js** v18+[下载](https://nodejs.org/)
- **操作系统** Windows / macOS / Linux
- **网络** 能访问闲鱼(goofish.com) + 你选择的 AI 服务商
## License
MIT — 随便用,觉得有用给个 Star ⭐
View File
@@ -0,0 +1,6 @@
{
"enabled": false,
"notifyOnlyGood": true,
"minScore": 75,
"channels": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+291
View File
@@ -0,0 +1,291 @@
import { getApiKey, getBaseUrl, getCurrentModel, isConfigured } from './config.mjs';
export { getCurrentModel, setCurrentModel, getAvailableModels } from './config.mjs';
export async function chatCompletion(messages, { temperature = 0.7, maxTokens = 1024, timeoutMs = 45000 } = {}) {
if (!isConfigured()) {
throw new Error('AI 未配置:请先在控制台「设置」中配置 API 密钥和模型');
}
const apiKey = getApiKey();
const baseUrl = getBaseUrl();
const model = getCurrentModel();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages,
temperature,
max_tokens: maxTokens,
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`AI API error ${res.status}: ${err}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content ?? '';
if (!content) {
const diag = JSON.stringify({
choicesLen: data.choices?.length,
finishReason: data.choices?.[0]?.finish_reason,
error: data.error,
});
console.warn(`[AI] 模型返回空内容: ${diag}`);
}
return content;
} finally {
clearTimeout(timer);
}
}
export const DEFAULT_COARSE_PROMPT = `你是一个二手商品标题初筛助手。你只看标题,判断该商品是否与用户需求存在【明确冲突】。
判定规则:
- 只有标题内容和用户需求直接矛盾时才排除(例如:用户要笔记本,标题明确是手机壳/配件/维修/求购/租赁等完全不同的东西)
- 标题中没有提及的细节(成色、电池、价格、地区等)绝对不能作为排除理由
- 任何拿不准的情况一律保留,宁可多留不能误删`;
export const DEFAULT_FINE_PROMPT = `你是一个二手商品细筛助手。根据用户需求和商品详情页信息,判断该商品是否值得进一步沟通。
判定规则:
- 只有当商品信息与用户需求存在【明确冲突】时才不通过(例如:用户要求电池90%以上,详情明确写了电池80%)
- 商品描述中未提及的信息不算冲突,可以通过后续聊天了解
- 价格偏高不算冲突(可以砍价),只有远超合理范围才排除
- 拿不准的一律通过,宁可多聊不能错过`;
export async function judgeByTitle(titles, requirements, customPromptHead, emitLog) {
const head = (customPromptHead && customPromptHead.trim()) ? customPromptHead.trim() : DEFAULT_COARSE_PROMPT;
const prompt = `${head}
用户需求:${requirements}
商品列表(JSON数组):
${JSON.stringify(titles.map((t, i) => ({ index: i, title: t })))}
请返回纯JSON数组,包含所有【没有明确冲突】的商品index(即应该保留的)。
只有标题和需求直接矛盾才排除,其余全部保留。
示例:[0, 1, 2, 3, 5, 7]
只输出JSON数组,不要有任何其他文字。`;
if (emitLog) emitLog(` [AI请求·粗筛] 需求="${requirements.slice(0, 60)}..." ${titles.length}个标题`);
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1 });
if (emitLog) emitLog(` [AI返回·粗筛] ${reply.slice(0, 200)}`);
if (!reply || !reply.trim()) {
if (emitLog) emitLog(` ⚠️ AI返回为空,保留本批全部`);
return titles.map((_, i) => i);
}
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleaned);
} catch {
const matches = reply.match(/\d+/g);
return matches ? matches.map(Number) : titles.map((_, i) => i);
}
}
export async function judgeByDetail(product, requirements, customPromptHead, emitLog) {
const head = (customPromptHead && customPromptHead.trim()) ? customPromptHead.trim() : DEFAULT_FINE_PROMPT;
const prompt = `${head}
用户需求:${requirements}
商品信息:
- 标题:${product.title}
- 价格:${product.price}
- 描述:${product.description}
- 卖家:${product.sellerName}${product.sellerLocation}
- 卖家好评率:${product.sellerRating || '未知'}
- 想要人数:${product.wantCount || '未知'}
请返回JSON格式:{"pass": true/false, "reason": "简短理由"}
判定标准:只有商品信息与需求存在明确冲突时pass为false,拿不准则pass为true。
不要输出任何额外文字。`;
if (emitLog) emitLog(` [AI请求·细筛] "${product.title.slice(0, 30)}..." 需求="${requirements.slice(0, 50)}..."`);
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1 });
if (emitLog) emitLog(` [AI返回·细筛] ${reply.slice(0, 200)}`);
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleaned);
} catch {
return { pass: false, reason: '解析失败' };
}
}
// ========== 聊天 prompt 三要素构建 ==========
export const DEFAULT_PERSONA = `你是一位活跃在闲鱼平台的买家。性格开朗、礼貌、真诚,说话自带"元气感"和"氛围感"的女大学生买家。你的核心目标是通过提供极高的情绪价值,让卖家产生"卖给你很开心"的心理,从而以更优惠的价格(谈价)或更优的条件(包邮/送小礼物)达成交易。
## Communication Style:
1. **语气词丰富:** 多用"哒、呀、呢、喔、呜呜、滴、哈罗"。
2. **表情包达人:** 灵活使用 Emoji(✨, 🥺, 🎈, 🥳, 💡, 🤏)。
3. **高情绪价值:** 进场先夸宝贝,中场卖萌示弱,收尾爽快礼貌。
4. **拒绝冷冰冰:** 禁止直接发数字(如"100?"),必须包装成有温度的请求。
核心沟通规则:
1. 每条消息严格不超过30个字
2. 一次回复可以发2-4条短消息,分条发送。而且除了信息交流外必须要能体现出礼貌可爱的人设
3. 语气活泼自然,像真人聊天,绝对不能像AI
4. 大量使用口语化表达和语气词(呀、哦、呢、嘻嘻、哈哈、嗯嗯)
5. 善用~代替句号,偶尔用表情(但不要每条都有)
6. 禁止一次性发送超过30字的长段落,会被识别为机器人
7. 回复节奏要像真人:先回应对方说的,再追问新问题
沟通流程(按优先级推进):
- 先确认商品是否还在售
- 了解描述中未提及的信息和用户需求匹配度
- 进入谈价环节(根据下方谈价策略执行)
- 确认发货方式和时间
禁止行为:
- 不要自报家门说"我是XXX"
- 不要用书面语、不要太客气太正式
- 不要连续问多个问题,一次最多问两个个点`;
function buildChatStrategy(userStrategy, customPersona) {
const persona = (customPersona && customPersona.trim()) ? customPersona.trim() : DEFAULT_PERSONA;
const base = `【聊单策略 —— 你的人设与沟通方式】
${persona}`;
if (!userStrategy) return base;
return base + `
【本任务谈价策略 —— 议价时必须遵守】
${userStrategy}
执行要点:按上述策略灵活谈价,但不要生硬照搬策略原文。把策略内化为你自己的沟通节奏。`;
}
function buildProductContext(product) {
const lines = [`【卖家发布的商品信息 —— 你正在咨询的这件商品】`];
lines.push('');
if (product.title) lines.push(`商品标题:${product.title}`);
if (product.price || product.detailPrice) lines.push(`标价:${product.detailPrice || product.price}`);
if (product.description) lines.push(`商品描述:${product.description}`);
if (product.sellerName) lines.push(`卖家昵称:${product.sellerName}`);
if (product.sellerLocation) lines.push(`卖家所在地:${product.sellerLocation}`);
if (product.sellerRating) lines.push(`卖家好评率:${product.sellerRating}`);
if (product.wantCount) lines.push(`想要人数:${product.wantCount}`);
if (product.images?.length > 0) lines.push(`商品图片数量:${product.images.length}`);
return lines.join('\n');
}
function buildBuyerRequirements(requirements) {
return `【采购需求 —— 你的真实购买意图,不要直接告诉卖家】
${requirements}
注意:以上是你内心的筛选标准,用于判断是否值得继续沟通。
不要把这些需求原封不动地告诉卖家,而是通过自然的提问来获取信息。`;
}
export function buildSessionPrompt(product, { requirements, chatStrategy, persona } = {}) {
return [
buildChatStrategy(chatStrategy, persona),
'',
buildProductContext(product),
'',
buildBuyerRequirements(requirements || ''),
'',
'【输出格式要求】',
'返回纯JSON数组,每个元素是一条短消息字符串(≤15字)。',
'例如:["你好呀~", "这个还在吗", "成色怎么样呢"]',
'不要输出任何JSON以外的内容。',
].join('\n');
}
export async function judgeGoalReached(product, chatHistory, { requirements, chatStrategy, emitLog } = {}) {
const recentHistory = chatHistory.slice(-20);
const historyText = recentHistory.map(m => {
const label = m.role === 'assistant' ? '我' : '卖家';
return `${label}: ${m.content}`;
}).join('\n');
const goalContext = [];
if (chatStrategy) goalContext.push(`谈价策略:${chatStrategy}`);
if (requirements) goalContext.push(`采购需求:${requirements}`);
const prompt = `你是一个聊天目标判定助手。根据以下信息判断这段买卖对话是否已经达成聊天目标。
达成目标的标准(满足任一即可):
1. 卖家已同意在买家心理价位范围内成交(明确报出了可接受的价格或同意了买家的出价)
2. 买卖双方已确认交易细节(价格+发货方式),对话接近可以下单的状态
3. 卖家明确拒绝降价且态度坚决,继续谈判已无意义
4. 商品已售出/下架,卖家明确表示没货了
商品信息:
- 标题:${product.title}
- 标价:${product.detailPrice || product.price}
${goalContext.length > 0 ? goalContext.join('\n') : '(无特定策略)'}
对话记录:
${historyText}
请返回JSON格式:{"reached": true/false, "reason": "简短说明结论(20字以内)"}
不要输出任何额外文字。`;
if (emitLog) emitLog(` [AI请求·目标判定] "${product.title.slice(0, 25)}..." 近${recentHistory.length}条对话`);
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1, maxTokens: 256 });
if (emitLog) emitLog(` [AI返回·目标判定] ${reply.slice(0, 200)}`);
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleaned);
} catch {
return { reached: false, reason: '判断失败' };
}
}
export async function generateChatMessages(product, chatHistory, { requirements, chatStrategy, persona, emitLog } = {}) {
const systemPrompt = buildSessionPrompt(product, { requirements, chatStrategy, persona });
const messages = [
{ role: 'system', content: systemPrompt },
...chatHistory,
];
if (chatHistory.length === 0) {
messages.push({
role: 'user',
content: '现在你刚点进这个商品的聊天页面,请生成开场白。2-3条短消息,先打招呼再自然地问一个关于商品的问题。',
});
} else {
messages.push({
role: 'user',
content: '卖家刚刚回复了(见上方对话),请根据对方的回复自然地继续聊,1-3条短消息。',
});
}
if (emitLog) emitLog(` [AI请求·聊天] 历史${chatHistory.length}条 "${product.title.slice(0, 25)}..."`);
const reply = await chatCompletion(messages, { temperature: 0.8, maxTokens: 512 });
if (emitLog) emitLog(` [AI返回·聊天] ${reply.slice(0, 200)}`);
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(cleaned);
if (Array.isArray(parsed)) {
return parsed.filter(m => typeof m === 'string' && m.length > 0 && m.length <= 20);
}
return [String(parsed)];
} catch {
const lines = reply.split('\n')
.map(l => l.replace(/^[\s"'\d.、\-\[\]]+/, '').replace(/["\],]+$/, '').trim())
.filter(l => l.length > 0 && l.length <= 20);
return lines.length > 0 ? lines : ['你好呀~'];
}
}
+188
View File
@@ -0,0 +1,188 @@
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 REPORT_DIR = path.join(ROOT, 'reports');
export function priceNumber(value) {
const m = String(value || '').replace(/,/g, '').match(/\d+(?:\.\d+)?/);
return m ? Number(m[0]) : null;
}
function normalize(value = '') {
return String(value || '').toLowerCase().replace(/\s+/g, '');
}
function queryTokens(task) {
const text = (task.config?.queries || []).join(' ').toLowerCase();
const tokens = text
.split(/[^a-z0-9\u4e00-\u9fa5]+/i)
.map(t => t.trim())
.filter(t => t.length >= 2);
return [...new Set(tokens)];
}
export function analyzeItem(item, task) {
const text = normalize(`${item.title || ''} ${item.description || ''}`);
const title = normalize(item.title || '');
const flags = [];
const positives = [];
let score = 60;
const badRules = [
['维修', '疑似维修/故障'], ['求购', '疑似求购'], ['租', '疑似租赁'], ['回收', '疑似回收'],
['展示', '疑似展示/仅展示'], ['配件', '疑似配件'], ['散热器', '疑似散热器/配件'],
['主板', '可能是主板/套装'], ['整机', '可能是整机非单件'], ['套装', '可能是套装'],
['坏', '疑似故障'], ['不亮', '疑似无法点亮'], ['暗病', '疑似暗病'], ['不能进系统', '无法进系统/疑似故障'], ['不能进系統', '无法进系统/疑似故障'],
];
for (const [word, label] of badRules) {
if (text.includes(word)) flags.push(label);
}
const wantsE31245v3 = /e3\s*-?\s*1245\s*[_-]?\s*v3/i.test((task.config?.queries || []).join(' '));
if (wantsE31245v3) {
const exactTarget = /e3\s*-?\s*1245\s*[_-]?\s*v3/i;
const otherE3v3 = /e3\s*-?\s*(12\d{2})\s*[_-]?\s*v3/ig;
const titleRaw = String(item.title || '');
const textRaw = `${item.title || ''} ${item.description || ''}`;
const models = [...textRaw.matchAll(otherE3v3)].map(m => m[1]);
const titleModels = [...titleRaw.matchAll(otherE3v3)].map(m => m[1]);
if (!exactTarget.test(textRaw)) {
score -= 35;
flags.push('未明确命中 E3-1245v3');
}
const wrongTitleModels = titleModels.filter(m => m !== '1245');
if (wrongTitleModels.length) {
score -= 35;
flags.push(`标题主型号疑似非1245v3E3-${[...new Set(wrongTitleModels)].join('/')}v3`);
} else if (models.some(m => m !== '1245') && !exactTarget.test(titleRaw)) {
score -= 18;
flags.push('详情/关联词含其他 E3 v3 型号,需核对主型号');
}
}
const tokens = queryTokens(task);
const matched = tokens.filter(t => title.includes(normalize(t)) || text.includes(normalize(t)));
const importantTokens = tokens.filter(t => /[a-z]*\d/.test(t) || /v\d/i.test(t));
const missingImportant = importantTokens.filter(t => !text.includes(normalize(t)));
if (matched.length > 0) {
score += Math.min(15, matched.length * 4);
positives.push(`命中关键词:${matched.slice(0, 4).join('/')}`);
}
if (missingImportant.length >= Math.max(1, Math.ceil(importantTokens.length / 2))) {
score -= 20;
flags.push(`关键型号不完整:缺 ${missingImportant.slice(0, 4).join('/')}`);
}
const price = priceNumber(item.detailPrice || item.price);
const min = task.config?.priceMin;
const max = task.config?.priceMax;
if (price != null) {
if (max != null && price > max) { score -= 25; flags.push(`超预算:${price} > ${max}`); }
else if (max != null && price <= max) { score += 8; positives.push(`价格在预算内:${price}`); }
if (min != null && price < min) { score -= 8; flags.push(`价格偏低需防异常:${price} < ${min}`); }
} else {
score -= 5;
flags.push('价格未识别');
}
if (item.description && String(item.description).trim().length >= 30) {
score += 8;
positives.push('描述较完整');
} else {
score -= 8;
flags.push('详情描述偏少');
}
if (item.images?.length) {
score += Math.min(6, item.images.length * 2);
positives.push(`图片${item.images.length}`);
}
if (item.sellerRating) positives.push(item.sellerRating);
if (item.wantCount && /\d/.test(item.wantCount)) flags.push(`热度:${item.wantCount}`);
if (item.localFineReason) positives.push(`本地细筛:${item.localFineReason}`);
score -= Math.min(40, [...new Set(flags)].filter(f => /疑似|可能|超预算|故障|/.test(f)).length * 10);
score = Math.max(0, Math.min(100, score));
let verdict = 'caution';
if (score >= 75 && !flags.some(f => /疑似|可能|超预算|故障|关键型号/.test(f))) verdict = 'recommend';
if (score < 45 || flags.some(f => /求购|维修|故障|超预算|关键型号/.test(f))) verdict = 'reject';
const reason = verdict === 'recommend'
? `匹配度较高,${positives.slice(0, 3).join('') || '暂无明显风险'}`
: verdict === 'caution'
? `需要人工核对:${[...new Set(flags)].slice(0, 3).join('') || '信息不足'}`
: `暂不推荐:${[...new Set(flags)].slice(0, 3).join('') || '综合分偏低'}`;
return {
score,
verdict,
reason,
flags: [...new Set(flags)].slice(0, 8),
positives: [...new Set(positives)].slice(0, 8),
analyzedAt: Date.now(),
};
}
export function buildAnalysis(task, { limit = 50 } = {}) {
const candidates = task.products?.fineFiltered || [];
const analyzed = candidates.map(item => ({ ...item, hermesAnalysis: analyzeItem(item, task) }));
const sorted = [...analyzed].sort((a, b) => (b.hermesAnalysis?.score || 0) - (a.hermesAnalysis?.score || 0));
const recommended = sorted.filter(i => i.hermesAnalysis.verdict === 'recommend');
const caution = sorted.filter(i => i.hermesAnalysis.verdict === 'caution');
const rejected = sorted.filter(i => i.hermesAnalysis.verdict === 'reject');
const summary = {
taskId: task.id,
taskName: task.name,
analyzedAt: Date.now(),
total: candidates.length,
recommendedCount: recommended.length,
cautionCount: caution.length,
rejectedCount: rejected.length,
hasGoodResults: recommended.length > 0,
topItems: sorted.slice(0, limit).map(item => ({
id: item.id,
title: item.title,
price: item.detailPrice || item.price || '',
location: item.sellerLocation || '',
href: item.href || item.url || '',
score: item.hermesAnalysis.score,
verdict: item.hermesAnalysis.verdict,
reason: item.hermesAnalysis.reason,
flags: item.hermesAnalysis.flags,
})),
};
return { items: analyzed, summary };
}
export function renderAnalysisMarkdown(task, summary) {
const verdictLabel = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const lines = [];
lines.push(`# ${task.name || task.id} — Hermes 分析结果`);
lines.push('');
lines.push(`- 任务ID\`${task.id}\``);
lines.push(`- 分析时间:${new Date(summary.analyzedAt).toLocaleString('zh-CN', { hour12: false })}`);
lines.push(`- 候选数量:${summary.total}`);
lines.push(`- 推荐/谨慎/排除:${summary.recommendedCount}/${summary.cautionCount}/${summary.rejectedCount}`);
lines.push('');
const show = summary.topItems.filter(i => i.verdict !== 'reject').slice(0, 20);
if (!show.length) {
lines.push('暂无合适候选。');
} else {
for (const item of show) {
lines.push(`## ${item.score}分|${verdictLabel[item.verdict] || item.verdict}${item.title || '(无标题)'}`);
lines.push(`- 价格:${item.price || '-'}`);
lines.push(`- 地区:${item.location || '-'}`);
if (item.href) lines.push(`- 链接:${item.href}`);
lines.push(`- 判断:${item.reason}`);
if (item.flags?.length) lines.push(`- 风险:${item.flags.join('')}`);
lines.push('');
}
}
fs.mkdirSync(REPORT_DIR, { recursive: true });
const out = path.join(REPORT_DIR, `${task.id}-analysis.md`);
fs.writeFileSync(out, lines.join('\n'), 'utf-8');
return { markdown: lines.join('\n'), path: out };
}
+53
View File
@@ -0,0 +1,53 @@
import { chromium } from 'playwright';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const USER_DATA_DIR = path.join(__dirname, '..', 'browser-data');
let _context = null;
let _launching = null;
export async function getBrowserContext() {
if (_context) return _context;
if (_launching) return _launching;
_launching = chromium.launchPersistentContext(USER_DATA_DIR, {
headless: false,
viewport: { width: 1366, height: 900 },
locale: 'zh-CN',
args: [
'--disable-blink-features=AutomationControlled',
'--no-first-run',
],
});
_context = await _launching;
_launching = null;
_context.on('close', () => { _context = null; });
return _context;
}
export async function newPage() {
const ctx = await getBrowserContext();
return ctx.newPage();
}
export async function getOpenPagesInfo() {
const ctx = _context;
if (!ctx) return { browserOpen: false, pages: [] };
const pages = await Promise.all(ctx.pages().map(async (page, index) => ({
index,
url: page.url(),
title: await page.title().catch(() => ''),
})));
return { browserOpen: true, pages };
}
export async function closeBrowser() {
if (_context) {
await _context.close().catch(() => {});
_context = null;
}
}
+886
View File
@@ -0,0 +1,886 @@
import { newPage } from './browser.mjs';
import { generateChatMessages, buildSessionPrompt, judgeGoalReached } from './ai.mjs';
import { randomDelay, sleep, waitIfVerification } from './utils.mjs';
export class ChatManager {
constructor(emitLog, shouldStop) {
this.sessions = new Map();
this.emitLog = emitLog;
this.shouldStop = shouldStop;
this._monitoring = false;
// WebSocket 监控状态
this._monitorPage = null; // 常驻 IM 页面
this._wsAlive = false; // WS 连接是否存活
this._wsReconnects = 0; // 重连次数
this._processingQueue = []; // 消息处理队列(防并发)
this._queueRunning = false;
this._catchingUp = false; // 补漏进行中(WS 消息只入队不处理)
}
async startChat(product, chatContext) {
if (!product.chatUrl) {
this.emitLog(` ⚠️ 商品 "${product.title.slice(0, 20)}..." 没有聊天链接,跳过`);
return null;
}
const sessionId = product.id;
if (this.sessions.has(sessionId)) {
this.emitLog(` ️ 已有会话: ${sessionId}`);
return sessionId;
}
this.emitLog(`💬 发起聊天: ${product.title.slice(0, 30)}...`);
const systemPrompt = buildSessionPrompt(product, chatContext);
const session = {
id: sessionId,
product,
chatContext,
systemPrompt,
messages: [], // { role: 'self'|'other', content, time, fingerprint }
chatHistory: [], // { role: 'assistant'|'user', content } — for AI context
seenFingerprints: new Set(),
status: 'initiating',
goalReason: '',
lastChecked: Date.now(),
backoffLevel: 0,
page: null,
};
this.sessions.set(sessionId, session);
try {
const page = await newPage();
session.page = page;
const shortTitle = product.title.slice(0, 15);
await page.goto(product.chatUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
const verifyOk = await this._checkAndWaitVerification(page, shortTitle);
if (!verifyOk) {
session.status = 'error';
await page.close().catch(() => {});
session.page = null;
return null;
}
const loaded = await this._waitForChatReady(page, shortTitle);
if (!loaded) {
this.emitLog(` ⚠️ 聊天页面加载不完整,尝试继续发送`);
}
// 先提取页面上已有的聊天记录,作为 AI 对话上下文
const existingMessages = await extractMessages(page);
this._syncSnapshot(session, existingMessages);
if (existingMessages.length > 0) {
this.emitLog(` 📋 检测到页面已有 ${existingMessages.length} 条聊天记录,作为上下文`);
}
// 判断是否需要发送消息:如果最后一条消息是自己发的且对方未回复,不再重复发送
const lastMsg = session.messages[session.messages.length - 1];
const needSend = !lastMsg || lastMsg.role === 'other';
if (!needSend) {
this.emitLog(` ℹ️ 最后一条为自己发送的消息,等待对方回复,不重复发送`);
} else {
const msgs = await generateChatMessages(
product, session.chatHistory, { ...chatContext, emitLog: this.emitLog }
);
this.emitLog(` 生成 ${msgs.length} 条消息`);
for (const msg of msgs) {
await this._sendMessage(page, msg);
const fp = msgFingerprint('self', msg);
session.messages.push({ role: 'self', content: msg, time: Date.now(), fingerprint: fp });
session.chatHistory.push({ role: 'assistant', content: msg });
session.seenFingerprints.add(fp);
this.emitLog(` 📤 发送: "${msg}"`);
await randomDelay(2500, 4000);
}
// 发送后再次采集快照
await sleep(2000);
const postSendMessages = await extractMessages(page);
this._syncSnapshot(session, postSendMessages);
}
session.status = 'waiting';
session.lastChecked = Date.now();
await page.close().catch(() => {});
session.page = null;
this.emitLog(` ✅ 开场消息已发送,等待回复(快照 ${session.messages.length} 条消息)`);
return sessionId;
} catch (err) {
this.emitLog(` ❌ 聊天发起失败: ${err.message}`);
session.status = 'error';
if (session.page) await session.page.close().catch(() => {});
session.page = null;
return null;
}
}
/**
* 同步页面快照到 session:基于内容指纹做去重,
* 只把尚未见过的消息追加到 session.messages 和 chatHistory
*/
_syncSnapshot(session, pageMessages) {
let newCount = 0;
for (const pm of pageMessages) {
if (session.seenFingerprints.has(pm.fingerprint)) continue;
session.seenFingerprints.add(pm.fingerprint);
session.messages.push(pm);
session.chatHistory.push({
role: pm.role === 'self' ? 'assistant' : 'user',
content: pm.content,
});
newCount++;
}
return newCount;
}
async _sendMessage(page, text) {
// 闲鱼 IM 输入框: textarea with placeholder containing "请输入消息"
const inputSel = 'textarea[class*="textarea-no-border"], textarea[placeholder*="请输入消息"]';
const inputEl = page.locator(inputSel).first();
const visible = await inputEl.isVisible({ timeout: 3000 }).catch(() => false);
if (!visible) {
this.emitLog(' ⚠️ 未找到聊天输入框');
return;
}
await inputEl.click();
await sleep(300);
// 清空已有内容后输入
await inputEl.fill('');
await sleep(100);
for (const char of text) {
await inputEl.type(char, { delay: 30 + Math.random() * 60 });
}
await sleep(500);
// 闲鱼发送按钮: "发 送" (注意中间有空格)
const sendBtn = page.locator('button:has-text("发 送"), button:has-text("发送")').first();
const btnVisible = await sendBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (btnVisible) {
await sendBtn.click();
await sleep(500);
return;
}
// 备选:按回车
await page.keyboard.press('Enter');
await sleep(500);
}
async _waitForChatReady(page, label = '') {
const prefix = label ? `[${label}] ` : '';
try {
await page.waitForSelector('#message-list-scrollable', { timeout: 15000 });
} catch {
this.emitLog(` ${prefix}⚠️ 消息列表15s内未加载,继续等待...`);
try {
await page.waitForSelector('#message-list-scrollable', { timeout: 20000 });
} catch {
this.emitLog(` ${prefix}⚠️ 消息列表加载超时,尝试继续`);
return false;
}
}
await sleep(1500);
await page.evaluate(() => {
const el = document.querySelector('#message-list-scrollable');
if (el) el.scrollTop = el.scrollHeight;
});
await sleep(800);
return true;
}
async _checkAndWaitVerification(page, label = '') {
return waitIfVerification(page, {
emitLog: this.emitLog,
shouldStop: this.shouldStop,
label,
});
}
// ==================== 监控入口 ====================
async monitorSessions() {
if (this._monitoring) return;
this._monitoring = true;
this.emitLog('👀 开始监控聊天会话...');
if (this.shouldStop()) {
this._monitoring = false;
this.emitLog(`👀 任务已被停止,聊天监控未启动(当前 ${this.sessions.size} 个会话已保存)`);
return;
}
const waitingCount = [...this.sessions.values()].filter(s => s.status === 'waiting').length;
if (waitingCount > 0) {
this.emitLog(` 📊 共 ${this.sessions.size} 个会话(${waitingCount} 个待监控)`);
}
// 第一步:立即建立 WS(补漏期间消息只入队不处理)
this._catchingUp = true;
try {
this.emitLog('🔌 建立 WebSocket 实时监听...');
await this._setupWsMonitor();
} catch (err) {
this.emitLog(` ⚠️ 初始 WS 连接失败: ${err.message}`);
}
// 第二步:对已有 waiting 会话做补漏
await this._catchUpSessions();
// 第三步:补漏完成,处理 WS 积压消息
this._catchingUp = false;
await this._drainDeferredQueue();
// 第四步:进入 WS 监控主循环(含重连逻辑)
await this._wsMonitorLoop();
this._monitoring = false;
await this._closeMonitorPage();
if (this.shouldStop()) {
this.emitLog(`👀 任务被手动停止,聊天监控结束(${this.sessions.size} 个会话已保存)`);
} else {
this.emitLog('👀 所有会话已结束,聊天监控停止');
}
}
// ==================== 启动补漏:DOM 快照检查 ====================
/**
* 对所有 waiting 会话做一次性 DOM 快照,捕获停机/断连期间遗漏的消息。
* 发现新消息的会话会触发 AI 回复。
*/
async _catchUpSessions() {
const waitingSessions = [...this.sessions.values()].filter(s => s.status === 'waiting');
if (waitingSessions.length === 0) return;
this.emitLog(` 🔄 补漏检查: ${waitingSessions.length} 个会话`);
for (const session of waitingSessions) {
if (this.shouldStop()) break;
try {
const hadNew = await this._checkSessionOnce(session);
if (hadNew) {
this.emitLog(` 📩 补漏发现新消息: ${session.product.title.slice(0, 20)}...`);
}
} catch (err) {
this.emitLog(` ⚠️ 补漏检查失败 ${session.id}: ${err.message}`);
}
await randomDelay(2000, 4000);
}
this.emitLog(` 🔄 补漏检查完成`);
}
/**
* 单次 DOM 快照检查(用于补漏和降级),打开聊天页提取消息后关闭。
* 如果有新的对方消息,触发 AI 回复。
*/
async _checkSessionOnce(session) {
const page = await newPage();
let hadNewReply = false;
const shortTitle = session.product.title.slice(0, 15);
try {
await page.goto(session.product.chatUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
const verifyOk = await this._checkAndWaitVerification(page, shortTitle);
if (!verifyOk) return false;
const loaded = await this._waitForChatReady(page, shortTitle);
if (!loaded) return false;
const pageMessages = await extractMessages(page);
session.lastChecked = Date.now();
const newOtherMsgs = pageMessages.filter(
m => m.role === 'other' && !session.seenFingerprints.has(m.fingerprint)
);
if (newOtherMsgs.length > 0) {
hadNewReply = true;
for (const msg of newOtherMsgs) {
session.seenFingerprints.add(msg.fingerprint);
session.messages.push(msg);
session.chatHistory.push({ role: 'user', content: msg.content });
this.emitLog(` 收到: "${msg.content}"`);
}
// 同步自己的新消息
const newSelfMsgs = pageMessages.filter(
m => m.role === 'self' && !session.seenFingerprints.has(m.fingerprint)
);
for (const msg of newSelfMsgs) {
session.seenFingerprints.add(msg.fingerprint);
session.messages.push(msg);
session.chatHistory.push({ role: 'assistant', content: msg.content });
}
// 判断目标 + AI 回复
await this._handleNewIncoming(session, page);
} else {
this._syncSnapshot(session, pageMessages);
}
} finally {
await page.close().catch(() => {});
}
return hadNewReply;
}
// ==================== WebSocket 实时监听 ====================
/**
* 主监控循环:打开 IM 页面,拦截 WS,断连时自动重连。
*/
async _wsMonitorLoop() {
const MAX_RECONNECTS = 10;
while (!this.shouldStop()) {
try {
// 如果 WS 未连接,建立连接
if (!this._wsAlive) {
this.emitLog('🔌 重新建立 WebSocket 连接...');
await this._setupWsMonitor();
}
// WS 已建立,进入等待循环
while (!this.shouldStop() && this._wsAlive) {
await sleep(3000);
// 所有会话都结束了,退出
if (!this._hasActiveSessions() && this.sessions.size > 0) break;
}
if (this.shouldStop()) break;
if (!this._hasActiveSessions() && this.sessions.size > 0) break;
// WS 断了,准备重连
this._wsReconnects++;
if (this._wsReconnects > MAX_RECONNECTS) {
this.emitLog(` ❌ WebSocket 重连次数超过 ${MAX_RECONNECTS},停止监控`);
break;
}
this.emitLog(` 🔄 WebSocket 断连,第 ${this._wsReconnects} 次重连...`);
await this._closeMonitorPage();
// 重连也遵循:先 WS 再补漏
this._catchingUp = true;
try {
this.emitLog('🔌 建立 WebSocket 实时监听...');
await this._setupWsMonitor();
} catch (err) {
this.emitLog(` ⚠️ 重连 WS 失败: ${err.message}`);
}
await this._catchUpSessions();
this._catchingUp = false;
await this._drainDeferredQueue();
await randomDelay(3000, 6000);
} catch (err) {
this.emitLog(` ⚠️ WebSocket 监控异常: ${err.message}`);
await this._closeMonitorPage();
await sleep(5000);
}
}
}
/**
* 打开 IM 页面并拦截 DingTalk IMPaaS WebSocket 连接。
*/
async _setupWsMonitor() {
this._wsAlive = false;
const page = await newPage();
this._monitorPage = page;
// 在页面导航前注册 WS 监听
page.on('websocket', ws => {
const url = ws.url();
// 只关注 DingTalk IMPaaS 连接
if (!url.includes('wss-goofish.dingtalk.com')) return;
this._wsAlive = true;
this._wsReconnects = 0;
this.emitLog(' ✅ WebSocket 连接已建立 (IMPaaS)');
ws.on('framereceived', frame => {
try {
this._onWsFrame(frame.payload);
} catch (err) {
this.emitLog(` ⚠️ WS 帧处理异常: ${err.message}`);
}
});
ws.on('close', () => {
this.emitLog(' ⚠️ WebSocket 连接已断开');
this._wsAlive = false;
});
});
// 监听页面崩溃/关闭
page.on('crash', () => {
this.emitLog(' ⚠️ 监控页面崩溃');
this._wsAlive = false;
});
page.on('close', () => {
this._wsAlive = false;
this._monitorPage = null;
});
// 打开 IM 首页(会自动建立 WS 连接)
await page.goto('https://www.goofish.com/im', { waitUntil: 'domcontentloaded', timeout: 30000 });
const verifyOk = await this._checkAndWaitVerification(page, 'IM首页');
if (!verifyOk) {
throw new Error('IM 页面验证未通过');
}
// 等待 WS 连接建立
const wsTimeout = 15000;
const wsStart = Date.now();
while (!this._wsAlive && Date.now() - wsStart < wsTimeout) {
await sleep(500);
}
if (!this._wsAlive) {
throw new Error('WebSocket 连接超时');
}
}
/**
* 处理收到的 WS 帧,提取 /s/sync bizType=40 的聊天消息。
*/
_onWsFrame(payload) {
if (typeof payload !== 'string') return;
let frame;
try { frame = JSON.parse(payload); } catch { return; }
// 只关注服务端推送的 /s/sync 帧
const lwp = frame.lwp || '';
if (lwp !== '/s/sync') return;
const body = frame.body;
if (!body?.syncPushPackage?.data) return;
for (const item of body.syncPushPackage.data) {
// bizType 40 = 聊天消息
if (item.bizType !== 40) continue;
try {
const binary = atob(item.data);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const decoded = new TextDecoder('utf-8').decode(bytes);
const msgInfo = this._parseWsMessage(decoded);
if (msgInfo) {
this._enqueueMessage(msgInfo);
}
} catch (err) {
this.emitLog(` ⚠️ 消息解码失败: ${err.message}`);
}
}
}
/**
* 从 base64 解码后的二进制字符串中提取消息信息。
* 数据格式:二进制协议中嵌入了 JSON 和可读字段。
*/
_parseWsMessage(decoded) {
// 提取 senderUserId
const senderMatch = decoded.match(/senderUserId\x00?(.+?)(?:\x00|\x01)/);
const senderUserId = senderMatch ? senderMatch[1].replace(/[^\d]/g, '') : '';
// 提取消息内容 JSON
const contentMatch = decoded.match(/\{"atUsers".*?"text":\{"text":"((?:[^"\\]|\\.)*)"\}\}/);
const text = contentMatch ? contentMatch[1] : '';
// 提取 reminderTitle(发送者昵称)
const titleMatch = decoded.match(/reminderTitle\x00?(.+?)(?:\x00|\x01)/);
const senderName = titleMatch ? titleMatch[1].replace(/[\x00-\x1f]/g, '').trim() : '';
// 提取 itemId(从 reminderUrl
const itemIdMatch = decoded.match(/itemId=(\d+)/);
const itemId = itemIdMatch ? itemIdMatch[1] : '';
// 提取 peerUserId(从 reminderUrl
const peerMatch = decoded.match(/peerUserId=(\d+)/);
const peerUserId = peerMatch ? peerMatch[1] : '';
if (!text || !senderUserId) return null;
return { senderUserId, text, senderName, itemId, peerUserId };
}
/**
* 将 WS 消息加入处理队列(串行处理,防止并发回复冲突)。
*/
_enqueueMessage(msgInfo) {
this._processingQueue.push(msgInfo);
if (this._catchingUp) return; // 补漏期间只入队,不触发处理
if (!this._queueRunning) {
this._queueRunning = true;
this._processQueue().catch(err => {
this.emitLog(` ⚠️ 消息队列处理异常: ${err.message}`);
}).finally(() => {
this._queueRunning = false;
});
}
}
async _processQueue() {
while (this._processingQueue.length > 0 && !this.shouldStop()) {
const msgInfo = this._processingQueue.shift();
await this._handleWsMessage(msgInfo);
}
}
/**
* 补漏完成后,统一处理积压的 WS 消息。
* 去重会自动过滤已被补漏覆盖的消息。
*/
async _drainDeferredQueue() {
if (this._processingQueue.length === 0) return;
this.emitLog(` 📬 处理补漏期间积压的 ${this._processingQueue.length} 条 WS 消息`);
this._queueRunning = true;
try {
await this._processQueue();
} finally {
this._queueRunning = false;
}
}
/**
* 处理一条 WS 推送的消息:匹配会话 → 记录 → 判断目标 → AI 回复。
*/
async _handleWsMessage(msgInfo) {
const { senderUserId, text, senderName, itemId } = msgInfo;
// 匹配会话:通过 chatUrl 中的 peerUserId 或 itemId
let session = null;
for (const s of this.sessions.values()) {
if (s.status !== 'waiting') continue;
const url = s.product.chatUrl || '';
const urlPeer = extractUrlParam(url, 'peerUserId');
const urlItem = extractUrlParam(url, 'itemId');
if ((urlPeer && urlPeer === senderUserId) || (urlItem && urlItem === itemId)) {
session = s;
break;
}
}
if (!session) return; // 不是我们监控的会话
const shortTitle = session.product.title.slice(0, 20);
this.emitLog(` 📩 [WS] ${shortTitle}... 收到消息: "${text}"`);
// 去重
const fp = msgFingerprint('other', text);
if (session.seenFingerprints.has(fp)) {
this.emitLog(` ️ 重复消息,跳过`);
return;
}
session.seenFingerprints.add(fp);
session.messages.push({ role: 'other', content: text, senderName, time: Date.now(), fingerprint: fp });
session.chatHistory.push({ role: 'user', content: text });
session.lastChecked = Date.now();
// 打开聊天页回复
const page = await newPage();
try {
await page.goto(session.product.chatUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
const verifyOk = await this._checkAndWaitVerification(page, shortTitle);
if (!verifyOk) {
this.emitLog(` ⚠️ ${shortTitle}... 回复页面验证失败`);
return;
}
await this._waitForChatReady(page, shortTitle);
await this._handleNewIncoming(session, page);
} catch (err) {
this.emitLog(` ⚠️ ${shortTitle}... 回复失败: ${err.message}`);
} finally {
await page.close().catch(() => {});
}
}
// ==================== 共用:目标判断 + AI 回复 ====================
/**
* 收到新消息后的通用处理:判断目标是否达成,未达成则生成 AI 回复。
* page 参数为已打开的聊天页面(用于发送消息)。
*/
async _handleNewIncoming(session, page) {
const shortTitle = session.product.title.slice(0, 20);
// 判断目标是否已达成
if (session.chatHistory.length >= 6) {
try {
const goalResult = await judgeGoalReached(
session.product, session.chatHistory, { ...session.chatContext, emitLog: this.emitLog }
);
if (goalResult.reached) {
session.status = 'goal_reached';
session.goalReason = goalResult.reason || '目标达成';
this.emitLog(` 🎯 ${shortTitle}... 聊天目标达成: ${session.goalReason}`);
return;
}
} catch (err) {
this.emitLog(` ⚠️ 目标判断出错: ${err.message}`);
}
}
// 生成 AI 回复
const replies = await generateChatMessages(
session.product,
session.chatHistory,
{ ...session.chatContext, emitLog: this.emitLog },
);
for (const reply of replies) {
await this._sendMessage(page, reply);
const fp = msgFingerprint('self', reply);
session.seenFingerprints.add(fp);
session.messages.push({ role: 'self', content: reply, time: Date.now(), fingerprint: fp });
session.chatHistory.push({ role: 'assistant', content: reply });
this.emitLog(` 📤 回复: "${reply}"`);
await randomDelay(2500, 4000);
}
}
// ==================== 辅助方法 ====================
_hasActiveSessions() {
return [...this.sessions.values()].some(s => s.status === 'waiting');
}
async _closeMonitorPage() {
if (this._monitorPage) {
await this._monitorPage.close().catch(() => {});
this._monitorPage = null;
}
this._wsAlive = false;
}
getSessionsData() {
const data = [];
for (const [id, session] of this.sessions) {
data.push({
id,
productTitle: session.product.title,
productPrice: session.product.price,
productDescription: session.product.description?.slice(0, 200) || '',
sellerName: session.product.sellerName || '',
status: session.status,
goalReason: session.goalReason || '',
messageCount: session.messages.length,
messages: session.messages.slice(-30).map(m => ({
role: m.role,
content: m.content,
time: m.time,
})),
lastChecked: session.lastChecked,
promptSummary: {
hasStrategy: !!session.chatContext?.chatStrategy,
hasProductContext: !!session.product.description,
hasRequirements: !!session.chatContext?.requirements,
},
});
}
return data;
}
getSessionsFullData() {
const data = [];
for (const [id, session] of this.sessions) {
data.push({
id,
product: session.product,
chatContext: session.chatContext,
status: session.status,
goalReason: session.goalReason || '',
messages: session.messages.map(m => ({
role: m.role,
content: m.content,
time: m.time,
fingerprint: m.fingerprint,
})),
chatHistory: session.chatHistory,
lastChecked: session.lastChecked,
backoffLevel: session.backoffLevel,
});
}
return data;
}
restoreSessions(savedSessions, chatContext) {
let restored = 0;
for (const s of savedSessions) {
if (s.status === 'goal_reached' || s.status === 'error') continue;
if (!s.product?.chatUrl) continue;
const ctx = s.chatContext || chatContext;
const systemPrompt = buildSessionPrompt(s.product, ctx);
const seenFingerprints = new Set();
const messages = (s.messages || []).map(m => {
const fp = m.fingerprint || msgFingerprint(m.role, m.content);
seenFingerprints.add(fp);
return { ...m, fingerprint: fp };
});
let chatHistory = s.chatHistory;
if (!chatHistory || chatHistory.length === 0) {
chatHistory = messages.map(m => ({
role: m.role === 'self' ? 'assistant' : 'user',
content: m.content,
}));
}
const session = {
id: s.id,
product: s.product,
chatContext: ctx,
systemPrompt,
messages,
chatHistory,
seenFingerprints,
status: 'waiting',
goalReason: s.goalReason || '',
lastChecked: Date.now(),
backoffLevel: 0,
page: null,
};
this.sessions.set(s.id, session);
restored++;
}
if (restored > 0) {
this.emitLog(` ♻️ 从持久化数据恢复了 ${restored} 个聊天会话`);
}
return restored;
}
async cleanup() {
await this._closeMonitorPage();
for (const session of this.sessions.values()) {
if (session.page) await session.page.close().catch(() => {});
}
this.sessions.clear();
}
}
/**
* 生成消息指纹,用于去重(同一角色+同样文本 = 同一条消息)
* 加入序号后缀处理同一人连续发相同内容的情况
*/
function msgFingerprint(role, content) {
return `${role}:${content.trim()}`;
}
/**
* 从 URL 中提取指定参数值
*/
function extractUrlParam(url, param) {
try {
const u = new URL(url);
return u.searchParams.get(param) || '';
} catch {
const match = url.match(new RegExp(`[?&]${param}=([^&]*)`));
return match ? match[1] : '';
}
}
/**
* 在闲鱼 IM 页面上精确提取所有消息。
*
* DOM 结构关键点:
* - 消息列表在 #message-list-scrollable > .ant-list-items 下
* - 每条消息是 li.ant-list-item
* - 自己的消息: li 的 style 含 "direction: rtl",文本 class 含 "message-text-right"
* - 对方的消息: li 的 style 含 "direction: ltr",文本 class 含 "message-text-left"
* - 发送者名称在 message-row 内第一个小字体 div
* - 消息文本在 div[class*="message-text"] 的 span 中
*/
async function extractMessages(page) {
return page.evaluate(() => {
const results = [];
const roleCounters = {};
const msgRows = document.querySelectorAll('[class*="message-row"]');
for (const row of msgRows) {
const li = row.closest('li');
if (!li) continue;
const liStyle = li.getAttribute('style') || '';
let role;
if (liStyle.includes('direction: rtl') || liStyle.includes('direction:rtl')) {
role = 'self';
} else if (liStyle.includes('direction: ltr') || liStyle.includes('direction:ltr')) {
role = 'other';
} else {
// 备用判断:检查 message-text 的 class
const textEl = row.querySelector('[class*="message-text"]');
if (!textEl) continue;
const cls = textEl.className || '';
if (cls.includes('right')) {
role = 'self';
} else if (cls.includes('left')) {
role = 'other';
} else {
continue;
}
}
// 提取发送者名称
// 在 message-row 内,sender name 是 font-size: 12px 的那个 div
let senderName = '';
const allDivs = row.querySelectorAll('div');
for (const d of allDivs) {
const s = d.getAttribute('style') || '';
if (s.includes('font-size: 12px') && s.includes('color: rgb(102, 102, 102)')) {
senderName = d.textContent.trim();
break;
}
}
// 提取消息文本
const textEl = row.querySelector('[class*="message-text"]');
if (!textEl) continue;
// 文本可能在 span 子元素中,或直接是 textContent
const spans = textEl.querySelectorAll('span');
let content = '';
if (spans.length > 0) {
content = [...spans].map(s => s.textContent.trim()).join('');
}
if (!content) {
content = textEl.textContent.trim();
}
if (!content || content.length > 500) continue;
// 处理同一 role 连续发同内容消息的去重序号
const baseKey = `${role}:${content}`;
roleCounters[baseKey] = (roleCounters[baseKey] || 0) + 1;
const count = roleCounters[baseKey];
const fingerprint = count > 1 ? `${baseKey}#${count}` : baseKey;
results.push({
role,
content,
senderName,
fingerprint,
time: Date.now(),
});
}
return results;
});
}
+192
View File
@@ -0,0 +1,192 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', 'data');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
export const PROVIDERS = [
{
id: 'openrouter',
name: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
models: [
{ id: 'minimax/minimax-m2.5', name: 'Minimax M2.5' },
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash' },
{ id: 'google/gemini-2.5-pro-preview', name: 'Gemini 2.5 Pro' },
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3' },
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1' },
{ id: 'qwen/qwen-2.5-72b-instruct', name: 'Qwen 2.5 72B' },
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
],
},
{
id: 'openai',
name: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
models: [
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' },
{ id: 'gpt-4.1', name: 'GPT-4.1' },
{ id: 'o3-mini', name: 'o3-mini' },
],
},
{
id: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek V3' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1' },
],
},
{
id: 'qwen',
name: '通义千问 (Qwen)',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: [
{ id: 'qwen-plus', name: 'Qwen Plus' },
{ id: 'qwen-turbo', name: 'Qwen Turbo' },
{ id: 'qwen-max', name: 'Qwen Max' },
{ id: 'qwen-long', name: 'Qwen Long' },
],
},
{
id: 'siliconflow',
name: 'SiliconFlow (硅基流动)',
baseUrl: 'https://api.siliconflow.cn/v1',
models: [
{ id: 'deepseek-ai/DeepSeek-V3', name: 'DeepSeek V3' },
{ id: 'deepseek-ai/DeepSeek-R1', name: 'DeepSeek R1' },
{ id: 'Qwen/Qwen2.5-72B-Instruct', name: 'Qwen 2.5 72B' },
{ id: 'THUDM/glm-4-9b-chat', name: 'GLM-4 9B' },
],
},
{
id: 'zhipu',
name: '智谱 AI (GLM)',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models: [
{ id: 'glm-4-flash', name: 'GLM-4 Flash' },
{ id: 'glm-4-plus', name: 'GLM-4 Plus' },
{ id: 'glm-4', name: 'GLM-4' },
],
},
{
id: 'moonshot',
name: 'Moonshot (Kimi)',
baseUrl: 'https://api.moonshot.cn/v1',
models: [
{ id: 'moonshot-v1-8k', name: 'Moonshot V1 8K' },
{ id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
{ id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
],
},
{
id: 'groq',
name: 'Groq',
baseUrl: 'https://api.groq.com/openai/v1',
models: [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B' },
{ id: 'gemma2-9b-it', name: 'Gemma 2 9B' },
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B' },
],
},
{
id: 'custom',
name: '自定义 (OpenAI 兼容)',
baseUrl: '',
models: [],
},
];
const DEFAULT_CONFIG = {
provider: '',
apiKey: '',
baseUrl: '',
model: '',
customModels: [],
};
let _config = null;
export function loadConfig() {
if (_config) return _config;
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
_config = { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} else {
_config = { ...DEFAULT_CONFIG };
}
} catch {
_config = { ...DEFAULT_CONFIG };
}
return _config;
}
export function saveConfig(newConfig) {
_config = { ...DEFAULT_CONFIG, ..._config, ...newConfig };
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8');
} catch (err) {
console.error('保存配置失败:', err.message);
}
return _config;
}
export function getConfig() {
return loadConfig();
}
export function isConfigured() {
const cfg = loadConfig();
return !!(cfg.apiKey && cfg.baseUrl && cfg.model);
}
export function getApiKey() {
return loadConfig().apiKey || '';
}
export function getBaseUrl() {
return loadConfig().baseUrl || '';
}
export function getCurrentModel() {
return loadConfig().model || '';
}
export function setCurrentModel(modelId) {
const cfg = loadConfig();
cfg.model = modelId;
saveConfig(cfg);
console.log(`🤖 AI 模型已切换为: ${modelId}`);
return true;
}
export function getAvailableModels() {
const cfg = loadConfig();
const provider = PROVIDERS.find(p => p.id === cfg.provider);
const presetModels = provider?.models || [];
const customModels = (cfg.customModels || []).map(m =>
typeof m === 'string' ? { id: m, name: m } : m
);
return [...presetModels, ...customModels];
}
export function getSafeConfig() {
const cfg = loadConfig();
return {
provider: cfg.provider,
apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}****${cfg.apiKey.slice(-4)}` : '',
baseUrl: cfg.baseUrl,
model: cfg.model,
customModels: cfg.customModels || [],
hasKey: !!cfg.apiKey,
};
}
+166
View File
@@ -0,0 +1,166 @@
import { newPage } from './browser.mjs';
import { randomDelay, extractUserId, sleep, waitIfVerification } from './utils.mjs';
const BATCH_SIZE = 15;
export async function coarseFilter(products, requirements, emitLog, shouldStop, customPromptHead) {
emitLog(`🔸 本地粗筛开始,共 ${products.length} 个商品(不调用外部模型接口)`);
const passed = [];
const rules = buildLocalRules(requirements, customPromptHead);
for (let i = 0; i < products.length; i += BATCH_SIZE) {
if (shouldStop()) break;
const batch = products.slice(i, i + BATCH_SIZE);
emitLog(` 本地判断第 ${i + 1}-${Math.min(i + BATCH_SIZE, products.length)} 个...`);
const kept = batch.filter(product => {
const result = localCoarseJudge(product, rules);
product.localCoarseReason = result.reason;
if (!result.pass) emitLog(`${product.title?.slice(0, 30) || ''} - ${result.reason}`);
return result.pass;
});
passed.push(...kept);
emitLog(` 本批保留 ${kept.length}/${batch.length}`);
await randomDelay(1000, 2000);
}
emitLog(`🔸 粗筛完成: ${passed.length}/${products.length} 通过`);
return passed;
}
export async function fineFilter(products, requirements, emitLog, shouldStop, customPromptHead) {
emitLog(`🔹 详情采集开始,共 ${products.length} 个商品(分析将由 Hermes 完成)`);
const passed = [];
const rules = buildLocalRules(requirements, customPromptHead);
for (let i = 0; i < products.length; i++) {
if (shouldStop()) break;
const product = products[i];
emitLog(` 细筛 [${i + 1}/${products.length}] ${product.title.slice(0, 30)}...`);
try {
const detail = await fetchProductDetail(product, emitLog, shouldStop);
const enriched = { ...product, ...detail };
const result = localFineJudge(enriched, rules);
enriched.localFineReason = result.reason;
enriched.hermesAnalysisStatus = 'pending';
if (result.pass) {
emitLog(` ✅ 已采集 - ${result.reason}`);
passed.push(enriched);
} else {
emitLog(` ❌ 本地排除 - ${result.reason}`);
}
} catch (err) {
emitLog(` ⚠️ 细筛出错,跳过: ${err.message}`);
}
await randomDelay(5000, 12000);
}
emitLog(`🔹 详情采集完成: ${passed.length}/${products.length} 个候选待 Hermes 分析`);
return passed;
}
function buildLocalRules(requirements = '', customPromptHead = '') {
const text = `${requirements}\n${customPromptHead}`.toLowerCase();
const negative = ['维修', '配件', '壳', '膜', '求购', '租', '回收', '展示机', '资源机', '监管机', '扩容', '改装'];
const requiredTokens = [];
for (const token of ['m1','m2','m3','m4','m5','16g','24g','32g','512','1t','国行']) {
if (text.includes(token)) requiredTokens.push(token);
}
return { negative, requiredTokens };
}
function normalizeText(value = '') {
return String(value || '').toLowerCase().replace(/\s+/g, '');
}
function localCoarseJudge(product, rules) {
const title = normalizeText(product.title);
const hit = rules.negative.find(word => title.includes(word));
if (hit) return { pass: false, reason: `标题疑似${hit}` };
const missing = rules.requiredTokens.filter(token => !title.includes(token));
if (missing.length >= 2) return { pass: false, reason: `标题缺少关键规格:${missing.slice(0, 3).join('/')}` };
return { pass: true, reason: '标题未见明确冲突' };
}
function localFineJudge(product, rules) {
const text = normalizeText(`${product.title || ''} ${product.description || ''}`);
const hit = rules.negative.find(word => text.includes(word));
if (hit) return { pass: false, reason: `详情疑似${hit}` };
return { pass: true, reason: '详情已采集,等待 Hermes 深度分析' };
}
async function fetchProductDetail(product, emitLog, shouldStop) {
const page = await newPage();
try {
await page.goto(product.href, { waitUntil: 'domcontentloaded', timeout: 30000 });
const verifyOk = await waitIfVerification(page, {
emitLog,
shouldStop,
label: product.title?.slice(0, 15),
});
if (!verifyOk) {
return { description: '', sellerName: '', sellerLocation: '', error: '校验未通过' };
}
await sleep(3000);
const detail = await page.evaluate(() => {
const descEl = document.querySelector('[class*="main--"][class*="open--"]')
|| document.querySelector('[class*="notLoginContainer"] [class*="main--"]');
const description = descEl?.textContent?.trim().slice(0, 500) || '';
const nickEl = document.querySelector('[class*="item-user-info-nick"]');
const sellerName = nickEl?.textContent?.trim() || '';
const labels = document.querySelectorAll('[class*="item-user-info-label"]');
const labelTexts = [...labels].map(l => l.textContent?.trim());
const sellerLocation = labelTexts[0] || '';
const sellerRating = labelTexts.find(t => t?.includes('好评')) || '';
const priceEl = document.querySelector('[class*="price--"][class*="windows"]')
|| document.querySelector('[class*="price--"]');
const detailPrice = priceEl?.textContent?.trim() || '';
const wantEl = document.querySelector('[class*="want--"]');
const wantCount = wantEl?.textContent?.trim() || '';
const chatLink = document.querySelector('a[href*="/im?itemId="]');
const chatUrl = chatLink?.href || '';
const buyLink = document.querySelector('a[href*="/create-order"]');
const buyUrl = buyLink?.href || '';
const images = [...document.querySelectorAll('[class*="carouselItem"] img')]
.map(img => img.src).filter(Boolean).slice(0, 5);
return {
description,
sellerName,
sellerLocation,
sellerRating,
detailPrice: detailPrice,
wantCount,
chatUrl,
buyUrl,
images,
};
});
const chatLink = await page.locator('a[href*="/im?itemId="]').first().getAttribute('href').catch(() => '');
if (chatLink) {
detail.chatUrl = chatLink.startsWith('http') ? chatLink : `https://www.goofish.com${chatLink}`;
detail.sellerId = extractUserId(chatLink);
}
return detail;
} catch (err) {
return { description: '', sellerName: '', sellerLocation: '', error: err.message };
} finally {
await page.close().catch(() => {});
}
}
+237
View File
@@ -0,0 +1,237 @@
import { getBrowserContext } from './browser.mjs';
import { sleep } from './utils.mjs';
const HOME_URL = 'https://www.goofish.com/';
const SEARCH_PROBE_URL = 'https://www.goofish.com/search?q=e3%201245%20v3';
const QR_LOGIN_URL = 'https://www.goofish.com/';
function accountTextValue(bodyText, labels) {
const lines = String(bodyText || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
for (const label of labels) {
const idx = lines.findIndex(line => line === label || line.startsWith(`${label}`) || line.startsWith(`${label}:`));
if (idx >= 0) {
const inline = lines[idx].replace(new RegExp(`^${label}[:]?\\s*`), '').trim();
if (inline && inline !== label) return inline;
if (lines[idx + 1]) return lines[idx + 1];
}
}
return '';
}
async function extractAccountInfo(page, bodyText) {
const title = await page.title().catch(() => '');
const nickTexts = await page.locator('[class*="nick--"], [class*="user-name"], [class*="nickname"], [class*="userName"]').evaluateAll(nodes => (
nodes.map(n => n.textContent?.trim()).filter(Boolean).slice(0, 5)
)).catch(() => []);
const avatarUrl = await page.locator('[class*="user-order-container"] img, img[class*="avatar"], [class*="avatar"] img').first().getAttribute('src', { timeout: 1200 }).catch(() => '');
const accountName = nickTexts[0] || accountTextValue(bodyText, ['昵称', '用户名', '账号']) || '';
return {
accountName,
nickCandidates: nickTexts,
avatarUrl: avatarUrl || '',
pageTitle: title,
};
}
function normalizeCookieDomain(domain = '') {
const d = String(domain || '').trim();
if (!d) return '.goofish.com';
if (/^(goofish\.com|www\.goofish\.com|2\.taobao\.com|taobao\.com|login\.taobao\.com|alibaba\.com)$/i.test(d)) {
return `.${d.replace(/^\./, '')}`;
}
return d;
}
function normalizeCookie(cookie) {
if (!cookie || typeof cookie !== 'object') return null;
const name = String(cookie.name || '').trim();
const value = cookie.value == null ? '' : String(cookie.value);
if (!name) return null;
const normalized = {
name,
value,
domain: normalizeCookieDomain(cookie.domain),
path: cookie.path || '/',
httpOnly: !!cookie.httpOnly,
secure: cookie.secure !== false,
sameSite: ['Strict', 'Lax', 'None'].includes(cookie.sameSite) ? cookie.sameSite : 'Lax',
};
const rawExpires = cookie.expires ?? cookie.expirationDate ?? cookie.expiry;
if (rawExpires != null && rawExpires !== -1 && rawExpires !== 'Session') {
const expires = Number(rawExpires);
if (Number.isFinite(expires) && expires > 0) {
normalized.expires = expires > 1e12 ? Math.floor(expires / 1000) : Math.floor(expires);
}
}
return normalized;
}
export function parseCookiesPayload(payload) {
let data = payload;
if (typeof data === 'string') data = JSON.parse(data);
const cookies = Array.isArray(data)
? data
: Array.isArray(data?.cookies)
? data.cookies
: Array.isArray(data?.data)
? data.data
: null;
if (!cookies) throw new Error('Cookie JSON 格式不正确:请上传 Cookie 数组,或包含 cookies 数组的 JSON 对象');
const normalized = cookies.map(normalizeCookie).filter(Boolean);
if (!normalized.length) throw new Error('没有识别到有效 Cookie;每条 Cookie 至少需要 name/value/domain');
return normalized;
}
export async function importCookiesFromJson(payload, emitLog = () => {}) {
const cookies = parseCookiesPayload(payload);
const ctx = await getBrowserContext();
await ctx.addCookies(cookies);
emitLog(`✅ 已导入 ${cookies.length} 条 Cookie 到 Playwright 持久化浏览器`);
const storedCookies = await ctx.cookies().catch(() => []);
const goofishCookies = storedCookies.filter(c => /(^|\.)goofish\.com$|(^|\.)taobao\.com$|(^|\.)alibaba\.com$/.test(c.domain));
return {
imported: cookies.length,
status: {
loggedIn: goofishCookies.length > 5,
loginWall: false,
loginTextVisible: false,
nickVisible: false,
avatarVisible: false,
cardCount: 0,
cookieCount: goofishCookies.length,
accountName: '',
nickCandidates: [],
avatarUrl: '',
url: 'cookie-import://local',
title: 'Cookie 已导入,点击“检测登录状态”可联网验证',
checkedAt: new Date().toISOString(),
storage: 'browser-data/',
},
};
}
export async function openLoginPage(emitLog = () => {}) {
const ctx = await getBrowserContext();
const page = ctx.pages()[0] || await ctx.newPage();
emitLog('正在打开闲鱼登录/首页窗口...');
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2000);
const status = await getLoginStatus(page);
if (status.loggedIn) emitLog('✅ 已检测到登录状态,cookie 已保存在 browser-data 中');
else emitLog('⚠️ 还未登录:请在弹出的 Chromium 窗口中完成扫码/验证码登录,完成后点击 Web 上的“检测登录状态”');
return status;
}
export async function getLoginQr(emitLog = () => {}) {
const ctx = await getBrowserContext();
const page = ctx.pages().find(p => /goofish\.com|login\.taobao\.com|havanaone/.test(p.url())) || ctx.pages()[0] || await ctx.newPage();
emitLog('正在打开闲鱼 Web 登录入口并生成后台二维码截图...');
await page.goto(QR_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2500);
let clicked = false;
for (const selector of ['text=立即登录', 'text=登录', 'button:has-text("登录")', 'a:has-text("登录")']) {
const loc = page.locator(selector).first();
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
await loc.click({ timeout: 3000 }).catch(() => {});
clicked = true;
await sleep(2500);
break;
}
}
for (const selector of ['text=扫码登录', 'text=二维码登录', 'text=使用二维码登录', '.icon-qrcode', '[class*=qrcode]']) {
const loc = page.locator(selector).first();
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
await loc.click({ timeout: 2500 }).catch(() => {});
await sleep(1500);
break;
}
}
const status = await getLoginStatus(page);
let qrImage = '';
if (!status.loggedIn) {
const modal = page.locator('[class*="login-modal"], [class*="Login"], [class*="qrcode"], [class*="qr"], iframe').first();
const target = await modal.isVisible({ timeout: 1500 }).catch(() => false) ? modal : page.locator('body');
const buf = await target.screenshot({ type: 'png', timeout: 8000 }).catch(() => null);
if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`;
}
if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码');
else emitLog(`已生成登录截图${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请在后台页面扫码后点“检测登录状态”`);
return { ...status, qrImage, qrCheckedAt: new Date().toISOString() };
}
export async function getLoginStatus(existingPage = null) {
const ctx = await getBrowserContext();
const page = existingPage || ctx.pages()[0] || await ctx.newPage();
if (!existingPage) {
await page.goto(SEARCH_PROBE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(async () => {
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
});
await sleep(3000);
}
const bodyText = await page.locator('body').innerText({ timeout: 3000 }).catch(() => '');
const hasLoginWall = /登录后可以更懂你|立即登录|请登录/.test(bodyText);
const loginTextVisible = await page.locator('text=登录').first().isVisible({ timeout: 1000 }).catch(() => false);
const nickVisible = await page.locator('[class*="nick--"]').first().isVisible({ timeout: 1500 }).catch(() => false);
const avatarVisible = await page.locator('[class*="user-order-container"] img').first().isVisible({ timeout: 1500 }).catch(() => false);
const cardCount = await page.locator('a[class*="feeds-item-wrap"], a[href*="/item"], a[href*="id="]').count().catch(() => 0);
const cookies = await ctx.cookies().catch(() => []);
const goofishCookies = cookies.filter(c => /(^|\.)goofish\.com$|(^|\.)taobao\.com$|(^|\.)alibaba\.com$/.test(c.domain));
const likelyLoggedIn = !hasLoginWall && !loginTextVisible && (nickVisible || avatarVisible || cardCount > 0 || goofishCookies.length > 5);
const accountInfo = likelyLoggedIn
? await extractAccountInfo(page, bodyText)
: { accountName: '', nickCandidates: [], avatarUrl: '', pageTitle: await page.title().catch(() => '') };
return {
loggedIn: likelyLoggedIn,
loginWall: hasLoginWall,
loginTextVisible,
nickVisible,
avatarVisible,
cardCount,
cookieCount: goofishCookies.length,
accountName: accountInfo.accountName,
nickCandidates: accountInfo.nickCandidates,
avatarUrl: accountInfo.avatarUrl,
url: page.url(),
title: accountInfo.pageTitle,
checkedAt: new Date().toISOString(),
storage: 'browser-data/',
};
}
export async function checkLogin(emitLog) {
const ctx = await getBrowserContext();
const page = ctx.pages()[0] || await ctx.newPage();
emitLog('正在打开闲鱼首页...');
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(3000);
for (let attempt = 0; attempt < 5; attempt++) {
const status = await getLoginStatus(page);
if (status.loggedIn) {
emitLog(`✅ 登录状态正常,cookie 数 ${status.cookieCount},持久化目录 browser-data/`);
return true;
}
const remaining = 5 - attempt - 1;
emitLog(`⚠️ 未检测到登录状态,请在浏览器中手动登录(剩余${remaining}次重试机会)`);
emitLog('等待20秒后重新检测...');
await sleep(20000);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
await sleep(3000);
}
emitLog('❌ 多次检测均未登录,请登录后重新启动任务');
return false;
}
+149
View File
@@ -0,0 +1,149 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const DATA_DIR = path.join(ROOT, 'data');
const NOTIFY_FILE = path.join(DATA_DIR, 'notify-config.json');
const DEFAULT_CONFIG = {
enabled: true,
notifyOnlyGood: true,
minScore: 75,
channels: [
{ id: 'feishu-default', type: 'hermes-send-message', name: '飞书当前会话', enabled: true, target: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812' },
{ id: 'weixin-default', type: 'hermes-send-message', name: '微信默认会话', enabled: false, target: 'weixin' },
],
};
const HERMES_TARGET_OPTIONS = [
{ value: 'feishu', label: '飞书 Home' },
{ value: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812', label: '飞书当前 DM' },
{ value: 'weixin', label: '微信 Home' },
{ value: 'weixin:o9cq809DWSYDlvbA9pj1akncjrhY@im.wechat', label: '微信 DM 1' },
{ value: 'weixin:o9cq808qCd1W-cIDo0iWjteE5lHY@im.wechat', label: '微信 DM 2' },
{ value: 'qqbot', label: 'QQ 机器人 Home(如已配置)' },
];
export function getNotifyConfig() {
try {
if (!fs.existsSync(NOTIFY_FILE)) return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS };
const cfg = JSON.parse(fs.readFileSync(NOTIFY_FILE, 'utf-8'));
return { ...DEFAULT_CONFIG, ...cfg, targetOptions: HERMES_TARGET_OPTIONS, channels: Array.isArray(cfg.channels) ? cfg.channels : DEFAULT_CONFIG.channels };
} catch {
return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS };
}
}
export function saveNotifyConfig(config = {}) {
const normalized = {
enabled: config.enabled !== false,
notifyOnlyGood: config.notifyOnlyGood !== false,
minScore: Math.max(0, Math.min(100, Number(config.minScore ?? 75))),
channels: (config.channels || []).map((c, i) => ({
id: c.id || `${c.type || 'channel'}-${Date.now()}-${i}`,
type: c.type || 'hermes-send-message',
name: c.name || c.type || '通知通道',
enabled: !!c.enabled,
url: c.url || '',
token: c.token || '',
target: c.target || c.platform || 'feishu',
})),
};
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(NOTIFY_FILE, JSON.stringify(normalized, null, 2), 'utf-8');
return { ...normalized, targetOptions: HERMES_TARGET_OPTIONS };
}
export function getHermesTargetOptions() {
return HERMES_TARGET_OPTIONS;
}
function buildNotificationText(task, summary) {
const label = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const items = summary.topItems
.filter(i => i.verdict === 'recommend' || i.score >= summary.minScore)
.slice(0, 5);
const lines = [];
lines.push(`🐟 闲鱼任务「${task.name || task.id}」发现 ${summary.recommendedCount} 个推荐候选`);
lines.push(`候选/推荐/谨慎/排除:${summary.total}/${summary.recommendedCount}/${summary.cautionCount}/${summary.rejectedCount}`);
lines.push('');
for (const item of items) {
lines.push(`${item.score}分|${label[item.verdict] || item.verdict}${item.title || ''}`);
lines.push(`价格:${item.price || '-'}|地区:${item.location || '-'}`);
if (item.reason) lines.push(`判断:${item.reason}`);
if (item.href) lines.push(item.href);
lines.push('');
}
if (summary.reportPath) lines.push(`报告:${summary.reportPath}`);
return lines.join('\n').trim();
}
async function runHermesSendMessage(target, message) {
return new Promise((resolve, reject) => {
const script = `import json\nfrom tools.send_message_tool import send_message_tool\nprint(send_message_tool({"action":"send","target":${JSON.stringify(target)},"message":${JSON.stringify(message)}}))\n`;
const child = spawn(process.env.HERMES_PYTHON || '/Users/chick/.hermes/hermes-agent/venv/bin/python3', ['-c', script], { cwd: process.env.HERMES_AGENT_DIR || '/Users/chick/.hermes/hermes-agent' });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Hermes send_message 超时')); }, 60000);
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', err => { clearTimeout(timer); reject(err); });
child.on('close', code => {
clearTimeout(timer);
if (code !== 0) return reject(new Error((stderr || stdout || `Hermes send_message 退出码 ${code}`).slice(0, 500)));
try {
const parsed = JSON.parse(stdout.trim().split('\n').pop() || '{}');
if (parsed.error) return reject(new Error(parsed.error));
resolve(parsed);
} catch {
resolve({ ok: true, stdout: stdout.trim(), stderr: stderr.trim() });
}
});
});
}
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
return text;
}
export async function sendNotifications(task, summary, options = {}) {
const cfg = options.config || getNotifyConfig();
const minScore = Number(cfg.minScore ?? 75);
const shouldNotify = cfg.enabled !== false && (!cfg.notifyOnlyGood || summary.topItems.some(i => i.verdict === 'recommend' || i.score >= minScore));
if (!shouldNotify && !options.force) {
return { ok: true, skipped: true, reason: '没有达到通知条件' };
}
const message = buildNotificationText(task, { ...summary, minScore });
const results = [];
for (const ch of (cfg.channels || []).filter(c => c.enabled)) {
try {
if (ch.type === 'hermes-send-message' || ch.type === 'hermes-webhook') {
if (!ch.target) throw new Error('请选择 Hermes 通知目标');
await runHermesSendMessage(ch.target, message);
} else if (ch.type === 'bark') {
const base = (ch.url || '').replace(/\/$/, '');
const endpoint = ch.token ? `${base}/${encodeURIComponent(ch.token)}` : base;
await postJson(endpoint, { title: `闲鱼发现候选:${task.name || task.id}`, body: message, group: 'xianyu-hunter' });
} else if (ch.type === 'pushplus') {
await postJson(ch.url || 'https://www.pushplus.plus/send', { token: ch.token, title: `闲鱼发现候选:${task.name || task.id}`, content: message, template: 'txt' });
} else {
await postJson(ch.url, { title: `闲鱼发现候选:${task.name || task.id}`, message, taskId: task.id });
}
results.push({ id: ch.id, name: ch.name, ok: true });
} catch (err) {
results.push({ id: ch.id, name: ch.name, ok: false, error: err.message });
}
}
return { ok: results.every(r => r.ok), skipped: false, message, results };
}
+387
View File
@@ -0,0 +1,387 @@
import { newPage } from './browser.mjs';
import { randomDelay, sleep, waitIfVerification } from './utils.mjs';
const BASE_SEARCH_URL = 'https://www.goofish.com/search';
export async function searchProducts(query, filters, maxPages, emitLog, shouldStop) {
const page = await newPage();
const allProducts = [];
try {
const url = new URL(BASE_SEARCH_URL);
url.searchParams.set('q', query);
emitLog(`🔍 搜索: "${query}" (最多${maxPages}页)`);
await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(4000);
const verifyOk = await waitIfVerification(page, { emitLog, shouldStop, label: query });
if (!verifyOk) {
emitLog(`⚠️ 搜索 "${query}" 校验未通过,跳过`);
return allProducts;
}
await applyFilters(page, filters, emitLog);
await sleep(2000);
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
if (shouldStop()) break;
const pageVerify = await waitIfVerification(page, { emitLog, shouldStop, label: `${query} P${pageNum}` });
if (!pageVerify) break;
emitLog(`📄 正在采集第 ${pageNum} 页...`);
const products = await scrapeCurrentPage(page);
emitLog(` 找到 ${products.length} 个商品`);
allProducts.push(...products);
if (pageNum < maxPages) {
const hasNext = await goNextPage(page);
if (!hasNext) {
emitLog(' 已到最后一页');
break;
}
await randomDelay(3000, 6000);
}
}
} catch (err) {
emitLog(`❌ 搜索出错: ${err.message}`);
} finally {
await page.close().catch(() => {});
}
emitLog(`🔍 "${query}" 搜索完成,共采集 ${allProducts.length} 个商品`);
return allProducts;
}
async function applyFilters(page, filters, emitLog) {
try {
// ---- 个人闲置勾选 ----
if (filters.personalSeller) {
const checkbox = page.locator('[class*="search-checkbox-item-container"]:has-text("个人闲置")');
const visible = await checkbox.isVisible({ timeout: 3000 }).catch(() => false);
if (visible) {
await checkbox.click();
emitLog(' 已勾选: 个人闲置');
await sleep(1500);
} else {
emitLog(' ⚠️ 未找到"个人闲置"复选框');
}
}
// ---- 价格区间 ----
if (filters.priceMin != null || filters.priceMax != null) {
const priceInputs = page.locator('input[class*="search-price-input"]');
const count = await priceInputs.count();
if (count >= 2) {
if (filters.priceMin != null) {
await priceInputs.nth(0).click();
await priceInputs.nth(0).fill('');
await priceInputs.nth(0).pressSequentially(String(filters.priceMin), { delay: 80 });
await sleep(300);
}
if (filters.priceMax != null) {
await priceInputs.nth(1).click();
await priceInputs.nth(1).fill('');
await priceInputs.nth(1).pressSequentially(String(filters.priceMax), { delay: 80 });
await sleep(300);
}
await sleep(500);
const confirmBtn = page.locator('button[class*="search-price-confirm"]');
try {
await confirmBtn.click({ timeout: 5000 });
emitLog(` 已设置价格区间: ${filters.priceMin ?? '不限'} - ${filters.priceMax ?? '不限'}`);
await sleep(2000);
} catch {
try {
const lastInput = priceInputs.nth(filters.priceMax != null ? 1 : 0);
await lastInput.press('Enter');
emitLog(` 已设置价格区间(回车提交): ${filters.priceMin ?? '不限'} - ${filters.priceMax ?? '不限'}`);
await sleep(2000);
} catch {
emitLog(' ⚠️ 价格筛选提交失败,按钮和回车均无效');
}
}
} else {
emitLog(` ⚠️ 价格输入框未找到(found ${count}),价格筛选未生效`);
}
}
// ---- 地域筛选(通过页面区域面板) ----
if (filters.region) {
await applyRegionFilter(page, filters.region, emitLog);
}
} catch (err) {
emitLog(` 筛选器应用失败: ${err.message}`);
}
}
/**
* 通过闲鱼搜索页的"区域"面板设置地域筛选。
* 支持输入:省份名(四川)、城市名(成都)、或"省份 城市"组合。
* 面板结构:点击"区域"按钮 → 左列省份 → 右列城市 → 确认
*/
async function applyRegionFilter(page, regionInput, emitLog) {
const input = regionInput.trim().replace(/[,,、\s]+/g, ' ');
const parts = input.split(' ').filter(Boolean);
// 所有省份名
const PROVINCES = [
'北京','天津','河北','山西','内蒙古','辽宁','吉林','黑龙江',
'上海','江苏','浙江','安徽','福建','江西','山东','河南',
'湖北','湖南','广东','广西','海南','重庆','四川','贵州',
'云南','西藏','陕西','甘肃','青海','宁夏','新疆','台湾',
'香港','澳门','海外',
];
// 区域组
const REGION_GROUPS = ['珠三角','江浙沪','京津冀','东三省'];
// 主要城市→省份映射(覆盖常见大城市)
const CITY_PROVINCE_MAP = {
'成都':'四川','绵阳':'四川','德阳':'四川','宜宾':'四川','南充':'四川','泸州':'四川','达州':'四川','乐山':'四川','眉山':'四川',
'广州':'广东','深圳':'广东','东莞':'广东','佛山':'广东','珠海':'广东','惠州':'广东','中山':'广东',
'杭州':'浙江','宁波':'浙江','温州':'浙江','嘉兴':'浙江','绍兴':'浙江','金华':'浙江',
'南京':'江苏','苏州':'江苏','无锡':'江苏','常州':'江苏','南通':'江苏','徐州':'江苏',
'武汉':'湖北','宜昌':'湖北','襄阳':'湖北',
'长沙':'湖南','株洲':'湖南','湘潭':'湖南','衡阳':'湖南',
'济南':'山东','青岛':'山东','烟台':'山东','潍坊':'山东','临沂':'山东',
'郑州':'河南','洛阳':'河南','开封':'河南','南阳':'河南',
'石家庄':'河北','唐山':'河北','保定':'河北','邯郸':'河北',
'太原':'山西','大同':'山西',
'西安':'陕西','咸阳':'陕西','宝鸡':'陕西',
'合肥':'安徽','芜湖':'安徽','蚌埠':'安徽',
'福州':'福建','厦门':'福建','泉州':'福建',
'南昌':'江西','赣州':'江西','九江':'江西',
'昆明':'云南','大理':'云南','丽江':'云南',
'贵阳':'贵州','遵义':'贵州',
'兰州':'甘肃','天水':'甘肃',
'沈阳':'辽宁','大连':'辽宁','鞍山':'辽宁',
'长春':'吉林','吉林市':'吉林',
'哈尔滨':'黑龙江','大庆':'黑龙江','齐齐哈尔':'黑龙江',
'南宁':'广西','桂林':'广西','柳州':'广西',
'海口':'海南','三亚':'海南',
'呼和浩特':'内蒙古','包头':'内蒙古','鄂尔多斯':'内蒙古',
'银川':'宁夏',
'西宁':'青海',
'乌鲁木齐':'新疆',
'拉萨':'西藏',
};
// 解析输入:分离出省份和城市
let province = null;
let city = null;
for (const part of parts) {
if (PROVINCES.includes(part)) {
province = part;
} else if (REGION_GROUPS.includes(part)) {
province = part;
} else if (CITY_PROVINCE_MAP[part]) {
city = part;
if (!province) province = CITY_PROVINCE_MAP[part];
} else {
// 模糊匹配:看看是否是省份的一部分
const matched = PROVINCES.find(p => p.includes(part) || part.includes(p));
if (matched) {
province = matched;
} else {
// 尝试作为城市处理,在面板中动态匹配
city = part;
}
}
}
if (!province && !city) {
emitLog(` ⚠️ 无法识别地区: "${regionInput}"`);
return;
}
emitLog(` 地区解析: 省份="${province || '自动'}" 城市="${city || '不限'}"`);
// Step 1: 点击"区域"按钮打开面板
const areaBtn = page.locator('[class*="areaText"]').first();
const areaVisible = await areaBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (!areaVisible) {
emitLog(' ⚠️ 未找到区域筛选按钮');
return;
}
await areaBtn.click();
await sleep(1200);
// Step 2: 在左列找到并点击省份
const panel = page.locator('[class*="panel--"]').first();
const panelVisible = await panel.isVisible({ timeout: 2000 }).catch(() => false);
if (!panelVisible) {
emitLog(' ⚠️ 区域面板未弹出');
return;
}
if (province) {
const provItems = panel.locator('[class*="provItem"]');
const provCount = await provItems.count();
let clicked = false;
for (let i = 0; i < provCount; i++) {
const text = await provItems.nth(i).textContent().catch(() => '');
if (text.trim() === province) {
await provItems.nth(i).click();
clicked = true;
emitLog(` 已选择省份: ${province}`);
await sleep(1000);
break;
}
}
if (!clicked) {
emitLog(` ⚠️ 省份列表中未找到: "${province}"`);
}
}
// Step 3: 如果指定了城市,在右列找到并点击
if (city) {
await sleep(800);
// 右列是面板中第二个 col
const cols = panel.locator('[class*="col--"]');
const colCount = await cols.count();
if (colCount >= 2) {
const rightCol = cols.nth(1);
const cityItems = rightCol.locator('[class*="provItem"]');
const cityCount = await cityItems.count();
let cityClicked = false;
for (let i = 0; i < cityCount; i++) {
const text = await cityItems.nth(i).textContent().catch(() => '');
if (text.trim() === city) {
await cityItems.nth(i).click();
cityClicked = true;
emitLog(` 已选择城市: ${city}`);
await sleep(800);
break;
}
}
if (!cityClicked && city) {
// 如果没有找到精确匹配的城市,而且还没选省份,
// 尝试遍历所有省份找到包含该城市的
if (!province) {
emitLog(` 城市 "${city}" 未找到,尝试遍历省份...`);
const leftCol = cols.nth(0);
const provItems = leftCol.locator('[class*="provItem"]');
const provCount = await provItems.count();
for (let i = 1; i < provCount; i++) { // 跳过"全国"
await provItems.nth(i).click();
await sleep(600);
const updatedCityItems = rightCol.locator('[class*="provItem"]');
const updatedCount = await updatedCityItems.count();
for (let j = 0; j < updatedCount; j++) {
const ct = await updatedCityItems.nth(j).textContent().catch(() => '');
if (ct.trim() === city) {
await updatedCityItems.nth(j).click();
cityClicked = true;
const provName = await provItems.nth(i).textContent().catch(() => '');
emitLog(` 找到城市: ${provName}${city}`);
break;
}
}
if (cityClicked) break;
}
if (!cityClicked) {
emitLog(` ⚠️ 所有省份中未找到城市: "${city}",将使用当前省份级筛选`);
}
} else {
emitLog(` ⚠️ ${province}下未找到城市: "${city}",将使用省份级筛选`);
}
}
}
}
// Step 4: 点击确认按钮
const confirmBtn = panel.locator('[class*="searchBtn"]').first();
const confirmVisible = await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (confirmVisible) {
await confirmBtn.click();
emitLog(` ✅ 地区筛选已应用`);
await sleep(2000);
} else {
emitLog(' ⚠️ 未找到确认按钮,尝试点击面板外关闭');
await page.mouse.click(100, 100);
await sleep(1000);
}
}
async function scrapeCurrentPage(page) {
await autoScroll(page);
return page.evaluate(() => {
const items = [];
const cards = document.querySelectorAll('a[class*="feeds-item-wrap"]');
cards.forEach(card => {
const href = card.getAttribute('href') || '';
const idMatch = href.match(/[?&]id=(\d+)/);
if (!idMatch) return;
const titleEl = card.querySelector('[class*="row1-wrap-title"]');
const priceEl = card.querySelector('[class*="price-wrap"]');
const descEl = card.querySelector('[class*="price-desc"]');
const sellerWrap = card.querySelector('[class*="seller-text-wrap"]');
const imgEl = card.querySelector('img[class*="feeds-image"]');
items.push({
id: idMatch[1],
href: href.startsWith('http') ? href : `https://www.goofish.com${href}`,
title: titleEl?.getAttribute('title') || titleEl?.textContent?.trim() || '',
price: priceEl?.textContent?.trim() || '',
priceDesc: descEl?.textContent?.trim() || '',
sellerLocation: sellerWrap?.getAttribute('title') || '',
image: imgEl?.src || '',
});
});
return items;
});
}
async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise(resolve => {
let scrolled = 0;
const maxScroll = document.body.scrollHeight;
const step = 400 + Math.random() * 300;
const timer = setInterval(() => {
window.scrollBy(0, step);
scrolled += step;
if (scrolled >= maxScroll) {
clearInterval(timer);
resolve();
}
}, 200 + Math.random() * 300);
setTimeout(() => { clearInterval(timer); resolve(); }, 8000);
});
});
await sleep(1000);
}
async function goNextPage(page) {
try {
const nextBtns = page.locator('button[class*="search-pagination-arrow-container"]');
const count = await nextBtns.count();
if (count >= 2) {
const nextBtn = nextBtns.nth(1);
const disabled = await nextBtn.getAttribute('disabled');
if (disabled !== null) return false;
await nextBtn.click();
await sleep(3000);
return true;
}
return false;
} catch {
return false;
}
}
+579
View File
@@ -0,0 +1,579 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { genId, timestamp } from './utils.mjs';
import { checkLogin } from './login.mjs';
import { searchProducts } from './search.mjs';
import { coarseFilter, fineFilter } from './filter.mjs';
import { buildAnalysis } from './analyzer.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', 'data');
const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
const STAGES = ['login', 'searching', 'coarse_filter', 'fine_filter'];
export class TaskManager {
constructor(broadcast) {
this.tasks = new Map();
this.broadcast = broadcast;
this._loadFromDisk();
this._startScheduler();
}
_buildTaskConfig(config = {}) {
const existingSchedule = config.schedule || {};
const scheduleEnabled = config.scheduleEnabled ?? existingSchedule.enabled ?? false;
const scheduleIntervalMinutes = config.scheduleIntervalMinutes ?? existingSchedule.intervalMinutes ?? 0;
return {
queries: Array.isArray(config.queries) ? config.queries.map(q => String(q).trim()).filter(Boolean) : [],
priceMin: config.priceMin === '' ? null : (config.priceMin ?? null),
priceMax: config.priceMax === '' ? null : (config.priceMax ?? null),
region: config.region || '',
personalSeller: !!config.personalSeller,
customRequirements: config.customRequirements || '',
coarsePrompt: config.coarsePrompt || '',
finePrompt: config.finePrompt || '',
maxPages: Math.max(1, Math.min(20, Number(config.maxPages || 3))),
maxItems: Math.max(1, Math.min(500, Number(config.maxItems || 60))),
schedule: {
enabled: !!scheduleEnabled,
intervalMinutes: Math.max(0, Number(scheduleIntervalMinutes || 0) || 0),
nextRunAt: config.scheduleNextRunAt ?? existingSchedule.nextRunAt ?? null,
lastRunAt: config.scheduleLastRunAt ?? existingSchedule.lastRunAt ?? null,
},
};
}
createTask(config) {
const id = genId();
const task = {
id,
name: config.name || `任务-${id}`,
config: this._buildTaskConfig(config),
stage: 'pending',
running: false,
stopped: false,
products: { raw: [], coarseFiltered: [], fineFiltered: [] },
hermesAnalysis: null,
chatSessions: [],
chatSessionsFull: [],
logs: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
this._normalizeSchedule(task);
this.tasks.set(id, task);
this._persistToDisk();
this._emit(id, 'task:created', this._serialize(task));
return task;
}
duplicateTask(sourceId) {
const source = this.tasks.get(sourceId);
if (!source) return null;
const id = genId();
const task = {
id,
name: `${source.name} (副本)`,
config: JSON.parse(JSON.stringify(source.config || {})),
stage: 'pending',
running: false,
stopped: false,
products: { raw: [], coarseFiltered: [], fineFiltered: [] },
hermesAnalysis: null,
chatSessions: [],
chatSessionsFull: [],
logs: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
this._normalizeSchedule(task);
this.tasks.set(id, task);
this._persistToDisk();
this._emit(id, 'task:created', this._serialize(task));
return task;
}
updateTask(id, patch = {}) {
const task = this.tasks.get(id);
if (!task) return null;
if (task.running) return null;
task.name = patch.name || task.name;
task.config = { ...task.config, ...this._buildTaskConfig({ ...task.config, ...patch }) };
task.updatedAt = Date.now();
this._normalizeSchedule(task);
this._persistToDisk();
this._emit(id, 'task:updated', this._serialize(task));
return task;
}
_resetTaskProducts(task) {
task.products = { raw: [], coarseFiltered: [], fineFiltered: [] };
task.hermesAnalysis = null;
task.chatSessions = [];
task.chatSessionsFull = [];
task.stage = 'pending';
task.stopped = false;
}
async startTask(id, configUpdates = {}) {
const task = this.tasks.get(id);
if (!task || task.running) return;
if (Object.keys(configUpdates || {}).length) {
const { reset, scheduled, ...patch } = configUpdates;
if (Object.keys(patch).length) task.config = { ...task.config, ...this._buildTaskConfig({ ...task.config, ...patch }) };
if (reset) this._resetTaskProducts(task);
if (scheduled) {
task.config.schedule.lastRunAt = Date.now();
this._normalizeSchedule(task, true);
}
}
task.running = true;
task.stopped = false;
this._persistToDisk();
this._emit(id, 'task:started', { id });
const emitLog = (msg) => {
const entry = { time: timestamp(), message: msg };
task.logs.push(entry);
this._emit(id, 'task:log', entry);
};
const shouldStop = () => task.stopped;
try {
await this._runPipeline(task, emitLog, shouldStop);
if (!task.stopped) {
task.stage = 'completed';
}
} catch (err) {
emitLog(`❌ 任务异常: ${err.message}`);
} finally {
task.running = false;
if (task.stopped) {
task.stage = 'stopped';
}
this._persistToDisk();
this._emit(id, 'task:finished', this._serialize(task));
}
}
stopTask(id) {
const task = this.tasks.get(id);
if (!task) return;
task.stopped = true;
task.stage = 'stopping';
this._persistToDisk();
this._emit(id, 'task:stopping', { id });
}
deleteTask(id) {
const task = this.tasks.get(id);
if (task) {
task.stopped = true;
this.tasks.delete(id);
this._persistToDisk();
}
}
clearTaskCache(id, { mode = 'all', minScore = 75 } = {}) {
const task = this.tasks.get(id);
if (!task || task.running) return null;
const before = {
raw: task.products?.raw?.length || 0,
coarseFiltered: task.products?.coarseFiltered?.length || 0,
fineFiltered: task.products?.fineFiltered?.length || 0,
hermesRecommended: task.hermesAnalysis?.recommendedCount || 0,
};
let kept = [];
const keepMode = mode === 'hermes-recommend' || mode === 'hermes-pass' ? mode : 'all';
if (keepMode !== 'all') {
const fine = task.products?.fineFiltered || [];
kept = fine.filter(item => {
const analysis = item.hermesAnalysis;
if (!analysis) return false;
if (keepMode === 'hermes-recommend') return analysis.verdict === 'recommend';
return (analysis.verdict === 'recommend' || analysis.verdict === 'caution') && Number(analysis.score || 0) >= Number(minScore || 0);
});
}
task.products = keepMode === 'all'
? { raw: [], coarseFiltered: [], fineFiltered: [] }
: { raw: kept, coarseFiltered: kept, fineFiltered: kept };
task.hermesAnalysis = null;
task.chatSessions = [];
task.chatSessionsFull = [];
task.stage = keepMode === 'all' ? 'pending' : (kept.length ? 'completed' : 'pending');
task.stopped = false;
task.updatedAt = Date.now();
task.logs.push({
time: timestamp(),
message: keepMode === 'all'
? `🧹 已清除本任务缓存:采集 ${before.raw} / 粗筛 ${before.coarseFiltered} / 细筛 ${before.fineFiltered}`
: `🧹 已清理缓存并保留 ${kept.length} 个优质结果(模式:${keepMode},最低分:${minScore}`,
});
this._persistToDisk();
this._emit(id, 'task:updated', this._serialize(task));
return {
ok: true,
mode: keepMode,
minScore: Number(minScore || 0),
before,
after: {
raw: task.products.raw.length,
coarseFiltered: task.products.coarseFiltered.length,
fineFiltered: task.products.fineFiltered.length,
},
kept: kept.length,
task: this._serialize(task),
};
}
getTask(id) {
const task = this.tasks.get(id);
return task ? this._serialize(task) : null;
}
getTaskFull(id) {
return this.tasks.get(id) || null;
}
analyzeTask(id, options = {}) {
const task = this.tasks.get(id);
if (!task) return null;
const analysis = buildAnalysis(task, options);
this.setTaskAnalysis(id, analysis);
return analysis;
}
setTaskAnalysis(id, analysis) {
const task = this.tasks.get(id);
if (!task || !analysis) return null;
const map = new Map((analysis.items || []).map(item => [item.id, item]));
task.products.fineFiltered = (task.products.fineFiltered || []).map(item => map.get(item.id) || item);
task.hermesAnalysis = analysis.summary;
task.updatedAt = Date.now();
this._persistToDisk();
this._emit(id, 'task:updated', this._serialize(task));
return task;
}
getAllTasks() {
return [...this.tasks.values()].map(t => this._serialize(t));
}
_resolveResumeStage(task) {
if (task.products.fineFiltered.length > 0) return 'fine_filter';
if (task.products.coarseFiltered.length > 0) return 'fine_filter';
if (task.products.raw.length > 0) return 'coarse_filter';
return null;
}
async _runPipeline(task, emitLog, shouldStop) {
const resumeStage = this._resolveResumeStage(task);
const stageOrder = ['login', 'searching', 'coarse_filter', 'fine_filter'];
const resumeIdx = resumeStage ? stageOrder.indexOf(resumeStage) : -1;
if (resumeStage) {
emitLog(`♻️ 检测到历史数据,从「${resumeStage}」阶段恢复`);
emitLog(` 已有: ${task.products.raw.length}采集 / ${task.products.coarseFiltered.length}粗筛 / ${task.products.fineFiltered.length}候选`);
}
// Stage 1: Login (always)
task.stage = 'login';
this._emitStage(task);
emitLog('========== 阶段1: 登录验证 ==========');
const loggedIn = await checkLogin(emitLog);
if (!loggedIn || shouldStop()) return;
const filterRequirements = this._buildFilterRequirements(task.config);
// Stage 2: Search
if (resumeIdx >= stageOrder.indexOf('searching')) {
emitLog(`⏭️ 跳过搜索采集(已有 ${task.products.raw.length} 个商品)`);
} else {
task.stage = 'searching';
this._emitStage(task);
emitLog('========== 阶段2: 搜索采集 ==========');
const filters = {
priceMin: task.config.priceMin,
priceMax: task.config.priceMax,
region: task.config.region,
personalSeller: task.config.personalSeller,
};
for (const query of task.config.queries) {
if (shouldStop()) break;
const products = await searchProducts(query, filters, task.config.maxPages, emitLog, shouldStop);
const deduped = products.filter(p => !task.products.raw.some(e => e.id === p.id));
const remaining = Math.max(0, (task.config.maxItems || 60) - task.products.raw.length);
const accepted = deduped.slice(0, remaining);
task.products.raw.push(...accepted);
this._emitProducts(task);
if (task.products.raw.length >= (task.config.maxItems || 60)) {
emitLog(`📦 已达到单次最大商品寻找量 ${task.config.maxItems || 60},停止继续搜索`);
break;
}
}
emitLog(`📊 总计采集 ${task.products.raw.length} 个商品(去重后)`);
this._persistToDisk();
}
if (shouldStop() || task.products.raw.length === 0) return;
// Stage 3: Coarse Filter
if (resumeIdx >= stageOrder.indexOf('coarse_filter')) {
emitLog(`⏭️ 跳过粗筛(已有 ${task.products.coarseFiltered.length} 个通过)`);
} else {
task.stage = 'coarse_filter';
this._emitStage(task);
emitLog('========== 阶段3: 粗筛(标题筛选) ==========');
const coarseRequirements = this._buildCoarseRequirements(task.config);
task.products.coarseFiltered = await coarseFilter(
task.products.raw, coarseRequirements, emitLog, shouldStop, task.config.coarsePrompt
);
this._emitProducts(task);
this._persistToDisk();
}
if (shouldStop() || task.products.coarseFiltered.length === 0) return;
// Stage 4: Fine Filter
if (resumeIdx >= stageOrder.indexOf('fine_filter')) {
emitLog(`⏭️ 跳过细筛(已有 ${task.products.fineFiltered.length} 个通过)`);
} else {
task.stage = 'fine_filter';
this._emitStage(task);
emitLog('========== 阶段4: 细筛(详情页筛选) ==========');
task.products.fineFiltered = await fineFilter(
task.products.coarseFiltered.slice(0, task.config.maxItems || 60), filterRequirements, emitLog, shouldStop, task.config.finePrompt
);
this._emitProducts(task);
this._persistToDisk();
}
if (shouldStop()) return;
task.chatSessions = [];
task.chatSessionsFull = [];
this._emit(task.id, 'task:chats', task.chatSessions);
this._persistToDisk();
emitLog(`🦐 已关闭联系卖家功能:保留 ${task.products.fineFiltered.length} 个候选商品,后续由 Hermes 读取 data/tasks.json 做深度分析报告`);
emitLog('========== 采集流程结束 ==========');
}
_buildCoarseRequirements(config) {
const parts = [];
if (config.queries.length > 0) {
parts.push(`目标商品: ${config.queries.join(', ')}`);
}
if (config.customRequirements) {
parts.push(`补充需求: ${config.customRequirements}`);
}
return parts.join('\n');
}
_buildFilterRequirements(config) {
const parts = [];
if (config.queries.length > 0) {
parts.push(`搜索关键词: ${config.queries.join(', ')}`);
}
if (config.priceMin != null || config.priceMax != null) {
parts.push(`价格范围: ${config.priceMin ?? '不限'} - ${config.priceMax ?? '不限'}`);
}
if (config.region) {
parts.push(`地区偏好: ${config.region}`);
}
if (config.personalSeller) {
parts.push('只要个人卖家');
}
if (config.customRequirements) {
parts.push(`商品需求: ${config.customRequirements}`);
}
return parts.join('\n');
}
_serialize(task) {
return {
id: task.id,
name: task.name,
config: task.config,
stage: task.stage,
running: task.running,
stopped: task.stopped,
products: {
rawCount: task.products.raw.length,
coarseCount: task.products.coarseFiltered.length,
fineCount: task.products.fineFiltered.length,
raw: task.products.raw.slice(0, 100),
coarseFiltered: task.products.coarseFiltered.slice(0, 50),
fineFiltered: task.products.fineFiltered,
},
chatSessions: task.chatSessions || [],
hermesAnalysis: task.hermesAnalysis || null,
logs: task.logs.slice(-200),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
_serializeFull(task) {
return {
id: task.id,
name: task.name,
config: task.config,
stage: task.stage,
products: {
raw: task.products.raw,
coarseFiltered: task.products.coarseFiltered,
fineFiltered: task.products.fineFiltered,
},
chatSessions: task.chatSessions || [],
chatSessionsFull: task.chatSessionsFull || [],
hermesAnalysis: task.hermesAnalysis || null,
logs: task.logs.slice(-500),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
_persistToDisk() {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const data = [...this.tasks.values()].map(t => this._serializeFull(t));
fs.writeFileSync(TASKS_FILE, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.error('持久化任务失败:', err.message);
}
}
_loadFromDisk() {
try {
if (!fs.existsSync(TASKS_FILE)) return;
const raw = fs.readFileSync(TASKS_FILE, 'utf-8');
const data = JSON.parse(raw);
for (const t of data) {
let chatSessionsFull = t.chatSessionsFull || [];
if (chatSessionsFull.length === 0 && (t.chatSessions || []).length > 0) {
const fineMap = new Map((t.products?.fineFiltered || []).map(p => [p.id, p]));
chatSessionsFull = t.chatSessions.map(s => {
const product = fineMap.get(s.id) || {
id: s.id,
title: s.productTitle || '',
price: s.productPrice || '',
description: s.productDescription || '',
sellerName: s.sellerName || '',
chatUrl: '',
};
const messages = (s.messages || []).map(m => ({
...m,
fingerprint: `${m.role}:${m.content?.trim()}`,
}));
const chatHistory = messages.map(m => ({
role: m.role === 'self' ? 'assistant' : 'user',
content: m.content,
}));
return {
id: s.id,
product,
chatContext: null,
status: s.status || 'waiting',
goalReason: s.goalReason || '',
messages,
chatHistory,
lastChecked: s.lastChecked || Date.now(),
backoffLevel: 0,
};
});
console.log(` 🔄 任务 ${t.id}: 从旧格式迁移了 ${chatSessionsFull.length} 个聊天会话`);
}
const task = {
id: t.id,
name: t.name,
config: this._buildTaskConfig(t.config || {}),
stage: (t.stage === 'completed' || t.stage === 'stopped') ? t.stage : 'stopped',
running: false,
stopped: true,
products: {
raw: t.products?.raw || [],
coarseFiltered: t.products?.coarseFiltered || [],
fineFiltered: t.products?.fineFiltered || [],
},
chatSessions: t.chatSessions || [],
chatSessionsFull,
hermesAnalysis: t.hermesAnalysis || null,
logs: t.logs || [],
createdAt: t.createdAt || Date.now(),
updatedAt: t.updatedAt || t.createdAt || Date.now(),
};
this._normalizeSchedule(task);
this.tasks.set(task.id, task);
}
console.log(`📂 从磁盘恢复了 ${data.length} 个任务`);
} catch (err) {
console.error('加载持久化任务失败:', err.message);
}
}
_normalizeSchedule(task, afterRun = false) {
if (!task.config.schedule) {
task.config.schedule = { enabled: false, intervalMinutes: 0, nextRunAt: null, lastRunAt: null };
}
const s = task.config.schedule;
s.intervalMinutes = Number(s.intervalMinutes || 0) || 0;
s.enabled = !!s.enabled && s.intervalMinutes > 0;
if (!s.enabled) {
s.nextRunAt = null;
return;
}
const base = afterRun ? Date.now() : (Number(s.nextRunAt) || Date.now());
if (!s.nextRunAt || Number(s.nextRunAt) <= Date.now()) {
s.nextRunAt = base + s.intervalMinutes * 60 * 1000;
}
}
_startScheduler() {
this.schedulerTimer = setInterval(() => this._tickSchedules(), 30000);
}
_tickSchedules() {
const now = Date.now();
for (const task of this.tasks.values()) {
const sched = task.config?.schedule;
if (!sched?.enabled || task.running || !sched.nextRunAt || sched.nextRunAt > now) continue;
task.logs.push({ time: timestamp(), message: `⏰ 定时任务触发,开始后台采集` });
this.startTask(task.id, { reset: true, scheduled: true });
}
}
_emit(taskId, event, data) {
this.broadcast({ event, taskId, data });
}
_emitStage(task) {
this._emit(task.id, 'task:stage', { id: task.id, stage: task.stage });
}
_emitProducts(task) {
this._emit(task.id, 'task:products', {
id: task.id,
rawCount: task.products.raw.length,
coarseCount: task.products.coarseFiltered.length,
fineCount: task.products.fineFiltered.length,
});
}
}
+78
View File
@@ -0,0 +1,78 @@
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function randomDelay(minMs, maxMs) {
const ms = minMs + Math.random() * (maxMs - minMs);
return sleep(ms);
}
export function randomInt(min, max) {
return Math.floor(min + Math.random() * (max - min));
}
export function genId() {
return crypto.randomUUID().slice(0, 8);
}
export function parsePrice(text) {
if (!text) return null;
const match = text.replace(/,/g, '').match(/[\d.]+/);
return match ? parseFloat(match[0]) : null;
}
export function extractItemId(url) {
const match = url.match(/[?&]id=(\d+)/);
return match ? match[1] : null;
}
export function extractUserId(url) {
const match = url.match(/[?&](?:userId|peerUserId)=(\d+)/);
return match ? match[1] : null;
}
export function timestamp() {
return new Date().toLocaleTimeString('zh-CN', { hour12: false });
}
/**
* 通用真人校验检测:检查当前页面是否触发了平台验证。
* 如果检测到验证页面,会持续轮询等待用户手动完成(最长 maxWaitMs)。
* @returns {Promise<boolean>} true=校验通过或无校验,false=超时或任务停止
*/
export async function waitIfVerification(page, { emitLog, shouldStop, label = '', maxWaitMs = 300000 } = {}) {
const prefix = label ? `[${label}] ` : '';
const checkInterval = 5000;
let waited = 0;
while (waited < maxWaitMs) {
const hasVerify = await page.evaluate(() => {
const bodyText = document.body?.innerText || '';
const url = location.href;
if (url.includes('verify') || url.includes('captcha') || url.includes('checkcode')) return true;
if (bodyText.includes('安全验证') || bodyText.includes('滑动验证')
|| bodyText.includes('请完成验证') || bodyText.includes('人机验证')
|| bodyText.includes('拖动滑块') || bodyText.includes('点击完成验证')) return true;
const iframe = document.querySelector('iframe[src*="captcha"], iframe[src*="verify"]');
if (iframe) return true;
return false;
}).catch(() => false);
if (!hasVerify) return true;
if (waited === 0) {
if (emitLog) emitLog(` ${prefix}🛑 检测到平台真人校验!请在浏览器中手动完成验证,完成后自动继续...`);
}
await sleep(checkInterval);
waited += checkInterval;
if (shouldStop && shouldStop()) {
if (emitLog) emitLog(` ${prefix}任务已停止,中断校验等待`);
return false;
}
}
if (emitLog) emitLog(` ${prefix}⚠️ 等待校验超时(${Math.round(maxWaitMs / 60000)}分钟),跳过本次`);
return false;
}
+898
View File
@@ -0,0 +1,898 @@
{
"name": "xianyu-assistant",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xianyu-assistant",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"express": "^4.21.0",
"playwright": "^1.49.0",
"ws": "^8.18.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "xianyu-assistant",
"version": "1.0.0",
"description": "闲鱼采集助手 - 搜索、详情采集、本地预筛,深度分析由 Hermes 完成",
"type": "module",
"scripts": {
"start": "node server.mjs",
"dev": "node --watch server.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"express": "^4.21.0",
"playwright": "^1.49.0",
"ws": "^8.18.0"
},
"license": "MIT"
}
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>闲鱼采集助手</title>
<link rel="stylesheet" href="style.css?v=19">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<div class="header-left">
<button class="brand-button" onclick="showHome()" title="返回首页"><h1>🐟 闲鱼采集助手</h1></button>
<span class="status-dot" id="ws-status"></span>
</div>
<div class="header-right">
<button class="btn theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换深色/浅色主题">
<span class="theme-icon" id="theme-icon">🌙</span><span id="theme-label">深色</span>
</button>
<button class="btn btn-home-main" onclick="showHome()">首页</button>
<button class="btn" onclick="showClearCacheModal()">清除缓存</button>
<button class="btn" onclick="showNotifyModal()">通知设置</button>
<button class="btn" onclick="showLoginModal()">账号登录 / Cookie</button>
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建任务</button>
</div>
</header>
<div class="main-layout">
<!-- Sidebar: Task List -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">任务列表</div>
<div id="task-list" class="task-list"></div>
</aside>
<!-- Main Content -->
<main class="content" id="main-content">
<div class="home-dashboard" id="empty-state">
<section class="hero-card">
<div>
<div class="hero-eyebrow">Hermes 主控 · 闲鱼采集助手</div>
<h2>把闲鱼搜索变成可复用的商品雷达</h2>
<p>创建任务后自动采集、粗筛、细筛,再由 Hermes 做推荐/谨慎/排除分析。默认只分析,不联系卖家。</p>
<div class="hero-actions">
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建采集任务</button>
<button class="btn" onclick="showLoginModal()">检测登录状态</button>
<button class="btn" onclick="showClearCacheModal()">整理缓存</button>
</div>
</div>
<div class="hero-orb">
<span>🐟</span>
<strong>AI</strong>
</div>
</section>
<section class="overview-grid" id="home-overview"></section>
<section class="home-split">
<div class="home-panel">
<div class="home-panel-head">
<div>
<h3>最近任务</h3>
<p>快速回到已有任务,继续采集或查看分析。</p>
</div>
<button class="btn btn-sm" onclick="showCreateModal()">创建</button>
</div>
<div class="home-task-list" id="home-task-list"></div>
</div>
<div class="home-panel">
<div class="home-panel-head">
<div>
<h3>推荐工作流</h3>
<p>按这个顺序使用,页面不会空等。</p>
</div>
</div>
<div class="workflow-list">
<button class="workflow-step workflow-step-clickable" type="button" onclick="showLoginModal()"><span>1</span><div><b>账号登录</b><small>扫码后 Cookie 自动复用 · 点击打开</small></div></button>
<button class="workflow-step workflow-step-clickable" type="button" onclick="showCreateModal()"><span>2</span><div><b>新建任务</b><small>关键词、价格、地区、个人卖家 · 点击创建</small></div></button>
<button class="workflow-step workflow-step-clickable" type="button" onclick="openHomeCard('fine')"><span>3</span><div><b>采集 + 预筛</b><small>只保留值得进一步看的候选 · 点击查看</small></div></button>
<button class="workflow-step workflow-step-clickable" type="button" onclick="openHomeCard('recommend')"><span>4</span><div><b>Hermes 分析</b><small>推荐/谨慎/排除,必要时通知 · 点击查看</small></div></button>
</div>
</div>
</section>
</div>
<!-- Task Detail (hidden initially) -->
<div id="task-detail" class="task-detail" style="display:none">
<!-- Pipeline -->
<div class="pipeline" id="pipeline"></div>
<!-- Tab Bar -->
<div class="tab-bar">
<button class="tab active" data-tab="products" onclick="switchTab('products')">商品列表</button>
<button class="tab" data-tab="analysis" onclick="switchTab('analysis')">Hermes 分析</button>
<button class="tab" data-tab="logs" onclick="switchTab('logs')">运行日志</button>
</div>
<!-- Products Tab -->
<div class="tab-content active" id="tab-products">
<div class="product-stats" id="product-stats"></div>
<div class="product-table-wrap">
<table class="product-table" id="product-table">
<thead>
<tr>
<th>图片</th><th>标题</th><th>价格</th><th>地区</th><th>阶段</th>
</tr>
</thead>
<tbody id="product-tbody"></tbody>
</table>
</div>
</div>
<!-- Analysis Tab -->
<div class="tab-content" id="tab-analysis">
<div id="analysis-panel" class="analysis-panel"></div>
</div>
<!-- Logs Tab -->
<div class="tab-content" id="tab-logs">
<div class="log-panel" id="log-panel"></div>
</div>
</div>
</main>
</div>
</div>
<!-- Clear Cache Modal -->
<div class="modal-overlay" id="clear-cache-modal-overlay" onclick="hideClearCacheModal()">
<div class="modal clear-cache-modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>清除采集缓存</h2>
<button class="modal-close" onclick="hideClearCacheModal()">&times;</button>
</div>
<div class="modal-body">
<div class="cache-warning">
这里清理的是任务内已采集商品、粗筛/细筛结果和 Hermes 分析缓存;不会删除任务配置,也不会清理登录 Cookie。
</div>
<div class="form-group">
<label>作用范围</label>
<select id="cache-task-scope" onchange="renderClearCachePreview()">
<option value="active">当前选中任务</option>
<option value="all">全部任务</option>
</select>
</div>
<div class="form-group">
<label>清理方式</label>
<select id="cache-clear-mode" onchange="renderClearCachePreview()">
<option value="hermes-recommend">只保留 Hermes 推荐结果</option>
<option value="hermes-pass">保留 Hermes 通过的优质结果(推荐/谨慎且达到最低分)</option>
<option value="all">全部清除</option>
</select>
</div>
<div class="form-group" id="cache-min-score-row">
<label>优质结果最低分</label>
<input id="cache-min-score" type="number" min="0" max="100" value="75" oninput="renderClearCachePreview()">
</div>
<div class="cache-preview" id="cache-preview"></div>
<pre class="login-log" id="cache-log"></pre>
</div>
<div class="modal-footer">
<button class="btn" onclick="hideClearCacheModal()">取消</button>
<button class="btn btn-danger" onclick="clearCacheFromModal()">确认清理</button>
</div>
</div>
</div>
<!-- Notification Modal -->
<div class="modal-overlay" id="notify-modal-overlay" onclick="hideNotifyModal()">
<div class="modal notify-modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>通知设置</h2>
<button class="modal-close" onclick="hideNotifyModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group checkbox-group">
<label><input id="n-enabled" type="checkbox"> 启用分析完成通知</label>
</div>
<div class="form-group checkbox-group">
<label><input id="n-good-only" type="checkbox"> 仅有合适结果时通知</label>
</div>
<div class="form-group">
<label>通知最低分</label>
<input id="n-min-score" type="number" min="0" max="100" value="75">
</div>
</div>
<div class="notify-help">
推荐:Hermes 平台消息会直接调用本机 Hermes send_message,可选择飞书/微信/QQ 等目标;Bark、PushPlus 和自定义 Webhook 仍保留为备用。
</div>
<div id="notify-channels"></div>
<div class="persona-actions" style="margin-top:12px">
<button type="button" class="btn btn-sm" onclick="addNotifyChannel('hermes-send-message')">+ Hermes平台消息</button>
<button type="button" class="btn btn-sm" onclick="addNotifyChannel('bark')">+ Bark</button>
<button type="button" class="btn btn-sm" onclick="addNotifyChannel('pushplus')">+ PushPlus</button>
<button type="button" class="btn btn-sm" onclick="addNotifyChannel('webhook')">+ 自定义Webhook</button>
</div>
<pre class="login-log" id="notify-log"></pre>
</div>
<div class="modal-footer">
<button class="btn" onclick="hideNotifyModal()">取消</button>
<button class="btn" onclick="testNotifyConfigFromForm()">发送测试通知</button>
<button class="btn btn-primary" onclick="saveNotifyConfigFromForm()">保存通知设置</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div class="modal-overlay" id="login-modal-overlay" onclick="hideLoginModal()">
<div class="modal login-modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>闲鱼账号登录 / Cookie 持久化</h2>
<button class="modal-close" onclick="hideLoginModal()">&times;</button>
</div>
<div class="modal-body">
<div class="login-status-card" id="login-status-card">
<div class="login-status-title">状态检测中...</div>
<div class="login-account-row" id="login-account-row" style="display:none">
<img id="login-avatar" class="login-avatar" alt="账号头像">
<div>
<div class="login-account-name" id="login-account-name"></div>
<div class="login-account-sub" id="login-account-sub"></div>
</div>
</div>
<div class="login-status-meta" id="login-status-meta"></div>
</div>
<div class="login-qr-panel" id="login-qr-panel" style="display:none">
<div class="login-qr-header">
<div>
<div class="login-qr-title">扫码登录</div>
<div class="login-qr-sub">点击“获取登录二维码”后,在这里直接扫码;二维码过期就重新获取。</div>
</div>
<button class="btn btn-sm" onclick="clearLoginQr()">清空</button>
</div>
<div class="login-qr-wrap">
<img id="login-qr-img" class="login-qr-img" alt="闲鱼登录二维码/登录面板截图">
</div>
</div>
<div class="login-actions">
<button class="btn btn-primary" onclick="fetchLoginQr()">获取/刷新登录二维码</button>
<button class="btn" onclick="checkLoginStatus()">刷新账号状态</button>
</div>
<div class="login-help">
<p>1. 点击“获取/刷新登录二维码”后,后台会从闲鱼 Web 登录入口生成二维码/登录面板截图,可直接在本页面扫码。</p>
<p>2. 扫码完成后点击“刷新账号状态”,本页会展示当前账号昵称、头像、Cookie 数和登录墙状态。</p>
<p>3. Cookie 会自动保存在项目的 <code>browser-data/</code> 目录,后续采集任务复用这个登录态。</p>
<p>4. 如果二维码过期或页面显示登录墙,重新获取二维码后再扫码。</p>
</div>
<pre class="login-log" id="login-log"></pre>
</div>
</div>
</div>
<!-- Create Task Modal -->
<div class="modal-overlay" id="modal-overlay" onclick="hideCreateModal()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 id="task-modal-title">新建采集任务</h2>
<button class="modal-close" onclick="hideCreateModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>任务名称</label>
<input id="f-name" type="text" placeholder="例如:找一台M4 MacBook Air">
</div>
<div class="form-group">
<label>搜索关键词 <span class="hint">(多个用逗号分隔)</span></label>
<input id="f-queries" type="text" placeholder="例如:MacBook Air M4, macbook air m4 13寸">
</div>
<div class="form-row">
<div class="form-group">
<label>最低价</label>
<input id="f-price-min" type="number" placeholder="不限">
</div>
<div class="form-group">
<label>最高价</label>
<input id="f-price-max" type="number" placeholder="不限">
</div>
<div class="form-group">
<label>最大页数</label>
<input id="f-pages" type="number" value="3" min="1" max="20">
</div>
<div class="form-group">
<label>单次最大商品数</label>
<input id="f-max-items" type="number" value="60" min="1" max="500">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>地区 <span class="hint">(省份或城市名)</span></label>
<input id="f-region" type="text" placeholder="例如:四川、成都、广东 深圳">
</div>
<div class="form-group checkbox-group">
<label><input id="f-personal" type="checkbox"> 仅个人闲置</label>
</div>
</div>
<div class="form-group">
<label>需求描述 <span class="hint">(用于本地预筛和后续 Hermes 分析,自然语言描述你想买什么样的)</span></label>
<textarea id="f-requirements" rows="3" placeholder="例如:只要成色9新以上的,电池健康90%以上,价格4500以内优先,最好有原装配件和发票。不要华强北翻新机。"></textarea>
</div>
<div class="form-group task-optimize-card">
<label>AI 优化生成 <span class="hint">(根据当前商品/预算/需求生成关键词和筛选条件)</span></label>
<div class="persona-actions">
<button type="button" class="btn btn-sm btn-primary" onclick="optimizeTaskForm()">AI 优化当前表单</button>
<span class="hint" id="optimize-status"></span>
</div>
</div>
<details class="advanced-section" open>
<summary class="advanced-toggle">定时任务</summary>
<div class="form-row" style="margin-top:12px">
<div class="form-group checkbox-group">
<label><input id="f-schedule-enabled" type="checkbox"> 启用后台定时采集</label>
</div>
<div class="form-group">
<label>间隔分钟</label>
<input id="f-schedule-interval" type="number" value="0" min="0" placeholder="例如:60">
</div>
</div>
<div class="hint">定时触发时会清空本轮旧结果重新采集;仍然只采集+预筛,不联系卖家。</div>
</details>
<!-- 高级设置折叠区 -->
<details class="advanced-section">
<summary class="advanced-toggle">高级设置 — 本地预筛提示词</summary>
<div class="form-group" style="margin-top:12px">
<label>粗筛提示 <span class="hint">(本地根据标题预筛时参考)</span></label>
<textarea id="f-coarse-prompt" rows="4" class="persona-editor"></textarea>
<div class="persona-actions">
<button type="button" class="btn btn-sm" onclick="resetCoarsePrompt()">恢复默认</button>
</div>
</div>
<div class="form-group">
<label>细筛提示 <span class="hint">(详情采集后给 Hermes 分析参考)</span></label>
<textarea id="f-fine-prompt" rows="4" class="persona-editor"></textarea>
<div class="persona-actions">
<button type="button" class="btn btn-sm" onclick="resetFinePrompt()">恢复默认</button>
</div>
</div>
</details>
</div>
<div class="modal-footer">
<button class="btn" onclick="hideCreateModal()">取消</button>
<button class="btn" id="save-only-btn" onclick="saveTask(false)">仅保存任务</button>
<button class="btn btn-primary" id="save-run-btn" onclick="saveTask(true)">保存并采集</button>
</div>
</div>
</div>
<script src="app.js?v=19"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import path from 'path';
import { fileURLToPath } from 'url';
import { TaskManager } from './lib/task.mjs';
import { checkLogin, getLoginStatus, openLoginPage, importCookiesFromJson, getLoginQr } from './lib/login.mjs';
import { closeBrowser, getOpenPagesInfo } from './lib/browser.mjs';
import {
DEFAULT_PERSONA, DEFAULT_COARSE_PROMPT, DEFAULT_FINE_PROMPT, chatCompletion,
} from './lib/ai.mjs';
import { buildAnalysis, renderAnalysisMarkdown } from './lib/analyzer.mjs';
import { getNotifyConfig, saveNotifyConfig, sendNotifications } from './lib/notifier.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,
maxAge: 0,
setHeaders: (res) => res.setHeader('Cache-Control', 'no-store'),
}));
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
ws.on('error', () => clients.delete(ws));
ws.send(JSON.stringify({
event: 'connected',
data: {
tasks: taskManager.getAllTasks(),
configured: true,
},
}));
});
function broadcast(msg) {
const payload = JSON.stringify(msg);
for (const ws of clients) {
if (ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
}
const taskManager = new TaskManager(broadcast);
// ========== Task API ==========
app.get('/api/tasks', (req, res) => {
res.json(taskManager.getAllTasks());
});
app.get('/api/tasks/:id', (req, res) => {
const task = taskManager.getTask(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
app.post('/api/tasks', (req, res) => {
const task = taskManager.createTask(req.body);
if (req.body?.autoStart !== false) taskManager.startTask(task.id);
res.json(taskManager.getTask(task.id));
});
app.put('/api/tasks/:id', (req, res) => {
const task = taskManager.updateTask(req.params.id, req.body || {});
if (!task) return res.status(404).json({ error: 'Task not found or task is running' });
res.json(taskManager.getTask(req.params.id));
});
app.post('/api/tasks/:id/start', (req, res) => {
const task = taskManager.getTask(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
taskManager.startTask(req.params.id, { reset: !!req.body?.reset });
res.json({ ok: true });
});
app.post('/api/tasks/:id/stop', (req, res) => {
taskManager.stopTask(req.params.id);
res.json({ ok: true });
});
app.post('/api/tasks/:id/duplicate', (req, res) => {
const newTask = taskManager.duplicateTask(req.params.id);
if (!newTask) return res.status(404).json({ error: 'Source task not found' });
res.json(newTask);
});
app.delete('/api/tasks/:id', (req, res) => {
taskManager.deleteTask(req.params.id);
res.json({ ok: true });
});
app.post('/api/tasks/:id/clear-cache', (req, res) => {
const result = taskManager.clearTaskCache(req.params.id, {
mode: req.body?.mode || 'all',
minScore: Number(req.body?.minScore ?? 75),
});
if (!result) return res.status(404).json({ error: 'Task not found or task is running' });
res.json(result);
});
app.post('/api/tasks/:id/analyze', async (req, res) => {
const task = taskManager.getTaskFull(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
const analysis = taskManager.analyzeTask(req.params.id, { limit: Number(req.body?.limit || 50) });
const report = renderAnalysisMarkdown(taskManager.getTaskFull(req.params.id), analysis.summary);
analysis.summary.reportPath = report.path;
taskManager.setTaskAnalysis(req.params.id, analysis);
let notification = null;
if (req.body?.notify !== false) {
notification = await sendNotifications(taskManager.getTaskFull(req.params.id), analysis.summary, { force: !!req.body?.forceNotify });
}
res.json({ ok: true, analysis: analysis.summary, reportPath: report.path, notification });
});
app.get('/api/notify-config', (req, res) => {
res.json(getNotifyConfig());
});
app.put('/api/notify-config', (req, res) => {
res.json(saveNotifyConfig(req.body || {}));
});
app.post('/api/notify-test', async (req, res) => {
try {
const config = req.body?.config || getNotifyConfig();
const targetLabel = (config.channels || []).filter(c => c.enabled).map(c => c.name || c.target || c.type).join('、') || '未启用通道';
const task = { id: 'notify-test', name: '通知渠道测试' };
const summary = {
minScore: Number(config.minScore ?? 75),
total: 1,
recommendedCount: 1,
cautionCount: 0,
rejectedCount: 0,
reportPath: '这是测试通知,不对应真实报告',
topItems: [{
title: req.body?.title || 'xianyu-hunter 测试通知',
score: 99,
verdict: 'recommend',
price: '测试',
location: '本机',
reason: `测试通知渠道:${targetLabel}`,
href: '',
}],
};
const result = await sendNotifications(task, summary, { force: true, config: { ...config, enabled: true, notifyOnlyGood: false } });
res.status(result.ok ? 200 : 500).json(result);
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/tasks/optimize', async (req, res) => {
const { seed = '', product = '', budget = '', region = '', currentRequirements = '' } = req.body || {};
const base = [product, seed, currentRequirements].filter(Boolean).join('\n');
if (!base.trim()) return res.status(400).json({ error: '请输入商品/需求描述后再优化' });
const fallback = buildLocalOptimizedTask({ seed, product, budget, region, currentRequirements });
try {
const prompt = `你是闲鱼二手商品监测任务配置助手。根据用户想找的商品,生成更适合闲鱼搜索和风控筛选的任务配置。只输出JSON,不要解释。
字段:name:string, queries:string[], priceMin:number|null, priceMax:number|null, maxPages:number, maxItems:number, personalSeller:boolean, customRequirements:string, coarsePrompt:string, finePrompt:string。
要求:queries 3-6个,包含常见别名/空格/大小写变体;customRequirements 写清排除配件/维修/求购/商家批量/异常低价;maxItems 保守在20-80。
用户输入:${JSON.stringify({ seed, product, budget, region, currentRequirements })}`;
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.2, maxTokens: 1200, timeoutMs: 20000 });
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(cleaned);
res.json({ ok: true, source: 'ai', task: { ...fallback, ...parsed } });
} catch (err) {
res.json({ ok: true, source: 'local-fallback', warning: err.message, task: fallback });
}
});
function buildLocalOptimizedTask({ seed = '', product = '', budget = '', region = '', currentRequirements = '' }) {
const text = `${product || seed}`.trim();
const queries = [...new Set(text.split(/[,,、]/).map(s => s.trim()).filter(Boolean))];
if (queries.length === 0 && text) queries.push(text);
const budgetNum = Number(String(budget).match(/\d+(?:\.\d+)?/)?.[0] || '') || null;
return {
name: text ? `${text} 监测` : '闲鱼商品监测',
queries: queries.slice(0, 6),
priceMin: null,
priceMax: budgetNum,
region,
maxPages: 2,
maxItems: 40,
personalSeller: true,
customRequirements: `${currentRequirements || `目标:${text}${budgetNum ? `,预算${budgetNum}左右` : ''}`};只做采集分析,不自动联系卖家;排除配件/维修/求购/租赁/回收/商家批量/标题党;优先个人自用、描述清楚、价格合理、可核验。`,
coarsePrompt: DEFAULT_COARSE_PROMPT,
finePrompt: DEFAULT_FINE_PROMPT,
};
}
app.get('/api/default-persona', (req, res) => {
res.json({ persona: DEFAULT_PERSONA });
});
app.get('/api/defaults', (req, res) => {
res.json({
persona: DEFAULT_PERSONA,
coarsePrompt: DEFAULT_COARSE_PROMPT,
finePrompt: DEFAULT_FINE_PROMPT,
});
});
// ========== Login / Cookie API ==========
// Open the persistent Playwright Chromium profile for manual Goofish login.
// Cookies are saved by Chromium under ./browser-data and reused by later tasks.
app.get('/api/login/status', async (req, res) => {
try {
const [status, pages] = await Promise.all([
getLoginStatus(),
getOpenPagesInfo(),
]);
res.json({ ok: true, ...status, ...pages });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/open', async (req, res) => {
try {
const logs = [];
const status = await openLoginPage(msg => logs.push(msg));
res.json({ ok: true, ...status, logs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/qr', async (req, res) => {
try {
const logs = [];
const status = await getLoginQr(msg => logs.push(msg));
res.json({ ok: true, ...status, logs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/wait', async (req, res) => {
try {
const logs = [];
const ok = await checkLogin(msg => logs.push(msg));
const status = await getLoginStatus();
res.json({ ok: true, loggedIn: ok && status.loggedIn, ...status, logs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/login/close-browser', async (req, res) => {
await closeBrowser();
res.json({ ok: true });
});
app.post('/api/login/import-cookies', async (req, res) => {
try {
const logs = [];
const payload = req.body?.cookies ?? req.body?.data ?? req.body;
const result = await importCookiesFromJson(payload, msg => logs.push(msg));
res.json({ ok: true, imported: result.imported, ...result.status, logs });
} catch (err) {
res.status(400).json({ ok: false, error: err.message });
}
});
server.listen(PORT, () => {
console.log(`🚀 闲鱼智能助手已启动: http://localhost:${PORT}`);
console.log(`🦐 分析模式: 项目只负责采集和本地预筛,深度分析由 Hermes 完成,无需配置外部模型接口`);
});
+53
View File
@@ -0,0 +1,53 @@
@echo off
chcp 65001 >nul
title 闲鱼智能助手
echo ============================
echo 闲鱼智能助手 启动脚本
echo ============================
echo.
:: 检查 node 是否可用
where node >nul 2>nul
if %errorlevel% neq 0 (
echo [错误] 未检测到 Node.js,请先安装 Node.js ^(v18+^)
echo 下载地址: https://nodejs.org/
pause
exit /b 1
)
:: 杀掉占用 3000 端口的进程
echo [1/4] 清理残留进程...
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":3000.*LISTENING"') do (
taskkill /F /PID %%a >nul 2>nul
)
:: 检查依赖
if not exist node_modules (
echo [2/4] 首次运行,安装依赖...
npm install
if %errorlevel% neq 0 (
echo [错误] 依赖安装失败
pause
exit /b 1
)
) else (
echo [2/4] 依赖已就绪
)
:: 安装 Playwright 浏览器
echo [3/4] 检查浏览器环境...
npx playwright install chromium >nul 2>nul
:: 启动服务
echo [4/4] 启动服务...
echo.
echo 请在浏览器中打开: http://localhost:3000
echo 首次使用请先点击「设置」配置 AI API 密钥
echo.
node server.mjs
:: 如果退出则暂停,方便查看错误
echo.
echo 服务已退出,按任意键关闭窗口...
pause >nul
@@ -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(()=>{});
+222
View File
@@ -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(() => {});