feat: publish xianyu hunter hermes skill package
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
browser-data/
|
||||
data/tasks.json
|
||||
data/config.json
|
||||
data/notify-config.json
|
||||
reports/
|
||||
*.log
|
||||
.runtime/
|
||||
.DS_Store
|
||||
@@ -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. 安全边界
|
||||
|
||||
默认不自动联系卖家、不下单、不付款。任何联系卖家/交易动作都应由用户明确授权并人工确认。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,275 @@
|
||||
<div align="center">
|
||||
|
||||
# 🐟 闲鱼扫货助手
|
||||
|
||||
**告别手动翻页、逐个砍价的低效扫货方式**
|
||||
|
||||
AI 帮你搜、帮你筛、帮你聊 —— 你只需要告诉它"你想买什么"
|
||||
|
||||
[](https://nodejs.org/)
|
||||
[](https://playwright.dev/)
|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 痛点
|
||||
|
||||
在闲鱼买一台二手 MacBook,你会遇到这些问题:
|
||||
|
||||
**搜索结果质量差** — 搜"MacBook Air M2 16G",出来一堆手机壳、贴膜、维修服务、求购帖。参数越长尾、越具体,搜索结果里的噪声越多,翻5页才能挑出几个靠谱的。
|
||||
|
||||
**标题党泛滥** — 标题写着"99新 无划痕",点进详情页一看:"轻微磕碰、电池健康82%、屏幕有一条细划痕"。不打开详情页,光看标题根本判断不了真实情况。
|
||||
|
||||
**平台筛选形同虚设** — 闲鱼的"几成新"完全靠卖家自己勾,8成新和99新混在一起。想知道真实成色?只能一个个点进去看图和描述。
|
||||
|
||||
**砍价是体力活** — 好不容易筛出几个,还得一个个打招呼、问成色、问电池、问能不能包邮、试探底价……十几个卖家,每个都重复同样的对话流程。
|
||||
|
||||
> **一个下午过去了,你只聊了3个卖家,还没一个谈拢。**
|
||||
|
||||
这个工具就是来解决这个问题的 —— **让 AI 帮你海量扫、逐个筛、自动聊。**
|
||||
|
||||
---
|
||||
|
||||
## 看看实际效果
|
||||
|
||||
### 一个任务搜 123 件商品,AI 自动筛到 8 件,同时跟 5 个卖家聊
|
||||
|
||||

|
||||
|
||||
### AI 逐个打开详情页,像老买家一样判断每件商品
|
||||
|
||||
> "电池健康87%不满足90%以上要求" / "价格3999在范围内,屏幕完美无划痕" / "商品是MacBook Pro不是Air,不符合需求"
|
||||
|
||||

|
||||
|
||||
### 自动跟卖家聊天、询价、砍价 —— 全程日志可追踪
|
||||
|
||||

|
||||
|
||||
### 手机上打开闲鱼:已经聊开了
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/xianyu-chat-1.png" width="270"> <img src="docs/xianyu-chat-2.png" width="270"> <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>
|
||||
|
||||

|
||||
|
||||
5页搜索采集 123 个商品 → AI 标题粗筛只保留 27 个相关的
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>细筛日志 —— AI 逐个分析给理由</b></summary>
|
||||
<br>
|
||||
|
||||

|
||||
|
||||
每个商品都有明确的通过/不通过理由,比人工筛选更一致
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>自动聊天日志</b></summary>
|
||||
<br>
|
||||
|
||||

|
||||
|
||||
逐个发起聊天 → 发送开场白 → 等待回复 → 持续监控跟进
|
||||
|
||||
</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 ⭐
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"notifyOnlyGood": true,
|
||||
"minScore": 75,
|
||||
"channels": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -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 : ['你好呀~'];
|
||||
}
|
||||
}
|
||||
@@ -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(`标题主型号疑似非1245v3:E3-${[...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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()">×</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()">×</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()">×</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()">×</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>
|
||||
@@ -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 完成,无需配置外部模型接口`);
|
||||
});
|
||||
@@ -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(()=>{});
|
||||
@@ -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(() => {});
|
||||
@@ -0,0 +1,2 @@
|
||||
99b481d82309ef94dd2ad457dbe6ba189d2747d308d2b00bea4abd03ed8a3752 xianyu-hunter-hermes-skill.zip
|
||||
16013f7bde07a95438471b17d40f1da45310298457b0520cfa85858c8fa2a75c xianyu-hunter-hermes-skill.tar.gz
|
||||
@@ -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`: 1–3 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 20–30s+ 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 BOSS’s 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.
|
||||