commit 638415609770c455f2fbc4519e83056ff06f7c93 Author: Hermes Agent Date: Wed May 6 19:26:45 2026 +0800 feat: publish xianyu hunter hermes skill package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..584cad0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +browser-data/ +data/tasks.json +data/config.json +data/notify-config.json +reports/ +*.log +.runtime/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d5f787 --- /dev/null +++ b/README.md @@ -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/-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. 安全边界 + +默认不自动联系卖家、不下单、不付款。任何联系卖家/交易动作都应由用户明确授权并人工确认。 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1c64734 --- /dev/null +++ b/install.sh @@ -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 diff --git a/project/xianyu-hunter/.gitignore b/project/xianyu-hunter/.gitignore new file mode 100644 index 0000000..05798b0 --- /dev/null +++ b/project/xianyu-hunter/.gitignore @@ -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 diff --git a/project/xianyu-hunter/README.md b/project/xianyu-hunter/README.md new file mode 100644 index 0000000..1ac3d5b --- /dev/null +++ b/project/xianyu-hunter/README.md @@ -0,0 +1,275 @@ +
+ +# 🐟 闲鱼扫货助手 + +**告别手动翻页、逐个砍价的低效扫货方式** + +AI 帮你搜、帮你筛、帮你聊 —— 你只需要告诉它"你想买什么" + +[![Node.js](https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white)](https://nodejs.org/) +[![Playwright](https://img.shields.io/badge/Playwright-Chromium-2EAD33?logo=playwright&logoColor=white)](https://playwright.dev/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +
+ +--- + +## 痛点 + +在闲鱼买一台二手 MacBook,你会遇到这些问题: + +**搜索结果质量差** — 搜"MacBook Air M2 16G",出来一堆手机壳、贴膜、维修服务、求购帖。参数越长尾、越具体,搜索结果里的噪声越多,翻5页才能挑出几个靠谱的。 + +**标题党泛滥** — 标题写着"99新 无划痕",点进详情页一看:"轻微磕碰、电池健康82%、屏幕有一条细划痕"。不打开详情页,光看标题根本判断不了真实情况。 + +**平台筛选形同虚设** — 闲鱼的"几成新"完全靠卖家自己勾,8成新和99新混在一起。想知道真实成色?只能一个个点进去看图和描述。 + +**砍价是体力活** — 好不容易筛出几个,还得一个个打招呼、问成色、问电池、问能不能包邮、试探底价……十几个卖家,每个都重复同样的对话流程。 + +> **一个下午过去了,你只聊了3个卖家,还没一个谈拢。** + +这个工具就是来解决这个问题的 —— **让 AI 帮你海量扫、逐个筛、自动聊。** + +--- + +## 看看实际效果 + +### 一个任务搜 123 件商品,AI 自动筛到 8 件,同时跟 5 个卖家聊 + +![商品列表](docs/product-list.png) + +### AI 逐个打开详情页,像老买家一样判断每件商品 + +> "电池健康87%不满足90%以上要求" / "价格3999在范围内,屏幕完美无划痕" / "商品是MacBook Pro不是Air,不符合需求" + +![细筛日志](docs/fine-filter.png) + +### 自动跟卖家聊天、询价、砍价 —— 全程日志可追踪 + +![聊天日志](docs/auto-chat.png) + +### 手机上打开闲鱼:已经聊开了 + +
+     +
+ +
+ +> 上面是真实运行结果。一个任务下来,十几个卖家已经在跟"你"热聊了 —— "可以再少点嘛"、"好的~"、"电池健康多少呀"、"能少点嘛~我急着用嘞"…… 卖家完全以为在跟真人说话。 + +--- + +## 为什么卖家愿意聊?—— 因为有「人设」 + +这不是那种发一句"最低多少"就完事的脚本。 + +**内置的 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 生成回复 +- 自动判断目标是否达成(谈妥/被拒/商品下架),不在死单上浪费时间 + +--- + +## 全部可自定义 + + + +不只是填关键词这么简单。每个任务都可以精细配置: + +**搜索条件** +- 多关键词(逗号分隔) +- 价格区间、地区、仅个人闲置 + +**AI 筛选规则** +- 用自然语言描述你的需求 +- 粗筛 / 细筛 Prompt 都可以自定义 + +**砍价策略** +- 心理价位、砍价节奏、底线 +- "先夸再砍"还是"直接报价" + +**聊天人设** +- 默认高情商女大学生 +- 可改为任何风格 + +
+ +--- + +## 它能做什么? + +> "我想买一台 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,性价比最高。 + +## 更多截图 + +
+搜索采集 & 粗筛日志 +
+ +![搜索与粗筛日志](docs/search-logs.png) + +5页搜索采集 123 个商品 → AI 标题粗筛只保留 27 个相关的 + +
+ +
+细筛日志 —— AI 逐个分析给理由 +
+ +![细筛日志](docs/fine-filter.png) + +每个商品都有明确的通过/不通过理由,比人工筛选更一致 + +
+ +
+自动聊天日志 +
+ +![自动聊天](docs/auto-chat.png) + +逐个发起聊天 → 发送开场白 → 等待回复 → 持续监控跟进 + +
+ +## 项目结构 + +``` +├── 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) +``` + +## 常见问题 + +
+首次运行要登录吗? +是的。首次启动时会弹出 Chromium 浏览器,你需要在闲鱼网页端手动登录一次。之后登录状态会自动保持。 +
+ +
+遇到滑块验证怎么办? +在弹出的浏览器窗口中手动完成验证即可,系统会自动检测并等待你完成,之后继续执行。 +
+ +
+任务中断了数据会丢吗? +不会。所有进度实时保存,重新启动任务会从上次中断的阶段恢复。聊天会话的完整上下文也会保留,续聊时不会重复发消息。 +
+ +
+API Key 安全吗? +API Key 仅保存在你本地的 data/config.json 中,该文件已加入 .gitignore,不会被提交或上传。 +
+ +
+一次任务大概花多少 API 费用? +取决于商品数量和聊天轮次。典型场景(搜3页、筛选50个、聊天5个卖家)大约消耗 ¥0.05 ~ ¥0.3,两轮筛选设计把调用量压到了最低。 +
+ +
+会不会被闲鱼封号? +本工具使用真实浏览器(非接口调用),消息频率有随机延迟控制,模拟真人操作。但任何自动化工具都有风险,建议不要在主力账号上高频使用。 +
+ +## 环境要求 + +- **Node.js** v18+([下载](https://nodejs.org/)) +- **操作系统** Windows / macOS / Linux +- **网络** 能访问闲鱼(goofish.com) + 你选择的 AI 服务商 + +## License + +MIT — 随便用,觉得有用给个 Star ⭐ diff --git a/project/xianyu-hunter/data/.gitkeep b/project/xianyu-hunter/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/project/xianyu-hunter/data/notify-config.example.json b/project/xianyu-hunter/data/notify-config.example.json new file mode 100644 index 0000000..402319b --- /dev/null +++ b/project/xianyu-hunter/data/notify-config.example.json @@ -0,0 +1,6 @@ +{ + "enabled": false, + "notifyOnlyGood": true, + "minScore": 75, + "channels": [] +} \ No newline at end of file diff --git a/project/xianyu-hunter/docs/auto-chat.png b/project/xianyu-hunter/docs/auto-chat.png new file mode 100644 index 0000000..28dba62 Binary files /dev/null and b/project/xianyu-hunter/docs/auto-chat.png differ diff --git a/project/xianyu-hunter/docs/create-task.png b/project/xianyu-hunter/docs/create-task.png new file mode 100644 index 0000000..cbba04c Binary files /dev/null and b/project/xianyu-hunter/docs/create-task.png differ diff --git a/project/xianyu-hunter/docs/fine-filter.png b/project/xianyu-hunter/docs/fine-filter.png new file mode 100644 index 0000000..d00bab2 Binary files /dev/null and b/project/xianyu-hunter/docs/fine-filter.png differ diff --git a/project/xianyu-hunter/docs/product-list.png b/project/xianyu-hunter/docs/product-list.png new file mode 100644 index 0000000..01eb1aa Binary files /dev/null and b/project/xianyu-hunter/docs/product-list.png differ diff --git a/project/xianyu-hunter/docs/search-logs.png b/project/xianyu-hunter/docs/search-logs.png new file mode 100644 index 0000000..2fdef72 Binary files /dev/null and b/project/xianyu-hunter/docs/search-logs.png differ diff --git a/project/xianyu-hunter/docs/xianyu-chat-1.png b/project/xianyu-hunter/docs/xianyu-chat-1.png new file mode 100644 index 0000000..075f744 Binary files /dev/null and b/project/xianyu-hunter/docs/xianyu-chat-1.png differ diff --git a/project/xianyu-hunter/docs/xianyu-chat-2.png b/project/xianyu-hunter/docs/xianyu-chat-2.png new file mode 100644 index 0000000..79c7322 Binary files /dev/null and b/project/xianyu-hunter/docs/xianyu-chat-2.png differ diff --git a/project/xianyu-hunter/docs/xianyu-chat-3.png b/project/xianyu-hunter/docs/xianyu-chat-3.png new file mode 100644 index 0000000..d640b92 Binary files /dev/null and b/project/xianyu-hunter/docs/xianyu-chat-3.png differ diff --git a/project/xianyu-hunter/lib/ai.mjs b/project/xianyu-hunter/lib/ai.mjs new file mode 100644 index 0000000..b4c2955 --- /dev/null +++ b/project/xianyu-hunter/lib/ai.mjs @@ -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 : ['你好呀~']; + } +} diff --git a/project/xianyu-hunter/lib/analyzer.mjs b/project/xianyu-hunter/lib/analyzer.mjs new file mode 100644 index 0000000..0d6ff11 --- /dev/null +++ b/project/xianyu-hunter/lib/analyzer.mjs @@ -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 }; +} diff --git a/project/xianyu-hunter/lib/browser.mjs b/project/xianyu-hunter/lib/browser.mjs new file mode 100644 index 0000000..7493d7c --- /dev/null +++ b/project/xianyu-hunter/lib/browser.mjs @@ -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; + } +} diff --git a/project/xianyu-hunter/lib/chat.mjs b/project/xianyu-hunter/lib/chat.mjs new file mode 100644 index 0000000..8615f4b --- /dev/null +++ b/project/xianyu-hunter/lib/chat.mjs @@ -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; + }); +} diff --git a/project/xianyu-hunter/lib/config.mjs b/project/xianyu-hunter/lib/config.mjs new file mode 100644 index 0000000..5289f39 --- /dev/null +++ b/project/xianyu-hunter/lib/config.mjs @@ -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, + }; +} diff --git a/project/xianyu-hunter/lib/filter.mjs b/project/xianyu-hunter/lib/filter.mjs new file mode 100644 index 0000000..533088a --- /dev/null +++ b/project/xianyu-hunter/lib/filter.mjs @@ -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(() => {}); + } +} diff --git a/project/xianyu-hunter/lib/login.mjs b/project/xianyu-hunter/lib/login.mjs new file mode 100644 index 0000000..4dafc5a --- /dev/null +++ b/project/xianyu-hunter/lib/login.mjs @@ -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; +} diff --git a/project/xianyu-hunter/lib/notifier.mjs b/project/xianyu-hunter/lib/notifier.mjs new file mode 100644 index 0000000..1bedf5f --- /dev/null +++ b/project/xianyu-hunter/lib/notifier.mjs @@ -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 }; +} diff --git a/project/xianyu-hunter/lib/search.mjs b/project/xianyu-hunter/lib/search.mjs new file mode 100644 index 0000000..c6370b1 --- /dev/null +++ b/project/xianyu-hunter/lib/search.mjs @@ -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; + } +} + diff --git a/project/xianyu-hunter/lib/task.mjs b/project/xianyu-hunter/lib/task.mjs new file mode 100644 index 0000000..16c97e3 --- /dev/null +++ b/project/xianyu-hunter/lib/task.mjs @@ -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, + }); + } +} diff --git a/project/xianyu-hunter/lib/utils.mjs b/project/xianyu-hunter/lib/utils.mjs new file mode 100644 index 0000000..a7c929d --- /dev/null +++ b/project/xianyu-hunter/lib/utils.mjs @@ -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} 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; +} diff --git a/project/xianyu-hunter/package-lock.json b/project/xianyu-hunter/package-lock.json new file mode 100644 index 0000000..c7eae6d --- /dev/null +++ b/project/xianyu-hunter/package-lock.json @@ -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 + } + } + } + } +} diff --git a/project/xianyu-hunter/package.json b/project/xianyu-hunter/package.json new file mode 100644 index 0000000..65d0387 --- /dev/null +++ b/project/xianyu-hunter/package.json @@ -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" +} diff --git a/project/xianyu-hunter/public/app.js b/project/xianyu-hunter/public/app.js new file mode 100644 index 0000000..be64f9d --- /dev/null +++ b/project/xianyu-hunter/public/app.js @@ -0,0 +1,1077 @@ +// ========== State ========== +let ws = null; +let tasks = []; +let activeTaskId = null; +let defaultCoarsePrompt = ''; +let defaultFinePrompt = ''; +let editingTaskId = null; +let notifyConfig = null; +const THEME_STORAGE_KEY = 'xianyu-hunter-theme'; + +// ========== Theme ========== +function getPreferredTheme() { + const saved = localStorage.getItem(THEME_STORAGE_KEY); + if (saved === 'light' || saved === 'dark') return saved; + return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +function applyTheme(theme) { + const normalized = theme === 'light' ? 'light' : 'dark'; + document.documentElement.dataset.theme = normalized; + const icon = document.getElementById('theme-icon'); + const label = document.getElementById('theme-label'); + if (icon) icon.textContent = normalized === 'light' ? '☀️' : '🌙'; + if (label) label.textContent = normalized === 'light' ? '浅色' : '深色'; +} + +function toggleTheme() { + const current = document.documentElement.dataset.theme || getPreferredTheme(); + const next = current === 'light' ? 'dark' : 'light'; + localStorage.setItem(THEME_STORAGE_KEY, next); + applyTheme(next); +} + +applyTheme(getPreferredTheme()); + +const STAGE_LABELS = { + pending: '等待中', + login: '登录验证', + searching: '搜索采集', + coarse_filter: '粗筛', + fine_filter: '细筛', + completed: '已完成', + stopped: '已停止', + stopping: '停止中', +}; + +const STAGES_ORDER = ['login', 'searching', 'coarse_filter', 'fine_filter']; + +// ========== WebSocket ========== +function connectWS() { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${protocol}//${location.host}`); + + ws.onopen = () => { + document.getElementById('ws-status').classList.add('connected'); + }; + + ws.onclose = () => { + document.getElementById('ws-status').classList.remove('connected'); + setTimeout(connectWS, 2000); + }; + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + handleWSMessage(msg); + }; +} + +function handleWSMessage(msg) { + const { event, taskId, data } = msg; + + switch (event) { + case 'connected': + if (data?.tasks) { + tasks = data.tasks; + renderTaskList(); + renderHomeDashboard(); + } + break; + + case 'task:created': + tasks.push(data); + renderTaskList(); + renderHomeDashboard(); + selectTask(data.id); + break; + + case 'task:updated': + { + const idx = tasks.findIndex(t => t.id === taskId); + if (idx >= 0) tasks[idx] = data; else tasks.push(data); + renderTaskList(); + renderHomeDashboard(); + if (activeTaskId === taskId) renderTaskDetail(); + } + break; + + case 'task:started': + case 'task:stopping': + updateTaskField(taskId, 'running', event === 'task:started'); + if (event === 'task:stopping') updateTaskField(taskId, 'stage', 'stopping'); + renderTaskList(); + if (activeTaskId === taskId) renderPipeline(); + break; + + case 'task:stage': + updateTaskField(taskId, 'stage', data.stage); + renderTaskList(); + if (activeTaskId === taskId) renderPipeline(); + break; + + case 'task:log': + appendLog(taskId, data); + break; + + case 'task:products': + updateProductCounts(taskId, data); + if (activeTaskId === taskId) renderProductStats(); + break; + + + case 'task:finished': + const idx = tasks.findIndex(t => t.id === taskId); + if (idx >= 0) tasks[idx] = data; + renderTaskList(); + renderHomeDashboard(); + if (activeTaskId === taskId) renderTaskDetail(); + break; + } +} + +function updateTaskField(taskId, field, value) { + const task = tasks.find(t => t.id === taskId); + if (task) task[field] = value; +} + +function updateProductCounts(taskId, data) { + const task = tasks.find(t => t.id === taskId); + if (!task) return; + if (!task.products) task.products = {}; + task.products.rawCount = data.rawCount; + task.products.coarseCount = data.coarseCount; + task.products.fineCount = data.fineCount; +} + +function appendLog(taskId, entry) { + const task = tasks.find(t => t.id === taskId); + if (!task) return; + if (!task.logs) task.logs = []; + task.logs.push(entry); + if (task.logs.length > 500) task.logs = task.logs.slice(-300); + + if (activeTaskId === taskId) { + const panel = document.getElementById('log-panel'); + const div = document.createElement('div'); + div.className = 'log-entry'; + div.innerHTML = `${entry.time}${escapeHtml(entry.message)}`; + panel.appendChild(div); + panel.scrollTop = panel.scrollHeight; + } +} + +// ========== API ========== +async function apiPut(url, body) { + const res = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok && data.error) { alert(data.error); throw new Error(data.error); } + return data; +} + +async function apiPost(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok && data.error) { + alert(data.error); + throw new Error(data.error); + } + return data; +} + +function showLoginModal() { + document.getElementById('login-modal-overlay').classList.add('show'); + checkLoginStatus(); +} + +function hideLoginModal() { + document.getElementById('login-modal-overlay').classList.remove('show'); +} + +function renderLoginStatus(data) { + const card = document.getElementById('login-status-card'); + const meta = document.getElementById('login-status-meta'); + const accountRow = document.getElementById('login-account-row'); + const accountName = document.getElementById('login-account-name'); + const accountSub = document.getElementById('login-account-sub'); + const avatar = document.getElementById('login-avatar'); + const ok = !!data.loggedIn; + card.classList.toggle('ok', ok); + card.classList.toggle('bad', !ok); + card.querySelector('.login-status-title').textContent = ok ? '✅ 已登录,Cookie 可复用' : '⚠️ 未登录或仍在登录墙'; + + accountRow.style.display = ok ? 'flex' : 'none'; + accountName.textContent = data.accountName || '已登录账号'; + accountSub.textContent = `Cookie ${data.cookieCount ?? 0} · ${data.checkedAt ? new Date(data.checkedAt).toLocaleString() : '刚刚检测'}`; + if (data.avatarUrl) { + avatar.src = data.avatarUrl; + avatar.style.display = 'block'; + } else { + avatar.removeAttribute('src'); + avatar.style.display = 'none'; + } + + meta.innerHTML = ` +
Cookie 数:${data.cookieCount ?? 0}
+
账号:${escapeHtml(data.accountName || (ok ? '已登录' : '未识别'))}
+
登录墙:${data.loginWall ? '是' : '否'} / 登录字样:${data.loginTextVisible ? '可见' : '不可见'}
+
商品卡片:${data.cardCount ?? 0}
+
浏览器:${data.browserOpen ? '已打开' : '未打开'},页面:${escapeHtml(data.title || '')}
+
当前 URL:${escapeHtml(data.url || '')}
+
保存目录:${escapeHtml(data.storage || 'browser-data/')}
+ `; +} + +function renderLoginQr(data) { + const panel = document.getElementById('login-qr-panel'); + const img = document.getElementById('login-qr-img'); + if (data.qrImage) { + img.src = data.qrImage; + panel.style.display = 'block'; + } else if (data.loggedIn) { + clearLoginQr(); + } +} + +function clearLoginQr() { + const panel = document.getElementById('login-qr-panel'); + const img = document.getElementById('login-qr-img'); + img.removeAttribute('src'); + panel.style.display = 'none'; +} + +function appendLoginLog(lines) { + const box = document.getElementById('login-log'); + const text = Array.isArray(lines) ? lines.join('\n') : String(lines || ''); + if (text) box.textContent = `${new Date().toLocaleTimeString()}\n${text}\n\n${box.textContent}`; +} + +async function checkLoginStatus() { + try { + const res = await fetch('/api/login/status'); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || '检测失败'); + renderLoginStatus(data); + appendLoginLog(`检测完成:${data.loggedIn ? '已登录' : '未登录'},cookie=${data.cookieCount ?? 0}`); + return data; + } catch (err) { + appendLoginLog(`检测登录状态失败:${err.message}`); + throw err; + } +} + +async function openLoginWindow() { + appendLoginLog('正在打开 Mac 本机 Chromium 闲鱼登录窗口...'); + try { + const result = await apiPost('/api/login/open', {}); + renderLoginStatus(result); + appendLoginLog(result.logs || '已请求打开登录窗口'); + } catch (err) { + appendLoginLog(`打开登录窗口失败:${err.message}`); + } +} + +async function fetchLoginQr() { + appendLoginLog('正在从闲鱼 Web 登录入口获取二维码/登录面板截图...'); + try { + const result = await apiPost('/api/login/qr', {}); + renderLoginStatus(result); + renderLoginQr(result); + appendLoginLog(result.logs || '二维码获取完成'); + } catch (err) { + appendLoginLog(`获取登录二维码失败:${err.message}`); + } +} + +async function waitLoginStatus() { + appendLoginLog('开始等待登录完成,期间请在 Chromium 中扫码/验证码登录...'); + try { + const result = await apiPost('/api/login/wait', {}); + renderLoginStatus(result); + appendLoginLog(result.logs || '等待检测完成'); + } catch (err) { + appendLoginLog(`等待检测失败:${err.message}`); + } +} + +async function closeLoginBrowser() { + if (!confirm('确认关闭当前登录会话浏览器?Cookie 已写入 browser-data 后通常不会丢。')) return; + try { + await apiPost('/api/login/close-browser', {}); + appendLoginLog('已关闭登录会话浏览器'); + await checkLoginStatus(); + } catch (err) { + appendLoginLog(`关闭登录会话失败:${err.message}`); + } +} + +async function importCookieFile(file) { + if (!file) return; + appendLoginLog(`正在读取 Cookie JSON:${file.name}`); + try { + const text = await file.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + throw new Error('JSON 解析失败,请确认文件是浏览器扩展导出的 Cookie JSON'); + } + + const result = await apiPost('/api/login/import-cookies', payload); + renderLoginStatus(result); + appendLoginLog([...(result.logs || []), `导入完成:${result.imported || 0} 条;状态:${result.loggedIn ? '已登录' : '仍需重新登录/验证'}`]); + } catch (err) { + appendLoginLog(`导入 Cookie 失败:${err.message}`); + } +} + +function collectTaskForm() { + const name = document.getElementById('f-name').value.trim(); + const queriesRaw = document.getElementById('f-queries').value.trim(); + const priceMin = document.getElementById('f-price-min').value; + const priceMax = document.getElementById('f-price-max').value; + const maxPages = document.getElementById('f-pages').value; + const maxItems = document.getElementById('f-max-items').value; + const region = document.getElementById('f-region').value.trim(); + const personalSeller = document.getElementById('f-personal').checked; + const customRequirements = document.getElementById('f-requirements').value.trim(); + const coarsePrompt = document.getElementById('f-coarse-prompt').value.trim(); + const finePrompt = document.getElementById('f-fine-prompt').value.trim(); + const scheduleEnabled = document.getElementById('f-schedule-enabled').checked; + const scheduleIntervalMinutes = document.getElementById('f-schedule-interval').value; + + if (!queriesRaw) throw new Error('请输入至少一个搜索关键词'); + const queries = queriesRaw.split(/[,,]/).map(s => s.trim()).filter(Boolean); + return { + name: name || queries[0], + queries, + priceMin: priceMin ? Number(priceMin) : null, + priceMax: priceMax ? Number(priceMax) : null, + maxPages: maxPages ? Number(maxPages) : 3, + maxItems: maxItems ? Number(maxItems) : 60, + region, + personalSeller, + customRequirements, + coarsePrompt: coarsePrompt || '', + finePrompt: finePrompt || '', + scheduleEnabled, + scheduleIntervalMinutes: scheduleIntervalMinutes ? Number(scheduleIntervalMinutes) : 0, + }; +} + +async function saveTask(autoStart) { + let payload; + try { + payload = collectTaskForm(); + } catch (err) { + alert(err.message); + return; + } + + try { + let task; + if (editingTaskId) { + task = await apiPut(`/api/tasks/${editingTaskId}`, payload); + if (autoStart) await apiPost(`/api/tasks/${editingTaskId}/start`, { reset: true }); + } else { + task = await apiPost('/api/tasks', { ...payload, autoStart }); + } + hideCreateModal(); + clearForm(); + editingTaskId = null; + if (task?.id) selectTask(task.id); + } catch { /* alert already shown */ } +} + +async function createTask() { return saveTask(true); } + +async function optimizeTaskForm() { + const status = document.getElementById('optimize-status'); + status.textContent = '优化中...'; + try { + const result = await apiPost('/api/tasks/optimize', { + product: document.getElementById('f-queries').value.trim() || document.getElementById('f-name').value.trim(), + budget: document.getElementById('f-price-max').value.trim(), + region: document.getElementById('f-region').value.trim(), + currentRequirements: document.getElementById('f-requirements').value.trim(), + }); + fillTaskForm(result.task || {}); + status.textContent = result.source === 'ai' ? '已由 AI 优化' : 'AI不可用,已用本地规则优化'; + } catch (err) { + status.textContent = `优化失败:${err.message}`; + } +} + +function fillTaskForm(taskOrConfig, name = '') { + const c = taskOrConfig.config || taskOrConfig; + document.getElementById('f-name').value = taskOrConfig.name || name || ''; + document.getElementById('f-queries').value = (c.queries || []).join(', '); + document.getElementById('f-price-min').value = c.priceMin ?? ''; + document.getElementById('f-price-max').value = c.priceMax ?? ''; + document.getElementById('f-pages').value = c.maxPages || 3; + document.getElementById('f-max-items').value = c.maxItems || 60; + document.getElementById('f-region').value = c.region || ''; + document.getElementById('f-personal').checked = !!c.personalSeller; + document.getElementById('f-requirements').value = c.customRequirements || ''; + document.getElementById('f-coarse-prompt').value = c.coarsePrompt || defaultCoarsePrompt; + document.getElementById('f-fine-prompt').value = c.finePrompt || defaultFinePrompt; + document.getElementById('f-schedule-enabled').checked = !!c.schedule?.enabled; + document.getElementById('f-schedule-interval').value = c.schedule?.intervalMinutes || 0; +} + +async function stopTask(id) { + await apiPost(`/api/tasks/${id}/stop`); +} + +async function startTaskNow(id) { + await apiPost(`/api/tasks/${id}/start`, {}); +} + +async function rerunTask(id) { + if (!confirm('重新采集会清空本任务本轮旧结果,确认继续?')) return; + await apiPost(`/api/tasks/${id}/start`, { reset: true }); +} + +async function analyzeTaskNow(id) { + const task = tasks.find(t => t.id === id); + if (!task?.products?.fineCount) { alert('当前没有待分析候选'); return; } + try { + const result = await apiPost(`/api/tasks/${id}/analyze`, { notify: true }); + alert(`分析完成:推荐 ${result.analysis.recommendedCount} / 谨慎 ${result.analysis.cautionCount} / 排除 ${result.analysis.rejectedCount}${result.notification?.skipped ? '\n通知:未达到通知条件' : ''}`); + await refreshTask(id); + } catch (err) { + alert(`分析失败:${err.message}`); + } +} + +function getClearTargets() { + const scope = document.getElementById('cache-task-scope')?.value || 'active'; + if (scope === 'all') return tasks.filter(t => !t.running); + const active = tasks.find(t => t.id === activeTaskId); + return active && !active.running ? [active] : []; +} + +function countQualityItems(task, mode, minScore) { + const fine = task?.products?.fineFiltered || []; + return fine.filter(item => { + const a = item.hermesAnalysis; + if (!a) return false; + if (mode === 'hermes-recommend') return a.verdict === 'recommend'; + if (mode === 'hermes-pass') return (a.verdict === 'recommend' || a.verdict === 'caution') && Number(a.score || 0) >= Number(minScore || 0); + return false; + }).length; +} + +function showClearCacheModal() { + document.getElementById('clear-cache-modal-overlay').classList.add('show'); + const scope = document.getElementById('cache-task-scope'); + if (scope && !activeTaskId) scope.value = 'all'; + document.getElementById('cache-log').textContent = ''; + renderClearCachePreview(); +} + +function hideClearCacheModal() { + document.getElementById('clear-cache-modal-overlay').classList.remove('show'); +} + +function renderClearCachePreview() { + const mode = document.getElementById('cache-clear-mode')?.value || 'all'; + const minScore = Number(document.getElementById('cache-min-score')?.value || 75); + const minRow = document.getElementById('cache-min-score-row'); + if (minRow) minRow.style.display = mode === 'hermes-pass' ? 'block' : 'none'; + + const targets = getClearTargets(); + const preview = document.getElementById('cache-preview'); + if (!preview) return; + if (!targets.length) { + preview.innerHTML = '
没有可清理的任务:请选择一个非运行中的任务,或切换到“全部任务”。
'; + return; + } + + const rows = targets.map(t => { + const raw = t.products?.rawCount ?? t.products?.raw?.length ?? 0; + const coarse = t.products?.coarseCount ?? t.products?.coarseFiltered?.length ?? 0; + const fine = t.products?.fineCount ?? t.products?.fineFiltered?.length ?? 0; + const keep = mode === 'all' ? 0 : countQualityItems(t, mode, minScore); + const hasAnalysis = !!t.hermesAnalysis; + return `
+
${escapeHtml(t.name || t.id)}
${hasAnalysis ? `Hermes 推荐 ${t.hermesAnalysis.recommendedCount || 0} / 谨慎 ${t.hermesAnalysis.cautionCount || 0}` : '尚无 Hermes 分析,保留优质结果模式会保留 0 件'}
+
采集 ${raw}|粗筛 ${coarse}|细筛 ${fine}|将保留 ${keep}
+
`; + }).join(''); + preview.innerHTML = rows; +} + +async function clearCacheFromModal() { + const mode = document.getElementById('cache-clear-mode').value; + const minScore = Number(document.getElementById('cache-min-score').value || 75); + const targets = getClearTargets(); + if (!targets.length) { alert('没有可清理的任务'); return; } + const label = mode === 'all' ? '全部清除' : mode === 'hermes-recommend' ? '只保留 Hermes 推荐结果' : `保留 Hermes 通过且 ≥${minScore} 分的优质结果`; + if (!confirm(`确认对 ${targets.length} 个任务执行「${label}」?\n\n此操作会改写任务缓存,但不会删除任务配置和登录 Cookie。`)) return; + + const log = document.getElementById('cache-log'); + log.textContent = `开始清理 ${targets.length} 个任务...\n`; + const results = []; + for (const task of targets) { + try { + const result = await apiPost(`/api/tasks/${task.id}/clear-cache`, { mode, minScore }); + results.push(result); + const idx = tasks.findIndex(t => t.id === task.id); + if (idx >= 0 && result.task) tasks[idx] = result.task; + log.textContent += `✅ ${task.name}: ${result.before.raw}/${result.before.coarseFiltered}/${result.before.fineFiltered} → ${result.after.raw}/${result.after.coarseFiltered}/${result.after.fineFiltered}\n`; + } catch (err) { + log.textContent += `❌ ${task.name}: ${err.message}\n`; + } + } + renderTaskList(); + if (activeTaskId) await refreshTask(activeTaskId); + renderClearCachePreview(); + const kept = results.reduce((sum, r) => sum + (r.kept || 0), 0); + log.textContent += `完成:保留 ${kept} 个优质结果。\n`; +} + +function duplicateTask(id) { + const task = tasks.find(t => t.id === id); + if (!task?.config) return; + editingTaskId = null; + fillTaskForm(task, task.name ? `${task.name} (副本)` : ''); + document.getElementById('task-modal-title').textContent = '复制为新任务'; + document.getElementById('save-run-btn').textContent = '创建并采集'; + document.getElementById('save-only-btn').textContent = '仅保存任务'; + showCreateModal(); +} + +function editTask(id) { + const task = tasks.find(t => t.id === id); + if (!task?.config) return; + if (task.running) { alert('任务运行中,请先停止后再编辑'); return; } + editingTaskId = id; + fillTaskForm(task, task.name || ''); + document.getElementById('task-modal-title').textContent = '编辑采集任务'; + document.getElementById('save-run-btn').textContent = '保存并重新采集'; + document.getElementById('save-only-btn').textContent = '仅保存修改'; + showCreateModal(); +} + +async function deleteTask(id) { + if (!confirm('确认删除此任务?')) return; + await fetch(`/api/tasks/${id}`, { method: 'DELETE' }); + tasks = tasks.filter(t => t.id !== id); + if (activeTaskId === id) { + activeTaskId = null; + renderTaskDetail(); + } + renderTaskList(); +} + +async function refreshTask(id) { + const res = await fetch(`/api/tasks/${id}`); + const task = await res.json(); + const idx = tasks.findIndex(t => t.id === id); + if (idx >= 0) tasks[idx] = task; + if (activeTaskId === id) renderTaskDetail(); +} + +// ========== Render: Task List ========== +function renderTaskList() { + const container = document.getElementById('task-list'); + container.innerHTML = tasks.map(t => ` +
+
${escapeHtml(t.name)}
+
+ ${STAGE_LABELS[t.stage] || t.stage} + + ${t.config?.schedule?.enabled ? '⏰ ' : ''}${t.products ? `${t.products.rawCount || 0}件` : ''} + +
+
+ `).join(''); +} + +function showHome() { + activeTaskId = null; + renderTaskList(); + const detail = document.getElementById('task-detail'); + const empty = document.getElementById('empty-state'); + if (detail) detail.style.display = 'none'; + if (empty) { + empty.style.display = 'block'; + empty.scrollTop = 0; + } + renderHomeDashboard(); +} + +function selectTask(id, tab = 'products') { + activeTaskId = id; + renderTaskList(); + refreshTask(id).then(() => { + renderTaskDetail(); + switchTab(tab); + }); +} + +function findLatestTask(predicate = () => true) { + return [...tasks] + .filter(predicate) + .sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0))[0]; +} + +function openHomeCard(target) { + const withProducts = t => Number(t.products?.rawCount || 0) > 0; + const withFine = t => Number(t.products?.fineCount || 0) > 0; + const withRecommend = t => Number(t.hermesAnalysis?.recommendedCount || 0) > 0; + const handlers = { + tasks: () => findLatestTask(), + raw: () => findLatestTask(withProducts), + fine: () => findLatestTask(withFine), + recommend: () => findLatestTask(withRecommend), + running: () => findLatestTask(t => t.running), + scheduled: () => findLatestTask(t => t.config?.schedule?.enabled), + }; + const task = (handlers[target] || handlers.tasks)(); + if (!task) { + showCreateModal(); + return; + } + const tab = target === 'recommend' ? 'analysis' : 'products'; + selectTask(task.id, tab); +} + +function scrollHomeRecentTasks() { + document.getElementById('home-task-list')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} + + +function formatTimeShort(ts) { + if (!ts) return '-'; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return '-'; + return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); +} + +function renderHomeDashboard() { + const overview = document.getElementById('home-overview'); + const list = document.getElementById('home-task-list'); + if (!overview || !list) return; + + const totals = tasks.reduce((acc, t) => { + acc.tasks += 1; + acc.running += t.running ? 1 : 0; + acc.raw += Number(t.products?.rawCount || 0); + acc.fine += Number(t.products?.fineCount || 0); + acc.recommend += Number(t.hermesAnalysis?.recommendedCount || 0); + acc.scheduled += t.config?.schedule?.enabled ? 1 : 0; + return acc; + }, { tasks: 0, running: 0, raw: 0, fine: 0, recommend: 0, scheduled: 0 }); + + const cards = [ + ['任务总数', totals.tasks, '已保存的监测配置', '📋', 'tasks', '打开最近任务'], + ['采集缓存', totals.raw, '当前保留商品', '📦', 'raw', '查看商品列表'], + ['细筛候选', totals.fine, '可进入 Hermes 分析', '🎯', 'fine', '查看候选商品'], + ['推荐结果', totals.recommend, 'Hermes 判定优质', '🦐', 'recommend', '查看分析结果'], + ['运行中', totals.running, '正在采集任务', '⚡', 'running', '查看运行任务'], + ['定时任务', totals.scheduled, '后台自动运行', '⏰', 'scheduled', '查看定时任务'], + ]; + overview.innerHTML = cards.map(([label, value, sub, icon, target, action]) => ` + + `).join(''); + + const recent = [...tasks].sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0)).slice(0, 6); + if (!recent.length) { + list.innerHTML = ` +
+
还没有任务
+ 可以先创建一个关键词任务,例如 CPU、Mac mini、硬盘、显卡等。 + +
+ `; + return; + } + + list.innerHTML = recent.map(t => ` + + `).join(''); +} + +// ========== Render: Task Detail ========== +function renderTaskDetail() { + const task = tasks.find(t => t.id === activeTaskId); + const detail = document.getElementById('task-detail'); + const empty = document.getElementById('empty-state'); + + if (!task) { + detail.style.display = 'none'; + empty.style.display = 'block'; + renderHomeDashboard(); + return; + } + + detail.style.display = 'flex'; + empty.style.display = 'none'; + + renderPipeline(); + renderProductStats(); + renderProducts(); + renderAnalysis(); + renderLogs(); +} + +function renderPipeline() { + const task = tasks.find(t => t.id === activeTaskId); + if (!task) return; + + const currentIdx = STAGES_ORDER.indexOf(task.stage); + const container = document.getElementById('pipeline'); + + let html = STAGES_ORDER.map((stage, i) => { + let cls = ''; + if (task.stage === 'completed') { + cls = 'done'; + } else if (task.stage === 'stopped' || task.stage === 'stopping') { + cls = i <= currentIdx ? 'done' : ''; + } else if (i < currentIdx) { + cls = 'done'; + } else if (i === currentIdx) { + cls = 'active'; + } + return ` + ${i > 0 ? '' : ''} +
${STAGE_LABELS[stage]}
+ `; + }).join(''); + + html += ` +
+ + + + ${task.running + ? `` + : ` + + + ` + } +
+ `; + + container.innerHTML = html; +} + +function renderProductStats() { + const task = tasks.find(t => t.id === activeTaskId); + if (!task?.products) return; + + document.getElementById('product-stats').innerHTML = ` +
${task.products.rawCount || 0}
采集总量
+
${task.products.coarseCount || 0}
粗筛通过
+
${task.products.fineCount || 0}
细筛通过
+
${task.hermesAnalysis ? task.hermesAnalysis.recommendedCount : (task.products.fineCount || 0)}
${task.hermesAnalysis ? '推荐候选' : '待 Hermes 分析'}
+
${task.config?.maxItems || 60}
单次上限
+
${task.config?.schedule?.enabled ? `${task.config.schedule.intervalMinutes}m` : '关'}
定时
+ `; +} + +function renderProducts() { + const task = tasks.find(t => t.id === activeTaskId); + if (!task?.products) return; + + const analyzedFine = new Map((task.products.fineFiltered || []).map(p => [p.id, p.hermesAnalysis])); + const fineIds = new Set((task.products.fineFiltered || []).map(p => p.id)); + const coarseIds = new Set((task.products.coarseFiltered || []).map(p => p.id)); + const all = task.products.raw || []; + const tbody = document.getElementById('product-tbody'); + tbody.innerHTML = all.map(p => { + let badge, badgeCls; + const analysis = p.hermesAnalysis || analyzedFine.get(p.id); + if (analysis) { badge = analysis.verdict === 'recommend' ? `推荐 ${analysis.score}` : analysis.verdict === 'reject' ? `排除 ${analysis.score}` : `谨慎 ${analysis.score}`; badgeCls = analysis.verdict === 'recommend' ? 'badge-fine' : analysis.verdict === 'reject' ? 'badge-reject' : 'badge-coarse'; } + else if (fineIds.has(p.id)) { badge = '待分析'; badgeCls = 'badge-fine'; } + else if (coarseIds.has(p.id)) { badge = '粗筛✓'; badgeCls = 'badge-coarse'; } + else { badge = '采集'; badgeCls = 'badge-raw'; } + + return ` + ${p.image ? `` : '-'} + ${p.href ? `${escapeHtml(p.title?.slice(0, 60) || '')}` : escapeHtml(p.title?.slice(0, 60) || '')}${analysis?.reason ? `
${escapeHtml(analysis.reason)}
` : ''} + ${escapeHtml(p.detailPrice || p.price || '')} + ${escapeHtml(p.sellerLocation || '')} + ${badge} + `; + }).join(''); +} + + +function renderAnalysis() { + const task = tasks.find(t => t.id === activeTaskId); + const panel = document.getElementById('analysis-panel'); + if (!panel) return; + const summary = task?.hermesAnalysis; + if (!task) return; + if (!summary) { + panel.innerHTML = ` +
+
🦐
+
还没有 Hermes 分析结果
+
细筛通过后,点击上方“执行分析”会生成推荐/谨慎/排除结果,并按通知设置推送。
+ +
+ `; + return; + } + const label = { recommend: '推荐', caution: '谨慎', reject: '排除' }; + const cls = { recommend: 'recommend', caution: 'caution', reject: 'reject' }; + const rows = (summary.topItems || []).map(item => ` +
+
+ ${item.score} + ${label[item.verdict] || item.verdict} + ${item.href ? `${escapeHtml(item.title || '(无标题)')}` : escapeHtml(item.title || '(无标题)')} +
+
价格:${escapeHtml(item.price || '-')}|地区:${escapeHtml(item.location || '-')}
+
${escapeHtml(item.reason || '')}
+ ${item.flags?.length ? `
风险:${item.flags.map(escapeHtml).join(';')}
` : ''} +
+ `).join(''); + panel.innerHTML = ` +
+
${summary.recommendedCount || 0}
推荐
+
${summary.cautionCount || 0}
谨慎
+
${summary.rejectedCount || 0}
排除
+
${summary.total || 0}
候选总数
+
${summary.reportPath ? `报告:${escapeHtml(summary.reportPath)}` : ''}
+
+
${rows || '
暂无分析条目
'}
+ `; +} + +function renderLogs() { + const task = tasks.find(t => t.id === activeTaskId); + const panel = document.getElementById('log-panel'); + const logs = task?.logs || []; + + panel.innerHTML = logs.map(l => + `
${l.time}${escapeHtml(l.message)}
` + ).join(''); + panel.scrollTop = panel.scrollHeight; +} + +// ========== Tab Switching ========== +function switchTab(tab) { + document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${tab}`)); +} + + +// ========== Notification Settings ========== +async function showNotifyModal() { + document.getElementById('notify-modal-overlay').classList.add('show'); + await loadNotifyConfig(); +} + +function hideNotifyModal() { + document.getElementById('notify-modal-overlay').classList.remove('show'); +} + +async function loadNotifyConfig() { + const res = await fetch('/api/notify-config'); + notifyConfig = await res.json(); + document.getElementById('n-enabled').checked = notifyConfig.enabled !== false; + document.getElementById('n-good-only').checked = notifyConfig.notifyOnlyGood !== false; + document.getElementById('n-min-score').value = notifyConfig.minScore ?? 75; + renderNotifyChannels(); +} + +function renderNotifyChannels() { + const box = document.getElementById('notify-channels'); + const channels = notifyConfig?.channels || []; + const targetOptions = notifyConfig?.targetOptions || [ + { value: 'feishu', label: '飞书 Home' }, + { value: 'weixin', label: '微信 Home' }, + { value: 'qqbot', label: 'QQ 机器人 Home(如已配置)' }, + ]; + const renderTargetOptions = (target = '') => { + const normalized = target || 'feishu'; + const known = targetOptions.some(o => o.value === normalized); + const opts = targetOptions.map(o => ``).join(''); + return `${opts}${known ? '' : ``}`; + }; + box.innerHTML = channels.map((c, i) => { + const isHermes = (c.type || 'hermes-send-message') === 'hermes-send-message' || c.type === 'hermes-webhook'; + return ` +
+
+
+
+
+ +
+ ${isHermes ? ` +
+
+
+
+ + ` : ` +
+
+
+ +
+ `} +
`; + }).join('') || '
暂无通道,点击下方按钮添加。
'; +} + +function renderNotifyChannelsFromDom() { + notifyConfig = collectNotifyConfig(); + renderNotifyChannels(); +} + +function addNotifyChannel(type) { + if (!notifyConfig) notifyConfig = { enabled: true, notifyOnlyGood: true, minScore: 75, channels: [] }; + const normalizedType = type === 'hermes-webhook' ? 'hermes-send-message' : type; + const defaults = { + 'hermes-send-message': { name: 'Hermes 平台消息', target: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812', url: '' }, + bark: { name: 'Bark', url: 'https://api.day.app', token: '' }, + pushplus: { name: 'PushPlus', url: 'https://www.pushplus.plus/send', token: '' }, + webhook: { name: '自定义 Webhook', url: '' }, + }[normalizedType] || {}; + notifyConfig.channels.push({ id: `${normalizedType}-${Date.now()}`, type: normalizedType, enabled: true, ...defaults }); + renderNotifyChannels(); +} + +function removeNotifyChannel(i) { + notifyConfig.channels.splice(i, 1); + renderNotifyChannels(); +} + +function collectNotifyConfig() { + const channels = [...document.querySelectorAll('.notify-channel')].map(el => { + const selectedTarget = el.querySelector('.nc-target')?.value?.trim() || ''; + const customTarget = el.querySelector('.nc-custom-target')?.value?.trim() || ''; + return { + enabled: el.querySelector('.nc-enabled').checked, + type: el.querySelector('.nc-type').value, + name: el.querySelector('.nc-name').value.trim(), + url: el.querySelector('.nc-url')?.value?.trim() || '', + token: el.querySelector('.nc-token')?.value?.trim() || '', + target: customTarget || selectedTarget, + id: notifyConfig?.channels?.[Number(el.dataset.index)]?.id, + }; + }); + return { + enabled: document.getElementById('n-enabled').checked, + notifyOnlyGood: document.getElementById('n-good-only').checked, + minScore: Number(document.getElementById('n-min-score').value || 75), + channels, + }; +} + +async function saveNotifyConfigFromForm() { + const log = document.getElementById('notify-log'); + try { + notifyConfig = await apiPut('/api/notify-config', collectNotifyConfig()); + log.textContent = `保存成功:${new Date().toLocaleTimeString()}\n${log.textContent || ''}`; + renderNotifyChannels(); + } catch (err) { + log.textContent = `保存失败:${err.message}\n${log.textContent || ''}`; + } +} + +async function testNotifyConfigFromForm() { + const log = document.getElementById('notify-log'); + const payload = collectNotifyConfig(); + const enabled = (payload.channels || []).filter(c => c.enabled); + if (!enabled.length) { + log.textContent = `测试失败:请至少启用一个通知渠道\n${log.textContent || ''}`; + return; + } + log.textContent = `正在发送测试通知到 ${enabled.map(c => c.name || c.target || c.type).join('、')}...\n${log.textContent || ''}`; + try { + const result = await apiPost('/api/notify-test', { config: payload, title: 'xianyu-hunter Web 测试通知' }); + const detail = (result.results || []).map(r => `${r.ok ? '✅' : '❌'} ${r.name || r.id}${r.error ? `:${r.error}` : ''}`).join('\n') || (result.ok ? '✅ 已发送' : '❌ 发送失败'); + log.textContent = `测试完成:${new Date().toLocaleTimeString()}\n${detail}\n${log.textContent || ''}`; + } catch (err) { + log.textContent = `测试失败:${err.message}\n${log.textContent || ''}`; + } +} + +// ========== Modal ========== +function showCreateModal() { + if (!editingTaskId && !document.getElementById('f-queries').value) { + document.getElementById('task-modal-title').textContent = '新建采集任务'; + document.getElementById('save-run-btn').textContent = '保存并采集'; + document.getElementById('save-only-btn').textContent = '仅保存任务'; + } + document.getElementById('modal-overlay').classList.add('show'); +} + +function hideCreateModal() { + document.getElementById('modal-overlay').classList.remove('show'); +} + +function clearForm() { + ['f-name', 'f-queries', 'f-price-min', 'f-price-max', 'f-region', 'f-requirements'].forEach(id => { + document.getElementById(id).value = ''; + }); + document.getElementById('f-pages').value = '3'; + document.getElementById('f-max-items').value = '60'; + document.getElementById('f-schedule-enabled').checked = false; + document.getElementById('f-schedule-interval').value = '0'; + document.getElementById('f-personal').checked = false; + document.getElementById('task-modal-title').textContent = '新建采集任务'; + document.getElementById('save-run-btn').textContent = '保存并采集'; + document.getElementById('save-only-btn').textContent = '仅保存任务'; + const opt = document.getElementById('optimize-status'); if (opt) opt.textContent = ''; + document.getElementById('f-coarse-prompt').value = defaultCoarsePrompt; + document.getElementById('f-fine-prompt').value = defaultFinePrompt; +} + +function resetCoarsePrompt() { + document.getElementById('f-coarse-prompt').value = defaultCoarsePrompt; +} + +function resetFinePrompt() { + document.getElementById('f-fine-prompt').value = defaultFinePrompt; +} + +async function loadDefaults() { + try { + const res = await fetch('/api/defaults'); + const data = await res.json(); + defaultCoarsePrompt = data.coarsePrompt || ''; + defaultFinePrompt = data.finePrompt || ''; + document.getElementById('f-coarse-prompt').value = defaultCoarsePrompt; + document.getElementById('f-fine-prompt').value = defaultFinePrompt; + } catch { /* ignore */ } +} + +// ========== Utils ========== +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// ========== Init ========== +connectWS(); +loadDefaults(); diff --git a/project/xianyu-hunter/public/index.html b/project/xianyu-hunter/public/index.html new file mode 100644 index 0000000..e9e77c8 --- /dev/null +++ b/project/xianyu-hunter/public/index.html @@ -0,0 +1,354 @@ + + + + + + 闲鱼采集助手 + + + +
+ +
+
+ + +
+
+ + + + + + +
+
+ +
+ + + + +
+
+
+
+
Hermes 主控 · 闲鱼采集助手
+

把闲鱼搜索变成可复用的商品雷达

+

创建任务后自动采集、粗筛、细筛,再由 Hermes 做推荐/谨慎/排除分析。默认只分析,不联系卖家。

+
+ + + +
+
+
+ 🐟 + AI +
+
+ +
+ +
+
+
+
+

最近任务

+

快速回到已有任务,继续采集或查看分析。

+
+ +
+
+
+
+
+
+

推荐工作流

+

按这个顺序使用,页面不会空等。

+
+
+
+ + + + +
+
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/project/xianyu-hunter/public/style.css b/project/xianyu-hunter/public/style.css new file mode 100644 index 0000000..46954bb --- /dev/null +++ b/project/xianyu-hunter/public/style.css @@ -0,0 +1,1023 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + color-scheme: dark; + --bg: #080b14; + --bg2: rgba(18, 24, 38, .86); + --bg3: rgba(28, 37, 58, .9); + --bg4: rgba(41, 53, 82, .82); + --surface: rgba(18, 24, 38, .76); + --surface-strong: rgba(28, 37, 58, .92); + --border: rgba(148, 163, 184, .18); + --border-strong: rgba(129, 140, 248, .36); + --text: #eef4ff; + --text2: #94a3b8; + --muted: #64748b; + --accent: #7c3aed; + --accent2: #a78bfa; + --accent3: #22d3ee; + --green: #2dd4bf; + --orange: #fbbf24; + --red: #fb7185; + --blue: #60a5fa; + --shadow: 0 24px 80px rgba(0, 0, 0, .42); + --shadow-soft: 0 14px 38px rgba(2, 6, 23, .26); + --glow: 0 0 42px rgba(124, 58, 237, .22); + --radius: 14px; +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #f5f7fb; + --bg2: rgba(255, 255, 255, .86); + --bg3: rgba(248, 250, 252, .94); + --bg4: rgba(241, 245, 249, .96); + --surface: rgba(255, 255, 255, .78); + --surface-strong: rgba(255, 255, 255, .96); + --border: rgba(15, 23, 42, .1); + --border-strong: rgba(124, 58, 237, .24); + --text: #172033; + --text2: #64748b; + --muted: #94a3b8; + --accent: #6d28d9; + --accent2: #8b5cf6; + --accent3: #0891b2; + --green: #0f9f8f; + --orange: #d97706; + --red: #e11d48; + --blue: #2563eb; + --shadow: 0 22px 64px rgba(15, 23, 42, .13); + --shadow-soft: 0 12px 30px rgba(15, 23, 42, .08); + --glow: 0 0 34px rgba(124, 58, 237, .12); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: + radial-gradient(circle at 12% 8%, rgba(124, 58, 237, .28), transparent 28%), + radial-gradient(circle at 86% 0%, rgba(34, 211, 238, .16), transparent 30%), + radial-gradient(circle at 72% 92%, rgba(45, 212, 191, .12), transparent 32%), + var(--bg); + color: var(--text); + height: 100vh; + overflow: hidden; + transition: background .28s ease, color .2s ease; +} + +:root[data-theme="light"] body { + background: + radial-gradient(circle at 10% 8%, rgba(124, 58, 237, .12), transparent 30%), + radial-gradient(circle at 90% 0%, rgba(14, 165, 233, .12), transparent 32%), + linear-gradient(135deg, #f8fbff 0%, #eef2ff 46%, #f8fafc 100%); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); + background-size: 44px 44px; + mask-image: linear-gradient(to bottom, rgba(0,0,0,.72), transparent 70%); +} + +:root[data-theme="light"] body::before { + background-image: + linear-gradient(rgba(15,23,42,.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(15,23,42,.035) 1px, transparent 1px); +} + +#app { display: flex; flex-direction: column; height: 100vh; position: relative; } + +/* Header */ +.header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(22px) saturate(150%); + -webkit-backdrop-filter: blur(22px) saturate(150%); + flex-shrink: 0; + position: relative; z-index: 3; +} +.header::after { + content: ''; position: absolute; left: 24px; right: 24px; bottom: -1px; height: 1px; + background: linear-gradient(90deg, transparent, var(--accent2), var(--accent3), transparent); + opacity: .55; +} +.header-left { display: flex; align-items: center; gap: 12px; } +.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } +.header h1 { + font-size: 19px; font-weight: 800; letter-spacing: -.03em; + background: linear-gradient(135deg, var(--text), var(--accent2) 56%, var(--accent3)); + -webkit-background-clip: text; background-clip: text; color: transparent; +} + +.brand-button { + appearance: none; + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} +.brand-button:hover h1 { filter: drop-shadow(0 0 14px rgba(167,139,250,.34)); } +.brand-button:focus-visible { outline: 2px solid var(--accent3); outline-offset: 4px; border-radius: 10px; } +.btn-home-main, .btn-home { + border-color: var(--border-strong); + background: linear-gradient(135deg, rgba(34,211,238,.12), rgba(124,58,237,.10)); + color: var(--accent2); + font-weight: 700; +} +.btn-home-main:hover, .btn-home:hover { box-shadow: var(--glow); } + +.theme-toggle { + min-width: 116px; + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + border-color: var(--border-strong); + background: linear-gradient(135deg, rgba(124,58,237,.14), rgba(34,211,238,.08)); +} +.theme-toggle .theme-icon { font-size: 15px; } +.theme-toggle:hover { transform: translateY(-1px); box-shadow: var(--glow); } + +.model-selector { display: flex; align-items: center; gap: 8px; } +.model-label { font-size: 13px; color: var(--text2); white-space: nowrap; } +.model-selector select { + padding: 6px 12px; border-radius: 10px; + background: var(--bg3); border: 1px solid var(--border); + color: var(--text); font-size: 13px; + cursor: pointer; outline: none; + transition: border-color .2s, box-shadow .2s, background .2s; +} +.model-selector select:hover { border-color: var(--accent); } +.model-selector select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,58,237,.14); } +.status-dot { + width: 10px; height: 10px; border-radius: 50%; + background: var(--red); + box-shadow: 0 0 0 4px rgba(251,113,133,.12); + transition: background .3s, box-shadow .3s; +} +.status-dot.connected { background: var(--green); box-shadow: 0 0 0 4px rgba(45,212,191,.14), 0 0 18px rgba(45,212,191,.42); } + +/* Config Warning Banner */ +.config-banner { + display: flex; align-items: center; justify-content: center; gap: 16px; + padding: 10px 24px; + background: rgba(253, 203, 110, .1); + border-bottom: 1px solid rgba(253, 203, 110, .3); + color: var(--orange); + font-size: 14px; + flex-shrink: 0; +} + +/* Pulse animation for settings button */ +@keyframes pulse-glow { + 0%, 100% { box-shadow: none; } + 50% { box-shadow: 0 0 12px rgba(253, 203, 110, .5); } +} +.pulse { + animation: pulse-glow 2s infinite; + border-color: var(--orange) !important; + color: var(--orange) !important; +} + +/* Buttons */ +.btn { + padding: 8px 18px; border-radius: 999px; border: 1px solid var(--border); + background: var(--bg3); color: var(--text); cursor: pointer; + font-size: 14px; transition: transform .18s ease, border-color .2s, box-shadow .2s, background .2s; + box-shadow: 0 1px 0 rgba(255,255,255,.05) inset; +} +.btn:hover { border-color: var(--accent2); transform: translateY(-1px); box-shadow: var(--shadow-soft); } +.btn:active { transform: translateY(0); } +.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; } +.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); border-color: transparent; color: #fff; box-shadow: 0 12px 28px rgba(124,58,237,.24); } +.btn-primary:hover { background: linear-gradient(135deg, var(--accent2), var(--accent3)); } +.btn-danger { background: transparent; border-color: color-mix(in srgb, var(--red), transparent 35%); color: var(--red); } +.btn-danger:hover { background: var(--red); color: #fff; } +.btn-sm { padding: 5px 12px; font-size: 12px; } + +/* Layout */ +.main-layout { display: flex; flex: 1; overflow: hidden; padding: 14px; gap: 14px; } + +/* Sidebar */ +.sidebar { + width: 280px; flex-shrink: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 22px; + display: flex; flex-direction: column; + box-shadow: var(--shadow-soft); + backdrop-filter: blur(18px) saturate(145%); + overflow: hidden; +} +.sidebar-header { + padding: 14px 16px; font-size: 13px; font-weight: 600; + color: var(--text2); text-transform: uppercase; letter-spacing: 1px; +} +.task-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; } +.task-card { + padding: 13px 14px; margin-bottom: 9px; + background: var(--bg3); border-radius: 16px; + cursor: pointer; border: 1px solid transparent; + transition: transform .18s ease, border-color .2s, box-shadow .2s, background .2s; +} +.task-card:hover { border-color: var(--border-strong); transform: translateX(2px); box-shadow: var(--shadow-soft); } +.task-card.active { border-color: var(--accent2); background: linear-gradient(135deg, rgba(124,58,237,.18), rgba(34,211,238,.08)); box-shadow: var(--glow); } +.task-card-name { font-size: 14px; font-weight: 500; margin-bottom: 6px; } +.task-card-meta { display: flex; justify-content: space-between; align-items: center; } +.task-card-stage { font-size: 11px; } +.stage-badge { + display: inline-block; padding: 2px 8px; border-radius: 10px; + font-size: 11px; font-weight: 500; +} +.stage-pending { background: var(--bg); color: var(--text2); } +.stage-login { background: rgba(116,185,255,.15); color: var(--blue); } +.stage-searching { background: rgba(253,203,110,.15); color: var(--orange); } +.stage-coarse_filter { background: rgba(162,155,254,.15); color: var(--accent2); } +.stage-fine_filter { background: rgba(108,92,231,.2); color: var(--accent); } +.stage-completed { background: rgba(0,206,201,.15); color: var(--green); } +.stage-stopped { background: rgba(255,118,117,.15); color: var(--red); } +.stage-stopping { background: rgba(255,118,117,.1); color: var(--red); } + +/* Content */ +.content { + flex: 1; overflow: hidden; display: flex; flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 22px; + box-shadow: var(--shadow-soft); + backdrop-filter: blur(18px) saturate(145%); +} + +.empty-state { + flex: 1; display: flex; flex-direction: column; + justify-content: center; align-items: center; color: var(--text2); +} +.empty-icon { font-size: 48px; margin-bottom: 16px; } +.empty-state p { margin: 4px 0; } +.subtle { font-size: 13px; opacity: .6; } + +/* Task Detail */ +.task-detail { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +/* Pipeline */ +.pipeline { + display: flex; align-items: center; gap: 8px; + padding: 16px 24px; background: transparent; + border-bottom: 1px solid var(--border); flex-shrink: 0; + flex-wrap: wrap; +} +.pipeline-step { + display: flex; align-items: center; gap: 8px; + padding: 8px 16px; border-radius: 999px; + font-size: 13px; background: var(--bg3); color: var(--text2); + border: 1px solid var(--border); + transition: all .3s; +} +.pipeline-step.active { + background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #fff; font-weight: 700; + box-shadow: var(--glow); border-color: transparent; +} +.pipeline-step.done { background: rgba(0,206,201,.15); color: var(--green); } +.pipeline-arrow { color: var(--text2); font-size: 18px; } +.pipeline-controls { margin-left: auto; display: flex; gap: 6px; align-items: center; } + +/* Tabs */ +.tab-bar { + display: flex; gap: 8px; padding: 10px 24px 0; + background: transparent; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.tab { + padding: 10px 18px; border: 1px solid transparent; background: transparent; + color: var(--text2); cursor: pointer; font-size: 14px; + border-radius: 14px 14px 0 0; border-bottom: 2px solid transparent; transition: all .2s; +} +.tab:hover { color: var(--text); background: var(--bg3); } +.tab.active { color: var(--accent2); border-color: var(--border); border-bottom-color: var(--accent); background: var(--bg3); } +.tab-content { display: none; flex: 1; overflow: auto; padding: 16px 24px; } +.tab-content.active { display: block; } + +/* Product Stats */ +.product-stats { + display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; +} +.stat-card { + padding: 14px 20px; background: var(--bg3); border-radius: 18px; + border: 1px solid var(--border); min-width: 120px; + box-shadow: var(--shadow-soft); +} +.stat-value { font-size: 24px; font-weight: 700; } +.stat-label { font-size: 12px; color: var(--text2); margin-top: 2px; } + +/* Product Table */ +.product-table-wrap { overflow: auto; flex: 1; border: 1px solid var(--border); border-radius: 18px; background: var(--bg2); } +.product-table { + width: 100%; border-collapse: collapse; font-size: 13px; +} +.product-table th { + text-align: left; padding: 10px 12px; + background: var(--bg2); color: var(--text2); + font-weight: 500; position: sticky; top: 0; + border-bottom: 1px solid var(--border); +} +.product-table td { + padding: 10px 12px; border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.product-table tr:hover td { background: var(--bg3); } +.product-img { + width: 50px; height: 50px; object-fit: cover; border-radius: 6px; + background: var(--bg3); +} +.product-title { + max-width: 400px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} +.product-price { color: var(--orange); font-weight: 600; } +.badge-raw { background: var(--bg3); color: var(--text2); } +.badge-coarse { background: rgba(253,203,110,.15); color: var(--orange); } +.badge-fine { background: rgba(0,206,201,.15); color: var(--green); } +.badge-reject { background: rgba(255,118,117,.15); color: var(--red); } +.badge { + display: inline-block; padding: 2px 8px; border-radius: 10px; + font-size: 11px; +} + +/* Chat Sessions */ +.chat-session { + background: var(--bg2); border-radius: var(--radius); + border: 1px solid var(--border); margin-bottom: 12px; + overflow: hidden; +} +.chat-header { + padding: 12px 16px; display: flex; justify-content: space-between; + align-items: center; border-bottom: 1px solid var(--border); + cursor: pointer; +} +.chat-header:hover { background: var(--bg3); } +.chat-product-name { font-size: 14px; font-weight: 500; } +.chat-product-price { color: var(--orange); font-size: 13px; } +.chat-messages { + max-height: 360px; overflow-y: auto; padding: 12px 16px; +} +.chat-msg { + margin-bottom: 10px; display: flex; flex-direction: column; +} +.chat-msg.self { align-items: flex-end; } +.chat-msg.other { align-items: flex-start; } +.chat-bubble { + max-width: 70%; padding: 8px 14px; border-radius: 12px; + font-size: 13px; line-height: 1.5; +} +.chat-msg.self .chat-bubble { + background: var(--accent); color: #fff; + border-bottom-right-radius: 4px; +} +.chat-msg.other .chat-bubble { + background: var(--bg3); color: var(--text); + border-bottom-left-radius: 4px; +} +.chat-time { font-size: 10px; color: var(--text2); margin-top: 2px; } +.unread-badge { + background: var(--red); color: #fff; border-radius: 10px; + padding: 2px 7px; font-size: 11px; margin-left: 8px; +} + +/* Logs */ +.log-panel { + background: var(--bg2); border-radius: var(--radius); + border: 1px solid var(--border); padding: 12px; + height: 100%; overflow-y: auto; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 12px; +} +.log-entry { padding: 3px 0; color: var(--text2); line-height: 1.5; } +.log-time { color: var(--accent2); margin-right: 8px; } + +/* Modal */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.65); + display: none; justify-content: center; align-items: center; + z-index: 1000; +} +.modal-overlay.show { display: flex; } +.modal { + background: var(--surface-strong); border-radius: 24px; + width: min(760px, 92vw); max-height: 90vh; overflow-y: auto; + border: 1px solid var(--border); + box-shadow: var(--shadow); + backdrop-filter: blur(24px) saturate(160%); +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 20px 24px; border-bottom: 1px solid var(--border); +} +.modal-header h2 { font-size: 18px; } +.modal-close { + background: none; border: none; color: var(--text2); + font-size: 24px; cursor: pointer; line-height: 1; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 20px 24px; } +.modal-footer { + padding: 16px 24px; border-top: 1px solid var(--border); + display: flex; justify-content: flex-end; gap: 10px; +} + +/* Login Modal */ +.login-modal { width: 820px; } +.login-status-card { + padding: 14px 16px; + border: 1px solid var(--border); + background: var(--bg3); + border-radius: var(--radius); + margin-bottom: 14px; +} +.login-status-card.ok { border-color: rgba(0,206,201,.55); background: rgba(0,206,201,.08); } +.login-status-card.bad { border-color: rgba(253,203,110,.55); background: rgba(253,203,110,.07); } +.login-status-title { font-weight: 700; margin-bottom: 8px; } +.login-status-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 14px; + color: var(--text2); + font-size: 12px; + line-height: 1.5; + word-break: break-all; +} +.login-account-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0 12px; +} +.login-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--border); + background: var(--bg); +} +.login-account-name { font-size: 15px; font-weight: 700; } +.login-account-sub { font-size: 12px; color: var(--text2); margin-top: 3px; } +.login-qr-panel { + border: 1px solid var(--border); + background: var(--bg); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 14px; +} +.login-qr-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} +.login-qr-title { font-weight: 700; } +.login-qr-sub { color: var(--text2); font-size: 12px; margin-top: 4px; line-height: 1.5; } +.login-qr-wrap { + display: flex; + justify-content: center; + align-items: center; + max-height: 460px; + overflow: auto; + border-radius: var(--radius); + background: #fff; + padding: 10px; +} +.login-qr-img { + display: block; + max-width: 100%; + max-height: 440px; + object-fit: contain; +} +.login-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; } +.login-help { + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text2); + font-size: 13px; + line-height: 1.7; + margin-bottom: 14px; +} +.login-help code { + color: var(--accent2); + background: var(--bg3); + padding: 1px 5px; + border-radius: 4px; +} +.login-log { + min-height: 120px; + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: color-mix(in srgb, var(--bg), #000 18%); + color: var(--text2); + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 12px; + line-height: 1.5; +} + +/* Settings Modal */ +.settings-modal { width: 620px; } +.settings-desc { + font-size: 13px; color: var(--text2); margin-bottom: 20px; + line-height: 1.6; +} +.settings-test { + display: flex; align-items: center; gap: 12px; + padding: 16px 0 4px; + border-top: 1px solid var(--border); + margin-top: 8px; +} +.test-result { + font-size: 13px; color: var(--text2); + flex: 1; line-height: 1.5; +} +.test-result.success { color: var(--green); } +.test-result.error { color: var(--red); } + +.input-with-toggle { + display: flex; gap: 8px; align-items: stretch; +} +.input-with-toggle input { flex: 1; } +.toggle-pwd { + flex-shrink: 0; width: 40px; display: flex; + align-items: center; justify-content: center; + font-size: 16px; padding: 0; +} + +.custom-model-input { + display: flex; gap: 8px; +} +.custom-model-input input { flex: 1; } + +.custom-model-tags { + display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; +} +.model-tag { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 10px; border-radius: 6px; + background: rgba(108,92,231,.15); color: var(--accent2); + font-size: 12px; border: 1px solid rgba(108,92,231,.3); +} +.model-tag button { + background: none; border: none; color: var(--accent2); + cursor: pointer; font-size: 14px; line-height: 1; + padding: 0 2px; opacity: .7; +} +.model-tag button:hover { opacity: 1; color: var(--red); } + +/* Form */ +.form-group { margin-bottom: 16px; } +.form-group label { + display: block; font-size: 13px; font-weight: 500; + margin-bottom: 6px; color: var(--text2); +} +.hint { font-weight: 400; opacity: .6; } +.form-group input, .form-group textarea, .form-group select { + width: 100%; padding: 10px 14px; + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); font-size: 14px; + outline: none; transition: border-color .2s; +} +.form-group input:focus, .form-group textarea:focus, .form-group select:focus { + border-color: var(--accent); +} +.form-group textarea { resize: vertical; font-family: inherit; } +.form-group select { cursor: pointer; } +.form-row { display: flex; gap: 12px; } +.form-row .form-group { flex: 1; } +.checkbox-group { display: flex; align-items: flex-end; } +.checkbox-group label { + display: flex; align-items: center; gap: 6px; + cursor: pointer; font-size: 14px; color: var(--text); padding-bottom: 10px; +} + +/* Prompt Tags */ +.prompt-tag { + display: inline-block; padding: 1px 6px; border-radius: 4px; + font-size: 10px; font-weight: 500; + background: var(--bg3); color: var(--text2); opacity: .4; + border: 1px solid var(--border); +} +.prompt-tag.on { + opacity: 1; background: rgba(108,92,231,.15); + color: var(--accent2); border-color: var(--accent); +} +.chat-seller { + font-size: 12px; color: var(--text2); margin-left: 8px; +} +.chat-context { + padding: 8px 16px; background: var(--bg); + border-bottom: 1px solid var(--border); font-size: 12px; +} +.chat-context-label { + color: var(--text2); font-size: 11px; margin-bottom: 4px; + font-weight: 500; text-transform: uppercase; letter-spacing: .5px; +} +.chat-context-text { + color: var(--text); opacity: .7; line-height: 1.5; + max-height: 60px; overflow: hidden; +} + +/* Advanced Section (collapsible) */ +.advanced-section { + margin-top: 8px; border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; +} +.advanced-toggle { + padding: 10px 16px; font-size: 13px; font-weight: 500; + color: var(--text2); cursor: pointer; + background: var(--bg3); user-select: none; + list-style: none; display: flex; align-items: center; gap: 6px; +} +.advanced-toggle::before { + content: '▸'; display: inline-block; transition: transform .2s; + font-size: 11px; +} +.advanced-section[open] > .advanced-toggle::before { + transform: rotate(90deg); +} +.advanced-toggle:hover { color: var(--text); } +.advanced-section > .form-group { padding: 0 16px 16px; } +.persona-editor { + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 12px; line-height: 1.6; + min-height: 180px; resize: vertical; +} +.persona-actions { + display: flex; align-items: center; gap: 12px; margin-top: 8px; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text2); } + + +.task-optimize-card { + padding: 12px; + border: 1px solid rgba(108,92,231,.35); + background: rgba(108,92,231,.08); + border-radius: var(--radius); +} +.task-card-name { display: flex; align-items: center; gap: 6px; } + + +/* Analysis */ +.product-title a, .analysis-title a { color: var(--text); text-decoration: none; } +.product-title a:hover, .analysis-title a:hover { color: var(--accent2); text-decoration: underline; } +.product-reason { color: var(--text2); font-size: 11px; margin-top: 4px; white-space: normal; line-height: 1.4; } +.analysis-panel { display: flex; flex-direction: column; gap: 14px; } +.analysis-empty { min-height: 320px; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text2); text-align: center; gap: 6px; } +.analysis-summary { display: flex; gap: 14px; flex-wrap: wrap; align-items: stretch; } +.analysis-actions { display: flex; align-items: center; gap: 12px; padding: 12px 0; flex-wrap: wrap; } +.analysis-list { display: flex; flex-direction: column; gap: 10px; } +.analysis-item { border: 1px solid var(--border); background: var(--bg2); border-radius: var(--radius); padding: 12px 14px; } +.analysis-item.recommend { border-color: rgba(0,206,201,.38); background: rgba(0,206,201,.055); } +.analysis-item.caution { border-color: rgba(253,203,110,.32); } +.analysis-item.reject { opacity: .72; } +.analysis-item-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.analysis-score { font-size: 20px; font-weight: 800; color: var(--accent2); min-width: 34px; } +.analysis-title { font-weight: 650; line-height: 1.4; } +.analysis-meta, .analysis-reason, .analysis-flags { color: var(--text2); font-size: 12px; line-height: 1.55; margin-top: 3px; } +.analysis-flags { color: var(--orange); } +.notify-modal { width: 880px; } +.notify-help { padding: 10px 12px; border: 1px solid var(--border); background: var(--bg); color: var(--text2); border-radius: var(--radius); font-size: 13px; line-height: 1.6; margin-bottom: 14px; } +.notify-channel { padding: 14px; border: 1px solid var(--border); background: var(--bg); border-radius: var(--radius); margin-bottom: 12px; } + +/* Cache cleanup */ +.clear-cache-modal { width: 760px; } +.cache-warning { + padding: 12px 14px; + border: 1px solid rgba(253,203,110,.32); + background: rgba(253,203,110,.08); + color: var(--orange); + border-radius: var(--radius); + font-size: 13px; + line-height: 1.65; + margin-bottom: 16px; +} +.cache-preview { + display: flex; + flex-direction: column; + gap: 10px; + margin: 8px 0 14px; +} +.cache-preview-row { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + padding: 12px 14px; + border: 1px solid var(--border); + background: var(--bg3); + border-radius: var(--radius); +} +.cache-preview-empty { + padding: 14px; + border: 1px dashed var(--border); + border-radius: var(--radius); + color: var(--text2); + text-align: center; +} +.cache-counts { + color: var(--text2); + font-size: 12px; + white-space: nowrap; +} +.cache-counts b { color: var(--green); font-size: 14px; } + + +/* Home dashboard */ +.home-dashboard { + flex: 1; + overflow: auto; + padding: 22px; +} +.hero-card { + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1fr) 180px; + gap: 20px; + align-items: center; + padding: 28px; + border: 1px solid var(--border-strong); + border-radius: 26px; + background: + radial-gradient(circle at 82% 18%, rgba(34, 211, 238, .20), transparent 28%), + linear-gradient(135deg, rgba(124, 58, 237, .22), rgba(18, 24, 38, .52) 52%, rgba(45, 212, 191, .10)); + box-shadow: var(--shadow-soft), var(--glow); +} +:root[data-theme="light"] .hero-card { + background: + radial-gradient(circle at 82% 18%, rgba(14, 165, 233, .16), transparent 28%), + linear-gradient(135deg, rgba(124, 58, 237, .12), rgba(255,255,255,.82) 52%, rgba(45, 212, 191, .10)); +} +.hero-card::before { + content: ''; + position: absolute; + inset: -1px; + pointer-events: none; + background: linear-gradient(120deg, rgba(255,255,255,.16), transparent 34%, rgba(34,211,238,.14)); + opacity: .9; +} +.hero-card > * { position: relative; z-index: 1; } +.hero-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + margin-bottom: 12px; + border: 1px solid rgba(167,139,250,.34); + border-radius: 999px; + color: var(--accent2); + background: rgba(124,58,237,.12); + font-size: 12px; + font-weight: 700; + letter-spacing: .06em; +} +.hero-card h2 { + max-width: 760px; + font-size: clamp(28px, 4vw, 46px); + line-height: 1.08; + letter-spacing: -.05em; + margin-bottom: 12px; +} +.hero-card p { + max-width: 720px; + color: var(--text2); + font-size: 15px; + line-height: 1.75; +} +.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; } +.hero-orb { + width: 160px; + height: 160px; + margin-left: auto; + border-radius: 44px; + display: grid; + place-items: center; + position: relative; + background: + radial-gradient(circle at 35% 28%, rgba(255,255,255,.28), transparent 30%), + linear-gradient(145deg, rgba(124,58,237,.72), rgba(34,211,238,.42)); + border: 1px solid rgba(255,255,255,.20); + box-shadow: 0 22px 80px rgba(124,58,237,.28); + transform: rotate(-4deg); +} +.hero-orb span { font-size: 58px; filter: drop-shadow(0 8px 18px rgba(0,0,0,.28)); } +.hero-orb strong { + position: absolute; + right: 20px; + bottom: 18px; + font-size: 22px; + letter-spacing: -.08em; + color: rgba(255,255,255,.88); +} +.overview-grid { + display: grid; + grid-template-columns: repeat(6, minmax(130px, 1fr)); + gap: 12px; + margin: 16px 0; +} +.overview-card { + display: flex; + align-items: center; + gap: 11px; + min-height: 98px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 20px; + background: linear-gradient(145deg, var(--bg3), var(--surface)); + box-shadow: var(--shadow-soft); + color: var(--text); + text-align: left; + font: inherit; +} +.overview-card-clickable { + position: relative; + width: 100%; + cursor: pointer; + overflow: hidden; + transition: transform .18s ease, border-color .2s, box-shadow .2s, background .2s; +} +.overview-card-clickable::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(120deg, transparent, rgba(167,139,250,.14), transparent); + transform: translateX(-120%); + transition: transform .42s ease; +} +.overview-card-clickable:hover { + transform: translateY(-2px); + border-color: var(--border-strong); + box-shadow: var(--shadow-soft), var(--glow); +} +.overview-card-clickable:hover::after { transform: translateX(120%); } +.overview-card-clickable:focus-visible, +.workflow-step-clickable:focus-visible, +.home-task-item:focus-visible { + outline: 2px solid var(--accent3); + outline-offset: 3px; +} +.overview-card-clickable > * { position: relative; z-index: 1; } +.overview-action { + margin-top: 7px; + color: var(--accent2); + font-size: 11px; + font-weight: 750; + opacity: .86; +} +.overview-icon { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 15px; + background: rgba(124,58,237,.14); + border: 1px solid rgba(167,139,250,.20); + font-size: 20px; +} +.overview-value { font-size: 25px; font-weight: 850; letter-spacing: -.04em; } +.overview-label { font-size: 13px; font-weight: 700; margin-top: 1px; } +.overview-sub { color: var(--text2); font-size: 11px; line-height: 1.35; margin-top: 2px; } +.home-split { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, .75fr); + gap: 16px; +} +.home-panel { + padding: 16px; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--bg2); + box-shadow: var(--shadow-soft); +} +.home-panel-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 14px; +} +.home-panel h3 { font-size: 17px; margin-bottom: 5px; } +.home-panel p { color: var(--text2); font-size: 12px; line-height: 1.55; } +.home-task-list { display: flex; flex-direction: column; gap: 10px; } +.home-task-item { + width: 100%; + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + text-align: left; + padding: 13px 14px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--bg3); + color: var(--text); + cursor: pointer; + transition: transform .18s ease, border-color .2s, box-shadow .2s, background .2s; +} +.home-task-item:hover { transform: translateY(-1px); border-color: var(--border-strong); box-shadow: var(--shadow-soft); } +.home-task-item strong { display: block; font-size: 14px; margin-bottom: 4px; } +.home-task-item small { display: block; color: var(--text2); font-size: 11px; line-height: 1.45; } +.home-task-side { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; min-width: 88px; } +.home-empty-mini { + display: grid; + place-items: center; + gap: 8px; + min-height: 170px; + padding: 18px; + border: 1px dashed var(--border); + border-radius: 18px; + color: var(--text2); + text-align: center; +} +.home-empty-mini div { color: var(--text); font-weight: 800; } +.home-empty-mini small { max-width: 360px; line-height: 1.6; } +.workflow-list { display: flex; flex-direction: column; gap: 12px; } +.workflow-step { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--bg3); + color: var(--text); + text-align: left; + font: inherit; +} +.workflow-step-clickable { + cursor: pointer; + transition: transform .18s ease, border-color .2s, box-shadow .2s, background .2s; +} +.workflow-step-clickable:hover { + transform: translateX(3px); + border-color: var(--border-strong); + box-shadow: var(--shadow-soft); + background: linear-gradient(135deg, var(--bg3), rgba(124,58,237,.12)); +} +.workflow-step span { + width: 30px; + height: 30px; + flex: 0 0 auto; + display: grid; + place-items: center; + border-radius: 50%; + color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent3)); + font-weight: 850; + box-shadow: 0 10px 22px rgba(124,58,237,.22); +} +.workflow-step b { display: block; font-size: 13px; margin-bottom: 3px; } +.workflow-step small { color: var(--text2); font-size: 11px; line-height: 1.45; } +@media (max-width: 1180px) { + .overview-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); } + .hero-card { grid-template-columns: 1fr; } + .hero-orb { display: none; } + .home-split { grid-template-columns: 1fr; } +} +@media (max-width: 720px) { + .home-dashboard { padding: 12px; } + .hero-card { padding: 20px; } + .overview-grid { grid-template-columns: 1fr; } + .home-task-item { align-items: flex-start; flex-direction: column; } + .home-task-side { align-items: flex-start; flex-direction: row; flex-wrap: wrap; } +} + + +/* Responsive polish */ +@media (max-width: 900px) { + .header { align-items: flex-start; gap: 12px; } + .header-right { max-width: 58vw; } + .main-layout { padding: 10px; gap: 10px; } + .sidebar { width: 240px; } +} + +@media (max-width: 720px) { + body { overflow: auto; } + #app { min-height: 100vh; height: auto; } + .header, .main-layout, .form-row, .login-status-meta { flex-direction: column; display: flex; } + .header-right { max-width: none; width: 100%; justify-content: flex-start; } + .main-layout { overflow: visible; } + .sidebar, .content { width: 100%; } +} diff --git a/project/xianyu-hunter/server.mjs b/project/xianyu-hunter/server.mjs new file mode 100644 index 0000000..883a1b5 --- /dev/null +++ b/project/xianyu-hunter/server.mjs @@ -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 完成,无需配置外部模型接口`); +}); diff --git a/project/xianyu-hunter/start.bat b/project/xianyu-hunter/start.bat new file mode 100644 index 0000000..87bfbae --- /dev/null +++ b/project/xianyu-hunter/start.bat @@ -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 diff --git a/project/xianyu-hunter/tools/create_monitor_task.mjs b/project/xianyu-hunter/tools/create_monitor_task.mjs new file mode 100644 index 0000000..e64eca2 --- /dev/null +++ b/project/xianyu-hunter/tools/create_monitor_task.mjs @@ -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 --queries [--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); +} diff --git a/project/xianyu-hunter/tools/cron_prompt_template.md b/project/xianyu-hunter/tools/cron_prompt_template.md new file mode 100644 index 0000000..07c765d --- /dev/null +++ b/project/xianyu-hunter/tools/cron_prompt_template.md @@ -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/-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 +``` diff --git a/project/xianyu-hunter/tools/hermes_report.mjs b/project/xianyu-hunter/tools/hermes_report.mjs new file mode 100644 index 0000000..f09a9d4 --- /dev/null +++ b/project/xianyu-hunter/tools/hermes_report.mjs @@ -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); diff --git a/project/xianyu-hunter/tools/xianyu_goofish_login_entry_tmp.mjs b/project/xianyu-hunter/tools/xianyu_goofish_login_entry_tmp.mjs new file mode 100644 index 0000000..4f2edb9 --- /dev/null +++ b/project/xianyu-hunter/tools/xianyu_goofish_login_entry_tmp.mjs @@ -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(()=>{}); diff --git a/project/xianyu-hunter/tools/xianyu_ops.mjs b/project/xianyu-hunter/tools/xianyu_ops.mjs new file mode 100644 index 0000000..ce71d3d --- /dev/null +++ b/project/xianyu-hunter/tools/xianyu_ops.mjs @@ -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 --queries [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求> + node tools/xianyu_ops.mjs run --name --queries [--price-min N] [--price-max N] [--region 地区] [--pages N] --requirements <需求> [--limit 30] + node tools/xianyu_ops.mjs report [--task ] [--limit 30] + +Notes: + - Web UI is optional/backup: http://127.0.0.1: + - 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); +} diff --git a/project/xianyu-hunter/tools/xianyu_qr_login_tmp.mjs b/project/xianyu-hunter/tools/xianyu_qr_login_tmp.mjs new file mode 100644 index 0000000..30309ab --- /dev/null +++ b/project/xianyu-hunter/tools/xianyu_qr_login_tmp.mjs @@ -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(() => {}); diff --git a/releases/SHA256SUMS.txt b/releases/SHA256SUMS.txt new file mode 100755 index 0000000..8944b69 --- /dev/null +++ b/releases/SHA256SUMS.txt @@ -0,0 +1,2 @@ +99b481d82309ef94dd2ad457dbe6ba189d2747d308d2b00bea4abd03ed8a3752 xianyu-hunter-hermes-skill.zip +16013f7bde07a95438471b17d40f1da45310298457b0520cfa85858c8fa2a75c xianyu-hunter-hermes-skill.tar.gz diff --git a/releases/xianyu-hunter-hermes-skill.tar.gz b/releases/xianyu-hunter-hermes-skill.tar.gz new file mode 100644 index 0000000..6c03b87 Binary files /dev/null and b/releases/xianyu-hunter-hermes-skill.tar.gz differ diff --git a/releases/xianyu-hunter-hermes-skill.zip b/releases/xianyu-hunter-hermes-skill.zip new file mode 100644 index 0000000..d2a05e2 Binary files /dev/null and b/releases/xianyu-hunter-hermes-skill.zip differ diff --git a/skill/xianyu-hunter-monitor/SKILL.md b/skill/xianyu-hunter-monitor/SKILL.md new file mode 100644 index 0000000..397370e --- /dev/null +++ b/skill/xianyu-hunter-monitor/SKILL.md @@ -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 --limit 30 +``` + +Or call the report script directly: + +```bash +cd /Users/chick/.Hermes/workspace/research/xianyu-hunter +node tools/hermes_report.mjs --task --limit 30 +# or omit --task to use the latest task +node tools/hermes_report.mjs --limit 30 +``` + +The report is written to: + +```text +reports/-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/ | 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/-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. diff --git a/skill/xianyu-hunter-monitor/references/cookie-json-import-timeout-2026-05.md b/skill/xianyu-hunter-monitor/references/cookie-json-import-timeout-2026-05.md new file mode 100644 index 0000000..7cc1bc1 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/cookie-json-import-timeout-2026-05.md @@ -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 +``` diff --git a/skill/xianyu-hunter-monitor/references/hermes-main-control-refactor-2026-05.md b/skill/xianyu-hunter-monitor/references/hermes-main-control-refactor-2026-05.md new file mode 100644 index 0000000..1e69bd0 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/hermes-main-control-refactor-2026-05.md @@ -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 +``` + +命令: + +```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 --limit 30 +``` + +`run` 会:检查/启动服务 → 创建任务 → 等待完成 → 生成报告。 + +## 报告与定时 + +报告脚本: + +```bash +node tools/hermes_report.mjs --task --limit 30 +``` + +报告输出: + +```text +reports/-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 除非需要查看或排错。 diff --git a/skill/xianyu-hunter-monitor/references/hermes-send-message-notifications-2026-05.md b/skill/xianyu-hunter-monitor/references/hermes-send-message-notifications-2026-05.md new file mode 100644 index 0000000..fc1acf3 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/hermes-send-message-notifications-2026-05.md @@ -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 +``` diff --git a/skill/xianyu-hunter-monitor/references/login-ux-locks-and-partial-results-2026-05.md b/skill/xianyu-hunter-monitor/references/login-ux-locks-and-partial-results-2026-05.md new file mode 100644 index 0000000..57358da --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/login-ux-locks-and-partial-results-2026-05.md @@ -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')==''][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", "不能进系统", "回收", "主板套装"). diff --git a/skill/xianyu-hunter-monitor/references/qr-login-screenshot-2026-05.md b/skill/xianyu-hunter-monitor/references/qr-login-screenshot-2026-05.md new file mode 100644 index 0000000..4037cfa --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/qr-login-screenshot-2026-05.md @@ -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. diff --git a/skill/xianyu-hunter-monitor/references/web-analysis-notifications-2026-05.md b/skill/xianyu-hunter-monitor/references/web-analysis-notifications-2026-05.md new file mode 100644 index 0000000..607bb54 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-analysis-notifications-2026-05.md @@ -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/-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/` 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. diff --git a/skill/xianyu-hunter-monitor/references/web-cache-cleanup-2026-05.md b/skill/xianyu-hunter-monitor/references/web-cache-cleanup-2026-05.md new file mode 100644 index 0000000..3c2b1b7 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-cache-cleanup-2026-05.md @@ -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//clear-cache` with `{ "mode": "all" }`. +4. Verify response `ok: true` and all counts zero. +5. Delete smoke task. diff --git a/skill/xianyu-hunter-monitor/references/web-home-clickable-dashboard-cards-2026-05.md b/skill/xianyu-hunter-monitor/references/web-home-clickable-dashboard-cards-2026-05.md new file mode 100644 index 0000000..0a4566b --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-home-clickable-dashboard-cards-2026-05.md @@ -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 ` +``` + +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 +sleep 2 +kill -KILL 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. diff --git a/skill/xianyu-hunter-monitor/references/web-login-simplified-buttons-2026-05.md b/skill/xianyu-hunter-monitor/references/web-login-simplified-buttons-2026-05.md new file mode 100644 index 0000000..711cdd0 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-login-simplified-buttons-2026-05.md @@ -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 + +``` + +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. diff --git a/skill/xianyu-hunter-monitor/references/web-qr-login-account-panel-2026-05.md b/skill/xianyu-hunter-monitor/references/web-qr-login-account-panel-2026-05.md new file mode 100644 index 0000000..d4df969 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-qr-login-account-panel-2026-05.md @@ -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. diff --git a/skill/xianyu-hunter-monitor/references/web-task-editing-scheduling-ai-optimize-2026-05.md b/skill/xianyu-hunter-monitor/references/web-task-editing-scheduling-ai-optimize-2026-05.md new file mode 100644 index 0000000..1cc9d18 --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-task-editing-scheduling-ai-optimize-2026-05.md @@ -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 -o pid,ppid,command +kill -TERM +``` + +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. diff --git a/skill/xianyu-hunter-monitor/references/web-theme-toggle-visual-polish-2026-05.md b/skill/xianyu-hunter-monitor/references/web-theme-toggle-visual-polish-2026-05.md new file mode 100644 index 0000000..4a45a9e --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/web-theme-toggle-visual-polish-2026-05.md @@ -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.