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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
browser-data/
data/tasks.json
data/config.json
data/notify-config.json
reports/
*.log
.runtime/
.DS_Store
+219
View File
@@ -0,0 +1,219 @@
# xianyu-hunter Hermes Skill 便携包
这个包用于把「闲鱼采集助手 + Hermes 控制 Skill」安装到另一台 Hermes 环境里,让其他 Hermes 也能用自然语言执行闲鱼商品监测。
## 包内内容
```text
skill/xianyu-hunter-monitor/ Hermes Skill:告诉 Hermes 如何创建、运行、分析闲鱼监测任务
project/xianyu-hunter/ 本地 Web/CLI 采集助手项目,不含登录 Cookie、历史任务、缓存、node_modules
install.sh 一键安装脚本
README.md 本说明
```
## 目标效果
安装后可以对 Hermes 说:
```text
帮我在闲鱼监测 27寸戴尔显示器,广州,250元以内,正常显示,只做筛选分析不要联系卖家
```
Hermes 会优先加载 `xianyu-hunter-monitor` skill,并使用项目里的统一 CLI:
```bash
node tools/xianyu_ops.mjs run ...
```
默认模式:**只采集 + 筛选 + Hermes 分析,不自动联系卖家,不下单,不付款。**
## 前置要求
- macOS / Linux / WSL 均可,推荐 macOS,因为登录窗口和扫码更方便。
- 已安装 Hermes Agent。
- Node.js >= 18。
- npm 可用。
- 能访问 `https://www.goofish.com/`
检查:
```bash
node -v
npm -v
hermes --version
```
## 一键安装
在解压后的目录执行:
```bash
bash install.sh
```
默认安装到:
```text
~/.hermes/skills/social-media/xianyu-hunter-monitor/
~/xianyu-hunter/
```
如果你想改项目安装目录:
```bash
XIANHU_INSTALL_DIR="$HOME/apps/xianyu-hunter" bash install.sh
```
## 手动安装
```bash
# 1. 安装 skill
mkdir -p ~/.hermes/skills/social-media
cp -R skill/xianyu-hunter-monitor ~/.hermes/skills/social-media/
# 2. 安装项目
cp -R project/xianyu-hunter ~/xianyu-hunter
cd ~/xianyu-hunter
npm install
npx playwright install chromium
```
## 启动 Web 面板
```bash
cd ~/xianyu-hunter
node tools/xianyu_ops.mjs start --port 3000
```
打开:
```text
http://127.0.0.1:3000/
```
局域网访问可用本机 IP,例如:
```text
http://192.168.x.x:3000/
```
## 登录闲鱼/Goofish
Web 面板右上角点击:
```text
账号登录 / Cookie
```
然后:
1. 点击「获取/刷新登录二维码」。
2. 手机扫码登录。
3. 点击「刷新账号状态」。
4. Cookie 会保存在项目本地 `browser-data/`,不会包含在这个分享包里。
也可以用 CLI
```bash
cd ~/xianyu-hunter
node tools/xianyu_ops.mjs login
```
如果出现验证码/滑块,需要人工完成,不要绕过。
## 运行一次监测任务
```bash
cd ~/xianyu-hunter
node tools/xianyu_ops.mjs run \
--name '27寸戴尔显示器 广州 250以内' \
--queries '27寸 戴尔 显示器,dell 27 显示器,戴尔 27寸 显示器' \
--price-max 250 \
--region 广州 \
--pages 2 \
--requirements '找27寸戴尔显示器,价格250元以内,广州市,可正常显示;优先个人闲置、本地自提;排除坏屏/花屏/暗病/碎屏/仅配件/维修机/求购/商家批量/外地不方便交易;只做筛选分析,不自动联系卖家' \
--limit 30
```
生成报告:
```bash
node tools/xianyu_ops.mjs report --limit 30
```
报告在:
```text
reports/<task_id>-hermes-report.md
```
## 在 Hermes 里使用
重启 Hermes 会话或 `/reset` 后,直接说需求即可,例如:
```text
用闲鱼监测帮我找广州 250 元以内的 27 寸戴尔显示器,要求正常显示,只分析不要联系卖家
```
如果 Hermes 没有自动加载 skill,可以显式说:
```text
加载 xianyu-hunter-monitor skill,然后帮我跑这个闲鱼监测任务:...
```
## 最少配置原则
这个包故意不包含:
- 登录 Cookie / 浏览器资料:`browser-data/`
- 历史任务和商品数据:`data/tasks.json`
- 通知配置里的目标/密钥
- node_modules
- 运行报告缓存
所以其他 Hermes 只需要:
1. 安装依赖。
2. 扫码登录。
3. 直接用自然语言或 CLI 创建监测任务。
## 常见问题
### 1. 搜索结果为空
先确认是否真的登录。未登录时闲鱼搜索页可能显示「登录后可以更懂你」。在 Web 面板刷新账号状态,或执行:
```bash
node tools/xianyu_ops.mjs login
```
### 2. 浏览器提示验证码/滑块
需要人工完成。不要尝试绕过验证码。
### 3. 端口 3000 被占用
```bash
node tools/xianyu_ops.mjs start --port 3010
```
打开:
```text
http://127.0.0.1:3010/
```
### 4. Hermes 找不到 skill
确认目录:
```bash
ls ~/.hermes/skills/social-media/xianyu-hunter-monitor/SKILL.md
hermes skills list | grep -i xianyu
```
然后重启 Hermes 或 `/reset`
### 5. 安全边界
默认不自动联系卖家、不下单、不付款。任何联系卖家/交易动作都应由用户明确授权并人工确认。
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
SKILL_DEST="$HERMES_HOME/skills/social-media/xianyu-hunter-monitor"
PROJECT_DEST="${XIANHU_INSTALL_DIR:-$HOME/xianyu-hunter}"
echo "==> Installing xianyu-hunter-monitor skill"
mkdir -p "$(dirname "$SKILL_DEST")"
rm -rf "$SKILL_DEST"
cp -R "$ROOT/skill/xianyu-hunter-monitor" "$SKILL_DEST"
echo "==> Installing xianyu-hunter project to $PROJECT_DEST"
if [ -e "$PROJECT_DEST" ]; then
backup="$PROJECT_DEST.backup.$(date +%Y%m%d-%H%M%S)"
echo "Existing project found; moving to $backup"
mv "$PROJECT_DEST" "$backup"
fi
cp -R "$ROOT/project/xianyu-hunter" "$PROJECT_DEST"
cd "$PROJECT_DEST"
echo "==> Installing npm dependencies"
npm install
echo "==> Installing Playwright Chromium"
npx playwright install chromium
echo "==> Done"
echo "Skill: $SKILL_DEST"
echo "Project: $PROJECT_DEST"
echo
cat <<EOF
Next steps:
cd "$PROJECT_DEST"
node tools/xianyu_ops.mjs start --port 3000
open http://127.0.0.1:3000/
Then use the Web UI: 账号登录 / Cookie -> 获取/刷新登录二维码 -> 扫码 -> 刷新账号状态
EOF
+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(() => {});
+2
View File
@@ -0,0 +1,2 @@
99b481d82309ef94dd2ad457dbe6ba189d2747d308d2b00bea4abd03ed8a3752 xianyu-hunter-hermes-skill.zip
16013f7bde07a95438471b17d40f1da45310298457b0520cfa85858c8fa2a75c xianyu-hunter-hermes-skill.tar.gz
Binary file not shown.
Binary file not shown.
+435
View File
@@ -0,0 +1,435 @@
---
name: xianyu-hunter-monitor
description: Use when BOSS asks to monitor, search, filter, analyze, or follow up on 闲鱼/goofish products through the Disrush/xianyu-hunter Playwright-based assistant. Covers setup, login, API config, safe monitoring mode, task creation, result inspection, and operational limits.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [xianyu, goofish, monitoring, shopping, playwright, automation]
related_skills: [github-repo-management, goofish-monitor-deploy, systematic-debugging]
---
# Xianyu Hunter Monitor
## Overview
`Disrush/xianyu-hunter` is a Node.js + Playwright web automation project for 闲鱼/Goofish. The local BOSS fork now runs in **Hermes analysis mode**: the web app only searches product pages, extracts product cards/details, and performs lightweight local prefiltering. It does **not** require configuring an AI API inside the project, and it does **not** contact sellers or run the upstream auto-chat stage.
For BOSS, use this skill in **Hermes-controlled collection + analysis mode**: Hermes should operate the CLI directly (login/check/create/run/report/schedule) and treat the Web UI as a backup inspection channel only. Run modest searches, collect candidate listings into `data/tasks.json`, then analyze/summarize candidates from Hermes (or generate a Markdown report with `tools/hermes_report.mjs`).
Session-specific implementation notes for the Hermes-main-control refactor are stored in `references/hermes-main-control-refactor-2026-05.md`.
LAN dashboard login button and stale port/PID restart notes are stored in `references/web-login-lan-and-kill-stale-server-2026-05.md`.
Local working clone established during analysis:
```bash
/Users/chick/.Hermes/workspace/research/xianyu-hunter
```
The analyzed revision was `de268ba` (`feat: 消息监听从 DOM 轮询改造为 WebSocket 实时推送 + DOM 补漏`).
## When to Use
Use this skill when the user asks to:
- 监测闲鱼商品、扫货、蹲商品、查低价、找二手货。
- Analyze 闲鱼 listings against criteria such as price, condition, location, personal seller, warranty, battery health, etc.
- Run or configure `xianyu-hunter` / `闲鱼扫货助手` / `goofish.com` automation.
- Inspect previous xianyu-hunter task results under its `data/tasks.json`.
- Decide whether to contact sellers based on filtered candidates.
Do **not** use this skill for:
- Generic price research that does not involve 闲鱼/goofish.
- High-frequency scraping or bulk automated messaging.
- Any action that commits BOSS to a purchase, payment, or binding offer.
## Important Safety / Consent Rules
1. **Default to analysis-only.** Do not start auto-chat unless BOSS explicitly asks to contact sellers.
2. **No purchase commitments.** Never authorize payment, deposits, order creation, or final purchase via automation.
3. **Respect account risk.** The project uses a real browser and randomized delays, but 闲鱼 may still show captcha, rate-limit, or penalize automation. Keep pages/tasks modest.
4. **Manual captcha/login.** Login and verification must be completed by the user in the visible Chromium window.
5. **Protect secrets.** API key is stored locally at `data/config.json`; never print it. If inspecting config, redact as `[REDACTED]`.
6. **Do not hide automation risk.** Tell BOSS when a run may trigger platform verification or account risk.
## Project Anatomy
Key files:
- `server.mjs` — Express + WebSocket dashboard, listens on `PORT` or `3000`.
- `lib/browser.mjs` — Playwright persistent Chromium profile at `browser-data`, `headless: false`.
- `lib/login.mjs` — opens `https://www.goofish.com/` and checks login state.
- `lib/search.mjs` — searches `https://www.goofish.com/search?q=...`, applies filters, scrapes cards.
- `lib/filter.mjs` — opens detail pages and performs LLM fine filtering.
- `lib/chat.mjs` — optional seller chat engine, message extraction, WebSocket monitoring.
- `lib/ai.mjs` — OpenAI-compatible `/chat/completions` calls and prompts.
- `lib/config.mjs` — provider/model/API key config persisted to `data/config.json`.
- `data/tasks.json` — task state, candidate products, logs, chat sessions.
Runtime data:
- `browser-data/` stores Chromium login/session state.
- `data/config.json` stores AI provider config.
- `data/tasks.json` stores task progress/results.
## First-Time Setup
From the local clone:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
npm install
npx playwright install chromium
```
Start the dashboard:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
PORT=3000 npm start
```
Open locally or from LAN:
```text
http://localhost:3000
http://192.168.2.69:3000
```
Then:
1. Start the dashboard; no AI/API settings are needed in the web UI.
2. Use the Web UI button **打开闲鱼登录窗口** to trigger the Mac desktop Chromium login window, or run `node tools/xianyu_ops.mjs login` from the repo.
3. Complete QR/captcha/login through macOS remote desktop/screen sharing in the spawned Chromium window. The Web UI does not embed the Goofish login page.
4. If captcha/slider appears, BOSS must complete it manually.
## Recommended Monitoring-Only Task Parameters
Use restrained defaults:
- `maxPages`: 13 initially.
- `personalSeller`: true when searching high-scam/high-noise categories.
- `region`: set if local pickup matters.
- `priceMin`/`priceMax`: use broad but reasonable bounds.
- Requirements should include risk criteria, e.g. “只看国行、自用、无拆修、成色好;排除维修/配件/求购/商家批量”。
Preferred prompts for monitoring mode:
```text
coarsePrompt: 你是闲鱼商品监测助手。只做标题相关性初筛,目标是帮用户发现值得进一步分析的候选商品。只有标题与需求明确矛盾、明显是配件/维修/求购/租赁/广告时才排除;拿不准必须保留。返回纯JSON数组。
finePrompt: 你是闲鱼商品风险分析助手。根据商品详情判断是否值得用户人工查看。只做监测分析,不要鼓励自动联系卖家。优先识别:型号/规格不符、价格异常、疑似商家批量、维修/配件/求购、描述矛盾、成色/保修/拆修风险。拿不准可以通过,但理由必须提示待人工确认。返回JSON{"pass": true/false, "reason": "简短理由,包含关键风险或亮点"}
```
## Hermes CLI Control Flow
Preferred operation is through the unified CLI wrapper; the Web dashboard is only a backup view.
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
# Check/start/stop background local service
node tools/xianyu_ops.mjs status --port 3000
node tools/xianyu_ops.mjs start --port 3000
node tools/xianyu_ops.mjs stop
# Open persistent Chromium and let BOSS/Hermes check login state
node tools/xianyu_ops.mjs login
# If the dashboard lacks a login button, add a POST route that calls checkLogin()
# and a header button labelled “打开闲鱼登录窗口”; then restart the exact PID
# listening on port 3000 (the CLI stop command may lose its .runtime state).
# Create task only
node tools/xianyu_ops.mjs create \
--name 'Mac mini M4 监测' \
--queries 'Mac mini M4,macmini m4' \
--price-max 3300 \
--region 成都 \
--pages 2 \
--requirements '自用,国行,成色好,无拆修,预算3300以内;排除维修/配件/求购/商家批量'
# Full run: ensure service, create task, wait for completion, generate report
node tools/xianyu_ops.mjs run \
--name 'Mac mini M4 监测' \
--queries 'Mac mini M4,macmini m4' \
--price-max 3300 \
--region 成都 \
--pages 2 \
--requirements '自用,国行,成色好,无拆修,预算3300以内;排除维修/配件/求购/商家批量' \
--limit 30
```
The CLI starts the local service if needed, never requires project AI configuration, and never contacts sellers.
## Creating a Task via API
API/helper usage remains available for compatibility, but prefer `tools/xianyu_ops.mjs` above. The local fork starts collection immediately and does not require AI configuration.
### Helper script
A helper script was added to the local clone:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node tools/create_monitor_task.mjs \
--name 'Mac mini M4 监测' \
--queries 'Mac mini M4,macmini m4' \
--price-max 3300 \
--region 成都 \
--pages 2 \
--requirements '自用,国行,成色好,无拆修,预算3300以内;排除维修/配件/求购/商家批量;只做筛选分析,不自动联系卖家'
```
Environment override:
```bash
XIANHU_BASE_URL=http://127.0.0.1:3000 node tools/create_monitor_task.mjs ...
```
### Raw API
```bash
curl -sS http://127.0.0.1:3000/api/tasks \
-H 'Content-Type: application/json' \
-d '{
"name":"Mac mini M4 监测",
"queries":["Mac mini M4","macmini m4"],
"priceMin":null,
"priceMax":3300,
"region":"成都",
"personalSeller":true,
"maxPages":2,
"customRequirements":"自用,国行,成色好,无拆修,预算3300以内;排除维修/配件/求购/商家批量;只做筛选分析,不自动联系卖家",
"coarsePrompt":"你是闲鱼商品监测助手。只做标题相关性初筛,目标是帮用户发现值得进一步分析的候选商品。只有标题与需求明确矛盾、明显是配件/维修/求购/租赁/广告时才排除;拿不准必须保留。返回纯JSON数组。",
"finePrompt":"你是闲鱼商品风险分析助手。根据商品详情判断是否值得用户人工查看。只做监测分析,不要鼓励自动联系卖家。优先识别:型号/规格不符、价格异常、疑似商家批量、维修/配件/求购、描述矛盾、成色/保修/拆修风险。拿不准可以通过,但理由必须提示待人工确认。返回JSON{\"pass\": true/false, \"reason\": \"简短理由,包含关键风险或亮点\"}",
"analysisMode":"hermes"
}'
```
## Hermes Report Generation
After a task finishes, generate a local Markdown report from persisted candidates. Prefer the wrapper:
```bash
node tools/xianyu_ops.mjs report --task <task_id> --limit 30
```
Or call the report script directly:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node tools/hermes_report.mjs --task <task_id> --limit 30
# or omit --task to use the latest task
node tools/hermes_report.mjs --limit 30
```
The report is written to:
```text
reports/<task_id>-hermes-report.md
```
Use the generated report as a first-pass local risk summary, then add Hermes judgment in the chat response when BOSS asks for recommendations.
## Inspecting Results
Use API while server is running:
```bash
curl -sS http://127.0.0.1:3000/api/tasks | python3 -m json.tool
curl -sS http://127.0.0.1:3000/api/tasks/<task_id> | python3 -m json.tool
```
Or inspect persisted tasks directly:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
python3 - <<'PY'
import json
from pathlib import Path
p=Path('data/tasks.json')
if not p.exists():
print('no tasks.json')
raise SystemExit
for t in json.loads(p.read_text()):
print('\n#', t.get('name'), t.get('id'), 'stage=', t.get('stage'))
products=t.get('products',{})
print('raw/coarse/fine:', len(products.get('raw',[])), len(products.get('coarseFiltered',[])), len(products.get('fineFiltered',[])))
for item in products.get('fineFiltered',[])[:20]:
print('-', item.get('title'), item.get('price') or item.get('detailPrice'), item.get('sellerLocation'), item.get('href'))
if item.get('description'):
print(' desc:', item['description'][:120].replace('\n',' '))
PY
```
When presenting results to BOSS, include:
- Title and price.
- Location/seller info.
- Link.
- Why it passed/fail if logs contain the reason.
- Risk flags: price too low, “仅展示/配件/维修/求购”, no description, high 想要人数, seller looks commercial, missing proof/warranty.
- Suggested next manual question if contacting seller is needed.
## Hermes Web Analysis + Notifications
When BOSS asks to make xianyu-hunter more productized, prefer Web-visible features rather than backend-only endpoints. The current local fork can expose:
- `POST /api/tasks/:id/analyze` to score fine-filtered candidates, persist `hermesAnalysis`, generate `reports/<task_id>-analysis.md`, and optionally notify.
- `GET/PUT /api/notify-config` backed by `data/notify-config.json` for Hermes webhook, Bark, PushPlus, and generic webhook channels.
- A header **通知设置** modal, task-level **执行分析** button, and **Hermes 分析** tab with recommendation/caution/reject cards.
Implementation and verification details are in `references/web-analysis-notifications-2026-05.md`.
## Scheduling with Hermes
For scheduled monitoring, create a Hermes cron job whose prompt is self-contained and uses the CLI wrapper. A reusable prompt template is stored at:
```text
/Users/chick/.Hermes/workspace/research/xianyu-hunter/tools/cron_prompt_template.md
```
Recommended cron job settings:
- `workdir`: `/Users/chick/.Hermes/workspace/research/xianyu-hunter`
- `enabled_toolsets`: `["terminal", "file"]`
- `deliver`: origin/current chat
- prompt: include task name, keywords, page count, region/price, requirements, and explicitly state no seller contact.
The cron prompt should run:
```bash
node tools/xianyu_ops.mjs run --name '<任务名>' --queries '<关键词1,关键词2>' --pages 1 --requirements '<需求>' --limit 30
```
If login/captcha is required, the cron run should report that manual intervention is needed rather than trying to bypass verification.
## Web Login / Cookie Persistence
The local BOSS fork should expose account login controls in the Web UI, not only through the CLI. The preferred product shape is a visible **账号登录 / Cookie** entry in the header that opens a login status panel.
Expected implementation pattern:
- `server.mjs` exposes login APIs:
- `GET /api/login/status` — opens/uses the persistent Playwright profile to probe a real Goofish search page and reports `loggedIn`, `loginWall`, `cookieCount`, `cardCount`, current URL/title, and storage path.
- `POST /api/login/open` — opens the persistent Chromium profile at Goofish for manual扫码/验证码 login.
- `POST /api/login/wait` — waits/rechecks login completion using the same login detection as tasks.
- `POST /api/login/close-browser` — closes the Playwright browser context without deleting cookies.
- `public/index.html` header should have `账号登录 / Cookie`, opening a modal that explains the login flow and shows status.
- `public/app.js` should render login status and logs instead of relying on alert-only UX.
- Cookies are persisted automatically in the Playwright persistent profile at `browser-data/`; do not export or print raw cookies.
- Cookie JSON upload should be treated as a fast local persistence step: after `ctx.addCookies()`, return status from `ctx.cookies()` only. Do **not** navigate to Goofish inside the import endpoint; run the slower real network/login probe only when the user clicks “检测登录状态”.
Important detection pitfall: `nick--` and avatar elements can still exist on a login-wall page. Treat login as valid only when the search probe does **not** show `登录后可以更懂你` / `立即登录` / `请登录` and either product cards load or enough relevant cookies are present. Do not accept homepage-only login checks as sufficient.
Session-specific implementation notes are captured in `references/web-login-cookie-panel-2026-05.md`.
Cookie JSON import timeout and verification notes are captured in `references/cookie-json-import-timeout-2026-05.md`.
Web dashboard QR-login and account status panel implementation notes are captured in `references/web-qr-login-account-panel-2026-05.md`.
BOSS later simplified the preferred login UX: the visible Web login modal should expose only **获取/刷新登录二维码** and **刷新账号状态**; hide legacy buttons such as manual browser open, Cookie JSON upload, wait-detect, and close-session from the normal flow. Details are captured in `references/web-login-simplified-buttons-2026-05.md`.
Login UX, persistent-profile lock recovery (`ProcessSingleton` / `SingletonLock`), and partial-result recovery from `coarseFiltered` are captured in `references/login-ux-locks-and-partial-results-2026-05.md`.
Web task editing, manual save-only tasks, scheduling, `maxItems`, AI-optimize fallback, API smoke tests, and stale port restart pitfalls are captured in `references/web-task-editing-scheduling-ai-optimize-2026-05.md`.
Web cache cleanup implementation notes are captured in `references/web-cache-cleanup-2026-05.md`.
Web dashboard homepage/productized empty-state implementation notes are captured in `references/web-home-dashboard-polish-2026-05.md`.
Home dashboard clickable overview/workflow cards and deep-link behavior are captured in `references/web-home-clickable-dashboard-cards-2026-05.md`.
Return navigation after deep-linking from the home dashboard is captured in `references/web-home-return-navigation-2026-05.md`.
Important BOSS UX correction: do not present a direct `login.taobao.com` screenshot as the "Xianyu QR" even though Goofish uses Alibaba/Taobao unified login under the hood. Open/login from `goofish.com` first, label any redirect honestly, and prefer the visible browser login flow when BOSS wants to scan manually.
Real QR-login screenshot flow for Feishu/mobile convenience is captured in `references/qr-login-screenshot-2026-05.md`. When BOSS says Cookie JSON is inconvenient, prefer opening the real Taobao/Goofish login page in the persistent profile, screenshotting the QR, keeping the browser alive briefly, and replying with `MEDIA:/tmp/xianyu-login-qr.png`.
## Troubleshooting
### Project asks for AI API configuration
The local BOSS fork should not ask for API configuration. If this appears, verify you are running `/Users/chick/.Hermes/workspace/research/xianyu-hunter` and that `server.mjs` no longer gates `/api/tasks` behind `isConfigured()`. Also ensure the browser is loading `public/app.js?v=9` and `public/index.html` from the local fork.
### Browser asks for login or captcha
This is expected. BOSS must complete it in the visible Chromium window. Do not attempt to bypass captcha.
### CLI login says OK but search page still shows login wall
`node tools/xianyu_ops.mjs login` can produce a false-positive `loggedIn: true` if the homepage check is weak, while the search page body still contains `登录后可以更懂你` / `立即登录` and result cards never load. Verify with a real search page before blaming selectors:
```bash
node --input-type=module <<'JS'
import { chromium } from 'playwright';
const ctx = await chromium.launchPersistentContext('./browser-data', {headless:false});
const page = await ctx.newPage();
await page.goto('https://www.goofish.com/search?q='+encodeURIComponent('e3 1245 v3'), {waitUntil:'domcontentloaded', timeout:30000});
await page.waitForTimeout(8000);
console.log((await page.locator('body').innerText()).slice(0,1000));
await ctx.close();
JS
```
If the body shows `登录后可以更懂你,推荐你喜欢的商品!立即登录`, ask BOSS to manually log in in the visible Chromium window, then rerun the task. Do not treat zero results as a valid market result in this state.
### Search returns zero or selector errors
Goofish DOM changes often. Re-check selectors in:
- `lib/search.mjs` card selector: `a[class*="feeds-item-wrap"]`
- title selector: `[class*="row1-wrap-title"]`
- price selector: `[class*="price-wrap"]`
- detail selector in `lib/filter.mjs`
Open the browser page and inspect manually if needed.
### Task enters chat stage unexpectedly
The local BOSS fork should no longer enter `chatting`; `lib/task.mjs` stops after detail collection and clears `chatSessions`. If a task enters chat, you are probably running upstream code or an old cached working tree. Stop the task and re-check `lib/task.mjs` for `ChatManager` imports.
### Port conflict
Use another port:
```bash
PORT=3010 npm start
```
Set helper base URL:
```bash
XIANHU_BASE_URL=http://127.0.0.1:3010 node tools/create_monitor_task.mjs ...
```
## Recommended Future Hardening
If BOSS wants this as a durable production monitor, implement these patches in the repo:
1. Add `chatEnabled: false` task config and skip Stage 5 unless enabled.
2. Add a read-only CLI/API endpoint that returns candidate listings + AI reasons without starting chats.
3. Add scheduled runs with deduplication and notifications back to Hermes/Feishu.
4. Store task templates separately from results.
5. Add rate limits and cooldowns per keyword/account.
6. Add export formats: Markdown report, CSV, JSON.
## Verification Checklist
- [ ] `node -v` is >= 18.
- [ ] `npm install` completed in the repo clone.
- [ ] `npx playwright install chromium` completed.
- [ ] `node tools/xianyu_ops.mjs start --port 3000` launches the local service.
- [ ] `node tools/xianyu_ops.mjs status --port 3000` reports service state.
- [ ] Browser is logged into goofish.com.
- [ ] Task uses modest page count and monitoring-focused requirements.
- [ ] No AI/API settings banner appears in the dashboard.
- [ ] Task stops after detail collection; no seller contact/chat stage runs.
- [ ] `node tools/hermes_report.mjs --limit 30` can generate a report from `data/tasks.json`.
- [ ] Results are summarized with links, prices, risk flags, and suggested manual next steps.
@@ -0,0 +1,68 @@
# Cookie JSON Import Panel and Timeout Pitfall — 2026-05
Context: while adding Web UI account controls for the local BOSS `xianyu-hunter` fork, `POST /api/login/import-cookies` initially timed out even for valid Cookie JSON.
## Durable lesson
Do **not** make the Cookie JSON import endpoint perform a real Goofish page navigation immediately after `ctx.addCookies()`. In practice this can hang for 2030s+ because the persistent Playwright context may already have a stale/blocked tab, Goofish network verification may stall, or the login probe page may hit a login wall/captcha.
Instead, import should be a fast local persistence operation:
1. Parse payload as either `[...]`, `{ "cookies": [...] }`, or `{ "data": [...] }`.
2. Normalize cookie fields for Playwright (`name`, `value`, `domain`, `path`, `secure`, `httpOnly`, `sameSite`, optional `expires`).
3. `await ctx.addCookies(cookies)`.
4. Return local status from `await ctx.cookies()` only: imported count, relevant Goofish/Taobao/Alibaba cookie count, timestamp, and storage path.
5. Tell the UI/user to click **检测登录状态** for the slower network probe.
## Known-good response shape
For valid import, return quickly with JSON similar to:
```json
{
"ok": true,
"imported": 1,
"loggedIn": true,
"cookieCount": 10,
"url": "cookie-import://local",
"title": "Cookie 已导入,点击“检测登录状态”可联网验证",
"storage": "browser-data/"
}
```
`loggedIn` here is only a heuristic from cookie count, not a real network login verification.
## Verification commands
Run from `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
```bash
node --check lib/login.mjs && node --check server.mjs && node --check public/app.js
curl -sS -i -m 10 -X POST http://127.0.0.1:3000/api/login/import-cookies \
-H 'Content-Type: application/json' \
-d '{}' | sed -n '1,60p'
curl -sS -i -m 10 -X POST http://127.0.0.1:3000/api/login/import-cookies \
-H 'Content-Type: application/json' \
-d '{"cookies":[{"name":"test_cookie_for_import_probe","value":"1","domain":".goofish.com","path":"/"}]}' | sed -n '1,120p'
curl -sS -m 10 http://127.0.0.1:3000/ | grep -E '上传 Cookie JSON|账号登录 / Cookie'
```
Expected:
- Invalid payload returns HTTP 400 with the Chinese format error.
- Valid payload returns HTTP 200 within 10 seconds.
- UI contains `账号登录 / Cookie` and `上传 Cookie JSON`.
## Restart pitfall
If changes appear not to take effect, kill the exact listener PID on port 3000. The wrapper/background shell may leave a stale `node server.mjs` and Playwright Chromium process alive, so a normal restart can accidentally leave the old code serving requests.
```bash
for p in $(lsof -tiTCP:3000 -sTCP:LISTEN 2>/dev/null); do kill "$p" 2>/dev/null || true; done
sleep 1
for p in $(lsof -tiTCP:3000 -sTCP:LISTEN 2>/dev/null); do kill -9 "$p" 2>/dev/null || true; done
PORT=3000 node server.mjs
```
@@ -0,0 +1,101 @@
# Hermes 主控重构记录(2026-05
## 背景
BOSS 明确希望闲鱼采集项目由 Hermes 直接操作:登录检查、创建任务、分析结果、定时执行;Web UI 仅作为备用渠道。
## 当前本地项目定位
工作目录:
```bash
/Users/chick/.Hermes/workspace/research/xianyu-hunter
```
当前模式:
- Hermes 是主控入口。
- Web UI 仅备用查看。
- 项目不需要配置 AI API / API Key / 模型。
- 项目不会自动联系卖家、不会聊天、不会询价。
- 项目只负责:搜索采集、详情采集、本地保守预筛、持久化结果。
- 深度判断由 Hermes 根据 `data/tasks.json` 或报告完成。
## 新增/主要入口
统一 CLI
```bash
node tools/xianyu_ops.mjs <command>
```
命令:
```bash
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 '<任务名>' --queries '<关键词1,关键词2>' --requirements '<需求>'
node tools/xianyu_ops.mjs run --name '<任务名>' --queries '<关键词1,关键词2>' --requirements '<需求>' --limit 30
node tools/xianyu_ops.mjs report --task <task_id> --limit 30
```
`run` 会:检查/启动服务 → 创建任务 → 等待完成 → 生成报告。
## 报告与定时
报告脚本:
```bash
node tools/hermes_report.mjs --task <task_id> --limit 30
```
报告输出:
```text
reports/<task_id>-hermes-report.md
```
定时任务提示模板:
```text
tools/cron_prompt_template.md
```
定时任务要显式声明:不联系卖家、不下单、不承诺交易;如遇登录/验证码,报告需要人工处理,不绕过验证。
## 已验证
已做语法检查:
```bash
node --check server.mjs
node --check lib/task.mjs
node --check lib/filter.mjs
node --check public/app.js
node --check tools/xianyu_ops.mjs
node --check tools/hermes_report.mjs
```
已验证服务可启动:
```bash
node tools/xianyu_ops.mjs start --port 3000
node tools/xianyu_ops.mjs status --port 3000
```
注意:真实闲鱼端到端采集仍取决于账号登录状态、验证码和 Goofish DOM 选择器是否仍有效。
## 未来会话触发语
用户说以下任意内容时,应加载 `xianyu-hunter-monitor`
- “帮我监测闲鱼 …”
- “蹲一下闲鱼 …”
- “分析最新闲鱼采集结果”
- “检查闲鱼登录状态”
- “启动闲鱼助手”
- “每 X 小时监测 …”
优先用 `tools/xianyu_ops.mjs`,不要让 BOSS 手动打开 Web 除非需要查看或排错。
@@ -0,0 +1,115 @@
# Xianyu-hunter Hermes send_message notifications (2026-05)
Session learning: BOSS wanted xianyu-hunter notification settings changed from a Hermes webhook URL to a Web-visible platform selector that sends through Hermes `send_message` directly.
## Implementation pattern
- Backend file: `/Users/chick/.Hermes/workspace/research/xianyu-hunter/lib/notifier.mjs`.
- Keep `GET/PUT /api/notify-config`, but add `targetOptions` to the returned config for UI dropdowns.
- Use channel type `hermes-send-message` instead of `hermes-webhook`.
- Migrate existing `hermes-webhook` channels to `hermes-send-message` and map bare `feishu` to the current Feishu DM when appropriate.
- Server-side send implementation can spawn Hermes Agent's Python venv and call the tool handler:
```js
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' }
);
```
## UI pattern
- Frontend files: `public/app.js`, `public/index.html`.
- In notification modal, label Hermes channel as **Hermes 平台消息**.
- Provide a dropdown populated from `notifyConfig.targetOptions` and a separate optional custom target input.
- Target examples:
- `feishu:oc_3e963a150d98f254d8f38881bcfa1812`
- `weixin:o9cq809DWSYDlvbA9pj1akncjrhY@im.wechat`
- `weixin:o9cq808qCd1W-cIDo0iWjteE5lHY@im.wechat`
- `qqbot` if configured
- Bump static cache query strings (`app.js?v=...`, `style.css?v=...`) after UI edits.
## Verification
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check server.mjs
node --check lib/notifier.mjs
node --check public/app.js
node tools/xianyu_ops.mjs stop || true
node tools/xianyu_ops.mjs start --port 3000
curl -sS http://127.0.0.1:3000/api/notify-config | python3 -m json.tool | head -90
```
Smoke-test notifier without running a real task:
```bash
node --input-type=module <<'JS'
import { sendNotifications } from './lib/notifier.mjs';
const task = { id: 'smoke-test', name: '通知配置 smoke test' };
const summary = {
minScore: 75, total: 1, recommendedCount: 1, cautionCount: 0, rejectedCount: 0,
reportPath: 'smoke-test',
topItems: [{ title: 'xianyu-hunter 通知测试', score: 99, verdict: 'recommend', price: '测试', location: '本机', reason: '验证服务端可直接调用 Hermes send_message', href: '' }]
};
const config = { enabled: true, notifyOnlyGood: false, minScore: 75, channels: [{ id:'smoke-feishu', type:'hermes-send-message', name:'飞书 smoke', enabled:true, target:'feishu:oc_3e963a150d98f254d8f38881bcfa1812' }] };
const r = await sendNotifications(task, summary, { force: true, config });
console.log(JSON.stringify(r, null, 2));
if (!r.ok) process.exit(1);
JS
```
## Test notification channel
When BOSS asks to add/test notification channels, add a Web-visible test path rather than relying on real task analysis:
- Backend route in `server.mjs`:
- `POST /api/notify-test`
- Accepts `{ config, title }` where `config` has the same shape as `/api/notify-config`.
- Builds a fake task `{ id: 'notify-test', name: '通知渠道测试' }` plus a one-item `summary`.
- Calls `sendNotifications(task, summary, { force: true, config: { ...config, enabled: true, notifyOnlyGood: false } })` so the test bypasses score/good-only gating.
- Return HTTP 200 if `result.ok`, otherwise 500 with per-channel errors.
- Frontend in `public/app.js`:
- Add `testNotifyConfigFromForm()` beside `saveNotifyConfigFromForm()`.
- It calls `collectNotifyConfig()`, requires at least one enabled channel, posts to `/api/notify-test`, and writes ✅/❌ per-channel results into `#notify-log`.
- It should not require saving first; this lets BOSS test temporary target edits directly from the modal.
- UI in `public/index.html`:
- Add a modal footer button: `发送测试通知` wired to `testNotifyConfigFromForm()`.
- Bump static cache query strings (`app.js?v=...`, `style.css?v=...`).
Verification command:
```bash
curl -sS -X POST http://127.0.0.1:3000/api/notify-test \
-H 'Content-Type: application/json' \
-d '{"config":{"enabled":true,"notifyOnlyGood":false,"minScore":75,"channels":[{"id":"api-test-feishu","type":"hermes-send-message","name":"API测试飞书","enabled":true,"target":"feishu:oc_3e963a150d98f254d8f38881bcfa1812"}]},"title":"xianyu-hunter API 测试通知"}' \
| python3 -m json.tool
```
A known-good result has `ok: true` and `results[0].ok: true`; in the 2026-05 run this sent a real Feishu test message successfully.
Do **not** spawn Homebrew Python such as `/Users/chick/homebrew/opt/python@3.11/bin/python3.11` for Hermes `send_message`. It may lack Hermes dependencies (`yaml`, gateway modules) and fail with:
```text
Failed to load gateway config: No module named 'yaml'
```
Use Hermes' own venv interpreter:
```text
/Users/chick/.hermes/hermes-agent/venv/bin/python3
```
You can verify it with:
```bash
/Users/chick/.hermes/hermes-agent/venv/bin/python3 - <<'PY'
import yaml
import gateway.config
from tools.send_message_tool import send_message_tool
print(send_message_tool({'action':'list'}))
PY
```
@@ -0,0 +1,78 @@
# Goofish Login UX, Browser Lock, and Partial-Result Recovery — 2026-05
## What happened
During a Goofish/Xianyu search task, a temporary helper opened a direct Taobao unified-login URL and sent its screenshot as a "real login QR". BOSS correctly objected that it looked like a Taobao login page and not the expected Xianyu entry.
Goofish web login is backed by Alibaba/Taobao account infrastructure, so `login.taobao.com` can be normal after following the Goofish login flow. However, for BOSS UX, do **not** present a direct Taobao URL/screenshot as if it were the Xianyu login entry.
## Preferred login workflow
1. Start/keep the dashboard service.
2. Open `https://www.goofish.com/` in the persistent Playwright profile (`browser-data/`).
3. Let BOSS interact with the visible browser window and click the Goofish page's own login/modal/QR controls.
4. If a screenshot is needed, label it accurately: "Goofish page login/modal; may redirect to Taobao unified login".
5. After BOSS says login is done, close any temporary helper Chromium using the same profile before running task automation.
6. Verify with `GET /api/login/status` against a real search page, not homepage only.
## Pitfall: ProcessSingleton / SingletonLock
If a temp QR/login helper is still using `browser-data/`, later dashboard/API/task runs may fail with:
```text
browserType.launchPersistentContext: Failed to create a ProcessSingleton for your profile directory
... browser-data/SingletonLock: File exists (17)
```
Safe recovery used in this session:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
pkill -f 'xianyu_goofish_login_entry_tmp.mjs|xianyu_qr_login_tmp.mjs' || true
pkill -f 'Google Chrome for Testing.*xianyu-hunter/browser-data' || true
sleep 2
for p in $(lsof -tiTCP:3000 -sTCP:LISTEN 2>/dev/null); do kill "$p" 2>/dev/null || true; done
sleep 1
PORT=3000 node server.mjs
```
Then verify:
```bash
curl -sS -m 60 http://127.0.0.1:3000/api/login/status | python3 -m json.tool
```
A good result includes `loggedIn: true`, `loginWall: false`, nonzero `cookieCount`, and product cards on a search URL.
## Partial-result recovery
If a run is interrupted during `fine_filter`, `tools/hermes_report.mjs` may report `候选 0` because it only sees `fineFiltered`. Do not conclude there were no products. Inspect `data/tasks.json` and mine `products.coarseFiltered` or `products.raw` directly.
Example used for E3-1245v3:
```bash
python3 - <<'PY'
import json,re
from pathlib import Path
p=Path('data/tasks.json')
data=json.loads(p.read_text())
t=[x for x in data if x.get('id')=='<task_id>'][0]
items=t.get('products',{}).get('coarseFiltered',[]) or t.get('products',{}).get('raw',[])
def price_num(v):
m=re.search(r'(\d+(?:\.\d+)?)', str(v or '').replace(',',''))
return float(m.group(1)) if m else None
for it in items:
title=(it.get('title') or '').lower().replace('-',' ')
text=' '.join(str(it.get(k,'')) for k in ['title','description','detailDescription']).lower()
if '1245' in title and 'v3' in title and not any(w in text for w in ['回收','求购','主板','套装','整机','散热','维修','不包含 cpu']):
print(it.get('price') or it.get('detailPrice'), it.get('location') or it.get('sellerLocation'), it.get('title'), it.get('href') or it.get('url'))
PY
```
## Presentation guidance
When reporting results to BOSS:
- State the live status first (login ok, task id, counts raw/coarse/fine, whether run completed or was interrupted).
- If a report says 0 candidates but raw/coarse has data, explicitly explain that this is a stage/report limitation.
- Rank candidates by closeness to budget and risk, and call out exclusions (e.g. "不包含 CPU", "不能进系统", "回收", "主板套装").
@@ -0,0 +1,62 @@
# Real QR Login Capture for Xianyu/Goofish
Session learning: BOSS found Cookie JSON import inconvenient and asked for the real login QR code directly in chat. The web app's `POST /api/login/open` opens a visible Chromium window on the Mac, but that is not enough for Feishu/mobile use; generating a screenshot and returning it as `MEDIA:/tmp/xianyu-login-qr.png` is more convenient.
## Working pattern
From `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
1. Close the existing Playwright browser context through the app if it may hold the profile lock:
```bash
curl -sS -m 10 -X POST http://127.0.0.1:3000/api/login/close-browser \
-H 'Content-Type: application/json' -d '{}'
```
2. Run a Playwright helper from inside the repo, not `/tmp`, so `import { chromium } from 'playwright'` resolves against the project dependencies.
3. Launch a persistent context using the real project profile:
```js
const userDataDir = '/Users/chick/.Hermes/workspace/research/xianyu-hunter/browser-data';
const ctx = await chromium.launchPersistentContext(userDataDir, {
headless: false,
viewport: { width: 1100, height: 900 },
locale: 'zh-CN',
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
});
```
4. Navigate to Taobao/Goofish login:
```js
const loginUrl = 'https://login.taobao.com/member/login.jhtml?redirectURL=' + encodeURIComponent('https://www.goofish.com/');
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
```
5. If possible, click QR-login affordances such as `扫码登录`, `二维码登录`, `.icon-qrcode`, or `[class*=qrcode]`, wait a few seconds, then screenshot:
```js
await page.screenshot({ path: '/tmp/xianyu-login-qr.png', fullPage: false });
```
6. Keep the process alive for about 5 minutes so the QR remains valid while BOSS scans:
```js
await page.waitForTimeout(5 * 60 * 1000);
```
7. Reply with Feishu media syntax:
```text
MEDIA:/tmp/xianyu-login-qr.png
```
After BOSS scans, call `/api/login/status` or run the normal task/login verification.
## Pitfalls
- Running the helper from `/tmp` failed with `ERR_MODULE_NOT_FOUND: Cannot find package 'playwright'`; create/run the helper inside the xianyu-hunter repo or set module resolution explicitly.
- A persistent Chromium profile can be locked by the dashboard's existing browser context. Use `/api/login/close-browser` first or kill stale Goofish Playwright processes if navigation hangs.
- `vision_analyze` can fail due provider/API key issues; do not depend on it to validate the QR screenshot. If the screenshot exists and is recent, send it; BOSS can scan/confirm.
- Do not print or export raw cookies. QR login is a user-driven login flow and is preferable to asking BOSS to handle Cookie JSON when convenience is the goal.
@@ -0,0 +1,99 @@
# Web Hermes Analysis + Notifications (2026-05)
Session learning from extending BOSS's local `xianyu-hunter` fork with Web-visible analysis and notification controls.
## Product shape BOSS expects
When adding analysis/notification capability, make it visible in the Web dashboard, not only as backend APIs:
- Header button: **通知设置**.
- Task controls: **执行分析** beside edit/duplicate/start/rerun/delete.
- New task tab: **Hermes 分析** with recommendation/caution/reject summary and item cards.
- Product list should show analysis verdict badges and concise reasons inline.
- Analysis should persist into `data/tasks.json` and generate a Markdown report under `reports/`.
## Backend API pattern
Add a lightweight local analysis layer and notifier:
- `lib/analyzer.mjs`
- `buildAnalysis(task, { limit })` returns `{ items, summary }`.
- `renderAnalysisMarkdown(task, summary)` writes `reports/<task_id>-analysis.md`.
- Include item-level `hermesAnalysis: { score, verdict, reason, flags, positives, analyzedAt }`.
- `lib/notifier.mjs`
- `GET /api/notify-config` and `PUT /api/notify-config` backed by `data/notify-config.json`.
- `sendNotifications(task, summary, { force })` supports:
- `hermes-webhook`: POST `{ message, target, source, taskId }`.
- `bark`: POST `{ title, body, group }` to `https://api.day.app/<key>` or configured endpoint.
- `pushplus`: POST `{ token, title, content, template:'txt' }`.
- `webhook`: generic JSON `{ title, message, taskId }`.
- `server.mjs`
- `POST /api/tasks/:id/analyze` should call `taskManager.analyzeTask`, write report, persist summary, and optionally notify.
## TaskManager persistence details
- Add `getTaskFull(id)` for routes that need full in-memory task data.
- Add `setTaskAnalysis(id, analysis)` that merges item `hermesAnalysis` into `products.fineFiltered`, stores `task.hermesAnalysis`, persists to disk, and emits `task:updated`.
- Include `hermesAnalysis` in `_serialize`, `_serializeFull`, and `_loadFromDisk`.
- Clear `task.hermesAnalysis = null` when resetting products/rerunning a task.
- New/duplicated tasks should initialize `hermesAnalysis: null`.
## Frontend implementation details
- Bump asset query versions in `index.html` after changing CSS/JS to avoid stale browser cache (`style.css?v=11`, `app.js?v=11`, etc.).
- `public/app.js` helpers:
- `analyzeTaskNow(id)` calls `POST /api/tasks/:id/analyze` with `{ notify: true }`, then refreshes task detail.
- `renderAnalysis()` renders empty state, stat cards, report path, and scored item cards.
- When rendering raw product rows, map analysis from `products.fineFiltered` by `id`; raw items often do not contain the merged `hermesAnalysis` directly.
- Preserve notification channel `id` when saving from the form; otherwise repeated saves create new channel IDs.
## Verification checklist
Run syntax checks:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check server.mjs
node --check lib/task.mjs
node --check lib/analyzer.mjs
node --check lib/notifier.mjs
node --check public/app.js
```
Restart exact stale listener if `xianyu_ops.mjs stop` says `no-recorded-server` but port is still occupied:
```bash
pid=$(lsof -nP -iTCP:3000 -sTCP:LISTEN -t || true)
[ -z "$pid" ] || kill "$pid"
node tools/xianyu_ops.mjs start --port 3000
```
Smoke test APIs:
```bash
curl -fsS http://127.0.0.1:3000/api/notify-config | python3 -m json.tool
curl -fsS http://127.0.0.1:3000/api/tasks -o /tmp/xh_tasks.json
TASK=$(python3 - <<'PY'
import json
j=json.load(open('/tmp/xh_tasks.json'))
print(j[0]['id'] if j else '')
PY
)
curl -fsS -X POST "http://127.0.0.1:3000/api/tasks/$TASK/analyze" \
-H 'Content-Type: application/json' \
-d '{"notify":false,"limit":10}' | python3 -m json.tool
```
Important pitfall: piping a large `/api/tasks` response directly into `python3 - <<'PY'` consumes stdin for the script, not JSON. Save curl output to a temp file first.
## Model-specific scoring pitfall: E3-1245v3
For CPU searches like `E3-1245v3`, scoring must distinguish true target models from related models used as SEO keywords.
Rules added in `lib/analyzer.mjs`:
- If the task query indicates `E3-1245v3`, require an exact-ish match: `e3\s*-?\s*1245\s*[_-]?\s*v3`.
- Penalize titles whose main model is another E3 v3, e.g. `E3-1275v3 ... 性能超 E3-1245v3`, because the title may only use 1245v3 as a comparison/keyword.
- Flag failure terms beyond obvious `坏/不亮/暗病`, including `不能进系统`.
This reduced false recommendations in the E3-1245v3 task from related CPUs and broken parts.
@@ -0,0 +1,62 @@
# Web Cache Cleanup Entry — 2026-05
BOSS requested a Web UI entry to clear xianyu-hunter cached collection results while optionally preserving Hermes-approved quality results.
Implemented in local clone:
```text
/Users/chick/.Hermes/workspace/research/xianyu-hunter
```
Files changed:
- `lib/task.mjs`
- Added `TaskManager.clearTaskCache(id, { mode, minScore })`.
- Refuses running tasks by returning `null`.
- `mode: 'all'`: clears `products.raw`, `products.coarseFiltered`, `products.fineFiltered`, `hermesAnalysis`, chat arrays; resets stage to `pending`.
- `mode: 'hermes-recommend'`: keeps only `fineFiltered` items with `item.hermesAnalysis.verdict === 'recommend'`.
- `mode: 'hermes-pass'`: keeps `recommend` or `caution` items whose score is >= `minScore`.
- For keep modes, retained items are written to all three product buckets so the task still visibly has cached quality results.
- Adds a log line and broadcasts `task:updated`.
- `server.mjs`
- Added `POST /api/tasks/:id/clear-cache` with JSON body:
```json
{ "mode": "all|hermes-recommend|hermes-pass", "minScore": 75 }
```
- `public/index.html`
- Header button: `清除缓存`.
- Modal `#clear-cache-modal-overlay` with scope (`active`/`all`), mode, min score, preview, and log.
- Cache-busted assets to `style.css?v=15` and `app.js?v=15`.
- `public/app.js`
- Added `showClearCacheModal`, `hideClearCacheModal`, `renderClearCachePreview`, `clearCacheFromModal`, `getClearTargets`, `countQualityItems`.
- Frontend can clean current selected task or all non-running tasks.
- Shows preview of raw/coarse/fine counts and how many quality items will be retained.
- `public/style.css`
- Added `.clear-cache-modal`, `.cache-warning`, `.cache-preview*`, `.cache-counts` styles.
Verification performed:
```bash
node --check public/app.js
node --check server.mjs
node --check lib/task.mjs
```
Restart caveat:
- `node tools/xianyu_ops.mjs start --port 3000` reported running but did not replace stale PID 9407, so new route initially 404'd.
- Correct recovery was to kill the stale listener (`kill 9407`) and start tracked server with `PORT=3000 node server.mjs`.
- Active verified process after implementation: PID 11594 from Hermes background session `proc_41df821080bd`.
Smoke test:
1. Fetch `/` and verify `app.js?v=15`, `clear-cache-modal-overlay`.
2. Create save-only `__cache_smoke_test__` task via `POST /api/tasks`.
3. Call `POST /api/tasks/<id>/clear-cache` with `{ "mode": "all" }`.
4. Verify response `ok: true` and all counts zero.
5. Delete smoke task.
@@ -0,0 +1,50 @@
# Web Home Dashboard Clickable Cards (2026-05)
Session context: BOSS liked the xianyu-hunter Web dashboard visual polish, then requested that the home cards be actionable instead of decorative.
Implementation pattern used in `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
- Keep the home dashboard as the landing/empty state, but make overview cards real `<button>` elements.
- Add a generic routing helper in `public/app.js`:
- `findLatestTask(predicate)` sorts tasks by `updatedAt || createdAt` descending.
- `openHomeCard(target)` maps dashboard metrics to the most relevant task:
- `tasks` → latest task
- `raw` → latest task with `products.rawCount > 0`
- `fine` → latest task with `products.fineCount > 0`
- `recommend` → latest task with `hermesAnalysis.recommendedCount > 0`, then open `analysis` tab
- `running` → latest running task
- `scheduled` → latest task with `config.schedule.enabled`
- If no matching task exists, open `showCreateModal()` instead of silently doing nothing.
- Extend `selectTask(id, tab = 'products')` so cards can deep-link into either the product list or Hermes analysis tab after `refreshTask()` and `renderTaskDetail()`.
- Convert workflow steps into clickable buttons:
- 登录 → `showLoginModal()`
- 新建任务 → `showCreateModal()`
- 采集/预筛 → `openHomeCard('fine')`
- Hermes 分析 → `openHomeCard('recommend')`
- Style clickable cards with visible affordances:
- `cursor: pointer`, hover translate, border/glow changes, focus-visible outline.
- Include a small action line such as `查看分析结果 →` so the behavior is legible.
Verification checklist:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check public/app.js
git diff --check -- public/index.html public/style.css public/app.js
node tools/xianyu_ops.mjs status --port 3000
node --input-type=module <<'JS'
const base = 'http://127.0.0.1:3000';
for (const path of ['/', '/style.css?v=<CACHE_VERSION>', '/app.js?v=<CACHE_VERSION>']) {
const res = await fetch(base + path);
const text = await res.text();
console.log(path, res.status, text.length);
if (!res.ok) process.exitCode = 1;
}
JS
```
Pitfalls:
- If cards are changed from `div` to `button`, preserve visual reset (`font: inherit`, `text-align: left`, `color: var(--text)`, `width: 100%`) or they will inherit browser button styling.
- Bump both `style.css?v=N` and `app.js?v=N` in `public/index.html` after Web UI changes, otherwise BOSS may see stale assets.
- Do not make a dashboard card a dead click target; when there is no matching data, open the task creation modal as a useful fallback.
@@ -0,0 +1,49 @@
# Web home dashboard polish and verification (2026-05)
Use this note when continuing productization of BOSS's local `xianyu-hunter` Web dashboard.
## Context
BOSS disliked an empty/weak landing page and prefers productized, visible Web UI improvements over backend-only work. The local fork's homepage was changed from a plain empty state into a dashboard in `public/index.html`, with runtime data populated by `renderHomeDashboard()` in `public/app.js`.
## Implemented shape
- `public/index.html`
- Main content empty state is now `.home-dashboard`.
- Includes hero card, overview grid `#home-overview`, recent task list `#home-task-list`, and workflow panel.
- Cache-busting versions bumped to `style.css?v=17` and `app.js?v=17`.
- `public/app.js`
- `renderHomeDashboard()` computes totals: task count, running count, raw cached products, fine candidates, Hermes recommendations, scheduled task count.
- Renders recent tasks sorted by `updatedAt || createdAt`.
- Called after WebSocket connect/task create/update/finish and when no active task is selected.
- `public/style.css`
- Added `/* Home dashboard */` section: hero card, overview cards, recent task cards, workflow cards, responsive breakpoints, light/dark theme support.
## Verification commands
Run from `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
```bash
node --check public/app.js
node --check server.mjs
git diff --check -- public/index.html public/style.css public/app.js
node tools/xianyu_ops.mjs status --port 3000
node --input-type=module <<'JS'
const base = 'http://127.0.0.1:3000';
for (const path of ['/', '/style.css?v=17', '/app.js?v=17', '/api/tasks']) {
const res = await fetch(base + path);
const text = await res.text();
console.log(path, res.status, text.length, text.slice(0, 80).replace(/\n/g, ' '));
if (!res.ok) process.exitCode = 1;
}
JS
```
Expected: all syntax/diff checks pass, service is running on port 3000, and all HTTP probes return 200.
## Pitfalls
- After executing tool calls, always convert results into a visible response for BOSS; an empty assistant message after tools is treated as a failure.
- Use `git diff --check -- <paths>` directly; do not combine `--cached --no-index` incorrectly.
- When adding UI assets, bump query-string cache versions in `public/index.html` so BOSS sees changes immediately.
- Keep dashboard changes directly visible at `http://127.0.0.1:3000/` or LAN address; BOSS values immediately inspectable Web UI.
@@ -0,0 +1,43 @@
# Web Home Return Navigation — 2026-05
Context: after making the xianyu-hunter Web home dashboard cards clickable, BOSS pointed out that users could jump into a task detail page but had no obvious way back to the home dashboard.
Reusable fix pattern:
1. Add a `showHome()` function in `public/app.js` that:
- sets `activeTaskId = null`;
- calls `renderTaskList()` to clear sidebar active state;
- hides `#task-detail`;
- shows `#empty-state` / home dashboard;
- resets home scroll position if available;
- calls `renderHomeDashboard()` so stats/recent tasks refresh.
2. Make the top brand/title clickable and call `showHome()`.
3. Add a visible header button labelled `首页` for discoverability.
4. Add a task-detail pipeline control `← 首页` next to task actions so the return path remains visible after jumping into a task.
5. Add CSS for `.brand-button`, `.btn-home-main`, and `.btn-home` so the controls look intentional and keyboard focus is visible.
6. Bump static asset cache versions in `public/index.html` after changing `public/app.js` or `public/style.css`.
Verification used:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check public/app.js
git diff --check -- public/index.html public/style.css public/app.js
node tools/xianyu_ops.mjs status --port 3000
node --input-type=module <<'JS'
const base = 'http://127.0.0.1:3000';
const checks = [
['/', 'showHome'],
['/style.css?v=<version>', 'brand-button'],
['/app.js?v=<version>', 'function showHome()'],
];
for (const [path, needle] of checks) {
const res = await fetch(base + path);
const text = await res.text();
console.log(path, res.status, text.length, text.includes(needle));
if (!res.ok || !text.includes(needle)) process.exitCode = 1;
}
JS
```
UX lesson: when dashboard cards deep-link into content, always provide an equally obvious return path. Do not rely only on browser back, especially in a single-page Web UI with hidden/shown panels.
@@ -0,0 +1,80 @@
# Web Login + Cookie Persistence Panel (2026-05)
Context: BOSS wanted the xianyu-hunter Web UI to support login like the original project, saving cookies for reuse. A plain “open login window” button was not enough; the Web UI needed a visible login/cookie panel with status detection.
## Implemented shape
Files in `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
- `lib/browser.mjs`
- Added `getOpenPagesInfo()` to report whether the persistent Playwright browser context is open and list page title/URL.
- `lib/login.mjs`
- Added `openLoginPage(emitLog)`.
- Added `getLoginStatus(existingPage = null)`.
- `checkLogin()` now uses stronger login status detection and logs cookie count + `browser-data/` persistence.
- `server.mjs`
- Imports `checkLogin`, `getLoginStatus`, `openLoginPage`, `closeBrowser`, `getOpenPagesInfo`.
- Added APIs:
- `GET /api/login/status`
- `POST /api/login/open`
- `POST /api/login/wait`
- `POST /api/login/close-browser`
- `public/index.html`
- Header button: `账号登录 / Cookie`.
- Login modal: status card, actions, help text, log panel.
- `public/app.js`
- Added `showLoginModal`, `hideLoginModal`, `renderLoginStatus`, `appendLoginLog`, `checkLoginStatus`, `openLoginWindow`, `waitLoginStatus`, `closeLoginBrowser`.
- `public/style.css`
- Added login modal styles (`.login-modal`, `.login-status-card`, `.login-actions`, `.login-help`, `.login-log`).
## Login detection rule
Do not trust homepage-only checks. In this session, CLI `login` said OK while the real search page still showed:
- `登录后可以更懂你,推荐你喜欢的商品!`
- `立即登录`
Use a real search probe such as:
```text
https://www.goofish.com/search?q=e3%201245%20v3
```
Treat status as logged in only if:
- login wall text is absent, and
- either product cards load or enough Goofish/Taobao/Alibaba cookies exist.
`nick--` and avatar elements alone are false-positive prone on login-wall pages.
## Verification commands
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check server.mjs
node --check lib/browser.mjs
node --check lib/login.mjs
node --check public/app.js
# Restart if needed
lsof -tiTCP:3000 -sTCP:LISTEN | xargs -r kill
PORT=3000 node server.mjs
# Verify UI markers and API
curl -sS http://127.0.0.1:3000/ | grep -E '账号登录 / Cookie|闲鱼账号登录 / Cookie'
curl -sS http://127.0.0.1:3000/api/login/status | python3 -m json.tool
lsof -nP -iTCP:3000 -sTCP:LISTEN
```
Expected LAN URL for BOSSs Mac in this environment:
```text
http://192.168.2.69:3000
```
## Operational notes
- Cookies are stored in `browser-data/` via Playwright persistent context. Do not print raw cookies.
- `POST /api/login/open` opens a visible Chromium window on the Mac desktop. BOSS must use remote control/screen sharing to scan QR or complete captcha there.
- `POST /api/login/close-browser` should not delete cookies; it only closes the context.
- If a stale server process keeps old routes, kill the listener on port 3000 before starting the new server.
@@ -0,0 +1,84 @@
# Web login button + LAN exposure notes (2026-05)
Session learning from BOSS testing xianyu-hunter login from a LAN browser.
## Symptoms
- Dashboard was reachable from LAN at `http://192.168.2.69:3000`, because `lsof -nP -iTCP:3000 -sTCP:LISTEN` showed `TCP *:3000 (LISTEN)`.
- BOSS could not find a login entry in the Web UI because `public/index.html` only had `+ 新建任务`.
- `node tools/xianyu_ops.mjs stop` can report `no-recorded-server` / lose `.runtime` state while an old `node server.mjs` is still listening on port 3000. In that case new code is not active until the old PID is killed.
## Fix pattern
1. Add server imports:
```js
import { checkLogin } from './lib/login.mjs';
import { closeBrowser } from './lib/browser.mjs';
```
2. Add API routes before `server.listen`:
```js
app.post('/api/login/open', async (req, res) => {
try {
const logs = [];
const ok = await checkLogin(msg => logs.push(msg));
res.json({ ok, loggedIn: ok, 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 });
});
```
3. Add a Web UI header button:
```html
<button class="btn" onclick="openLoginWindow()">打开闲鱼登录窗口</button>
```
4. Add `openLoginWindow()` in `public/app.js` near `apiPost()`:
```js
async function openLoginWindow() {
alert('即将在 Mac 桌面打开一个 Chromium 闲鱼窗口;请通过远程桌面/屏幕共享在那个窗口里完成扫码或验证码登录。');
try {
const result = await apiPost('/api/login/open', {});
const logs = (result.logs || []).join('\n');
if (result.loggedIn) {
alert(`登录状态正常。\n\n${logs}`);
} else {
alert(`还未检测到登录完成,请在弹出的 Chromium 窗口里登录后再试。\n\n${logs}`);
}
} catch (err) {
alert(`打开登录窗口失败:${err.message}`);
}
}
```
## Verification
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check server.mjs
node --check public/app.js
lsof -nP -iTCP:3000 -sTCP:LISTEN
curl -sS http://127.0.0.1:3000/ | grep -o '打开闲鱼登录窗口' | head -1
curl -sS -X POST http://127.0.0.1:3000/api/login/close-browser
```
If `curl -X POST /api/login/close-browser` returns an HTML `Cannot POST` page, the stale server is still running. Kill the actual listening PID from `lsof` and restart:
```bash
kill -TERM <pid>
sleep 2
kill -KILL <pid> 2>/dev/null || true
PORT=3000 node server.mjs
```
Run the final server as a tracked background process in Hermes rather than using shell-level `nohup` wrappers.
@@ -0,0 +1,44 @@
# Web QR Login Simplification — 2026-05
Session learning from BOSS refining the xianyu-hunter Web login UX.
## User preference
BOSS found the login panel too busy after adding QR login and account status. For this project, the preferred Web login UX is deliberately minimal:
- Keep only **获取/刷新登录二维码** and **刷新账号状态** in the visible login modal.
- Do not expose legacy/backup actions as primary UI buttons:
- 打开闲鱼登录窗口
- 等待并检测
- 上传 Cookie JSON
- 关闭登录浏览器/关闭登录会话
- Keep backup APIs/functions available if useful for troubleshooting, but hide them from the normal Web UI unless BOSS explicitly asks for advanced controls.
- Simplify help text to the QR-first flow: get QR → scan → refresh status → cookies persist in `browser-data/`.
## Implementation shape used
In `/Users/chick/.Hermes/workspace/research/xianyu-hunter/public/index.html`, the login actions block should be:
```html
<div class="login-actions">
<button class="btn btn-primary" onclick="fetchLoginQr()">获取/刷新登录二维码</button>
<button class="btn" onclick="checkLoginStatus()">刷新账号状态</button>
</div>
```
The help text should avoid mentioning JSON import or manual browser opening in the normal flow.
`public/app.js` may still contain `openLoginWindow()`, `waitLoginStatus()`, `importCookieFile()`, and `closeLoginBrowser()` as hidden troubleshooting utilities, but no visible element should call them in the standard modal.
## Verification commands
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check public/app.js
node --check server.mjs
node --check lib/login.mjs
PORT=3000 node server.mjs
curl -sS http://127.0.0.1:3000/ | grep -E '获取/刷新登录二维码|刷新账号状态|上传 Cookie JSON|打开闲鱼登录窗口|等待并检测|关闭登录会话'
```
Expected UI marker result: only the two kept buttons and QR/status help text should appear; no legacy button labels should appear.
@@ -0,0 +1,67 @@
# Web 后台二维码登录与账号状态面板(2026-05)
## Trigger
BOSS 反馈 Cookie JSON/外部截图仍不够方便,希望登录流程直接植入 xianyu-hunter Web 后台:点击登录即可获取真实登录二维码,并在后台看到当前账号基础状态。
## Implemented pattern
Files in local clone `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
- `lib/login.mjs`
- Add `getLoginQr(emitLog)`.
- It opens `https://www.goofish.com/` through the persistent Playwright profile, clicks the visible login entry when present, tries QR-login selectors, and returns a `data:image/png;base64,...` screenshot as `qrImage` when not logged in.
- It reuses `getLoginStatus(page)` so already-logged-in accounts return status and no QR image.
- `getLoginStatus()` now also returns account basics: `accountName`, `nickCandidates`, `avatarUrl`.
- `server.mjs`
- Import `getLoginQr` and expose `POST /api/login/qr`.
- `public/index.html`
- In the login modal, add account row (`login-account-row`) and QR panel (`login-qr-panel`).
- Primary action is now **获取登录二维码**; **打开闲鱼登录窗口** remains a fallback for captcha/expired QR/manual verification.
- `public/app.js`
- Add `fetchLoginQr()`, `renderLoginQr()`, `clearLoginQr()`.
- Extend `renderLoginStatus()` to show nickname/avatar/cookie count/account state.
- `public/style.css`
- Add `.login-qr-panel`, `.login-qr-img`, `.login-account-row`, `.login-avatar` styles.
## Important workflow corrections learned
1. Do **not** manually fabricate a Taobao login URL and send it to BOSS as “闲鱼二维码”. Goofish may use Taobao/Alibaba unified login under the hood, but the user experience must start from the Goofish Web login entry to avoid confusion.
2. Prefer an in-dashboard QR panel over sending QR screenshots through chat. BOSS explicitly wants the backend page to own login, QR display, and account status.
3. Keep visible Chromium login as fallback only. QR/captcha/verification may still require the desktop browser.
4. Avoid concurrent Playwright profiles. Temporary QR scripts and the main service can conflict on `browser-data/SingletonLock`; close/kill temporary QR browser processes before running tasks or status probes.
5. If `POST /api/login/qr` is called when already logged in, return status and no QR image; frontend should clear/hide the QR panel and show account info instead.
## Verification recipe
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check lib/login.mjs
node --check server.mjs
node --check public/app.js
# restart exact listener if needed
for p in $(lsof -tiTCP:3000 -sTCP:LISTEN 2>/dev/null); do kill "$p" 2>/dev/null || true; done
PORT=3000 node server.mjs
```
API checks:
```bash
curl -sS -m 60 http://127.0.0.1:3000/api/login/status | python3 -m json.tool
curl -sS -m 90 -X POST http://127.0.0.1:3000/api/login/qr \
-H 'Content-Type: application/json' -d '{}' > /tmp/xianyu_qr_api.json
python3 - <<'PY'
import json
j=json.load(open('/tmp/xianyu_qr_api.json'))
print(j.get('ok'), j.get('loggedIn'), j.get('cookieCount'), j.get('accountName'), bool(j.get('qrImage')))
PY
curl -sS -m 10 http://127.0.0.1:3000/ | grep -E '获取登录二维码|扫码登录|login-qr-panel'
```
Expected logged-in example from the session:
- `loggedIn: true`
- `cookieCount: 27`
- `accountName: 札幌打滚的风筝鱼`
- `qrImage: false` because QR is unnecessary while logged in.
@@ -0,0 +1,133 @@
# Web Task Editing, Scheduling, and AI-Optimize UI (2026-05)
Session learning from extending BOSS's local `xianyu-hunter` fork at:
```text
/Users/chick/.Hermes/workspace/research/xianyu-hunter
```
## Feature shape implemented
When BOSS asks for Web后台 task management instead of Hermes-only CLI control, keep the product shape focused on monitoring/analysis-only:
- Web task modal supports both create and edit.
- Existing tasks can be edited only when not running.
- Task form supports:
- search keywords (`queries`)
- price min/max
- region
- personal seller toggle
- max pages
- `maxItems` / 单次最大商品寻找量
- custom requirements
- coarse/fine prompts
- schedule enabled + interval minutes
- AI optimize/current-form generation
- Save modes:
- `autoStart:false` = 手动新增任务但不立即采集
- save + run = create and start immediately
- edit + reset run = save then rerun with old products cleared
- Keep seller chat disabled. Scheduled/background runs must still stop at collection + local prefilter/detail collection and never auto-contact sellers.
## Backend/API pattern
Good API additions:
```text
PUT /api/tasks/:id edit task config (reject while running)
POST /api/tasks/optimize generate optimized config from current form
POST /api/tasks accepts autoStart:false
POST /api/tasks/:id/start accepts { reset:true }
```
Task config should normalize legacy and new schedule shapes:
```js
const existingSchedule = config.schedule || {};
const scheduleEnabled = config.scheduleEnabled ?? existingSchedule.enabled ?? false;
const scheduleIntervalMinutes = config.scheduleIntervalMinutes ?? existingSchedule.intervalMinutes ?? 0;
```
`maxItems` should be enforced after each query's dedupe and before fine detail fetch:
```js
const remaining = Math.max(0, (task.config.maxItems || 60) - task.products.raw.length);
const accepted = deduped.slice(0, remaining);
task.products.raw.push(...accepted);
if (task.products.raw.length >= (task.config.maxItems || 60)) break;
await fineFilter(task.products.coarseFiltered.slice(0, task.config.maxItems || 60), ...)
```
Scheduler pattern:
- `TaskManager` starts an interval timer in constructor after `_loadFromDisk()`.
- Tick every ~30s.
- For enabled schedule with due `nextRunAt`, non-running task: push a log and call `startTask(id, { reset: true, scheduled: true })`.
- On scheduled run, set `lastRunAt` and normalize next run.
## AI optimize fallback
`POST /api/tasks/optimize` can try `chatCompletion()`, but must tolerate missing AI config. If it fails, return `{ ok:true, source:'local-fallback', task }` so the UI still works.
Local fallback should generate conservative monitoring defaults:
- `maxPages`: 2
- `maxItems`: 40
- `personalSeller`: true
- requirements explicitly include: no seller contact, exclude parts/repair/wanted/rental/recycling/commercial bulk/title spam.
## Frontend/UI notes
- Reuse the create modal for editing by tracking `editingTaskId`.
- Add buttons in pipeline controls: 编辑, 复制, 继续/启动, 重新采集, 删除.
- `重新采集` should confirm because it clears current round products.
- `fillTaskForm(taskOrConfig)` avoids duplicate create/edit/duplicate mapping bugs.
- Add cache-busting version bump for `style.css` and `app.js`; also serve static files with `Cache-Control: no-store` to avoid stale dashboard assets.
## Verification recipe
Before final response:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check server.mjs && node --check lib/task.mjs && node --check public/app.js
```
Restart the exact server on port 3000. If `node tools/xianyu_ops.mjs stop` says `no-recorded-server` but port is in use, kill both the listener and parent npm process:
```bash
lsof -nP -iTCP:3000 -sTCP:LISTEN
ps -p <listener_pid> -o pid,ppid,command
kill -TERM <parent_pid> <listener_pid>
```
Then start in background and verify API:
```bash
PORT=3000 npm start
curl -sS http://127.0.0.1:3000/api/tasks | python3 -m json.tool
```
Smoke-test the new APIs with a temporary task and delete it after:
```bash
python3 - <<'PY'
import json, urllib.request
base='http://127.0.0.1:3000'
def req(method,path,data=None):
body=json.dumps(data).encode() if data is not None else None
r=urllib.request.Request(base+path,data=body,method=method,headers={'Content-Type':'application/json'})
with urllib.request.urlopen(r,timeout=30) as resp:
return json.loads(resp.read().decode())
opt=req('POST','/api/tasks/optimize',{'product':'e3-1245v3 cpu','budget':'150','currentRequirements':'只要CPU本体,排除主板套装'})
created=req('POST','/api/tasks',{**opt['task'],'name':'__api_verify_temp__','autoStart':False,'scheduleEnabled':True,'scheduleIntervalMinutes':120,'maxItems':25})
updated=req('PUT',f"/api/tasks/{created['id']}",{'name':'__api_verify_temp_updated__','queries':['e3 1245 v3 verify'],'maxItems':30,'scheduleEnabled':False,'scheduleIntervalMinutes':0})
req('DELETE',f"/api/tasks/{created['id']}")
print('ok', opt.get('source'), created['id'], updated['config']['maxItems'])
PY
```
## Pitfall
Do not leave a final user response empty after tools. If a tool result shows a port conflict or failed restart, process it immediately, continue with the fix, and give BOSS the final verified state.
@@ -0,0 +1,66 @@
# Web Theme Toggle + Visual Polish — 2026-05
Session learning from BOSS's xianyu-hunter dashboard refinement request: when asked to make the UI more polished, at minimum provide explicit dark/light theme switching and make the result visible in the Web UI immediately.
## Implemented pattern
Files in `/Users/chick/.Hermes/workspace/research/xianyu-hunter`:
- `public/index.html`
- Add a header button before existing settings/login/create actions:
- `id="theme-toggle"`
- `onclick="toggleTheme()"`
- child spans `theme-icon` and `theme-label`
- Bump static asset query versions, e.g. `style.css?v=14`, `app.js?v=14`, to avoid stale browser cache.
- `public/app.js`
- Add `THEME_STORAGE_KEY = 'xianyu-hunter-theme'`.
- Add `getPreferredTheme()`:
- prefer saved `localStorage` value if `light`/`dark`.
- otherwise follow `window.matchMedia('(prefers-color-scheme: light)')`.
- Add `applyTheme(theme)`:
- sets `document.documentElement.dataset.theme`.
- updates `theme-icon` and `theme-label` when DOM elements exist.
- Add `toggleTheme()`:
- toggles `light`/`dark`, persists to `localStorage`, reapplies.
- Call `applyTheme(getPreferredTheme())` early so theme applies before app data rendering.
- `public/style.css`
- Use `:root` as default dark theme and `:root[data-theme="light"]` for light variables.
- Expand variables beyond the older basic palette: `--surface`, `--surface-strong`, `--border-strong`, `--muted`, `--accent3`, `--shadow`, `--shadow-soft`, `--glow`.
- Prefer real light-theme variables rather than simple inversion.
- Polish high-visibility surfaces: header, sidebar, content shell, task cards, buttons, pipeline, tabs, stat cards, product table wrapper, modal, log blocks.
- Use cache-safe CSS constructs with existing browser target: gradients, `backdrop-filter`, CSS variables, and `color-mix()`.
## Verification commands
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check public/app.js
node --check server.mjs
python3 - <<'PY'
from pathlib import Path
html=Path('public/index.html').read_text()
css=Path('public/style.css').read_text()
js=Path('public/app.js').read_text()
for needle in ['style.css?v=', 'app.js?v=', 'theme-toggle']:
print(needle, needle in html)
print('light theme vars', ':root[data-theme="light"]' in css)
print('toggleTheme', 'function toggleTheme()' in js)
PY
node tools/xianyu_ops.mjs stop || true
node tools/xianyu_ops.mjs start --port 3000
node tools/xianyu_ops.mjs status --port 3000
python3 - <<'PY'
import urllib.request
html=urllib.request.urlopen('http://127.0.0.1:3000/', timeout=10).read().decode('utf-8')
print('theme button', 'theme-toggle' in html)
print('css v', 'style.css?v=' in html)
print('app v', 'app.js?v=' in html)
PY
```
## Pitfalls
- Do not stop after backend/CSS edits only; BOSS expects visible product UI changes and live verification.
- If CSS/JS filenames use query-string cache busting, bump both versions in `index.html`; otherwise the browser may keep the previous UI.
- `applyTheme()` may run before the header DOM exists, so DOM updates must guard missing `theme-icon`/`theme-label` elements.
- Preserve existing login/notification/task controls while adding the theme toggle; do not regress the simplified login UX.