Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed69e839ab | |||
| 4fe9a73ebc | |||
| a1dd817886 | |||
| 20957cbc7b | |||
| ab58aa4098 | |||
| 76b5d53bb5 | |||
| 00f6062e74 | |||
| 12176de1a5 | |||
| 8bd36aaa37 | |||
| d2051cdab7 | |||
| cfb915b1d7 | |||
| 4e2b314930 | |||
| a0a4dd68ef | |||
| 97ae87ccee | |||
| 174ef75a54 | |||
| 639a31fc84 | |||
| 80eef46b4e | |||
| 60bee91650 | |||
| cf9e9f0137 | |||
| b2f1f58a1b | |||
| 81308a474e | |||
| d818b5bed8 | |||
| e82649379b | |||
| e0a66af0f0 | |||
| c86e677b78 | |||
| 6364934423 | |||
| ef78aaf3a2 | |||
| 292b12d9b1 | |||
| 2c5066d1f4 | |||
| 8eefa3b1c9 | |||
| 052bf8bbf7 | |||
| c3c3abbbff | |||
| 81de69be3a | |||
| a39a858f0a | |||
| 855eaee04e | |||
| 1bcd96f38a | |||
| c2ac5525b3 | |||
| 748b09261d | |||
| 4b1153babe | |||
| 7aafd4af59 | |||
| a5abd3769f | |||
| 8070960444 | |||
| b1c0bcb464 | |||
| e18e0ed7a4 | |||
| c161f0f9f0 | |||
| dcad060230 | |||
| ff84d19ded | |||
| f594e500a8 | |||
| f2a3ee6ee4 | |||
| f377ec000c | |||
| 988908f348 | |||
| 2b623b35b6 | |||
| 6cdcb9dd51 | |||
| 835c146fb7 | |||
| fc818b3c2c | |||
| 0ce46b15bb | |||
| 37f1b46a77 | |||
| 2d005d14a8 | |||
| 1708626731 | |||
| 5fe081b28c | |||
| 0c75ab3f7e | |||
| 017cbc5692 | |||
| cd5da59187 | |||
| d7d5211fde | |||
| de817730ca | |||
| 9cdbcd3066 | |||
| b113885dde | |||
| ef449058aa | |||
| 706e01dbca | |||
| ba67a3e086 |
+1
-1
@@ -23,4 +23,4 @@ docs/.vitepress/cache
|
||||
# Database files
|
||||
*.db
|
||||
autoresearch/results/
|
||||
extension/dist/
|
||||
autoresearch-results.tsv
|
||||
|
||||
-724
@@ -1,724 +0,0 @@
|
||||
# CLI-EXPLORER — 适配器探索式开发完全指南
|
||||
|
||||
> 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
|
||||
> 从零到发布,覆盖 API 发现、方案选择、适配器编写、测试验证全流程。
|
||||
|
||||
> [!TIP]
|
||||
> **只想为一个具体页面快速生成一个命令?** 看 [CLI-ONESHOT.md](./CLI-ONESHOT.md)(~150 行,4 步搞定)。
|
||||
> 本文档适合从零探索一个新站点的完整流程。
|
||||
|
||||
---
|
||||
|
||||
## AI Agent 开发者必读:用浏览器探索
|
||||
|
||||
> [!CAUTION]
|
||||
> **你(AI Agent)必须通过浏览器打开目标网站去探索!**
|
||||
> 不要只靠 `opencli explore` 命令或静态分析来发现 API。
|
||||
> 你拥有浏览器工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
|
||||
|
||||
### 为什么?
|
||||
|
||||
很多 API 是**懒加载**的(用户必须点击某个按钮/标签才会触发网络请求)。字幕、评论、关注列表等深层数据不会在页面首次加载时出现在 Network 面板中。**如果你不主动去浏览和交互页面,你永远发现不了这些 API。**
|
||||
|
||||
### AI Agent 探索工作流(必须遵循)
|
||||
|
||||
| 步骤 | 工具 | 做什么 |
|
||||
|------|------|--------|
|
||||
| 0. 打开浏览器 | `browser_navigate` | 导航到目标页面 |
|
||||
| 1. 观察页面 | `browser_snapshot` | 观察可交互元素(按钮/标签/链接) |
|
||||
| 2. 首次抓包 | `browser_network_requests` | 筛选 JSON API 端点,记录 URL pattern |
|
||||
| 3. 模拟交互 | `browser_click` + `browser_wait_for` | 点击"字幕""评论""关注"等按钮 |
|
||||
| 4. 二次抓包 | `browser_network_requests` | 对比步骤 2,找出新触发的 API |
|
||||
| 5. 验证 API | `browser_evaluate` | `fetch(url, {credentials:'include'})` 测试返回结构 |
|
||||
| 6. 写代码 | — | 基于确认的 API 写适配器 |
|
||||
|
||||
### 常犯错误
|
||||
|
||||
| ❌ 错误做法 | ✅ 正确做法 |
|
||||
|------------|------------|
|
||||
| 只用 `opencli explore` 命令,等结果自动出来 | 用浏览器工具打开页面,主动浏览 |
|
||||
| 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
|
||||
| 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
|
||||
| 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
|
||||
| 完全依赖 `__INITIAL_STATE__` 拿所有数据 | `__INITIAL_STATE__` 只有首屏数据,深层数据要调 API |
|
||||
|
||||
### 实战成功案例:5 分钟实现「关注列表」适配器
|
||||
|
||||
以下是用上述工作流实际发现 Bilibili 关注列表 API 的完整过程:
|
||||
|
||||
```
|
||||
1. browser_navigate → https://space.bilibili.com/{uid}/fans/follow
|
||||
2. browser_network_requests → 发现:
|
||||
GET /x/relation/followings?vmid={uid}&pn=1&ps=24 → [200]
|
||||
GET /x/relation/stat?vmid={uid} → [200]
|
||||
3. browser_evaluate → 验证 API:
|
||||
fetch('/x/relation/followings?vmid=137702077&pn=1&ps=5', {credentials:'include'})
|
||||
→ { code: 0, data: { total: 1342, list: [{mid, uname, sign, ...}] } }
|
||||
4. 结论:标准 Cookie API,无需 Wbi 签名
|
||||
5. 写 following.ts → 一次构建通过
|
||||
```
|
||||
|
||||
**关键决策点**:
|
||||
- 直接访问 `fans/follow` 页面(不是首页),页面加载就会触发 following API
|
||||
- 看到 URL 里没有 `/wbi/` → 不需要签名 → 直接用 `fetchJson` 而非 `apiGet`
|
||||
- API 返回 `code: 0` + 非空 `list` → Tier 2 Cookie 策略确认
|
||||
|
||||
---
|
||||
|
||||
## 核心流程
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────┐
|
||||
│ 1. 发现 API │ ──▶ │ 2. 选择策略 │ ──▶ │ 3. 写适配器 │ ──▶ │ 4. 测试 │
|
||||
└─────────────┘ └─────────────┘ └──────────────┘ └────────┘
|
||||
explore cascade YAML / TS run + verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: 发现 API
|
||||
|
||||
### 1a. 自动化发现(推荐)
|
||||
|
||||
OpenCLI 内置 Deep Explore,自动分析网站网络请求:
|
||||
|
||||
```bash
|
||||
opencli explore https://www.example.com --site mysite
|
||||
```
|
||||
|
||||
输出到 `.opencli/explore/mysite/`:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `manifest.json` | 站点元数据、框架检测(Vue2/3、React、Next.js、Pinia、Vuex) |
|
||||
| `endpoints.json` | 已发现的 API 端点,按评分排序,含 URL pattern、方法、响应类型 |
|
||||
| `capabilities.json` | 推理出的功能(`hot`、`search`、`feed`…),含置信度和推荐参数 |
|
||||
| `auth.json` | 认证方式检测(Cookie/Header/无认证),策略候选列表 |
|
||||
|
||||
### 1b. 手动抓包验证
|
||||
|
||||
Explore 的自动分析可能不完美,用 verbose 模式手动确认:
|
||||
|
||||
```bash
|
||||
# 在浏览器中打开目标页面,观察网络请求
|
||||
opencli explore https://www.example.com --site mysite -v
|
||||
|
||||
# 或直接用 evaluate 测试 API
|
||||
opencli bilibili hot -v # 查看已有命令的 pipeline 每步数据流
|
||||
```
|
||||
|
||||
关注抓包结果中的关键信息:
|
||||
- **URL pattern**: `/api/v2/hot?limit=20` → 这就是你要调用的端点
|
||||
- **Method**: `GET` / `POST`
|
||||
- **Request Headers**: Cookie? Bearer? 自定义签名头(X-s、X-t)?
|
||||
- **Response Body**: JSON 结构,特别是数据在哪个路径(`data.items`、`data.list`)
|
||||
|
||||
### 1c. 高阶 API 发现捷径法则 (Heuristics)
|
||||
|
||||
在开始死磕复杂的抓包拦截之前,按照以下优先级进行尝试:
|
||||
|
||||
1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。另外如功能完备的**雪球 (xueqiu)** 也可以走这种纯 API 的方式极简获取,成为你构建简单 YAML 的黄金标杆。
|
||||
2. **全局状态查找法 (`__INITIAL_STATE__`)**: 许多服务端渲染 (SSR) 的网站(如小红书、Bilibili)会将首页或详情页的完整数据挂载到全局 window 对象上。与其去拦截网络请求,不如直接 `page.evaluate('() => window.__INITIAL_STATE__')` 获取整个数据树。
|
||||
3. **主动交互触发法 (Active Interaction)**: 很多深层 API(如视频字幕、评论下的回复)是懒加载的。在静态抓包找不到数据时,尝试在 `evaluate` 步骤或手动打断点时,主动去**点击(Click)页面上的对应按钮**(如"CC"、"展开全部"),从而诱发隐藏的 Network Fetch。
|
||||
4. **框架探测与 Store Action 截断**: 如果站点使用 Vue + Pinia,可以使用 `tap` 步骤调用 action,让前端框架代替你完成复杂的鉴权签名封装。
|
||||
5. **底层 XHR/Fetch 拦截**: 最后手段,当上述都不行时,使用 TypeScript 适配器进行无侵入式的请求抓取。
|
||||
|
||||
### 1d. 框架检测
|
||||
|
||||
Explore 自动检测前端框架。如果需要手动确认:
|
||||
|
||||
```bash
|
||||
# 在已打开目标网站的情况下
|
||||
opencli evaluate "(()=>{
|
||||
const vue3 = !!document.querySelector('#app')?.__vue_app__;
|
||||
const vue2 = !!document.querySelector('#app')?.__vue__;
|
||||
const react = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
const pinia = vue3 && !!document.querySelector('#app').__vue_app__.config.globalProperties.\$pinia;
|
||||
return JSON.stringify({vue3, vue2, react, pinia});
|
||||
})()"
|
||||
```
|
||||
|
||||
Vue + Pinia 的站点(如小红书)可以直接通过 Store Action 绕过签名。
|
||||
|
||||
---
|
||||
|
||||
## Step 2: 选择认证策略
|
||||
|
||||
OpenCLI 提供 5 级认证策略。使用 `cascade` 命令自动探测:
|
||||
|
||||
```bash
|
||||
opencli cascade https://api.example.com/hot
|
||||
```
|
||||
|
||||
### 策略决策树
|
||||
|
||||
```
|
||||
直接 fetch(url) 能拿到数据?
|
||||
→ ✅ Tier 1: public(公开 API,不需要浏览器)
|
||||
→ ❌ fetch(url, {credentials:'include'}) 带 Cookie 能拿到?
|
||||
→ ✅ Tier 2: cookie(最常见,evaluate 步骤内 fetch)
|
||||
→ ❌ → 加上 Bearer / CSRF header 后能拿到?
|
||||
→ ✅ Tier 3: header(如 Twitter ct0 + Bearer)
|
||||
→ ❌ → 网站有 Pinia/Vuex Store?
|
||||
→ ✅ Tier 4: intercept(Store Action + XHR 拦截)
|
||||
→ ❌ Tier 5: ui(UI 自动化,最后手段)
|
||||
```
|
||||
|
||||
### 各策略对比
|
||||
|
||||
| Tier | 策略 | 速度 | 复杂度 | 适用场景 | 实例 |
|
||||
|------|------|------|--------|---------|------|
|
||||
| 1 | `public` | ⚡ ~1s | 最简 | 公开 API,无需登录 | Hacker News, V2EX |
|
||||
| 2 | `cookie` | 🔄 ~7s | 简单 | Cookie 认证即可 | Bilibili, Zhihu, Reddit |
|
||||
| 3 | `header` | 🔄 ~7s | 中等 | 需要 CSRF token 或 Bearer | Twitter GraphQL |
|
||||
| 4 | `intercept` | 🔄 ~10s | 较高 | 请求有复杂签名 | 小红书 (Pinia + XHR) |
|
||||
| 5 | `ui` | 🐌 ~15s+ | 最高 | 无 API,纯 DOM 解析 | 遗留网站 |
|
||||
|
||||
---
|
||||
|
||||
## Step 2.5: 准备工作(写代码之前)
|
||||
|
||||
### 先找模板:从最相似的现有适配器开始
|
||||
|
||||
**不要从零开始写**。先看看同站点已有哪些适配器:
|
||||
|
||||
```bash
|
||||
ls src/clis/<site>/ # 看看已有什么
|
||||
cat src/clis/<site>/feed.ts # 读最相似的那个
|
||||
```
|
||||
|
||||
最高效的方式是 **复制最相似的适配器,然后改 3 个地方**:
|
||||
1. `name` → 新命令名
|
||||
2. API URL → 你在 Step 1 发现的端点
|
||||
3. 字段映射 → 对应新 API 的字段
|
||||
|
||||
### 平台 SDK 速查表
|
||||
|
||||
写 TS 适配器之前,先看看你的目标站点有没有**现成的 helper 函数**可以复用:
|
||||
|
||||
#### Bilibili (`src/clis/bilibili/utils.ts`)
|
||||
|
||||
| 函数 | 用途 | 何时使用 |
|
||||
|------|------|----------|
|
||||
| `fetchJson(page, url)` | 带 Cookie 的 fetch + JSON 解析 | 普通 Cookie-tier API |
|
||||
| `apiGet(page, path, {signed, params})` | 带 Wbi 签名的 API 调用 | URL 含 `/wbi/` 的接口 |
|
||||
| `getSelfUid(page)` | 获取当前登录用户的 UID | "我的xxx" 类命令 |
|
||||
| `resolveUid(page, input)` | 解析用户输入的 UID(支持数字/URL) | `--uid` 参数处理 |
|
||||
| `wbiSign(page, params)` | 底层 Wbi 签名生成 | 通常不直接用,`apiGet` 已封装 |
|
||||
| `stripHtml(s)` | 去除 HTML 标签 | 清理富文本字段 |
|
||||
|
||||
**如何判断需不需要 `apiGet`**?看 Network 请求 URL:
|
||||
- 含 `/wbi/` 或 `w_rid=` → 必须用 `apiGet(..., { signed: true })`
|
||||
- 不含 → 直接用 `fetchJson`
|
||||
|
||||
> 其他站点(Twitter、小红书等)暂无专用 SDK,直接用 `page.evaluate` + `fetch` 即可。
|
||||
|
||||
---
|
||||
|
||||
## Step 3: 编写适配器
|
||||
|
||||
### YAML vs TS?先看决策树
|
||||
|
||||
```
|
||||
你的 pipeline 里有 evaluate 步骤(内嵌 JS 代码)?
|
||||
→ ✅ 用 TypeScript (src/clis/<site>/<name>.ts),保存即自动动态注册
|
||||
→ ❌ 纯声明式(navigate + tap + map + limit)?
|
||||
→ ✅ 用 YAML (src/clis/<site>/<name>.yaml),保存即自动注册
|
||||
```
|
||||
|
||||
| 场景 | 选择 | 示例 |
|
||||
|------|------|------|
|
||||
| 纯 fetch/select/map/limit | YAML | `v2ex/hot.yaml`, `hackernews/top.yaml` |
|
||||
| navigate + evaluate(fetch) + map | YAML(评估复杂度) | `zhihu/hot.yaml` |
|
||||
| navigate + tap + map | YAML ✅ | `xiaohongshu/feed.yaml`, `xiaohongshu/notifications.yaml` |
|
||||
| 有复杂 JS 逻辑(Pinia state 读取、条件分支) | TS | `xiaohongshu/me.ts`, `bilibili/me.ts` |
|
||||
| XHR 拦截 + 签名 | TS | `xiaohongshu/search.ts` |
|
||||
| GraphQL / 分页 / Wbi 签名 | TS | `bilibili/search.ts`, `twitter/search.ts` |
|
||||
|
||||
> **经验法则**:如果你发现 YAML 里嵌了超过 10 行 JS,改用 TS 更可维护。
|
||||
|
||||
### 通用模式:分页 API
|
||||
|
||||
很多 API 使用 `pn`(页码)+ `ps`(每页数量)分页。标准处理模式:
|
||||
|
||||
```typescript
|
||||
args: [
|
||||
{ name: 'page', type: 'int', required: false, default: 1, help: '页码' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 50, help: '每页数量 (最大 50)' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const pn = kwargs.page ?? 1;
|
||||
const ps = Math.min(kwargs.limit ?? 50, 50); // 尊重 API 的 ps 上限
|
||||
const payload = await fetchJson(page,
|
||||
`https://api.example.com/list?pn=${pn}&ps=${ps}`
|
||||
);
|
||||
return payload.data?.list || [];
|
||||
},
|
||||
```
|
||||
|
||||
> 大多数站点的 `ps` 上限是 20~50。超过会被静默截断或返回错误。
|
||||
|
||||
### 方式 A: YAML Pipeline(声明式,推荐)
|
||||
|
||||
文件路径: `src/clis/<site>/<name>.yaml`,放入即自动注册。
|
||||
|
||||
#### Tier 1 — 公开 API 模板
|
||||
|
||||
```yaml
|
||||
# src/clis/v2ex/hot.yaml
|
||||
site: v2ex
|
||||
name: hot
|
||||
description: V2EX 热门话题
|
||||
domain: www.v2ex.com
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: https://www.v2ex.com/api/topics/hot.json
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
replies: ${{ item.replies }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, replies]
|
||||
```
|
||||
|
||||
#### Tier 2 — Cookie 认证模板(最常用)
|
||||
|
||||
```yaml
|
||||
# src/clis/zhihu/hot.yaml
|
||||
site: zhihu
|
||||
name: hot
|
||||
description: 知乎热榜
|
||||
domain: www.zhihu.com
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.zhihu.com # 先加载页面建立 session
|
||||
|
||||
- evaluate: | # 在浏览器内发请求,自动带 Cookie
|
||||
(async () => {
|
||||
const res = await fetch('/api/v3/feed/topstory/hot-lists/total?limit=50', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data || []).map(item => {
|
||||
const t = item.target || {};
|
||||
return {
|
||||
title: t.title,
|
||||
heat: item.detail_text || '',
|
||||
answers: t.answer_count,
|
||||
};
|
||||
});
|
||||
})()
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
heat: ${{ item.heat }}
|
||||
answers: ${{ item.answers }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, heat, answers]
|
||||
```
|
||||
|
||||
> **关键**: `evaluate` 步骤内的 `fetch` 运行在浏览器页面内,自动携带 `credentials: 'include'`,无需手动处理 Cookie。
|
||||
|
||||
#### 进阶 — 带搜索参数
|
||||
|
||||
```yaml
|
||||
# src/clis/zhihu/search.yaml
|
||||
site: zhihu
|
||||
name: search
|
||||
description: 知乎搜索
|
||||
|
||||
args:
|
||||
query:
|
||||
type: str
|
||||
required: true
|
||||
positional: true
|
||||
description: Search query
|
||||
limit:
|
||||
type: int
|
||||
default: 10
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.zhihu.com
|
||||
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const q = encodeURIComponent('${{ args.query }}');
|
||||
const res = await fetch('/api/v4/search_v3?q=' + q + '&t=general&limit=${{ args.limit }}', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data || [])
|
||||
.filter(item => item.type === 'search_result')
|
||||
.map(item => ({
|
||||
title: (item.object?.title || '').replace(/<[^>]+>/g, ''),
|
||||
type: item.object?.type || '',
|
||||
author: item.object?.author?.name || '',
|
||||
votes: item.object?.voteup_count || 0,
|
||||
}));
|
||||
})()
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
type: ${{ item.type }}
|
||||
author: ${{ item.author }}
|
||||
votes: ${{ item.votes }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, type, author, votes]
|
||||
```
|
||||
|
||||
#### Tier 4 — Store Action Bridge(`tap` 步骤,intercept 策略推荐)
|
||||
|
||||
适用于 Vue + Pinia/Vuex 的网站(如小红书),无须手动写 XHR 拦截代码:
|
||||
|
||||
```yaml
|
||||
# src/clis/xiaohongshu/notifications.yaml
|
||||
site: xiaohongshu
|
||||
name: notifications
|
||||
description: "小红书通知"
|
||||
domain: www.xiaohongshu.com
|
||||
strategy: intercept
|
||||
browser: true
|
||||
|
||||
args:
|
||||
type:
|
||||
type: str
|
||||
default: mentions
|
||||
description: "Notification type: mentions, likes, or connections"
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
|
||||
columns: [rank, user, action, content, note, time]
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.xiaohongshu.com/notification
|
||||
- wait: 3
|
||||
- tap:
|
||||
store: notification # Pinia store name
|
||||
action: getNotification # Store action to call
|
||||
args: # Action arguments
|
||||
- ${{ args.type | default('mentions') }}
|
||||
capture: /you/ # URL pattern to capture response
|
||||
select: data.message_list # Extract sub-path from response
|
||||
timeout: 8
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
user: ${{ item.user_info.nickname }}
|
||||
action: ${{ item.title }}
|
||||
content: ${{ item.comment_info.content }}
|
||||
- limit: ${{ args.limit | default(20) }}
|
||||
```
|
||||
|
||||
> **`tap` 步骤自动完成**:注入 fetch+XHR 双拦截 → 查找 Pinia/Vuex store → 调用 action → 捕获匹配 URL 的响应 → 清理拦截。
|
||||
> 如果 store 或 action 找不到,会返回 `hint` 列出所有可用的 store actions,方便调试。
|
||||
|
||||
| tap 参数 | 必填 | 说明 |
|
||||
|---------|------|------|
|
||||
| `store` | ✅ | Pinia store 名称(如 `feed`, `search`, `notification`) |
|
||||
| `action` | ✅ | Store action 方法名 |
|
||||
| `capture` | ✅ | URL 子串匹配(匹配网络请求 URL) |
|
||||
| `args` | ❌ | 传给 action 的参数数组 |
|
||||
| `select` | ❌ | 从 captured JSON 中提取的路径(如 `data.items`) |
|
||||
| `timeout` | ❌ | 等待网络响应的超时秒数(默认 5s) |
|
||||
| `framework` | ❌ | `pinia` 或 `vuex`(默认自动检测) |
|
||||
|
||||
### 方式 B: TypeScript 适配器(编程式)
|
||||
|
||||
适用于需要嵌入 JS 代码读取 Pinia state、XHR 拦截、GraphQL、分页、复杂数据转换等场景。
|
||||
|
||||
文件路径: `src/clis/<site>/<name>.ts`。文件将会在运行时被动态扫描并注册(切勿在 `index.ts` 中手动 `import`)。
|
||||
|
||||
#### Tier 3 — Header 认证(Twitter)
|
||||
|
||||
```typescript
|
||||
// src/clis/twitter/search.ts
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'search',
|
||||
description: 'Search tweets',
|
||||
strategy: Strategy.HEADER,
|
||||
args: [{ name: 'query', required: true, positional: true }],
|
||||
columns: ['rank', 'author', 'text', 'likes'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://x.com');
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
// 从 Cookie 提取 CSRF token
|
||||
const ct0 = document.cookie.split(';')
|
||||
.map(c => c.trim())
|
||||
.find(c => c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) return { error: 'Not logged in' };
|
||||
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D...';
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
};
|
||||
|
||||
const variables = JSON.stringify({ rawQuery: '${kwargs.query}', count: 20 });
|
||||
const url = '/i/api/graphql/xxx/SearchTimeline?variables=' + encodeURIComponent(variables);
|
||||
const res = await fetch(url, { headers, credentials: 'include' });
|
||||
return await res.json();
|
||||
})()
|
||||
`);
|
||||
// ... 解析 data
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Tier 4 — XHR/Fetch 双重拦截 (Twitter/小红书 通用模式)
|
||||
|
||||
```typescript
|
||||
// src/clis/xiaohongshu/user.ts
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'xiaohongshu',
|
||||
name: 'user',
|
||||
description: '获取用户笔记',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
args: [{ name: 'id', required: true }],
|
||||
columns: ['rank', 'title', 'likes', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto(`https://www.xiaohongshu.com/user/profile/${kwargs.id}`);
|
||||
await page.wait(5);
|
||||
|
||||
// XHR/Fetch 底层拦截:捕获所有包含 'v1/user/posted' 的请求
|
||||
await page.installInterceptor('v1/user/posted');
|
||||
|
||||
// 触发后端 API:模拟人类用户向底部滚动2次
|
||||
await page.autoScroll({ times: 2, delayMs: 2000 });
|
||||
|
||||
// 提取所有被拦截捕获的 JSON 响应体
|
||||
const requests = await page.getInterceptedRequests();
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results = [];
|
||||
for (const req of requests) {
|
||||
if (req.data?.data?.notes) {
|
||||
for (const note of req.data.data.notes) {
|
||||
results.push({
|
||||
title: note.display_title || '',
|
||||
likes: note.interact_info?.liked_count || '0',
|
||||
url: `https://explore/${note.note_id || note.id}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 20).map((item, i) => ({
|
||||
rank: i + 1, ...item,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> **拦截核心思路**:不自己构造签名,而是利用 `installInterceptor` 劫持网站自己的 `XMLHttpRequest` 和 `fetch`,让网站发请求,我们直接在底层取出解析好的 `response.json()`。
|
||||
|
||||
> **级联请求**(如 BVID→CID→字幕)的完整模板和要点见下方[进阶模式: 级联请求](#进阶模式-级联请求-cascading-requests)章节。
|
||||
|
||||
---
|
||||
|
||||
## Step 4: 测试
|
||||
|
||||
> **构建通过 ≠ 功能正常**。`npm run build` 只验证 TypeScript / YAML 语法,不验证运行时行为。
|
||||
> 每个新命令 **必须实际运行** 并确认输出正确后才算完成。
|
||||
|
||||
### 必做清单
|
||||
|
||||
```bash
|
||||
# 1. 构建(确认语法无误)
|
||||
npm run build
|
||||
|
||||
# 2. 确认命令已注册
|
||||
opencli list | grep mysite
|
||||
|
||||
# 3. 实际运行命令(最关键!)
|
||||
opencli mysite hot --limit 3 -v # verbose 查看每步数据流
|
||||
opencli mysite hot --limit 3 -f json # JSON 输出确认字段完整
|
||||
```
|
||||
|
||||
### tap 步骤调试(intercept 策略专用)
|
||||
|
||||
> **不要猜 store name / action name**。先用 evaluate 探索,再写 YAML。
|
||||
|
||||
#### Step 1: 列出所有 Pinia store
|
||||
|
||||
在浏览器中打开目标网站后:
|
||||
|
||||
```bash
|
||||
opencli evaluate "(() => {
|
||||
const app = document.querySelector('#app')?.__vue_app__;
|
||||
const pinia = app?.config?.globalProperties?.\$pinia;
|
||||
return [...pinia._s.keys()];
|
||||
})()"
|
||||
# 输出: ["user", "feed", "search", "notification", ...]
|
||||
```
|
||||
|
||||
#### Step 2: 查看 store 的 action 名称
|
||||
|
||||
故意写一个错误 action 名,tap 会返回所有可用 actions:
|
||||
|
||||
```
|
||||
⚠ tap: Action not found: wrongName on store notification
|
||||
💡 Available: getNotification, replyComment, getNotificationCount, reset
|
||||
```
|
||||
|
||||
#### Step 3: 用 network requests 确认 capture 模式
|
||||
|
||||
```bash
|
||||
# 在浏览器打开目标页面,查看网络请求
|
||||
# 找到目标 API 的 URL 特征(如 "/you/mentions"、"homefeed")
|
||||
```
|
||||
|
||||
#### 完整流程
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐
|
||||
│ 1. navigate │ ──▶ │ 2. 探索 store │ ──▶ │ 3. 写 YAML │ ──▶ │ 4. 测试 │
|
||||
│ 到目标页面 │ │ name/action │ │ tap 步骤 │ │ 运行验证 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └────────┘
|
||||
```
|
||||
|
||||
### Verbose 模式 & 输出验证
|
||||
|
||||
```bash
|
||||
opencli bilibili hot --limit 1 -v # 查看 pipeline 每步数据流
|
||||
opencli mysite hot -f json | jq '.[0]' # 确认 JSON 可被解析
|
||||
opencli mysite hot -f csv > data.csv # 确认 CSV 可导入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: 提交发布
|
||||
|
||||
文件放入 `src/clis/<site>/` 即自动注册(YAML 或 TS 无需手动 import),然后:
|
||||
|
||||
```bash
|
||||
opencli list | grep mysite # 确认注册
|
||||
git add src/clis/mysite/ && git commit -m "feat(mysite): add hot" && git push
|
||||
```
|
||||
|
||||
> **架构理念**:OpenCLI 内建 **Zero-Dependency jq** 数据流 — 所有解析在 `evaluate` 的原生 JS 内完成,外层 YAML 用 `select`/`map` 提取,无需依赖系统 `jq` 二进制。
|
||||
|
||||
---
|
||||
|
||||
## 进阶模式: 级联请求 (Cascading Requests)
|
||||
|
||||
当目标数据需要多步 API 链式获取时(如 `BVID → CID → 字幕列表 → 字幕内容`),必须使用 **TS 适配器**。YAML 无法处理这种多步逻辑。
|
||||
|
||||
### 模板代码
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { apiGet } from './utils.js'; // 复用平台 SDK
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'subtitle',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [{ name: 'bvid', required: true }],
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
// Step 1: 建立 Session
|
||||
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
|
||||
|
||||
// Step 2: 从页面提取中间 ID (__INITIAL_STATE__)
|
||||
const cid = await page.evaluate(`(async () => {
|
||||
return window.__INITIAL_STATE__?.videoData?.cid;
|
||||
})()`);
|
||||
if (!cid) throw new Error('无法提取 CID');
|
||||
|
||||
// Step 3: 用中间 ID 调用下一级 API (自动 Wbi 签名)
|
||||
const payload = await apiGet(page, '/x/player/wbi/v2', {
|
||||
params: { bvid: kwargs.bvid, cid },
|
||||
signed: true, // ← 自动生成 w_rid
|
||||
});
|
||||
|
||||
// Step 4: 检测风控降级 (空值断言)
|
||||
const subtitles = payload.data?.subtitle?.subtitles || [];
|
||||
const url = subtitles[0]?.subtitle_url;
|
||||
if (!url) throw new Error('subtitle_url 为空,疑似风控降级');
|
||||
|
||||
// Step 5: 拉取最终数据 (CDN JSON)
|
||||
const items = await page.evaluate(`(async () => {
|
||||
const res = await fetch(${JSON.stringify('https:' + url)});
|
||||
const json = await res.json();
|
||||
return { data: json.body || json };
|
||||
})()`);
|
||||
|
||||
return items.data.map((item, idx) => ({ ... }));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 关键要点
|
||||
|
||||
| 步骤 | 注意事项 |
|
||||
|------|----------|
|
||||
| 提取中间 ID | 优先从 `__INITIAL_STATE__` 拿,避免额外 API 调用 |
|
||||
| Wbi 签名 | B 站 `/wbi/` 接口**强制校验** `w_rid`,纯 `fetch` 会被 403 |
|
||||
| 空值断言 | 即使 HTTP 200,核心字段可能为空串(风控降级) |
|
||||
| CDN URL | 常以 `//` 开头,记得补 `https:` |
|
||||
| `JSON.stringify` | 拼接 URL 到 evaluate 时必须用它转义,避免注入 |
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
| 陷阱 | 表现 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| 缺少 `navigate` | evaluate 报 `Target page context` 错误 | 在 evaluate 前加 `navigate:` 步骤 |
|
||||
| 嵌套字段访问 | `${{ item.node?.title }}` 不工作 | 在 evaluate 中 flatten 数据,不在模板中用 optional chaining |
|
||||
| 缺少 `strategy: public` | 公开 API 也启动浏览器,7s → 1s | 公开 API 加上 `strategy: public` + `browser: false` |
|
||||
| evaluate 返回字符串 | map 步骤收到 `""` 而非数组 | pipeline 有 auto-parse,但建议在 evaluate 内 `.map()` 整形 |
|
||||
| 搜索参数被 URL 编码 | `${{ args.query }}` 被浏览器二次编码 | 在 evaluate 内用 `encodeURIComponent()` 手动编码 |
|
||||
| Cookie 过期 | 返回 401 / 空数据 | 在浏览器里重新登录目标站点 |
|
||||
| Extension tab 残留 | Chrome 多出 `chrome-extension://` tab | 已自动清理;若残留,手动关闭即可 |
|
||||
| TS evaluate 格式 | `() => {}` 报 `result is not a function` | TS 中 `page.evaluate()` 必须用 IIFE:`(async () => { ... })()` |
|
||||
| 页面异步加载 | evaluate 拿到空数据(store state 还没更新) | 在 evaluate 内用 polling 等待数据出现,或增加 `wait` 时间 |
|
||||
| YAML 内嵌大段 JS | 调试困难,字符串转义问题 | 超过 10 行 JS 的命令改用 TS adapter |
|
||||
| **风控被拦截(伪200)** | 获取到的 JSON 里核心数据是 `""` (空串) | 极易被误判。必须添加断言!无核心数据立刻要求升级鉴权 Tier 并重新配置 Cookie |
|
||||
| **API 没找见** | `explore` 工具打分出来的都拿不到深层数据 | 点击页面按钮诱发懒加载数据,再结合 `getInterceptedRequests` 获取 |
|
||||
|
||||
---
|
||||
|
||||
## 用 AI Agent 自动生成适配器
|
||||
|
||||
最快的方式是让 AI Agent 完成全流程:
|
||||
|
||||
```bash
|
||||
# 一键:探索 → 分析 → 合成 → 注册
|
||||
opencli generate https://www.example.com --goal "hot"
|
||||
|
||||
# 或分步执行:
|
||||
opencli explore https://www.example.com --site mysite # 发现 API
|
||||
opencli explore https://www.example.com --auto --click "字幕,CC" # 模拟点击触发懒加载 API
|
||||
opencli synthesize mysite # 生成候选 YAML
|
||||
opencli verify mysite/hot --smoke # 冒烟测试
|
||||
```
|
||||
|
||||
生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `src/clis/mysite/` 并微调。
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
# CLI-ONESHOT — 单点快速 CLI 生成
|
||||
|
||||
> 给一个 URL + 一句话描述,4 步生成一个 CLI 命令。
|
||||
> 完整探索式开发请看 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。
|
||||
|
||||
---
|
||||
|
||||
## 输入
|
||||
|
||||
| 项目 | 示例 |
|
||||
|------|------|
|
||||
| **URL** | `https://x.com/jakevin7/lists` |
|
||||
| **Goal** | 获取我的 Twitter Lists |
|
||||
|
||||
---
|
||||
|
||||
## 流程
|
||||
|
||||
### Step 1: 打开页面 + 抓包
|
||||
|
||||
```
|
||||
1. browser_navigate → 打开目标 URL
|
||||
2. 等待 3-5 秒(让页面加载完、API 请求触发)
|
||||
3. browser_network_requests → 筛选 JSON API
|
||||
```
|
||||
|
||||
**关键**:只关注返回 `application/json` 的请求,忽略静态资源。
|
||||
如果没有自动触发 API,手动点击目标按钮/标签再抓一次。
|
||||
|
||||
### Step 2: 锁定一个接口
|
||||
|
||||
从抓包结果中找到**那个**目标 API。看这几个字段:
|
||||
|
||||
| 字段 | 关注什么 |
|
||||
|------|----------|
|
||||
| URL | API 路径 pattern(如 `/i/api/graphql/xxx/ListsManagePinTimeline`) |
|
||||
| Method | GET / POST |
|
||||
| Headers | 有 Cookie? Bearer? CSRF? 自定义签名? |
|
||||
| Response | 数据在哪个路径(如 `data.list.lists`) |
|
||||
|
||||
### Step 3: 验证接口能复现
|
||||
|
||||
在 `browser_evaluate` 中用 `fetch` 复现请求:
|
||||
|
||||
```javascript
|
||||
// Tier 2 (Cookie): 大多数情况
|
||||
fetch('/api/endpoint', { credentials: 'include' }).then(r => r.json())
|
||||
|
||||
// Tier 3 (Header): 如 Twitter 需要额外 header
|
||||
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
|
||||
fetch('/api/endpoint', {
|
||||
headers: { 'Authorization': 'Bearer ...', 'X-Csrf-Token': ct0 },
|
||||
credentials: 'include'
|
||||
}).then(r => r.json())
|
||||
```
|
||||
|
||||
如果 fetch 能拿到数据 → 用 YAML 或简单 TS adapter。
|
||||
如果 fetch 拿不到(签名/风控)→ 用 intercept 策略。
|
||||
|
||||
### Step 4: 套模板,生成 adapter
|
||||
|
||||
根据 Step 3 判定的策略,选一个模板生成文件。
|
||||
|
||||
---
|
||||
|
||||
## 认证速查
|
||||
|
||||
```
|
||||
fetch(url) 直接能拿到? → Tier 1: public (YAML, browser: false)
|
||||
fetch(url, {credentials:'include'})? → Tier 2: cookie (YAML)
|
||||
加 Bearer/CSRF header 后拿到? → Tier 3: header (TS)
|
||||
都不行,但页面自己能请求成功? → Tier 4: intercept (TS, installInterceptor)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模板
|
||||
|
||||
### YAML — Cookie/Public(最简)
|
||||
|
||||
```yaml
|
||||
# src/clis/<site>/<name>.yaml
|
||||
site: mysite
|
||||
name: mycommand
|
||||
description: "一句话描述"
|
||||
domain: www.example.com
|
||||
strategy: cookie # 或 public (加 browser: false)
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.example.com/target-page
|
||||
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const res = await fetch('/api/target', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d.data?.items || []).map(item => ({
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
}));
|
||||
})()
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
value: ${{ item.value }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, value]
|
||||
```
|
||||
|
||||
### TS — Intercept(抓包模式)
|
||||
|
||||
```typescript
|
||||
// src/clis/<site>/<name>.ts
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
name: 'mycommand',
|
||||
description: '一句话描述',
|
||||
domain: 'www.example.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20 },
|
||||
],
|
||||
columns: ['rank', 'title', 'value'],
|
||||
func: async (page, kwargs) => {
|
||||
// 1. 导航
|
||||
await page.goto('https://www.example.com/target-page');
|
||||
await page.wait(3);
|
||||
|
||||
// 2. 注入拦截器(URL 子串匹配)
|
||||
await page.installInterceptor('target-api-keyword');
|
||||
|
||||
// 3. 触发 API(滚动/点击)
|
||||
await page.autoScroll({ times: 2, delayMs: 2000 });
|
||||
|
||||
// 4. 读取拦截的响应
|
||||
const requests = await page.getInterceptedRequests();
|
||||
if (!requests?.length) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
const items = req.data?.data?.items || [];
|
||||
results.push(...items);
|
||||
}
|
||||
|
||||
return results.slice(0, kwargs.limit).map((item, i) => ({
|
||||
rank: i + 1,
|
||||
title: item.title || '',
|
||||
value: item.value || '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### TS — Header(如 Twitter GraphQL)
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'mycommand',
|
||||
description: '一句话描述',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.HEADER,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20 },
|
||||
],
|
||||
columns: ['rank', 'name', 'value'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://x.com');
|
||||
const data = await page.evaluate(`(async () => {
|
||||
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
|
||||
if (!ct0) return { error: 'Not logged in' };
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D...';
|
||||
const res = await fetch('/i/api/graphql/QUERY_ID/Endpoint', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.json();
|
||||
})()`);
|
||||
// 解析 data...
|
||||
return [];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试(必做)
|
||||
|
||||
```bash
|
||||
npm run build # 语法检查
|
||||
opencli list | grep mysite # 确认注册
|
||||
opencli mysite mycommand --limit 3 -v # 实际运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 就这样,没了
|
||||
|
||||
写完文件 → build → run → 提交。有问题再看 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。
|
||||
+5
-5
@@ -30,7 +30,7 @@ This is the most common type of contribution. Start with YAML when possible, and
|
||||
|
||||
### YAML Adapter (Recommended for data-fetching commands)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.yaml`:
|
||||
Create a file like `clis/<site>/<command>.yaml`:
|
||||
|
||||
```yaml
|
||||
site: mysite
|
||||
@@ -66,14 +66,14 @@ pipeline:
|
||||
columns: [rank, title, score, url]
|
||||
```
|
||||
|
||||
See [`hackernews/top.yaml`](src/clis/hackernews/top.yaml) for a real example.
|
||||
See [`hackernews/top.yaml`](clis/hackernews/top.yaml) for a real example.
|
||||
|
||||
### TypeScript Adapter (For complex browser interactions)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.ts`:
|
||||
Create a file like `clis/<site>/<command>.ts`:
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
@@ -109,7 +109,7 @@ cli({
|
||||
});
|
||||
```
|
||||
|
||||
Use `opencli explore <url>` to discover APIs and see [CLI-EXPLORER.md](./CLI-EXPLORER.md) if you need the full adapter workflow.
|
||||
Use `opencli explore <url>` to discover APIs and see [opencli-explorer skill](./skills/opencli-explorer/SKILL.md) if you need the full adapter workflow.
|
||||
|
||||
### Validate Your Adapter
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
|
||||
> Zero risk · Reuse Chrome/Chromium login · AI-powered discovery · Universal CLI Hub
|
||||
|
||||
[](./README.zh-CN.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
**Built for AI Agents** — Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.
|
||||
**Built for AI Agents** — Load the [`opencli-operate` skill](./skills/opencli-operate/SKILL.md) to give any AI agent (Claude Code, Cursor) direct browser control. Operate any website, then crystallize those interactions into reusable CLI commands. Configure `opencli list` in your `AGENT.md` or `.cursorrules` so the AI auto-discovers all available tools.
|
||||
|
||||
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
|
||||
|
||||
@@ -21,33 +21,17 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **Browser Automation** — `operate` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 70+ pre-built adapters, or crystallize your own with `opencli record`.
|
||||
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
|
||||
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `operate` controls the browser directly.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Why opencli?
|
||||
|
||||
There are many great browser automation tools. Here's when opencli is the right choice:
|
||||
|
||||
| Your need | Best tool | Why |
|
||||
|-----------|-----------|-----|
|
||||
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
|
||||
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
|
||||
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
|
||||
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
|
||||
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
|
||||
|
||||
**What makes opencli different:**
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
|
||||
- **Broad coverage** — 70+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
|
||||
- **Broad coverage** — 79+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
---
|
||||
|
||||
@@ -55,7 +39,7 @@ There are many great browser automation tools. Here's when opencli is the right
|
||||
|
||||
### 1. Install Browser Bridge Extension
|
||||
|
||||
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome/Chromium extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
|
||||
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
|
||||
@@ -67,6 +51,9 @@ There are many great browser automation tools. Here's when opencli is the right
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
|
||||
# Install AI skills for Claude Code / Cursor
|
||||
npx skills add jackwener/opencli
|
||||
```
|
||||
|
||||
### 3. Verify & Try
|
||||
@@ -86,21 +73,9 @@ opencli bilibili hot --limit 5 # Browser command (requires Extension)
|
||||
|
||||
### 4. Browser Automation — Make Websites Accessible for AI Agents
|
||||
|
||||
#### AI Agent Quickstart (1 step)
|
||||
Point your AI agent (Claude Code, Cursor) to [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md). It has everything needed — full command reference, examples, and workflow.
|
||||
|
||||
Point your AI agent (Claude Code, Cursor) to [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md). It has everything needed.
|
||||
|
||||
#### Human Quickstart (3 steps)
|
||||
|
||||
```bash
|
||||
opencli operate open https://news.ycombinator.com # 1. Open a page
|
||||
opencli operate state # 2. See interactive elements
|
||||
opencli operate eval "document.title" # 3. Extract data
|
||||
```
|
||||
|
||||
More commands: `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, `close`.
|
||||
|
||||
See [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) for full documentation.
|
||||
Available commands: `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, `close`.
|
||||
|
||||
### Update
|
||||
|
||||
@@ -113,7 +88,14 @@ npm install -g @jackwener/opencli@latest
|
||||
OpenCLI provides [skills](./skills/) for AI agents (Claude Code, etc.):
|
||||
|
||||
```bash
|
||||
# Install all OpenCLI skills
|
||||
npx skills add jackwener/opencli
|
||||
|
||||
# Or install specific skills
|
||||
npx skills add jackwener/opencli --skill opencli-usage # Command reference
|
||||
npx skills add jackwener/opencli --skill opencli-operate # Browser automation for AI agents
|
||||
npx skills add jackwener/opencli --skill opencli-explorer # Adapter development guide
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot # Quick command reference
|
||||
```
|
||||
|
||||
---
|
||||
@@ -136,9 +118,9 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
- **Chrome or Chromium** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome/Chromium login session. You must be logged into the target website in Chrome or Chromium before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
@@ -147,14 +129,19 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
|
||||
| **tieba** | `hot` `posts` `search` `read` |
|
||||
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
|
||||
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
|
||||
| **gemini** | `new` `ask` `image` |
|
||||
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
|
||||
| **1688** | `search` `item` `store` |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
|
||||
| **yuanbao** | `new` `ask` |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
|
||||
| **xianyu** | `search` `item` `chat` |
|
||||
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
|
||||
|
||||
70+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
79+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
## CLI Hub
|
||||
|
||||
@@ -263,14 +250,15 @@ opencli plugin uninstall my-tool
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
|
||||
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles |
|
||||
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | TS | VK (VKontakte) wall, feed, and search |
|
||||
|
||||
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
|
||||
|
||||
## For AI Agents (Developer Guide)
|
||||
|
||||
> **Quick mode**: To generate a single command for a specific page URL, see [CLI-ONESHOT.md](./CLI-ONESHOT.md) — just a URL + one-line goal, 4 steps done.
|
||||
> **Quick mode**: To generate a single command for a specific page URL, see [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — just a URL + one-line goal, 4 steps done.
|
||||
|
||||
> **Full mode**: Before writing any adapter code, read [CLI-EXPLORER.md](./CLI-EXPLORER.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
|
||||
> **Full mode**: Before writing any adapter code, read [opencli-explorer skill](./skills/opencli-explorer/SKILL.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
|
||||
|
||||
```bash
|
||||
opencli explore https://example.com --site mysite # Discover APIs + capabilities
|
||||
@@ -285,9 +273,9 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions` in Chrome or Chromium.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
|
||||
- **Empty data or 'Unauthorized' error** — Your Chrome login session may have expired. Navigate to the target site and log in again.
|
||||
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
|
||||
- **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs.
|
||||
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
|
||||
|
||||
|
||||
+36
-39
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
> 零风控 · 复用 Chrome/Chromium 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
|
||||
[](./README.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh`、`docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
**专为 AI Agent 打造**:只需在全局 `.cursorrules` 或 `AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`),AI 便能瞬间学会自动调用相应的本地工具!
|
||||
**专为 AI Agent 打造**:加载 [`opencli-operate` skill](./skills/opencli-operate/SKILL.md),赋予 AI Agent(Claude Code、Cursor 等)直接操控浏览器的能力——操作任意网站,并将这些交互沉淀为可复用的 CLI 命令。在 `AGENT.md` 或 `.cursorrules` 中配置 `opencli list`,AI 即可自动发现并调用所有可用工具。
|
||||
|
||||
**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
|
||||
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
|
||||
@@ -22,41 +22,24 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
## 亮点
|
||||
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **浏览器自动化** — `operate` 赋予 AI Agent 直接操控浏览器的能力:点击、输入、提取、截图,任意交互皆可脚本化
|
||||
- **网页转 CLI** — 将任意网站变成确定性命令行工具:79+ 预置适配器,或用 `opencli record` 沉淀自己的操作
|
||||
- **多站点覆盖** — 79+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
- **零风控** — 复用 Chrome/Chromium 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
## 为什么选 opencli?
|
||||
|
||||
浏览器自动化工具很多,opencli 适合什么场景?
|
||||
|
||||
| 你的需求 | 最佳工具 | 原因 |
|
||||
|----------|----------|------|
|
||||
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
|
||||
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
|
||||
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
|
||||
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
|
||||
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
|
||||
|
||||
**opencli 的核心差异:**
|
||||
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略,`operate` 直接控制浏览器
|
||||
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
|
||||
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
|
||||
- **覆盖广泛** — 70+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
|
||||
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
|
||||
- **Chrome 或 Chromium** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com)
|
||||
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome/Chromium 登录状态。运行命令前,你必须已在 Chrome 或 Chromium 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
|
||||
### Browser Bridge 扩展配置
|
||||
|
||||
@@ -64,7 +47,7 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
|
||||
|
||||
**方式一:下载构建好的安装包(推荐)**
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`。
|
||||
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
2. 解压后在 Chrome 或 Chromium 中打开 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹。
|
||||
|
||||
**方式二:加载源码(针对开发者)**
|
||||
@@ -86,6 +69,9 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
|
||||
# 安装 AI Skills(Claude Code / Cursor)
|
||||
npx skills add jackwener/opencli
|
||||
```
|
||||
|
||||
直接使用:
|
||||
@@ -116,6 +102,12 @@ opencli list # 可以在任何地方使用了!
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
### 浏览器自动化 — 让 AI Agent 直接控制浏览器
|
||||
|
||||
将 [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) 指向你的 AI Agent(Claude Code、Cursor),即可开箱即用,内含完整命令参考与使用示例。
|
||||
|
||||
可用命令:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`screenshot`、`scroll`、`back`、`eval`、`network`、`init`、`verify`、`close`。
|
||||
|
||||
### 安装 AI Skills
|
||||
|
||||
OpenCLI 提供 [skills](./skills/) 供 AI Agent(Claude Code 等)使用:
|
||||
@@ -140,6 +132,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
|
||||
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
@@ -153,6 +146,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
||||
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
|
||||
@@ -174,7 +168,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **web** | `read` | 浏览器 |
|
||||
| **weibo** | `hot` `search` | 浏览器 |
|
||||
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
| **sinafinance** | `news` | 🌐 公开 |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
|
||||
@@ -184,17 +178,18 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
|
||||
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 浏览器 |
|
||||
| **linux-do** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` | 浏览器 |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
||||
| **steam** | `top-sellers` | 公开 |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
|
||||
| **google** | `news` `search` `suggest` `trends` | 公开 |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` | 浏览器 |
|
||||
| **gemini** | `new` `ask` `image` | 浏览器 |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | 浏览器 |
|
||||
| **1688** | `search` `item` `store` | 浏览器 |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
|
||||
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 浏览器 |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | 浏览器 |
|
||||
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
|
||||
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
|
||||
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
|
||||
@@ -206,9 +201,11 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
|
||||
| **xianyu** | `search` `item` `chat` | 浏览器 |
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
|
||||
70+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
79+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
@@ -373,9 +370,9 @@ opencli plugin uninstall my-tool # 卸载
|
||||
|
||||
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
|
||||
|
||||
> **快速模式**:只想为某个页面快速生成一个命令?看 [CLI-ONESHOT.md](./CLI-ONESHOT.md) — 给一个 URL + 一句话描述,4 步搞定。
|
||||
> **快速模式**:只想为某个页面快速生成一个命令?看 [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — 给一个 URL + 一句话描述,4 步搞定。
|
||||
|
||||
> **完整模式**:在编写任何新代码前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
|
||||
> **完整模式**:在编写任何新代码前,先阅读 [opencli-explorer skill](./skills/opencli-explorer/SKILL.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
|
||||
|
||||
```bash
|
||||
# 1. Deep Explore — 网络拦截 → 响应分析 → 能力推理 → 框架检测
|
||||
@@ -396,11 +393,11 @@ opencli cascade https://api.example.com/data
|
||||
## 常见问题排查
|
||||
|
||||
- **"Extension not connected" 报错**
|
||||
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- 确保你当前的 Chrome 或 Chromium 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
|
||||
- 其他 Chrome 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
|
||||
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
|
||||
- **返回空数据,或者报错 "Unauthorized"**
|
||||
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
|
||||
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 (如 parseArgs, fs 等)**
|
||||
- 确保 Node.js 版本 `>= 20`。
|
||||
- **Daemon 问题**
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ src/
|
||||
|---|---|
|
||||
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
|
||||
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
|
||||
| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/search.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` |
|
||||
| 站点 / adapter 逻辑 | `clis/apple-podcasts/commands.test.ts`, `clis/apple-podcasts/utils.test.ts`, `clis/bloomberg/utils.test.ts`, `clis/chaoxing/utils.test.ts`, `clis/coupang/utils.test.ts`, `clis/google/utils.test.ts`, `clis/grok/ask.test.ts`, `clis/twitter/timeline.test.ts`, `clis/weread/utils.test.ts`, `clis/xiaohongshu/creator-note-detail.test.ts`, `clis/xiaohongshu/creator-notes-summary.test.ts`, `clis/xiaohongshu/creator-notes.test.ts`, `clis/xiaohongshu/search.test.ts`, `clis/xiaohongshu/user-helpers.test.ts`, `clis/xiaoyuzhou/utils.test.ts`, `clis/youtube/transcript-group.test.ts`, `clis/zhihu/download.test.ts` |
|
||||
|
||||
这些测试覆盖的重点包括:
|
||||
|
||||
@@ -110,7 +110,7 @@ npx vitest run tests/e2e/
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run src/clis/apple-podcasts/commands.test.ts
|
||||
npx vitest run clis/apple-podcasts/commands.test.ts
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试
|
||||
@@ -132,7 +132,7 @@ npx vitest src/
|
||||
|
||||
## 如何添加新测试
|
||||
|
||||
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
||||
### 新增 YAML Adapter(如 `clis/producthunt/trending.yaml`)
|
||||
|
||||
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验
|
||||
2. 根据 adapter 类型,在对应测试文件补一个 `it()` block
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "extract-title-iana",
|
||||
"steps": [
|
||||
"opencli operate open https://www.iana.org",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent\""
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -67,7 +67,7 @@
|
||||
"name": "extract-github-readme-heading",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/vercel/next.js",
|
||||
"opencli operate eval \"document.querySelector('article h1, article h2')?.textContent?.trim()\""
|
||||
"opencli operate eval \"document.querySelector('[data-testid=readme] h1, [data-testid=readme] h2, #readme h1, #readme h2, article h1, article h2, .markdown-body h1, .markdown-body h2')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -88,7 +88,8 @@
|
||||
"name": "extract-npm-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/package/express",
|
||||
"opencli operate eval \"document.querySelector('p[class*=description], [data-testid=package-description], #readme p')?.textContent?.trim()\""
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -142,7 +143,7 @@
|
||||
"name": "list-quotes-3",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote, [class*=quote]')].slice(0,3).map(el=>({text:(el.querySelector('.text, [class*=text]')?.textContent)||(el.querySelector('span')?.textContent),author:(el.querySelector('.author, [class*=author]')?.textContent)||(el.querySelector('small')?.textContent)})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -164,7 +165,7 @@
|
||||
"name": "list-github-trending",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,3).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' '),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,3).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim()),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -175,7 +176,7 @@
|
||||
"name": "list-github-trending-lang",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,5).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' ')})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,5).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim())})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -207,8 +208,7 @@
|
||||
{
|
||||
"name": "search-google",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com",
|
||||
"opencli operate eval \"document.querySelector('textarea[name=q], input[name=q]').value='opencli github';document.querySelector('form').submit();'submitted'\"",
|
||||
"opencli operate open https://www.google.com/search?q=opencli+github",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
|
||||
],
|
||||
@@ -249,8 +249,7 @@
|
||||
{
|
||||
"name": "search-wiki",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org",
|
||||
"opencli operate eval \"document.querySelector('input[name=search]').value='Rust programming language';document.querySelector('form#searchform, form[role=search]').submit();'submitted'\"",
|
||||
"opencli operate open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
@@ -263,11 +262,9 @@
|
||||
{
|
||||
"name": "search-npm",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com",
|
||||
"opencli operate state",
|
||||
"opencli operate type 1 \"react\"",
|
||||
"opencli operate keys Enter",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
"opencli operate open https://www.npmjs.com/search?q=react",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3, .package-list-item h3, a[class*=package] h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -294,7 +291,7 @@
|
||||
"opencli operate open https://example.com",
|
||||
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
"opencli operate eval \"document.title + ' ' + location.href\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
@@ -328,8 +325,8 @@
|
||||
"name": "nav-click-wiki-link",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
|
||||
"opencli operate eval \"document.querySelector('#toc a, .toc a, [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History, #History ~ p')?.textContent?.slice(0,100)\""
|
||||
"opencli operate eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -479,7 +476,7 @@
|
||||
"name": "form-textarea",
|
||||
"steps": [
|
||||
"opencli operate open https://httpbin.org/forms/post",
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments]');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments], textarea[name=delivery], textarea');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
@@ -580,7 +577,8 @@
|
||||
"name": "bench-imdb-matrix",
|
||||
"steps": [
|
||||
"opencli operate open https://www.imdb.com/title/tt0133093/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1 span, [data-testid=hero__pageTitle] span')?.textContent,year:document.querySelector('a[href*=releaseinfo], [data-testid=hero-title-block__metadata] a')?.textContent})\""
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:debug — Hypothesis-driven debugging for specific failing tasks.
|
||||
*
|
||||
* Scientific method: Gather → Hypothesize → Test → Classify → Log → Repeat
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/debug.ts --task extract-npm-description
|
||||
* npx tsx autoresearch/commands/debug.ts --task bench-imdb-matrix --iterations 5
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
const TASKS_FILE = join(__dirname, '..', 'browse-tasks.json');
|
||||
const DEBUG_LOG = join(ROOT, 'debug-results.tsv');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: { type: string; value?: string; minLength?: number; pattern?: string };
|
||||
}
|
||||
|
||||
function exec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT, timeout: 30_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function initLog(): void {
|
||||
if (!existsSync(DEBUG_LOG)) {
|
||||
writeFileSync(DEBUG_LOG, '# AutoResearch Debug Log\niteration\ttask\thypothesis\tresult\tverdict\tdescription\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(iteration: number, task: string, hypothesis: string, result: string, verdict: string, description: string): void {
|
||||
appendFileSync(DEBUG_LOG, `${iteration}\t${task}\t${hypothesis}\t${result}\t${verdict}\t${description}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const taskName = args.task;
|
||||
const maxIterations = args.iterations ?? 10;
|
||||
|
||||
if (!taskName) {
|
||||
console.error('Usage: npx tsx autoresearch/commands/debug.ts --task <task-name> [--iterations N]');
|
||||
console.error('\nAvailable tasks:');
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
// Show only failing tasks
|
||||
for (const task of tasks) {
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
let lastOutput = '';
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
const passed = lastOutput.trim().length > 0; // simplified check
|
||||
if (!passed) console.error(` ✗ ${task.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const task = tasks.find(t => t.name === taskName);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${taskName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
|
||||
console.log(` Steps: ${task.steps.length}`);
|
||||
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
|
||||
console.log(` Max iterations: ${maxIterations}\n`);
|
||||
|
||||
initLog();
|
||||
|
||||
// Phase 1: Gather — run the task and capture output
|
||||
console.log('Phase 1: Gathering symptoms...');
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
|
||||
lastOutput = exec(step);
|
||||
if (i < task.steps.length - 1) {
|
||||
console.log(` → ${lastOutput.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
|
||||
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
|
||||
|
||||
// Phase 2: Hypothesize + investigate via Claude Code
|
||||
for (let iter = 1; iter <= maxIterations; iter++) {
|
||||
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
|
||||
|
||||
const prompt = `You are debugging a failing browser automation task.
|
||||
|
||||
## Task: ${taskName}
|
||||
Steps:
|
||||
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
## Judge criteria
|
||||
${JSON.stringify(task.judge)}
|
||||
|
||||
## Last output
|
||||
${lastOutput.slice(0, 500)}
|
||||
|
||||
## Instructions
|
||||
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
|
||||
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
|
||||
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
|
||||
4. If CONFIRMED: describe the root cause and suggest a fix
|
||||
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
|
||||
|
||||
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
|
||||
// Extract hypothesis and result
|
||||
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
|
||||
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
|
||||
|
||||
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
|
||||
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
|
||||
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
|
||||
|
||||
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
|
||||
console.log(` Verdict: ${verdict} — ${description.slice(0, 100)}`);
|
||||
|
||||
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
|
||||
|
||||
if (verdict === 'CONFIRMED') {
|
||||
console.log(`\n✅ Root cause found at iteration ${iter}!`);
|
||||
console.log(` ${description}`);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(` Error: ${err.message?.slice(0, 100)}`);
|
||||
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
|
||||
}
|
||||
|
||||
// Re-run task for fresh output
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
}
|
||||
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:fix — Iterative error elimination.
|
||||
*
|
||||
* Auto-detects broken state (build → test → browse tests) and iteratively
|
||||
* fixes errors one at a time. Stops when error count reaches 0.
|
||||
*
|
||||
* Priority: build errors → test failures → browse task failures
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/fix.ts
|
||||
* npx tsx autoresearch/commands/fix.ts --iterations 10
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function exec(cmd: string): { ok: boolean; output: string } {
|
||||
try {
|
||||
const output = execSync(cmd, {
|
||||
cwd: ROOT, timeout: 120_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return { ok: true, output };
|
||||
} catch (err: any) {
|
||||
return { ok: false, output: (err.stdout ?? '') + '\n' + (err.stderr ?? '') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect current broken state and return verify command + error count */
|
||||
function detectBrokenState(): { verify: string; errors: number; description: string } | null {
|
||||
// 1. Build
|
||||
const build = exec('npm run build 2>&1');
|
||||
if (!build.ok) {
|
||||
const errorCount = (build.output.match(/error TS/g) || []).length || 1;
|
||||
return {
|
||||
verify: 'npm run build 2>&1 | grep -c "error TS" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} TypeScript build error(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Tests
|
||||
const test = exec('npm test 2>&1');
|
||||
if (!test.ok) {
|
||||
const failMatch = test.output.match(/(\d+)\s+fail/i);
|
||||
const errorCount = failMatch ? parseInt(failMatch[1], 10) : 1;
|
||||
return {
|
||||
verify: 'npm test 2>&1 | grep -oP "\\d+(?= fail)" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} test failure(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Browse tests
|
||||
const browse = exec('npx tsx autoresearch/eval-browse.ts 2>&1');
|
||||
const scoreMatch = browse.output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const passed = parseInt(scoreMatch[1], 10);
|
||||
const total = parseInt(scoreMatch[2], 10);
|
||||
const failures = total - passed;
|
||||
if (failures > 0) {
|
||||
return {
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
errors: failures,
|
||||
description: `${failures} browse task failure(s) (${passed}/${total})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null; // all clean
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const maxIterations = args.iterations ?? 20;
|
||||
|
||||
console.log('\n🔧 AutoResearch Fix — Detecting broken state...\n');
|
||||
|
||||
const broken = detectBrokenState();
|
||||
if (!broken) {
|
||||
console.log(' ✓ All clean — nothing to fix!\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Found: ${broken.description}`);
|
||||
console.log(` Verify: ${broken.verify}\n`);
|
||||
|
||||
const config = {
|
||||
goal: `Fix all errors: ${broken.description}`,
|
||||
scope: ['src/**/*.ts', 'extension/src/**/*.ts'],
|
||||
metric: 'error_count',
|
||||
direction: 'lower' as const,
|
||||
verify: broken.verify,
|
||||
guard: 'npm run build',
|
||||
iterations: maxIterations,
|
||||
minDelta: 1,
|
||||
};
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: async (ctx: ModifyContext) => {
|
||||
const prompt = `Fix ONE error. Current error count: ${ctx.currentMetric}. Goal: 0 errors.
|
||||
|
||||
Read the error output, understand the root cause, and make ONE focused fix.
|
||||
Do NOT fix multiple unrelated errors at once.
|
||||
Do NOT modify test files.
|
||||
|
||||
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await engine.run();
|
||||
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
|
||||
if (finalMetric === 0) {
|
||||
console.log('\n✅ All errors fixed!\n');
|
||||
} else {
|
||||
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:plan — Interactive configuration wizard.
|
||||
*
|
||||
* Walks through goal, scope, metric, verify, guard settings
|
||||
* and outputs a ready-to-paste run command.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/plan.ts
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔬 AutoResearch — Configuration Wizard\n');
|
||||
|
||||
// Offer presets first
|
||||
const presetNames = Object.keys(PRESETS);
|
||||
console.log('Available presets:');
|
||||
presetNames.forEach((name, i) => {
|
||||
console.log(` [${i + 1}] ${name} — ${PRESETS[name].goal}`);
|
||||
});
|
||||
console.log(` [0] Custom config\n`);
|
||||
|
||||
const choice = await ask('Choose preset or 0 for custom: ');
|
||||
const idx = parseInt(choice, 10);
|
||||
|
||||
if (idx > 0 && idx <= presetNames.length) {
|
||||
const name = presetNames[idx - 1];
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
const iterFlag = iterations ? ` --iterations ${iterations}` : '';
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` npx tsx autoresearch/commands/run.ts --preset ${name}${iterFlag}\n`);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom config
|
||||
const goal = await ask('Goal (what to improve): ');
|
||||
const scope = await ask('Scope (file globs, comma-separated): ');
|
||||
const metric = await ask('Metric name (e.g. pass_count, coverage): ');
|
||||
const direction = await ask('Direction (higher/lower): ') as 'higher' | 'lower';
|
||||
const verify = await ask('Verify command (must output a number): ');
|
||||
|
||||
// Dry-run verify
|
||||
console.log('\n Dry-running verify command...');
|
||||
try {
|
||||
const output = execSync(verify, { cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
const { extractMetric } = await import('../config.js');
|
||||
const value = extractMetric(output);
|
||||
if (value != null) {
|
||||
console.log(` ✓ Verify works — current ${metric}: ${value}`);
|
||||
} else {
|
||||
console.log(` ⚠ Verify ran but no number extracted from output:\n ${output.slice(0, 200)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(` ✗ Verify failed: ${err.message?.slice(0, 100)}`);
|
||||
}
|
||||
|
||||
const guard = await ask('Guard command (optional, press Enter to skip): ');
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
|
||||
const parts = ['npx tsx autoresearch/commands/run.ts'];
|
||||
parts.push(`--goal "${goal}"`);
|
||||
parts.push(`--scope "${scope}"`);
|
||||
parts.push(`--metric "${metric}"`);
|
||||
parts.push(`--direction ${direction}`);
|
||||
parts.push(`--verify "${verify}"`);
|
||||
if (guard) parts.push(`--guard "${guard}"`);
|
||||
if (iterations) parts.push(`--iterations ${iterations}`);
|
||||
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` ${parts.join(' \\\n ')}\n`);
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch — Main autonomous iteration loop.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
|
||||
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
|
||||
*
|
||||
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
|
||||
* Engine handles commit, verify, guard, keep/discard, and logging.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, type AutoResearchConfig } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
|
||||
const recent = ctx.recentLog.slice(-10).map(r =>
|
||||
` ${r.status.padEnd(12)} ${r.description}`
|
||||
).join('\n');
|
||||
|
||||
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
|
||||
|
||||
## Goal
|
||||
${config.goal}
|
||||
|
||||
## Current State
|
||||
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
|
||||
- Iteration: ${ctx.iteration}
|
||||
- Consecutive discards: ${ctx.consecutiveDiscards}
|
||||
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
|
||||
|
||||
## Recent History
|
||||
${recent || ' (no history yet)'}
|
||||
|
||||
## Git Log (recent experiments)
|
||||
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
|
||||
|
||||
## Scope (files you can modify)
|
||||
${ctx.scopeFiles.join('\n')}
|
||||
|
||||
## Rules
|
||||
1. Make ONE atomic change (one logical intent, even if multiple files)
|
||||
2. Read the failing test output or code BEFORE modifying
|
||||
3. DO NOT modify test files or the verify command
|
||||
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
|
||||
5. If previous approach was discarded, try something DIFFERENT
|
||||
6. Focus on the specific failures — read error messages carefully`;
|
||||
}
|
||||
|
||||
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
|
||||
const prompt = buildModifyPrompt(ctx, config);
|
||||
|
||||
console.log(' Claude Code making a change...');
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{
|
||||
cwd: ROOT,
|
||||
timeout: 300_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}
|
||||
).trim();
|
||||
|
||||
// Extract description from Claude's response (last non-empty line or summary)
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
|
||||
return desc.slice(0, 120);
|
||||
} catch (err: any) {
|
||||
console.error(' Claude Code failed:', err.message?.slice(0, 100));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Resolve config from preset or CLI args
|
||||
let config: AutoResearchConfig;
|
||||
if (args.preset) {
|
||||
config = PRESETS[args.preset];
|
||||
if (!config) {
|
||||
console.error(`Unknown preset: ${args.preset}`);
|
||||
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Allow CLI overrides
|
||||
if (args.iterations != null) config = { ...config, iterations: args.iterations };
|
||||
if (args.guard != null) config = { ...config, guard: args.guard };
|
||||
} else if (args.goal && args.verify) {
|
||||
config = {
|
||||
goal: args.goal,
|
||||
scope: args.scope ?? ['src/**/*.ts'],
|
||||
metric: args.metric ?? 'score',
|
||||
direction: args.direction ?? 'higher',
|
||||
verify: args.verify,
|
||||
guard: args.guard,
|
||||
iterations: args.iterations,
|
||||
minDelta: args.minDelta,
|
||||
};
|
||||
} else {
|
||||
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
|
||||
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
|
||||
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 AutoResearch: ${config.goal}`);
|
||||
console.log(` Metric: ${config.metric} (${config.direction})`);
|
||||
console.log(` Verify: ${config.verify}`);
|
||||
console.log(` Guard: ${config.guard ?? '(none)'}`);
|
||||
console.log(` Iterations: ${config.iterations ?? '∞'}`);
|
||||
console.log('');
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: (ctx) => modify(ctx, config),
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
await engine.run();
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AutoResearch Configuration — type definitions and CLI parsing.
|
||||
*
|
||||
* Based on Karpathy's autoresearch: constraint + mechanical metric + unbounded loop.
|
||||
*/
|
||||
|
||||
export interface AutoResearchConfig {
|
||||
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
|
||||
goal: string;
|
||||
/** Glob patterns for files the agent can modify */
|
||||
scope: string[];
|
||||
/** What the metric measures, e.g. "pass_count" */
|
||||
metric: string;
|
||||
/** Whether improvement means the number goes up or down */
|
||||
direction: 'higher' | 'lower';
|
||||
/** Shell command that outputs a number (the metric value) */
|
||||
verify: string;
|
||||
/** Optional guard command — must pass for a keep decision */
|
||||
guard?: string;
|
||||
/** Max iterations (undefined = unbounded) */
|
||||
iterations?: number;
|
||||
/** Minimum delta to count as real improvement (noise filter) */
|
||||
minDelta?: number;
|
||||
}
|
||||
|
||||
export type IterationStatus =
|
||||
| 'baseline'
|
||||
| 'keep'
|
||||
| 'keep (reworked)'
|
||||
| 'discard'
|
||||
| 'crash'
|
||||
| 'no-op'
|
||||
| 'hook-blocked';
|
||||
|
||||
export interface IterationResult {
|
||||
iteration: number;
|
||||
commit: string;
|
||||
metric: number;
|
||||
delta: number;
|
||||
guard: 'pass' | 'fail' | '-';
|
||||
status: IterationStatus;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
|
||||
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
|
||||
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (arg) {
|
||||
case '--preset': config.preset = next; i++; break;
|
||||
case '--goal': config.goal = next; i++; break;
|
||||
case '--scope': config.scope = next?.split(','); i++; break;
|
||||
case '--metric': config.metric = next; i++; break;
|
||||
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
|
||||
case '--verify': config.verify = next; i++; break;
|
||||
case '--guard': config.guard = next; i++; break;
|
||||
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
|
||||
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
|
||||
case '--task': config.task = next; i++; break;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Extract a number from command output using common patterns */
|
||||
export function extractMetric(output: string): number | null {
|
||||
// Try: last line that looks like a number
|
||||
const lines = output.trim().split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
|
||||
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
|
||||
if (scoreMatch) return parseFloat(scoreMatch[1]);
|
||||
const numMatch = line.match(/^[\d.]+$/);
|
||||
if (numMatch) return parseFloat(numMatch[0]);
|
||||
}
|
||||
// Fallback: first number in output
|
||||
const fallback = output.match(/(\d+(?:\.\d+)?)/);
|
||||
return fallback ? parseFloat(fallback[1]) : null;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* AutoResearch Engine — Karpathy's 8-phase autonomous iteration loop.
|
||||
*
|
||||
* Phase 0: Precondition checks (git clean, no locks)
|
||||
* Phase 1: Review (read scope files + log + git history)
|
||||
* Phase 2: Ideate (select next change based on history)
|
||||
* Phase 3: Modify (one atomic change — delegated to caller)
|
||||
* Phase 4: Commit (git add + commit with experiment prefix)
|
||||
* Phase 5: Verify (run verify command, extract metric)
|
||||
* Phase 5.5: Guard (optional regression check)
|
||||
* Phase 6: Decide (keep/discard/crash + rollback)
|
||||
* Phase 7: Log (append TSV)
|
||||
* Phase 8: Repeat
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
export interface EngineCallbacks {
|
||||
/** Called at Phase 2-3: review context, ideate, and make ONE change.
|
||||
* Return a one-sentence description of what was changed, or null to skip. */
|
||||
modify(context: ModifyContext): Promise<string | null>;
|
||||
|
||||
/** Called when engine needs to report status */
|
||||
onStatus?(msg: string): void;
|
||||
}
|
||||
|
||||
export interface ModifyContext {
|
||||
iteration: number;
|
||||
bestMetric: number;
|
||||
currentMetric: number;
|
||||
recentLog: IterationResult[];
|
||||
gitLog: string;
|
||||
scopeFiles: string[];
|
||||
consecutiveDiscards: number;
|
||||
stuckHint: string | null;
|
||||
}
|
||||
|
||||
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
|
||||
|
||||
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: opts?.cwd ?? ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function execStrict(cmd: string, opts?: { timeout?: number }): string {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private config: AutoResearchConfig;
|
||||
private logger: Logger;
|
||||
private callbacks: EngineCallbacks;
|
||||
private bestMetric: number = 0;
|
||||
private currentMetric: number = 0;
|
||||
private iteration: number = 0;
|
||||
|
||||
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
|
||||
this.config = config;
|
||||
this.logger = new Logger(logPath);
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
private log(msg: string): void {
|
||||
this.callbacks.onStatus?.(msg);
|
||||
}
|
||||
|
||||
/** Phase 0: Precondition checks */
|
||||
private checkPreconditions(): void {
|
||||
// Git repo exists
|
||||
try { execStrict('git rev-parse --git-dir'); }
|
||||
catch { throw new Error('Not a git repository'); }
|
||||
|
||||
// Clean working tree
|
||||
const status = exec('git status --porcelain');
|
||||
if (status) throw new Error(`Working tree not clean:\n${status}`);
|
||||
|
||||
// No stale locks
|
||||
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
|
||||
throw new Error('Stale .git/index.lock found — remove it first');
|
||||
}
|
||||
|
||||
// Not detached HEAD
|
||||
try { execStrict('git symbolic-ref HEAD'); }
|
||||
catch { throw new Error('Detached HEAD — checkout a branch first'); }
|
||||
}
|
||||
|
||||
/** Phase 5: Run verify command and extract metric */
|
||||
private runVerify(): number | null {
|
||||
this.log(' verify...');
|
||||
const output = exec(this.config.verify, { timeout: 300_000 });
|
||||
return extractMetric(output);
|
||||
}
|
||||
|
||||
/** Phase 5.5: Run guard command */
|
||||
private runGuard(): boolean {
|
||||
if (!this.config.guard) return true;
|
||||
this.log(' guard...');
|
||||
try {
|
||||
execStrict(this.config.guard, { timeout: 300_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 4: Commit changes */
|
||||
private commit(description: string): string | null {
|
||||
// Stage all changes in scope (but not untracked outside scope)
|
||||
exec('git add -A');
|
||||
const diff = exec('git diff --cached --quiet; echo $?');
|
||||
if (diff === '0') return null; // no changes
|
||||
|
||||
try {
|
||||
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
|
||||
return exec('git rev-parse --short HEAD');
|
||||
} catch {
|
||||
// Hook failure
|
||||
exec('git reset HEAD');
|
||||
return 'hook-blocked';
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 6: Rollback */
|
||||
private safeRevert(): void {
|
||||
try {
|
||||
execStrict('git revert HEAD --no-edit');
|
||||
} catch {
|
||||
exec('git revert --abort');
|
||||
exec('git reset --hard HEAD~1');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get stuck hint when >5 consecutive discards */
|
||||
private getStuckHint(discards: number): string | null {
|
||||
if (discards < 5) return null;
|
||||
const hints = [
|
||||
'Re-read ALL scope files from scratch. Try a completely different approach.',
|
||||
'Review entire results log — what worked before? Try combining successful changes.',
|
||||
'Try the OPPOSITE of what has been failing.',
|
||||
'Try a radical architectural change instead of incremental tweaks.',
|
||||
'Simplify — remove complexity rather than adding it.',
|
||||
];
|
||||
return hints[Math.min(discards - 5, hints.length - 1)];
|
||||
}
|
||||
|
||||
/** Run the main loop */
|
||||
async run(): Promise<IterationResult[]> {
|
||||
const results: IterationResult[] = [];
|
||||
|
||||
// Phase 0: Preconditions
|
||||
this.log('Phase 0: Precondition checks...');
|
||||
this.checkPreconditions();
|
||||
|
||||
// Initialize logger
|
||||
this.logger.init(this.config);
|
||||
|
||||
// Baseline measurement
|
||||
this.log('Measuring baseline...');
|
||||
const baseline = this.runVerify();
|
||||
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
|
||||
this.bestMetric = baseline;
|
||||
this.currentMetric = baseline;
|
||||
|
||||
const baselineCommit = exec('git rev-parse --short HEAD');
|
||||
const baselineResult: IterationResult = {
|
||||
iteration: 0,
|
||||
commit: baselineCommit,
|
||||
metric: baseline,
|
||||
delta: 0,
|
||||
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
|
||||
status: 'baseline',
|
||||
description: `initial state — ${this.config.metric} ${baseline}`,
|
||||
};
|
||||
this.logger.append(baselineResult);
|
||||
results.push(baselineResult);
|
||||
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
|
||||
|
||||
// Main loop
|
||||
const maxIter = this.config.iterations ?? Infinity;
|
||||
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
|
||||
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
|
||||
|
||||
// Phase 1: Review
|
||||
const gitLog = exec('git log --oneline -20');
|
||||
const recentLog = this.logger.readLast(20);
|
||||
const scopeFiles = this.config.scope;
|
||||
const consecutiveDiscards = this.logger.consecutiveDiscards();
|
||||
|
||||
// Phase 2-3: Ideate + Modify (delegated to callback)
|
||||
const context: ModifyContext = {
|
||||
iteration: this.iteration,
|
||||
bestMetric: this.bestMetric,
|
||||
currentMetric: this.currentMetric,
|
||||
recentLog,
|
||||
gitLog,
|
||||
scopeFiles,
|
||||
consecutiveDiscards,
|
||||
stuckHint: this.getStuckHint(consecutiveDiscards),
|
||||
};
|
||||
|
||||
let description: string | null;
|
||||
try {
|
||||
description = await this.callbacks.modify(context);
|
||||
} catch (err: any) {
|
||||
this.log(` modify error: ${err.message}`);
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `modify crashed: ${err.message?.slice(0, 80)}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: 'no changes made',
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 4: Commit
|
||||
this.log(` commit: ${description}`);
|
||||
const commitHash = this.commit(description);
|
||||
if (!commitHash) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: `no diff after: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
if (commitHash === 'hook-blocked') {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'hook-blocked',
|
||||
description: `hook rejected: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 5: Verify
|
||||
const metric = this.runVerify();
|
||||
if (metric == null) {
|
||||
this.log(' verify crashed — reverting');
|
||||
this.safeRevert();
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `verify crashed: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
const improved = this.config.direction === 'higher'
|
||||
? metric > this.bestMetric
|
||||
: metric < this.bestMetric;
|
||||
const delta = +(metric - this.bestMetric).toFixed(4);
|
||||
const absDelta = Math.abs(delta);
|
||||
const minDelta = this.config.minDelta ?? 0;
|
||||
|
||||
// Phase 5.5: Guard
|
||||
let guardResult: 'pass' | 'fail' | '-' = '-';
|
||||
if (this.config.guard && improved && absDelta >= minDelta) {
|
||||
guardResult = this.runGuard() ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
// Phase 6: Decide
|
||||
let status: IterationStatus;
|
||||
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
|
||||
status = 'keep';
|
||||
this.bestMetric = metric;
|
||||
this.currentMetric = metric;
|
||||
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
} else if (improved && guardResult === 'fail') {
|
||||
this.log(' guard failed — reverting');
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
this.log(` ✗ DISCARD (guard) — ${description}`);
|
||||
} else {
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
|
||||
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
}
|
||||
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: status === 'keep' ? commitHash : '-',
|
||||
metric,
|
||||
delta,
|
||||
guard: guardResult,
|
||||
status,
|
||||
description,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
|
||||
const discards = results.filter(r => r.status === 'discard');
|
||||
this.log(`\n${'━'.repeat(50)}`);
|
||||
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
|
||||
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Combined Test Suite Runner — runs browse + V2EX + Zhihu tasks.
|
||||
* Reports combined score for AutoResearch iteration.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-all.ts # Run all
|
||||
* npx tsx autoresearch/eval-all.ts --suite v2ex # Run one suite
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface SuiteResult {
|
||||
name: string;
|
||||
passed: number;
|
||||
total: number;
|
||||
failures: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function runSuite(name: string, script: string): SuiteResult {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const output = execSync(`npx tsx ${script}`, {
|
||||
cwd: ROOT,
|
||||
timeout: 600_000,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Parse SCORE=X/Y from output
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
|
||||
// Parse failures
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
} catch (err: any) {
|
||||
const output = err.stdout ?? '';
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleSuite = args.includes('--suite') ? args[args.indexOf('--suite') + 1] : null;
|
||||
|
||||
const suites = [
|
||||
{ name: 'browse', script: 'autoresearch/eval-browse.ts' },
|
||||
{ name: 'v2ex', script: 'autoresearch/eval-v2ex.ts' },
|
||||
{ name: 'zhihu', script: 'autoresearch/eval-zhihu.ts' },
|
||||
].filter(s => !singleSuite || s.name === singleSuite);
|
||||
|
||||
console.log(`\n🔬 Combined AutoResearch — ${suites.length} suites\n`);
|
||||
|
||||
const results: SuiteResult[] = [];
|
||||
for (const suite of suites) {
|
||||
console.log(` Running ${suite.name}...`);
|
||||
const result = runSuite(suite.name, suite.script);
|
||||
results.push(result);
|
||||
const icon = result.passed === result.total ? '✓' : '✗';
|
||||
console.log(` ${icon} ${result.name}: ${result.passed}/${result.total} (${Math.round(result.duration / 1000)}s)`);
|
||||
if (result.failures.length > 0) {
|
||||
for (const f of result.failures.slice(0, 5)) {
|
||||
console.log(` ✗ ${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.reduce((s, r) => s + r.passed, 0);
|
||||
const totalTasks = results.reduce((s, r) => s + r.total, 0);
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
const allFailures = results.flatMap(r => r.failures.map(f => `${r.name}:${f}`));
|
||||
|
||||
console.log(`\n${'━'.repeat(50)}`);
|
||||
console.log(` Combined: ${totalPassed}/${totalTasks}`);
|
||||
for (const r of results) {
|
||||
console.log(` ${r.name}: ${r.passed}/${r.total}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
if (allFailures.length > 0) {
|
||||
console.log(`\n All failures:`);
|
||||
for (const f of allFailures) console.log(` ✗ ${f}`);
|
||||
}
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('all-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `all-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${totalTasks}`,
|
||||
suites: Object.fromEntries(results.map(r => [r.name, `${r.passed}/${r.total}`])),
|
||||
failures: allFailures,
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(`\n Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${totalTasks}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -76,7 +76,7 @@ function runCommand(cmd: string): string {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 5: Publish Testing — end-to-end content creation via operate commands
|
||||
*
|
||||
* Tests the full chain: read content → navigate to platform → fill title+body → (optionally) publish → verify → cleanup
|
||||
*
|
||||
* Task types:
|
||||
* fill-only: navigate + fill fields + verify content was entered (safe, no side effects)
|
||||
* publish: full publish + verify + cleanup (deletes the post after verification)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-publish.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-publish.ts --task twitter-fill # Run single task
|
||||
* npx tsx autoresearch/eval-publish.ts --type fill-only # Run only fill tasks (safe)
|
||||
* npx tsx autoresearch/eval-publish.ts --type publish # Run only publish tasks (destructive)
|
||||
* npx tsx autoresearch/eval-publish.ts --platform twitter # Run only twitter tasks
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const TASKS_FILE = join(__dirname, 'publish-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface PublishTask {
|
||||
name: string;
|
||||
platform: string;
|
||||
type: 'fill-only' | 'publish';
|
||||
description: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
cleanup?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
platform: string;
|
||||
taskType: 'fill-only' | 'publish';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
cleanupResult?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern, 'i').test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: PublishTask): TaskResult {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Run main steps
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
process.stderr.write(` step ${i + 1}/${task.steps.length}: ${step.slice(0, 60)}...\n`);
|
||||
lastOutput = runCommand(step, 45000);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
// Run cleanup steps (if publish type and cleanup defined)
|
||||
let cleanupResult: string | undefined;
|
||||
if (task.cleanup && task.cleanup.length > 0) {
|
||||
process.stderr.write(` cleanup: ${task.cleanup.length} steps...\n`);
|
||||
let cleanupOutput = '';
|
||||
for (const step of task.cleanup) {
|
||||
cleanupOutput = runCommand(step, 30000);
|
||||
}
|
||||
cleanupResult = cleanupOutput.slice(0, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
cleanupResult,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const filterType = args.includes('--type') ? args[args.indexOf('--type') + 1] : null;
|
||||
const filterPlatform = args.includes('--platform') ? args[args.indexOf('--platform') + 1] : null;
|
||||
|
||||
const allTasks: PublishTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
let tasks = allTasks;
|
||||
|
||||
if (singleTask) tasks = tasks.filter(t => t.name === singleTask);
|
||||
if (filterType) tasks = tasks.filter(t => t.type === filterType);
|
||||
if (filterPlatform) tasks = tasks.filter(t => t.platform === filterPlatform);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`No tasks matched filters: task=${singleTask}, type=${filterType}, platform=${filterPlatform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fillTasks = tasks.filter(t => t.type === 'fill-only');
|
||||
const publishTasks = tasks.filter(t => t.type === 'publish');
|
||||
|
||||
console.log(`\n📝 Layer 5: Publish Testing — ${tasks.length} tasks`);
|
||||
console.log(` fill-only: ${fillTasks.length} | publish: ${publishTasks.length}`);
|
||||
console.log(` platforms: ${[...new Set(tasks.map(t => t.platform))].join(', ')}\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const icon = task.type === 'publish' ? '🚀' : '📋';
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${icon} ${task.name} (${task.platform})...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const status = result.passed ? '✓' : '✗';
|
||||
const cleanup = result.cleanupResult ? ` [cleanup: ${result.cleanupResult.slice(0, 30)}]` : '';
|
||||
console.log(` ${status} (${(result.duration / 1000).toFixed(1)}s)${cleanup}`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const fillPassed = results.filter(r => r.taskType === 'fill-only' && r.passed).length;
|
||||
const publishPassed = results.filter(r => r.taskType === 'publish' && r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
const fillTotal = results.filter(r => r.taskType === 'fill-only').length;
|
||||
const publishTotal = results.filter(r => r.taskType === 'publish').length;
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length}`);
|
||||
console.log(` fill-only: ${fillPassed}/${fillTotal}`);
|
||||
console.log(` publish: ${publishPassed}/${publishTotal}`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
// Platform breakdown
|
||||
const platforms = [...new Set(results.map(r => r.platform))];
|
||||
for (const p of platforms) {
|
||||
const pr = results.filter(r => r.platform === p);
|
||||
const pp = pr.filter(r => r.passed).length;
|
||||
console.log(` ${p}: ${pp}/${pr.length}`);
|
||||
}
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.platform}/${f.taskType}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('publish-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `publish-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
fillScore: `${fillPassed}/${fillTotal}`,
|
||||
publishScore: `${publishPassed}/${publishTotal}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 4: Save as CLI Testing — "Save as CLI" Pipeline
|
||||
*
|
||||
* Tests the full operate init → write adapter → operate verify flow.
|
||||
* Validates that browser exploration can be crystallized into reusable CLI adapters.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-save.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-save.ts --task hn-top # Run single task
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'save-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const USER_CLIS_DIR = join(homedir(), '.opencli', 'clis');
|
||||
|
||||
interface SaveTask {
|
||||
name: string;
|
||||
site: string;
|
||||
command: string;
|
||||
/** Inline adapter code (simple tasks) */
|
||||
adapter?: string;
|
||||
/** Path to adapter file relative to autoresearch/ dir (complex tasks — avoids JSON escape issues) */
|
||||
adapterFile?: string;
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
phase: 'init' | 'write' | 'verify' | 'judge';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
set: 'train' | 'test';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
// operate verify outputs table text; try JSON parse first, then count non-empty lines
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON — try line counting */ }
|
||||
// Table output: count data rows (skip header, separator, empty lines)
|
||||
const lines = output.split('\n').filter(l => l.trim() && !l.startsWith('─') && !l.startsWith('┌') && !l.startsWith('└') && !l.startsWith('├'));
|
||||
// Subtract header row
|
||||
const dataLines = lines.length > 1 ? lines.length - 1 : 0;
|
||||
return dataLines >= criteria.minLength;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
|
||||
/** Run a command, using local dist/main.js instead of global opencli for consistency */
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
// Use local build so tests always run against the current source
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupAdapter(site: string, command: string): void {
|
||||
const siteDir = join(USER_CLIS_DIR, site);
|
||||
const filePath = join(siteDir, `${command}.ts`);
|
||||
try {
|
||||
if (existsSync(filePath)) rmSync(filePath);
|
||||
// Remove site dir if empty
|
||||
if (existsSync(siteDir)) {
|
||||
const remaining = readdirSync(siteDir);
|
||||
if (remaining.length === 0) rmSync(siteDir, { recursive: true });
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function runTask(task: SaveTask): TaskResult {
|
||||
const start = Date.now();
|
||||
const { site, command } = task;
|
||||
const adapterDir = join(USER_CLIS_DIR, site);
|
||||
const adapterPath = join(adapterDir, `${command}.ts`);
|
||||
|
||||
// Cleanup any leftover from previous runs
|
||||
cleanupAdapter(site, command);
|
||||
|
||||
try {
|
||||
// Phase 1: init — create scaffold
|
||||
const initOutput = runCommand(`opencli operate init ${site}/${command}`);
|
||||
if (!existsSync(adapterPath)) {
|
||||
return {
|
||||
name: task.name, phase: 'init', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: `init failed: file not created. Output: ${initOutput.slice(0, 100)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: write — overwrite scaffold with real adapter code
|
||||
if (task.adapterFile) {
|
||||
// Read from file (complex adapters — avoids JSON string escape issues)
|
||||
const srcPath = join(__dirname, task.adapterFile);
|
||||
const code = readFileSync(srcPath, 'utf-8');
|
||||
writeFileSync(adapterPath, code, 'utf-8');
|
||||
} else if (task.adapter) {
|
||||
writeFileSync(adapterPath, task.adapter, 'utf-8');
|
||||
}
|
||||
|
||||
// Phase 3: verify — run the adapter via operate verify
|
||||
const verifyOutput = runCommand(
|
||||
`opencli operate verify ${site}/${command}`,
|
||||
45000, // longer timeout for network calls
|
||||
);
|
||||
|
||||
if (verifyOutput.includes('✗ Adapter failed')) {
|
||||
return {
|
||||
name: task.name, phase: 'verify', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: `verify failed: ${verifyOutput.slice(0, 200)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 4: judge — check output quality
|
||||
const passed = judge(task.judge, verifyOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
phase: 'judge',
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Judge failed on output: ${verifyOutput.slice(0, 150)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name, phase: 'verify', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
} finally {
|
||||
// Always cleanup test adapters
|
||||
cleanupAdapter(site, command);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
|
||||
const allTasks: SaveTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const tasks = singleTask ? allTasks.filter(t => t.name === singleTask) : allTasks;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`Task "${singleTask}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🧪 Layer 4: Save as CLI — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
const phase = result.passed ? '' : ` (${result.phase})`;
|
||||
console.log(` ${icon}${phase} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const trainResults = results.filter(r => r.set === 'train');
|
||||
const testResults = results.filter(r => r.set === 'test');
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const trainPassed = trainResults.filter(r => r.passed).length;
|
||||
const testPassed = testResults.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length} (train: ${trainPassed}/${trainResults.length}, test: ${testPassed}/${testResults.length})`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.phase}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('save-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `save-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
trainScore: `${trainPassed}/${trainResults.length}`,
|
||||
testScore: `${testPassed}/${testResults.length}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -18,7 +18,6 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'skill-tasks.yaml');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md');
|
||||
|
||||
@@ -160,13 +159,20 @@ Always close the browser with 'opencli operate close' when done.`;
|
||||
}
|
||||
|
||||
function extractVerdict(text: string): { success: boolean; explanation: string } {
|
||||
// Try to find {"success": ...} JSON in the text
|
||||
const jsonMatches = text.match(/\{"success"\s*:\s*(true|false)\s*,\s*"explanation"\s*:\s*"([^"]*)"\s*\}/g);
|
||||
if (jsonMatches) {
|
||||
const last = jsonMatches[jsonMatches.length - 1];
|
||||
try {
|
||||
return JSON.parse(last);
|
||||
} catch { /* fall through */ }
|
||||
// Try to find and parse {"success": ...} JSON from the last occurrence
|
||||
const idx = text.lastIndexOf('{"success"');
|
||||
if (idx !== -1) {
|
||||
// Find the matching closing brace (handle escaped quotes in explanation)
|
||||
const sub = text.slice(idx);
|
||||
let braceCount = 0;
|
||||
let end = -1;
|
||||
for (let i = 0; i < sub.length; i++) {
|
||||
if (sub[i] === '{') braceCount++;
|
||||
else if (sub[i] === '}') { braceCount--; if (braceCount === 0) { end = i + 1; break; } }
|
||||
}
|
||||
if (end > 0) {
|
||||
try { return JSON.parse(sub.slice(0, end)); } catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for success indicators in text
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* V2EX Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task v2ex-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'v2ex-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name prefix pattern
|
||||
function getLayer(name: string): string {
|
||||
if (['v2ex-open-', 'v2ex-state-', 'v2ex-get-title', 'v2ex-click-tab', 'v2ex-scroll-down',
|
||||
'v2ex-get-first-', 'v2ex-eval-extract', 'v2ex-get-url', 'v2ex-back-nav', 'v2ex-wait-'].some(p => name.startsWith(p)))
|
||||
return 'L1-atomic';
|
||||
if (['v2ex-hot-topics', 'v2ex-node-list', 'v2ex-topic-meta', 'v2ex-node-topics',
|
||||
'v2ex-node-pagination', 'v2ex-tab-content', 'v2ex-topic-replies-extract',
|
||||
'v2ex-topic-reply-count', 'v2ex-member-info', 'v2ex-search-results'].includes(name))
|
||||
return 'L2-single-page';
|
||||
if (['v2ex-click-topic-read', 'v2ex-click-author', 'v2ex-navigate-node', 'v2ex-pagination-page2',
|
||||
'v2ex-topic-and-back', 'v2ex-tab-then-topic', 'v2ex-scroll-find-more',
|
||||
'v2ex-node-to-topic', 'v2ex-multi-tab-compare', 'v2ex-topic-reply-to-author'].some(p => name.startsWith(p)))
|
||||
return 'L3-multi-step';
|
||||
if (['v2ex-reply-', 'v2ex-favorite-', 'v2ex-thank-', 'v2ex-create-'].some(p => name.startsWith(p)))
|
||||
return 'L4-write';
|
||||
if (['v2ex-collect-', 'v2ex-multi-node-', 'v2ex-topic-deep-', 'v2ex-cross-page-', 'v2ex-full-'].some(p => name.startsWith(p)))
|
||||
return 'L5-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 V2EX Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('v2ex-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `v2ex-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Zhihu Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task zhihu-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'zhihu-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name
|
||||
function getLayer(name: string): string {
|
||||
const l1 = ['zhihu-open-home', 'zhihu-get-title', 'zhihu-state', 'zhihu-get-url', 'zhihu-scroll-down',
|
||||
'zhihu-click-tab-hot', 'zhihu-back-navigation', 'zhihu-wait-page-load', 'zhihu-keys-escape', 'zhihu-screenshot'];
|
||||
const l2 = ['zhihu-feed-titles', 'zhihu-hot-list', 'zhihu-hot-metrics', 'zhihu-nav-tabs',
|
||||
'zhihu-feed-with-authors', 'zhihu-feed-types', 'zhihu-user-avatar', 'zhihu-search-input-exists'];
|
||||
const l3 = ['zhihu-question-title', 'zhihu-question-meta', 'zhihu-first-answer', 'zhihu-answer-votes',
|
||||
'zhihu-question-buttons', 'zhihu-multiple-answers', 'zhihu-question-description', 'zhihu-answer-count-number'];
|
||||
const l4 = ['zhihu-hot-to-question', 'zhihu-feed-to-question', 'zhihu-question-to-author',
|
||||
'zhihu-search-navigate', 'zhihu-topic-page', 'zhihu-user-profile', 'zhihu-question-and-back', 'zhihu-scroll-load-more'];
|
||||
const l5 = ['zhihu-upvote-button-find', 'zhihu-follow-question-find', 'zhihu-comment-button-find',
|
||||
'zhihu-bookmark-find', 'zhihu-write-answer-btn', 'zhihu-share-find'];
|
||||
const l6 = ['zhihu-hot-read-answer-author', 'zhihu-hot-to-author-profile', 'zhihu-multi-hot-topics',
|
||||
'zhihu-search-then-read', 'zhihu-question-scroll-answers', 'zhihu-compare-tabs', 'zhihu-user-answers', 'zhihu-topic-questions'];
|
||||
const l7 = ['zhihu-search-basic', 'zhihu-search-people', 'zhihu-search-topic',
|
||||
'zhihu-search-click-result', 'zhihu-search-filter-answers', 'zhihu-search-and-back'];
|
||||
const l8 = ['zhihu-full-browse-workflow', 'zhihu-deep-author-chain', 'zhihu-cross-question-compare',
|
||||
'zhihu-search-read-chain', 'zhihu-3-page-chain', 'zhihu-hot-scroll-deep-read'];
|
||||
|
||||
if (l1.includes(name)) return 'L1-atomic';
|
||||
if (l2.includes(name)) return 'L2-feed';
|
||||
if (l3.includes(name)) return 'L3-question';
|
||||
if (l4.includes(name)) return 'L4-navigation';
|
||||
if (l5.includes(name)) return 'L5-write';
|
||||
if (l6.includes(name)) return 'L6-chain';
|
||||
if (l7.includes(name)) return 'L7-search';
|
||||
if (l8.includes(name)) return 'L8-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 Zhihu Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('zhihu-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `zhihu-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AutoResearch TSV Logger — append-only results log with metadata header.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import type { AutoResearchConfig, IterationResult } from './config.js';
|
||||
|
||||
const COLUMNS = ['iteration', 'commit', 'metric', 'delta', 'guard', 'status', 'description'];
|
||||
|
||||
export class Logger {
|
||||
constructor(private path: string) {}
|
||||
|
||||
/** Create the TSV file with metadata header */
|
||||
init(config: AutoResearchConfig): void {
|
||||
const header = [
|
||||
`# metric_direction: ${config.direction === 'higher' ? 'higher_is_better' : 'lower_is_better'}`,
|
||||
`# goal: ${config.goal}`,
|
||||
`# scope: ${config.scope.join(', ')}`,
|
||||
`# verify: ${config.verify}`,
|
||||
config.guard ? `# guard: ${config.guard}` : null,
|
||||
COLUMNS.join('\t'),
|
||||
].filter(Boolean).join('\n');
|
||||
writeFileSync(this.path, header + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Append one iteration result */
|
||||
append(result: IterationResult): void {
|
||||
const row = [
|
||||
result.iteration,
|
||||
result.commit,
|
||||
result.metric,
|
||||
result.delta >= 0 ? `+${result.delta}` : result.delta,
|
||||
result.guard,
|
||||
result.status,
|
||||
result.description,
|
||||
].join('\t');
|
||||
appendFileSync(this.path, row + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Read last N entries for pattern recognition */
|
||||
readLast(n: number): IterationResult[] {
|
||||
if (!existsSync(this.path)) return [];
|
||||
const lines = readFileSync(this.path, 'utf-8').split('\n')
|
||||
.filter(l => l && !l.startsWith('#') && !l.startsWith('iteration'));
|
||||
return lines.slice(-n).map(line => {
|
||||
const [iteration, commit, metric, delta, guard, status, ...desc] = line.split('\t');
|
||||
return {
|
||||
iteration: parseInt(iteration, 10),
|
||||
commit,
|
||||
metric: parseFloat(metric),
|
||||
delta: parseFloat(delta),
|
||||
guard: guard as 'pass' | 'fail' | '-',
|
||||
status: status as IterationResult['status'],
|
||||
description: desc.join('\t'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Count consecutive discards from the end */
|
||||
consecutiveDiscards(): number {
|
||||
const entries = this.readLast(20);
|
||||
let count = 0;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].status === 'discard') count++;
|
||||
else break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Preset: Combined Reliability (browse + V2EX + Zhihu)
|
||||
*
|
||||
* Optimizes across ALL test suites simultaneously.
|
||||
* Current baseline: 57/59 + 60/60 + 60/60 = 177/179
|
||||
* Target: 179/179 (100%)
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const combinedReliability: AutoResearchConfig = {
|
||||
goal: 'Fix all remaining test failures across browse + V2EX + Zhihu (177/179 → 179/179)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
'autoresearch/browse-tasks.json',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-all.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 10,
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export { operateReliability } from './operate-reliability.js';
|
||||
export { skillQuality } from './skill-quality.js';
|
||||
export { v2exReliability } from './v2ex-reliability.js';
|
||||
export { zhihuReliability } from './zhihu-reliability.js';
|
||||
export { combinedReliability } from './combined-reliability.js';
|
||||
export { saveReliability } from './save-reliability.js';
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
import { operateReliability } from './operate-reliability.js';
|
||||
import { skillQuality } from './skill-quality.js';
|
||||
import { v2exReliability } from './v2ex-reliability.js';
|
||||
import { zhihuReliability } from './zhihu-reliability.js';
|
||||
import { combinedReliability } from './combined-reliability.js';
|
||||
import { saveReliability } from './save-reliability.js';
|
||||
|
||||
export const PRESETS: Record<string, AutoResearchConfig> = {
|
||||
'operate-reliability': operateReliability,
|
||||
'skill-quality': skillQuality,
|
||||
'v2ex-reliability': v2exReliability,
|
||||
'zhihu-reliability': zhihuReliability,
|
||||
'combined': combinedReliability,
|
||||
'save-reliability': saveReliability,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: Operate Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
|
||||
* Metric: number of passing browse-tasks (out of 59).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const operateReliability: AutoResearchConfig = {
|
||||
goal: 'Increase operate command pass rate to 59/59 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Preset: Save as CLI Reliability
|
||||
*
|
||||
* Optimizes the "Save as CLI" pipeline: operate init → write adapter → run.
|
||||
* Covers PUBLIC (no auth) and COOKIE (browser session) strategies.
|
||||
* Metric: number of passing save-tasks.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const saveReliability: AutoResearchConfig = {
|
||||
goal: 'Increase "Save as CLI" pipeline pass rate to 100%. The flow is: operate init creates a scaffold, user writes adapter code, opencli discovers and runs it. Covers both PUBLIC (fetch API) and COOKIE (browser session) strategies. Focus on: init template correctness, user CLI discovery, adapter loading, verify command robustness, and browser session handling.',
|
||||
scope: [
|
||||
'src/cli.ts',
|
||||
'src/discovery.ts',
|
||||
'src/registry.ts',
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
'autoresearch/save-tasks.json',
|
||||
'autoresearch/save-adapters/*.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-save.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Preset: Skill E2E Quality
|
||||
*
|
||||
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Metric: number of passing skill-tasks (out of 35).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const skillQuality: AutoResearchConfig = {
|
||||
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
|
||||
scope: [
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 20,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: V2EX Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the V2EX-specific test suite.
|
||||
* 40 tasks across 5 difficulty layers (atomic → complex chain).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const v2exReliability: AutoResearchConfig = {
|
||||
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-v2ex.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Preset: Zhihu Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Zhihu test suite.
|
||||
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
|
||||
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const zhihuReliability: AutoResearchConfig = {
|
||||
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-zhihu.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"name": "twitter-fill-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to tweet composer, fill in content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval - fill only test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "3-step: open compose → paste text via ClipboardEvent → verify text in composer"
|
||||
},
|
||||
{
|
||||
"name": "twitter-post-and-delete",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Post a tweet, verify success, then delete it",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "6-step chain: open compose → paste text → click post → wait → verify toast → cleanup: find tweet → menu → delete → confirm"
|
||||
},
|
||||
{
|
||||
"name": "twitter-read-hn-then-post",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Read HN top story title, compose a tweet about it, post, then delete",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const title = document.title || 'HN Story'; const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Interesting from HN: ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "9-step cross-site chain: read HN title → navigate to twitter compose → paste content → post → verify → cleanup delete"
|
||||
},
|
||||
{
|
||||
"name": "twitter-reply-to-own-tweet",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to own profile, find latest tweet, open reply box, fill reply text",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: home → find first tweet → click reply → fill reply text → verify content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to a popular question, open answer editor, fill in answer content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这是一个 OpenCLI 发文测试,时间戳: ' + Date.now() + '</p><p>这段内容用于验证 operate 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to question → click '写回答' → find editor → fill rich content (title + body) → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-article",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhihu article editor (zhuanlan), fill title + body (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ta = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'); if (!ta) return 'no-title-input'; ta.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(ta, '[AutoTest] OpenCLI 发文能力验证 ' + Date.now()); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是 OpenCLI autoresearch 发文测试集的一部分。</p><p>测试链路:导航 → 填写标题 → 填写正文 → 验证内容。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'))?.value || ''; const body = document.querySelector('[contenteditable=true]')?.textContent || ''; return JSON.stringify({ title: title.slice(0, 50), body: body.slice(0, 50) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to zhuanlan editor → fill title textarea → fill rich text body → verify both title and body content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-hn-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Read HN top story, then navigate to zhihu question and fill an answer about it",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || document.querySelector('[data-zop-retarget=\\\"answer\\\"]'); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 分享一个来自 Hacker News 的有趣内容</p><p>这是一个跨平台内容搬运测试,时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step cross-site chain: read HN title → navigate zhihu question → click 写回答 → fill answer with HN content → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-thread-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to compose, type first tweet, add thread tweet, type second tweet, verify both",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 1 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'first-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const addBtn = document.querySelector('[data-testid=\\\"addButton\\\"]') || document.querySelector('[aria-label=\\\"Add post\\\"]'); if (addBtn) { addBtn.click(); return 'thread-added'; } return 'no-add-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const box = boxes[boxes.length - 1]; if (!box) return 'no-second-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 2 - continuation'); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'second-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const t1 = boxes[0]?.textContent || ''; const t2 = boxes[boxes.length - 1]?.textContent || ''; return JSON.stringify({ tweet1: t1, tweet2: t2 }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Thread tweet 2"
|
||||
},
|
||||
"note": "10-step thread compose: open composer → fill tweet 1 → click add thread → fill tweet 2 → verify both tweets present"
|
||||
},
|
||||
{
|
||||
"name": "twitter-quote-retweet-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to home, find first tweet, open retweet menu, select Quote, fill quote text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const retweet = tweet.querySelector('[data-testid=\\\"retweet\\\"]'); if (retweet) { retweet.click(); return 'retweet-menu-opened'; } return 'no-retweet-btn'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Quote') || item.textContent?.includes('引用')) { item.click(); return 'quote-selected'; } } return 'no-quote-option'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Quote retweet test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'quote-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Quote retweet test"
|
||||
},
|
||||
"note": "8-step quote retweet: home → find tweet → click retweet → select Quote → fill quote text → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-search-then-reply-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'opencli' on twitter, find first result, click reply, fill reply text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/search?q=opencli&src=typed_query&f=live",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply from search result ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'reply-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Reply from search"
|
||||
},
|
||||
"note": "8-step search-then-reply: navigate to search URL → verify results → click reply on first → fill reply → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'AI agent' on zhihu, click first question result, click 写回答, fill answer, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI%20agent",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-question-links'; const link = links[0]; const href = link.getAttribute('href'); return 'found: ' + href; })()\"",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-links'; const link = links[0]; const href = link.getAttribute('href'); const match = href.match(/\\\\/question\\\\/(\\\\d+)/); if (match) { window.location.href = 'https://www.zhihu.com/question/' + match[1]; return 'navigating-to-question'; } link.click(); return 'clicked-link'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || Array.from(document.querySelectorAll('a')).find(a => a.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] AI agent 搜索后回答测试 ' + Date.now() + '</p><p>这是通过搜索 → 进入问题 → 填写回答的完整链路测试。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "9-step search-then-answer: search zhihu → find question link → navigate → click 写回答 → fill answer → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-question-fill-comment",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to question page, scroll to first answer, click comment, fill comment text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const answer = document.querySelector('[data-testid=\\\"answer\\\"]') || document.querySelector('.AnswerItem') || document.querySelector('.List-item'); if (answer) { answer.scrollIntoView({ behavior: 'smooth', block: 'center' }); return 'answer-scrolled'; } return 'no-answer'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const commentBtns = document.querySelectorAll('button'); for (const btn of commentBtns) { if (btn.textContent?.match(/评论|条评论|comment/i)) { btn.click(); return 'comment-opened: ' + btn.textContent.trim(); } } const commentIcons = document.querySelectorAll('[data-testid=\\\"comment\\\"]') || []; for (const icon of commentIcons) { icon.click(); return 'comment-icon-clicked'; } return 'no-comment-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-comment-editor'; editor.focus(); if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { editor.value = '[AutoTest] 评论测试 ' + Date.now(); editor.dispatchEvent(new Event('input', { bubbles: true })); } else { editor.innerHTML = '<p>[AutoTest] 评论测试 ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); } return 'comment-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; return editor.value || editor.textContent || ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step comment chain: navigate question → scroll to answer → click comment → fill comment text → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-article-with-formatting",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhuanlan editor, fill title, fill body with multiple paragraphs and bold text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] 格式化文章测试 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是第一段:OpenCLI 格式化发文测试。</p><p><strong>[AutoTest-Bold] 这是加粗的第二段,用于验证富文本格式。</strong></p><p>这是第三段,包含普通文本内容,时间戳: ' + Date.now() + '。</p><p>这是第四段,测试多段落填充能力。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled-with-formatting'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; const hasBold = editor.querySelector('strong') || editor.querySelector('b'); const paragraphs = editor.querySelectorAll('p'); return JSON.stringify({ paragraphCount: paragraphs.length, hasBold: !!hasBold, preview: editor.textContent?.slice(0, 80) }); })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), bodyHasBold: body.includes('AutoTest-Bold'), bodyLength: body.length }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step formatted article: navigate editor → fill title → fill body with <strong> bold + 4 paragraphs → verify formatting + content"
|
||||
},
|
||||
{
|
||||
"name": "cross-zhihu-to-twitter",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read zhihu hot topic title, navigate to twitter compose, fill tweet with zhihu content, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const hotItem = document.querySelector('.HotItem-content a') || document.querySelector('.HotList-item a') || document.querySelector('[data-testid=\\\"hot-item\\\"] a') || document.querySelector('.HotItem a'); if (hotItem) return hotItem.textContent?.trim()?.slice(0, 60) || 'no-text'; const titles = document.querySelectorAll('h2'); for (const t of titles) { if (t.textContent?.trim().length > 5) return t.textContent.trim().slice(0, 60); } return 'no-hot-topic'; })()\"",
|
||||
"opencli operate state save zhihu_hot_title",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Zhihu热榜话题搬运: 知乎上正在热议的话题 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'tweet-filled-with-zhihu'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Zhihu热榜话题搬运"
|
||||
},
|
||||
"note": "10-step cross-platform: read zhihu hot → save state → navigate twitter compose → fill tweet with zhihu content → verify"
|
||||
},
|
||||
{
|
||||
"name": "cross-twitter-to-zhihu",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read twitter trending/explore topic, navigate to zhihu zhuanlan editor, fill title and body, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/explore/tabs/trending",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const trends = document.querySelectorAll('[data-testid=\\\"trend\\\"]'); if (trends.length > 0) { const first = trends[0]; return first.textContent?.trim()?.slice(0, 80) || 'no-text'; } const spans = document.querySelectorAll('span'); for (const s of spans) { if (s.textContent?.startsWith('#') || s.textContent?.includes('Trending')) { return s.textContent.trim().slice(0, 80); } } return 'no-trending-topic'; })()\"",
|
||||
"opencli operate state save twitter_trending",
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] Twitter热点搬运: 来自推特的热门话题 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这篇文章搬运自 Twitter 热门话题。</p><p>Twitter 上正在讨论的热门话题为大家带来了新的视角和思考。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), body: body.slice(0, 60) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Twitter热点搬运"
|
||||
},
|
||||
"note": "10-step cross-platform reverse: read twitter trending → save state → navigate zhihu editor → fill title + body → verify"
|
||||
}
|
||||
]
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Layer 4: Save as CLI — test the full save pipeline
|
||||
# Tests: operate init → write adapter → operate verify
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== Layer 4: Save as CLI ==="
|
||||
echo "Testing: init → write → verify pipeline"
|
||||
echo ""
|
||||
|
||||
npx tsx autoresearch/eval-save.ts "$@"
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-xhs',
|
||||
name: 'explore-deep',
|
||||
description: '小红书探索页深度提取 + 去重 + 按互动排序',
|
||||
domain: 'www.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Number of items' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit ?? 15;
|
||||
// Step 1: Navigate to explore page
|
||||
await page.goto('https://www.xiaohongshu.com/explore');
|
||||
// Step 2: Wait for initial content via MutationObserver
|
||||
await page.evaluate(`new Promise(function(resolve) {
|
||||
var check = function() { return document.querySelectorAll('section.note-item').length > 0; };
|
||||
if (check()) return resolve(true);
|
||||
var observer = new MutationObserver(function(m, obs) { if (check()) { obs.disconnect(); resolve(true); } });
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(function() { observer.disconnect(); resolve(false); }, 8000);
|
||||
})`);
|
||||
// Step 3: Multi-round adaptive scroll (early stop when no new content)
|
||||
let prevCount = 0;
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
||||
await page.wait(1.5);
|
||||
const count = await page.evaluate('document.querySelectorAll("section.note-item").length') as number;
|
||||
if (count >= limit * 2 || count === prevCount) break;
|
||||
prevCount = count;
|
||||
}
|
||||
// Step 4: Extract with noteId deduplication + parse likes as integers
|
||||
const result = await page.evaluate(`(function() {
|
||||
var seen = {};
|
||||
var items = [];
|
||||
document.querySelectorAll('section.note-item').forEach(function(el) {
|
||||
var linkEl = el.querySelector('a[href]');
|
||||
var href = linkEl ? linkEl.getAttribute('href') || '' : '';
|
||||
var m = href.match(/explore\\/([a-f0-9]+)/);
|
||||
var noteId = m ? m[1] : '';
|
||||
if (!noteId || seen[noteId]) return;
|
||||
seen[noteId] = true;
|
||||
var titleEl = el.querySelector('.title span') || el.querySelector('a.title');
|
||||
var authorEl = el.querySelector('.author-wrapper .name') || el.querySelector('.author .name');
|
||||
var likesEl = el.querySelector('.like-wrapper .count') || el.querySelector('.interact-container .count');
|
||||
var title = (titleEl ? titleEl.textContent || '' : '').trim();
|
||||
var author = (authorEl ? authorEl.textContent || '' : '').trim();
|
||||
var likesRaw = (likesEl ? likesEl.textContent || '0' : '0').trim();
|
||||
var likes = parseInt(likesRaw.replace(/[^0-9]/g, '')) || 0;
|
||||
items.push({ title: title, author: author, likes: likes, url: 'https://www.xiaohongshu.com/explore/' + noteId });
|
||||
});
|
||||
return items;
|
||||
})()`);
|
||||
// Step 5: Sort by likes descending
|
||||
const sorted = (result as any[] || []).sort((a: any, b: any) => b.likes - a.likes);
|
||||
// Step 6: Slice and format
|
||||
return sorted.slice(0, limit).map((item: any, i: number) => ({
|
||||
rank: i + 1, title: item.title, author: item.author, likes: String(item.likes), url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-xhs',
|
||||
name: 'note-comments',
|
||||
description: '小红书笔记详情 + 评论(多步合并输出)',
|
||||
domain: 'www.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'id', type: 'string', default: '6745a82f000000000800b6ed', positional: true, help: 'Note ID' },
|
||||
{ name: 'limit', type: 'int', default: 5, help: 'Max comments' },
|
||||
],
|
||||
columns: ['section', 'title', 'author', 'likes', 'text'],
|
||||
func: async (page, kwargs) => {
|
||||
const noteId = kwargs.id ?? '6745a82f000000000800b6ed';
|
||||
const commentLimit = kwargs.limit ?? 5;
|
||||
// Step 1: Navigate to note detail page
|
||||
await page.goto('https://www.xiaohongshu.com/explore/' + noteId);
|
||||
await page.wait(3);
|
||||
// Step 2: Extract note metadata (title, author, likes)
|
||||
const meta = await page.evaluate(`(function() {
|
||||
return {
|
||||
title: (document.querySelector('#detail-title') || document.querySelector('.title') || {}).textContent?.trim() || '',
|
||||
author: (document.querySelector('.author-container .username') || document.querySelector('.user-nickname') || {}).textContent?.trim() || '',
|
||||
likes: (document.querySelector('[data-type="like"] .count') || document.querySelector('.like-wrapper .count') || {}).textContent?.trim() || '0',
|
||||
};
|
||||
})()`) as any;
|
||||
// Step 3: Scroll the note container to trigger comment loading
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate(`(function() {
|
||||
var scroller = document.querySelector('.note-scroller') || document.querySelector('.container');
|
||||
if (scroller && scroller.scrollTo) { scroller.scrollTo(0, 99999); } else { window.scrollTo(0, document.body.scrollHeight); }
|
||||
})()`);
|
||||
await page.wait(1);
|
||||
}
|
||||
// Step 4: Extract comments from DOM
|
||||
const comments = await page.evaluate(`(function() {
|
||||
var results = [];
|
||||
var commentEls = document.querySelectorAll('.parent-comment, .comment-item-root');
|
||||
commentEls.forEach(function(el) {
|
||||
var item = el.querySelector('.comment-item') || el.querySelector('.comment-inner');
|
||||
if (!item) return;
|
||||
var authorEl = item.querySelector('.author-wrapper .name') || item.querySelector('.user-name');
|
||||
var textEl = item.querySelector('.content') || item.querySelector('.note-text');
|
||||
var likesEl = item.querySelector('.count');
|
||||
var author = (authorEl ? authorEl.textContent || '' : '').trim();
|
||||
var text = (textEl ? textEl.textContent || '' : '').replace(/\\s+/g, ' ').trim();
|
||||
var likes = (likesEl ? likesEl.textContent || '0' : '0').trim();
|
||||
if (text) results.push({ author: author, text: text.slice(0, 80), likes: likes });
|
||||
});
|
||||
return results;
|
||||
})()`) as any[];
|
||||
// Step 5: Merge note meta + comments into unified output
|
||||
const rows: any[] = [{ section: 'note', title: meta.title, author: meta.author, likes: meta.likes, text: '' }];
|
||||
for (const c of (comments || []).slice(0, commentLimit)) {
|
||||
rows.push({ section: 'comment', title: '', author: c.author, likes: c.likes, text: c.text });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-xhs',
|
||||
name: 'search-full',
|
||||
description: '小红书搜索 + 滚动加载 + 去重',
|
||||
domain: 'www.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', type: 'string', default: '咖啡', positional: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of results' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = encodeURIComponent(kwargs.query ?? '咖啡');
|
||||
const limit = kwargs.limit ?? 10;
|
||||
// Step 1: Navigate to search page
|
||||
await page.goto('https://www.xiaohongshu.com/search_result?keyword=' + query + '&source=web_search_result_notes');
|
||||
// Step 2: Wait for async render via MutationObserver
|
||||
await page.evaluate(`new Promise(function(resolve) {
|
||||
var check = function() { return document.querySelectorAll('section.note-item').length > 0; };
|
||||
if (check()) return resolve(true);
|
||||
var observer = new MutationObserver(function(m, obs) { if (check()) { obs.disconnect(); resolve(true); } });
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(function() { observer.disconnect(); resolve(false); }, 8000);
|
||||
})`);
|
||||
// Step 3: Scroll 3x to load more content
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
||||
await page.wait(1);
|
||||
}
|
||||
// Step 4: Extract from DOM with deduplication
|
||||
const result = await page.evaluate(`(function() {
|
||||
var seen = {};
|
||||
var items = [];
|
||||
document.querySelectorAll('section.note-item').forEach(function(el) {
|
||||
var linkEl = el.querySelector('a[href]');
|
||||
var href = linkEl ? linkEl.getAttribute('href') || '' : '';
|
||||
var m = href.match(/explore\\/([a-f0-9]+)/);
|
||||
var noteId = m ? m[1] : href;
|
||||
if (!noteId || seen[noteId]) return;
|
||||
seen[noteId] = true;
|
||||
var titleEl = el.querySelector('.title span') || el.querySelector('a.title');
|
||||
var authorEl = el.querySelector('.author-wrapper .name') || el.querySelector('.author .name');
|
||||
var likesEl = el.querySelector('.like-wrapper .count') || el.querySelector('.interact-container .count');
|
||||
if (titleEl) {
|
||||
items.push({
|
||||
title: (titleEl.textContent || '').trim(),
|
||||
author: (authorEl ? authorEl.textContent || '' : '').trim(),
|
||||
likes: (likesEl ? likesEl.textContent || '0' : '0').trim(),
|
||||
url: 'https://www.xiaohongshu.com' + href,
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
})()`);
|
||||
return (result as any[]).slice(0, limit).map((item: any, i: number) => ({
|
||||
rank: i + 1, title: item.title, author: item.author, likes: item.likes, url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-zhihu',
|
||||
name: 'hot-detail',
|
||||
description: '知乎热榜 + 每个问题的第一个回答摘要',
|
||||
domain: 'www.zhihu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 5, help: 'Number of items' },
|
||||
],
|
||||
columns: ['rank', 'title', 'heat', 'top_answer_author', 'top_answer_excerpt'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit ?? 5;
|
||||
// Step 1: Navigate
|
||||
await page.goto('https://www.zhihu.com');
|
||||
await page.wait(2);
|
||||
// Step 2: Fetch hot list (handle 16+ digit IDs)
|
||||
const hotList = await page.evaluate(`(async () => {
|
||||
const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', { credentials: 'include' });
|
||||
const text = await res.text();
|
||||
const d = JSON.parse(text.replace(/("id"\\s*:\\s*)(\\d{16,})/g, '$1"$2"'));
|
||||
return (d?.data || []).map(item => {
|
||||
const t = item.target || {};
|
||||
return { qid: String(t.id || ''), title: t.title || '', heat: item.detail_text || '' };
|
||||
});
|
||||
})()`) as any[];
|
||||
// Step 3: For each hot question, fetch its top answer
|
||||
const items = hotList.slice(0, limit);
|
||||
const enriched = [];
|
||||
for (const item of items) {
|
||||
if (!item.qid) { enriched.push({ ...item, top_answer_author: '', top_answer_excerpt: '' }); continue; }
|
||||
const answer = await page.evaluate(`(async () => {
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${item.qid}/answers?limit=1&offset=0&sort_by=default&include=data[*].content,voteup_count,author', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
const a = d?.data?.[0];
|
||||
if (!a) return { author: '', excerpt: '' };
|
||||
return { author: a.author?.name || 'anonymous', excerpt: strip(a.content || '').slice(0, 120) };
|
||||
} catch { return { author: '', excerpt: '' }; }
|
||||
})()`) as any;
|
||||
enriched.push({ ...item, top_answer_author: answer.author, top_answer_excerpt: answer.excerpt });
|
||||
}
|
||||
// Step 4: Format output
|
||||
return enriched.map((item, i) => ({
|
||||
rank: i + 1, title: item.title, heat: item.heat,
|
||||
top_answer_author: item.top_answer_author, top_answer_excerpt: item.top_answer_excerpt,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-zhihu',
|
||||
name: 'question-full',
|
||||
description: '知乎问题 + 回答 + 相关推荐(三层数据合并)',
|
||||
domain: 'www.zhihu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'id', type: 'string', default: '19550225', positional: true, help: 'Question ID' },
|
||||
{ name: 'limit', type: 'int', default: 3, help: 'Number of answers' },
|
||||
],
|
||||
columns: ['section', 'title', 'author', 'votes', 'excerpt'],
|
||||
func: async (page, kwargs) => {
|
||||
const qid = kwargs.id ?? '19550225';
|
||||
const limit = kwargs.limit ?? 3;
|
||||
// Step 1: Navigate to question page
|
||||
await page.goto('https://www.zhihu.com/question/' + qid);
|
||||
await page.wait(2);
|
||||
// Step 2: Fetch question detail
|
||||
const question = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${qid}', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return { title: d.title || '', follower_count: d.follower_count || 0, answer_count: d.answer_count || 0 };
|
||||
} catch { return { title: '', follower_count: 0, answer_count: 0 }; }
|
||||
})()`) as any;
|
||||
// Step 3: Fetch top answers
|
||||
const answers = await page.evaluate(`(async () => {
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${qid}/answers?limit=${limit}&offset=0&sort_by=default&include=data[*].content,voteup_count,author', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data || []).map(a => ({ author: a.author?.name || 'anonymous', votes: a.voteup_count || 0, excerpt: strip(a.content || '').slice(0, 120) }));
|
||||
} catch { return []; }
|
||||
})()`) as any[];
|
||||
// Step 4: Fetch related questions
|
||||
const related = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${qid}/similar?limit=3', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data || []).map(q => ({ title: q.title || '', answer_count: q.answer_count || 0 }));
|
||||
} catch { return []; }
|
||||
})()`) as any[];
|
||||
// Step 5: Merge three layers into unified output
|
||||
const rows: any[] = [];
|
||||
rows.push({ section: 'question', title: question.title, author: '', votes: question.follower_count, excerpt: question.answer_count + ' answers' });
|
||||
for (const a of answers) {
|
||||
rows.push({ section: 'answer', title: '', author: a.author, votes: a.votes, excerpt: a.excerpt });
|
||||
}
|
||||
for (const r of related) {
|
||||
rows.push({ section: 'related', title: r.title, author: '', votes: 0, excerpt: r.answer_count + ' answers' });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-zhihu',
|
||||
name: 'search-detail',
|
||||
description: '知乎搜索 + 每条结果的问题统计',
|
||||
domain: 'www.zhihu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', type: 'string', default: 'AI', positional: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 5, help: 'Number of results' },
|
||||
],
|
||||
columns: ['rank', 'title', 'type', 'author', 'votes', 'answer_count', 'follower_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = kwargs.query ?? 'AI';
|
||||
const limit = kwargs.limit ?? 5;
|
||||
// Step 1: Navigate
|
||||
await page.goto('https://www.zhihu.com');
|
||||
await page.wait(2);
|
||||
// Step 2: Search API — filter results by type, extract question IDs
|
||||
const searchResults = await page.evaluate(`(async () => {
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent('${query}') + '&t=general&offset=0&limit=20', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data || []).filter(item => item.type === 'search_result').map(item => {
|
||||
const obj = item.object || {};
|
||||
const q = obj.question || {};
|
||||
const questionId = obj.type === 'answer' ? String(q.id || '') : obj.type === 'question' ? String(obj.id || '') : '';
|
||||
return { type: obj.type || '', title: strip(obj.title || q.name || ''), author: obj.author?.name || '', votes: obj.voteup_count || 0, questionId };
|
||||
});
|
||||
})()`) as any[];
|
||||
// Step 3: For each result, fetch question stats (answer_count, follower_count)
|
||||
const items = searchResults.slice(0, limit);
|
||||
const enriched = [];
|
||||
for (const item of items) {
|
||||
if (!item.questionId) { enriched.push({ ...item, answer_count: 0, follower_count: 0 }); continue; }
|
||||
const stats = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${item.questionId}', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return { answer_count: d.answer_count || 0, follower_count: d.follower_count || 0 };
|
||||
} catch { return { answer_count: 0, follower_count: 0 }; }
|
||||
})()`) as any;
|
||||
enriched.push({ ...item, answer_count: stats.answer_count, follower_count: stats.follower_count });
|
||||
}
|
||||
// Step 4: Format output
|
||||
return enriched.map((item, i) => ({
|
||||
rank: i + 1, title: item.title, type: item.type, author: item.author,
|
||||
votes: item.votes, answer_count: item.answer_count, follower_count: item.follower_count,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
[
|
||||
{
|
||||
"name": "httpbin-get",
|
||||
"site": "test-httpbin",
|
||||
"command": "get",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-httpbin',\n name: 'get',\n description: 'httpbin echo test',\n domain: 'httpbin.org',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [],\n columns: ['origin', 'url'],\n func: async () => {\n const res = await fetch('https://httpbin.org/get');\n const d = await res.json();\n return [{ origin: d.origin, url: d.url }];\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
},
|
||||
"note": "Simplest possible: httpbin echo, single row"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-posts",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "posts",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'posts',\n description: 'JSONPlaceholder posts',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of posts' },\n ],\n columns: ['id', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/posts');\n const posts = await res.json();\n return posts.slice(0, limit).map((p: any) => ({ id: p.id, title: p.title }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-users",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "users",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'users',\n description: 'JSONPlaceholder users',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of users' },\n ],\n columns: ['id', 'name', 'email'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/users');\n const users = await res.json();\n return users.slice(0, limit).map((u: any) => ({ id: u.id, name: u.name, email: u.email }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hn-top",
|
||||
"site": "test-hn",
|
||||
"command": "top",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'top',\n description: 'HackerNews top stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hn-ask",
|
||||
"site": "test-hn",
|
||||
"command": "ask",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'ask',\n description: 'HackerNews Ask HN stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/askstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wiki-summary",
|
||||
"site": "test-wiki",
|
||||
"command": "summary",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-wiki',\n name: 'summary',\n description: 'Wikipedia article summary',\n domain: 'en.wikipedia.org',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'title', type: 'string', default: 'JavaScript', positional: true, help: 'Article title' },\n ],\n columns: ['title', 'extract'],\n func: async (_page, kwargs) => {\n const title = encodeURIComponent(kwargs.title);\n const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${title}`);\n const d = await res.json();\n return [{ title: d.title, extract: d.extract?.slice(0, 200) }];\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "programming language"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lobsters-hot",
|
||||
"site": "test-lobsters",
|
||||
"command": "hot",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-lobsters',\n name: 'hot',\n description: 'Lobsters hottest stories',\n domain: 'lobste.rs',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['title', 'score', 'url'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://lobste.rs/hottest.json');\n const stories = await res.json();\n return stories.slice(0, limit).map((s: any) => ({\n title: s.title, score: s.score, url: s.short_id_url,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "devto-top",
|
||||
"site": "test-devto",
|
||||
"command": "top",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-devto',\n name: 'top',\n description: 'DEV.to top articles',\n domain: 'dev.to',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of articles' },\n ],\n columns: ['title', 'user', 'reactions'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://dev.to/api/articles?per_page=' + limit);\n const articles = await res.json();\n return articles.map((a: any) => ({\n title: a.title, user: a.user?.username, reactions: a.positive_reactions_count,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-with-top-answer",
|
||||
"site": "test-zhihu",
|
||||
"command": "hot-detail",
|
||||
"adapterFile": "save-adapters/zhihu-hot-detail.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "6-step chain: navigate → fetch hot list API → parse big-int IDs → loop N items → fetch answer API per question → strip HTML → merge"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-with-question-stats",
|
||||
"site": "test-zhihu",
|
||||
"command": "search-detail",
|
||||
"adapterFile": "save-adapters/zhihu-search-detail.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "7-step chain: navigate → search API → filter by type → extract question IDs → fetch question detail per result → merge stats → format"
|
||||
},
|
||||
{
|
||||
"name": "xhs-search-scroll-extract",
|
||||
"site": "test-xhs",
|
||||
"command": "search-full",
|
||||
"adapterFile": "save-adapters/xhs-search-full.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "6-step chain: navigate → MutationObserver wait → scroll 3x → DOM extract with URL dedup → slice + format"
|
||||
},
|
||||
{
|
||||
"name": "xhs-note-with-comments",
|
||||
"site": "test-xhs",
|
||||
"command": "note-comments",
|
||||
"adapterFile": "save-adapters/xhs-note-comments.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
},
|
||||
"note": "7-step chain: navigate → wait → extract note meta → scroll container 3x → extract comments DOM → merge note+comments → unified output"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-with-related",
|
||||
"site": "test-zhihu",
|
||||
"command": "question-full",
|
||||
"adapterFile": "save-adapters/zhihu-question-full.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
},
|
||||
"note": "8-step chain: navigate → wait → fetch question detail → fetch answers → strip HTML → fetch related questions → merge 3 layers → format"
|
||||
},
|
||||
{
|
||||
"name": "xhs-explore-scroll-dedupe",
|
||||
"site": "test-xhs",
|
||||
"command": "explore-deep",
|
||||
"adapterFile": "save-adapters/xhs-explore-deep.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "8-step chain: navigate → MutationObserver wait → adaptive scroll → DOM extract with dedup → parse likes → sort desc → slice → format"
|
||||
},
|
||||
{
|
||||
"name": "hn-new",
|
||||
"site": "test-hn",
|
||||
"command": "new",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'new',\n description: 'HackerNews newest stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/newstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews new stories using same Firebase API as hn-top/hn-ask"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-todos",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "todos",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'todos',\n description: 'JSONPlaceholder todos',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of todos' },\n ],\n columns: ['id', 'title', 'completed'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/todos');\n const todos = await res.json();\n return todos.slice(0, limit).map((t: any) => ({ id: t.id, title: t.title, completed: t.completed }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder todos — same base domain as posts/users, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "hn-show",
|
||||
"site": "test-hn",
|
||||
"command": "show",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'show',\n description: 'HackerNews Show HN stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/showstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews show stories using same Firebase API as hn-top/hn-ask/hn-new"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-comments",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "comments",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'comments',\n description: 'JSONPlaceholder comments',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of comments' },\n ],\n columns: ['id', 'name', 'email'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/comments');\n const comments = await res.json();\n return comments.slice(0, limit).map((c: any) => ({ id: c.id, name: c.name, email: c.email }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder comments — same base domain as posts/users/todos, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-albums",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "albums",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'albums',\n description: 'JSONPlaceholder albums',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of albums' },\n ],\n columns: ['id', 'userId', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/albums');\n const albums = await res.json();\n return albums.slice(0, limit).map((a: any) => ({ id: a.id, userId: a.userId, title: a.title }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder albums — same base domain as posts/users/todos/comments, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-photos",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "photos",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'photos',\n description: 'JSONPlaceholder photos',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of photos' },\n ],\n columns: ['id', 'albumId', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/photos');\n const photos = await res.json();\n return photos.slice(0, limit).map((p: any) => ({ id: p.id, albumId: p.albumId, title: p.title }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder photos — same base domain as posts/users/todos/comments/albums, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "hn-best",
|
||||
"site": "test-hn",
|
||||
"command": "best",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'best',\n description: 'HackerNews best stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/beststories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews best stories using same Firebase API as hn-top/hn-ask/hn-new/hn-show"
|
||||
},
|
||||
{
|
||||
"name": "hn-jobs",
|
||||
"site": "test-hn",
|
||||
"command": "jobs",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'jobs',\n description: 'HackerNews job stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of jobs' },\n ],\n columns: ['rank', 'title', 'url'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/jobstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, url: item.url ?? '',\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews job listings using same Firebase API as other HN adapters"
|
||||
},
|
||||
{
|
||||
"name": "restcountries-list",
|
||||
"site": "test-restcountries",
|
||||
"command": "list",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-restcountries',\n name: 'list',\n description: 'REST Countries list',\n domain: 'restcountries.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of countries' },\n ],\n columns: ['name', 'capital', 'region'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://restcountries.com/v3.1/all?fields=name,capital,region');\n const countries = await res.json();\n return countries.slice(0, limit).map((c: any) => ({\n name: c.name?.common ?? '',\n capital: c.capital?.[0] ?? '',\n region: c.region ?? '',\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: REST Countries API — stable, no-auth, returns 250 countries with name/capital/region"
|
||||
},
|
||||
{
|
||||
"name": "nager-holidays",
|
||||
"site": "test-nager",
|
||||
"command": "holidays",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-nager',\n name: 'holidays',\n description: 'US public holidays for current year',\n domain: 'date.nager.at',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of holidays' },\n ],\n columns: ['date', 'name', 'type'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const year = new Date().getFullYear();\n const res = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/US`);\n const holidays = await res.json();\n return holidays.slice(0, limit).map((h: any) => ({\n date: h.date,\n name: h.name,\n type: (h.types || []).join(','),\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Nager public holidays API — stable, no-auth, returns US federal holidays by year"
|
||||
},
|
||||
{
|
||||
"name": "catfact-list",
|
||||
"site": "test-catfact",
|
||||
"command": "facts",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-catfact',\n name: 'facts',\n description: 'Random cat facts',\n domain: 'catfact.ninja',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of facts' },\n ],\n columns: ['fact', 'length'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch(`https://catfact.ninja/facts?limit=${limit}`);\n const d = await res.json();\n return d.data.map((item: any) => ({\n fact: item.fact.slice(0, 100),\n length: item.length,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: catfact.ninja facts API — stable, no-auth, returns random cat facts"
|
||||
},
|
||||
{
|
||||
"name": "opentdb-trivia",
|
||||
"site": "test-opentdb",
|
||||
"command": "easy",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-opentdb',\n name: 'easy',\n description: 'Easy trivia questions from Open Trivia DB',\n domain: 'opentdb.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of questions' },\n ],\n columns: ['category', 'question', 'answer'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch(`https://opentdb.com/api.php?amount=${limit}&difficulty=easy&type=multiple`);\n const d = await res.json();\n return d.results.map((q: any) => ({\n category: q.category,\n question: q.question.replace(/"/g, '\"').replace(/'/g, \"'\").slice(0, 80),\n answer: q.correct_answer,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Open Trivia DB API — stable, no-auth, returns trivia questions with correct answers"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,899 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-tab",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-first-topic-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-extract-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "v2ex.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait selector \"a[href^='/t/']\"",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 2: Single Page Tasks (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-list",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-pagination-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"max\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=jobs",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-replies-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-count",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-member-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Livid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-search-results",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 3: Multi-Step (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-topic-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-navigate-node-from-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-pagination-page2",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "p=2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-then-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-find-more",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-to-topic-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-tab-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 4: Write Operations (5 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-type-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoResearch test reply"
|
||||
},
|
||||
"note": "Types into reply box but does NOT submit"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-favorite-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "favorite|收藏"
|
||||
},
|
||||
"note": "Finds favorite link but does NOT click it"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-thank-reply-find",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"found\":\\d+"
|
||||
},
|
||||
"note": "Finds thank buttons but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "\"textarea\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-create-topic-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/new",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"note": "Detects create topic form elements, does NOT submit"
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 5: Complex Chain (5 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-collect-hot-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-node-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-cross-page-data-collect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-full-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 6: State + Click Interaction (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate click 1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-tab-tech",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+|no-ref"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-count-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-scroll-state",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-type-search-box",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-value-after-type",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-screenshot-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-html-selector",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get html --selector h1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait text V2EX"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "found|appeared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-3-pages",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-navigate-extract-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-multi-node-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-topic-replies-pagination",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-member-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-search-navigate-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-tab-topic-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-node-page2-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-full-interaction",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-deep-5-step",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
|
||||
"opencli operate back",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA navigation, timing, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/go/python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-after-click",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on topic page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-and-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-concurrent-eval",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.*\"url\":.*\"links\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-unicode-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Agent-Style: state + click + type (no eval for interaction) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-first-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
},
|
||||
"note": "Finds the index of first topic link via data-opencli-ref"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-type-search",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate type 3 TypeScript",
|
||||
"opencli operate get value 3"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
},
|
||||
"note": "Types into search box using state index"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-navigate-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-has-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-after-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 800",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "page_scroll: [\\d.]+↑"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,848 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "知乎"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-state",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[@?\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-click-tab-hot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "zhihu\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate wait text 推荐",
|
||||
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "pressed|Pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-screenshot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate screenshot /tmp/zhihu-test.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-list",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-metrics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-nav-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "推荐"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-with-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-types",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"question\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-avatar",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-input-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search found"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L3: Question Page Operations (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-first-answer",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"content\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-votes",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-buttons",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multiple-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-count-number",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L4: Multi-Step Navigation (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-page",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-load-more",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L5: Write Operations (6 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-upvote-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "赞同"
|
||||
},
|
||||
"note": "Finds upvote button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-follow-question-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "关注问题"
|
||||
},
|
||||
"note": "Finds follow button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-comment-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "评论"
|
||||
},
|
||||
"note": "Finds comment button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-bookmark-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "收藏|found"
|
||||
},
|
||||
"note": "Finds bookmark button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-write-answer-btn",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "写回答"
|
||||
},
|
||||
"note": "Finds write answer button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-share-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "分享"
|
||||
},
|
||||
"note": "Finds share button but does NOT click"
|
||||
},
|
||||
{
|
||||
"_comment": "=== L6: Long Chain Workflows (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-read-answer-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multi-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-scroll-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-compare-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-questions",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L7: Search Workflows (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-basic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-people",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-click-result",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-filter-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"total\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-full-browse-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-deep-author-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"profile\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-cross-question-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"q1_title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-read-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-3-page-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-scroll-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA lazy load, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/people/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-click-verify-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on question page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-lazy-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-extract-structured",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-answer-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"answerCount\":\\d+"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,615 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.24.6",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@algolia/abtesting": ["@algolia/abtesting@1.15.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA=="],
|
||||
|
||||
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
|
||||
|
||||
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
|
||||
|
||||
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
|
||||
|
||||
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
|
||||
|
||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ=="],
|
||||
|
||||
"@algolia/client-analytics": ["@algolia/client-analytics@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q=="],
|
||||
|
||||
"@algolia/client-common": ["@algolia/client-common@5.49.2", "", {}, "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw=="],
|
||||
|
||||
"@algolia/client-insights": ["@algolia/client-insights@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA=="],
|
||||
|
||||
"@algolia/client-personalization": ["@algolia/client-personalization@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA=="],
|
||||
|
||||
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA=="],
|
||||
|
||||
"@algolia/client-search": ["@algolia/client-search@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg=="],
|
||||
|
||||
"@algolia/ingestion": ["@algolia/ingestion@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw=="],
|
||||
|
||||
"@algolia/monitoring": ["@algolia/monitoring@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg=="],
|
||||
|
||||
"@algolia/recommend": ["@algolia/recommend@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA=="],
|
||||
|
||||
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA=="],
|
||||
|
||||
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ=="],
|
||||
|
||||
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
|
||||
|
||||
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
|
||||
|
||||
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
||||
|
||||
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.74", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
||||
|
||||
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
||||
|
||||
"algoliasearch": ["algoliasearch@5.49.2", "", { "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", "@algolia/client-analytics": "5.49.2", "@algolia/client-common": "5.49.2", "@algolia/client-insights": "5.49.2", "@algolia/client-personalization": "5.49.2", "@algolia/client-query-suggestions": "5.49.2", "@algolia/client-search": "5.49.2", "@algolia/ingestion": "1.49.2", "@algolia/monitoring": "1.49.2", "@algolia/recommend": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": "bin/esbuild" }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
|
||||
|
||||
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3"], "bin": "bin/vitepress.js" }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="],
|
||||
|
||||
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" } }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './item.js';
|
||||
|
||||
describe('1688 item normalization', () => {
|
||||
it('normalizes public item payload into contract fields', () => {
|
||||
const result = __test__.normalizeItemPayload({
|
||||
href: 'https://detail.1688.com/offer/887904326744.html',
|
||||
title: '法式春季长袖开衫连衣裙女新款大码女装碎花吊带裙套装142077 - 阿里巴巴',
|
||||
bodyText: `
|
||||
青岛沁澜衣品服装有限公司
|
||||
入驻13年
|
||||
主营:大码女装
|
||||
店铺回头率
|
||||
87%
|
||||
山东青岛
|
||||
3套起批
|
||||
已售1600+套
|
||||
支持定制logo
|
||||
`,
|
||||
offerTitle: '法式春季长袖开衫连衣裙女新款大码女装碎花吊带裙套装142077',
|
||||
offerId: 887904326744,
|
||||
seller: {
|
||||
companyName: '青岛沁澜衣品服装有限公司',
|
||||
memberId: 'b2b-1641351767',
|
||||
winportUrl: 'https://yinuoweierfushi.1688.com/page/index.html?spm=a1',
|
||||
},
|
||||
trade: {
|
||||
beginAmount: 3,
|
||||
priceDisplay: '96.00-98.00',
|
||||
unit: '套',
|
||||
saleCount: 1655,
|
||||
offerIDatacenterSellInfo: {
|
||||
面料名称: '莫代尔',
|
||||
主面料成分: '莫代尔纤维',
|
||||
sellPointModel: '{"ignore":true}',
|
||||
},
|
||||
offerPriceModel: {
|
||||
currentPrices: [
|
||||
{ beginAmount: 3, price: '98.00' },
|
||||
{ beginAmount: 50, price: '97.00' },
|
||||
],
|
||||
},
|
||||
},
|
||||
gallery: {
|
||||
mainImage: ['https://example.com/1.jpg'],
|
||||
offerImgList: ['https://example.com/2.jpg'],
|
||||
wlImageInfos: [{ fullPathImageURI: 'https://example.com/3.jpg' }],
|
||||
},
|
||||
services: [
|
||||
{ serviceName: '延期必赔', agreeDeliveryHours: 360 },
|
||||
{ serviceName: '品质保障' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.offer_id).toBe('887904326744');
|
||||
expect(result.member_id).toBe('b2b-1641351767');
|
||||
expect(result.shop_id).toBe('yinuoweierfushi');
|
||||
expect(result.seller_url).toBe('https://yinuoweierfushi.1688.com');
|
||||
expect(result.price_text).toBe('¥96.00-98.00');
|
||||
expect(result.moq_text).toBe('3套起批');
|
||||
expect(result.origin_place).toBe('山东青岛');
|
||||
expect(result.delivery_days_text).toBe('360小时内发货');
|
||||
expect(result.private_label_text).toBe('支持定制logo');
|
||||
expect(result.visible_attributes).toEqual([
|
||||
{ key: '面料名称', value: '莫代尔' },
|
||||
{ key: '主面料成分', value: '莫代尔纤维' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import { isRecord } from '@jackwener/opencli/utils';
|
||||
import {
|
||||
assertAuthenticatedState,
|
||||
buildDetailUrl,
|
||||
buildProvenance,
|
||||
canonicalizeSellerUrl,
|
||||
cleanMultilineText,
|
||||
cleanText,
|
||||
extractLocation,
|
||||
extractMemberId,
|
||||
extractOfferId,
|
||||
extractShopId,
|
||||
gotoAndReadState,
|
||||
normalizePriceTiers,
|
||||
parseMoqText,
|
||||
parsePriceText,
|
||||
toNumber,
|
||||
uniqueNonEmpty,
|
||||
} from './shared.js';
|
||||
|
||||
interface BuyerProtectionModel {
|
||||
serviceName?: string;
|
||||
shortBuyerDesc?: string;
|
||||
packageBuyerDesc?: string;
|
||||
textDesc?: string;
|
||||
agreeDeliveryHours?: number;
|
||||
}
|
||||
|
||||
interface ItemBrowserPayload {
|
||||
href?: string;
|
||||
title?: string;
|
||||
bodyText?: string;
|
||||
offerTitle?: string;
|
||||
offerId?: string | number;
|
||||
seller?: {
|
||||
companyName?: string;
|
||||
memberId?: string;
|
||||
winportUrl?: string;
|
||||
sellerWinportUrlMap?: Record<string, string>;
|
||||
};
|
||||
trade?: {
|
||||
beginAmount?: string | number;
|
||||
priceDisplay?: string;
|
||||
unit?: string;
|
||||
saleCount?: string | number;
|
||||
offerIDatacenterSellInfo?: Record<string, unknown>;
|
||||
offerPriceModel?: {
|
||||
currentPrices?: Array<{ beginAmount?: string | number; price?: string | number }>;
|
||||
};
|
||||
};
|
||||
gallery?: {
|
||||
mainImage?: string[];
|
||||
offerImgList?: string[];
|
||||
wlImageInfos?: Array<{ fullPathImageURI?: string }>;
|
||||
};
|
||||
shipping?: {
|
||||
deliveryLimitText?: string;
|
||||
logisticsText?: string;
|
||||
protectionInfos?: BuyerProtectionModel[];
|
||||
buyerProtectionModel?: BuyerProtectionModel[];
|
||||
};
|
||||
services?: BuyerProtectionModel[];
|
||||
}
|
||||
|
||||
interface VisibleAttribute {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function normalizeItemPayload(payload: ItemBrowserPayload): Record<string, unknown> {
|
||||
const href = cleanText(payload.href);
|
||||
const bodyText = cleanMultilineText(payload.bodyText);
|
||||
const sellerName = cleanText(payload.seller?.companyName);
|
||||
const sellerUrlRaw = cleanText(
|
||||
payload.seller?.winportUrl
|
||||
?? payload.seller?.sellerWinportUrlMap?.defaultUrl
|
||||
?? payload.seller?.sellerWinportUrlMap?.indexUrl,
|
||||
);
|
||||
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw);
|
||||
const offerId = cleanText(String(payload.offerId ?? '')) || extractOfferId(href) || null;
|
||||
const memberId = cleanText(payload.seller?.memberId) || extractMemberId(sellerUrlRaw || href) || null;
|
||||
const shopId = extractShopId(sellerUrl ?? href);
|
||||
const unit = cleanText(payload.trade?.unit);
|
||||
const priceDisplay = cleanText(payload.trade?.priceDisplay);
|
||||
const priceRange = parsePriceText(priceDisplay ? `¥${priceDisplay}` : bodyText);
|
||||
const moqText = extractMoqText(bodyText, payload.trade?.beginAmount, unit);
|
||||
const moq = parseMoqText(moqText);
|
||||
const services = uniqueServices(payload);
|
||||
const serviceBadges = uniqueNonEmpty(services.map((service) => cleanText(service.serviceName)));
|
||||
const attributes = normalizeVisibleAttributes(payload.trade?.offerIDatacenterSellInfo);
|
||||
const priceTiers = normalizePriceTiers(payload.trade?.offerPriceModel?.currentPrices ?? [], unit || null);
|
||||
const images = uniqueNonEmpty([
|
||||
...(payload.gallery?.mainImage ?? []),
|
||||
...(payload.gallery?.offerImgList ?? []),
|
||||
...((payload.gallery?.wlImageInfos ?? []).map((item) => item.fullPathImageURI ?? '')),
|
||||
]);
|
||||
|
||||
const detailUrl = offerId ? buildDetailUrl(offerId) : href;
|
||||
const provenance = buildProvenance(href || detailUrl);
|
||||
|
||||
return {
|
||||
offer_id: offerId,
|
||||
member_id: memberId,
|
||||
shop_id: shopId,
|
||||
title: cleanText(payload.offerTitle) || stripAlibabaSuffix(payload.title) || firstNonEmptyLine(bodyText) || null,
|
||||
item_url: detailUrl,
|
||||
main_images: images,
|
||||
price_text: priceRange.price_text || null,
|
||||
price_tiers: priceTiers,
|
||||
currency: priceRange.currency,
|
||||
moq_text: moq.moq_text || null,
|
||||
moq_value: moq.moq_value,
|
||||
seller_name: sellerName || null,
|
||||
seller_url: sellerUrl,
|
||||
shop_name: sellerName || null,
|
||||
origin_place: extractLocation(bodyText),
|
||||
delivery_days_text: extractDeliveryDaysText(bodyText, services, payload.shipping),
|
||||
customization_text: extractKeywordLine(bodyText, ['来样定制', '来图定制', '支持定制', '可定制', '定制']),
|
||||
private_label_text: extractKeywordLine(bodyText, ['贴牌', '贴标', '定制logo', '打logo', 'OEM', 'ODM']),
|
||||
visible_attributes: attributes,
|
||||
sales_text: extractSalesText(bodyText),
|
||||
service_badges: serviceBadges,
|
||||
stock_quantity: extractStockQuantity(bodyText),
|
||||
...provenance,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVisibleAttributes(raw: unknown): VisibleAttribute[] {
|
||||
if (!isRecord(raw)) return [];
|
||||
return Object.entries(raw)
|
||||
.filter(([key, value]) => key !== 'sellPointModel' && cleanText(key) && cleanText(String(value)))
|
||||
.map(([key, value]) => ({ key: cleanText(key), value: cleanText(String(value)) }));
|
||||
}
|
||||
|
||||
function uniqueServices(payload: ItemBrowserPayload): BuyerProtectionModel[] {
|
||||
const combined = [
|
||||
...(Array.isArray(payload.services) ? payload.services : []),
|
||||
...(Array.isArray(payload.shipping?.protectionInfos) ? payload.shipping.protectionInfos : []),
|
||||
...(Array.isArray(payload.shipping?.buyerProtectionModel) ? payload.shipping.buyerProtectionModel : []),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: BuyerProtectionModel[] = [];
|
||||
for (const service of combined) {
|
||||
const key = cleanText(service.serviceName);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(service);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripAlibabaSuffix(title: string | undefined): string {
|
||||
return cleanText(title).replace(/\s*-\s*阿里巴巴$/, '').trim();
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return text.split('\n').map((line) => cleanText(line)).find(Boolean) ?? '';
|
||||
}
|
||||
|
||||
function extractMoqText(bodyText: string, beginAmount: string | number | undefined, unit: string): string {
|
||||
const lineMatch = bodyText.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/);
|
||||
if (lineMatch) return lineMatch[0];
|
||||
|
||||
const moqValue = toNumber(beginAmount);
|
||||
if (moqValue !== null) {
|
||||
return `${moqValue}${unit || ''}起批`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractDeliveryDaysText(
|
||||
bodyText: string,
|
||||
services: BuyerProtectionModel[],
|
||||
shipping: ItemBrowserPayload['shipping'],
|
||||
): string | null {
|
||||
const shippingText = cleanText(shipping?.deliveryLimitText) || cleanText(shipping?.logisticsText);
|
||||
if (shippingText) return shippingText;
|
||||
|
||||
const textMatch = bodyText.match(/\d+\s*(?:小时|天)(?:内)?发货/);
|
||||
if (textMatch) return textMatch[0];
|
||||
|
||||
const hourMatch = services.find((service) => typeof service.agreeDeliveryHours === 'number');
|
||||
if (hourMatch && typeof hourMatch.agreeDeliveryHours === 'number') {
|
||||
return `${hourMatch.agreeDeliveryHours}小时内发货`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractKeywordLine(bodyText: string, keywords: string[]): string | null {
|
||||
const lines = bodyText.split('\n').map((line) => cleanText(line)).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (keywords.some((keyword) => line.includes(keyword))) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSalesText(bodyText: string): string | null {
|
||||
const match = bodyText.match(/(?:全网销量|已售)\s*\d+(?:\.\d+)?\+?\s*[件套个单]?/);
|
||||
return match ? cleanText(match[0]) : null;
|
||||
}
|
||||
|
||||
function extractStockQuantity(bodyText: string): number | null {
|
||||
const match = bodyText.match(/库存\s*(\d+)/);
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
async function readItemPayload(page: IPage, itemUrl: string): Promise<ItemBrowserPayload> {
|
||||
const state = await gotoAndReadState(page, itemUrl, 2500, 'item');
|
||||
assertAuthenticatedState(state, 'item');
|
||||
|
||||
const payload = await page.evaluate(`
|
||||
(() => {
|
||||
const root = window.context ?? {};
|
||||
const model = root.result?.global?.globalData?.model ?? null;
|
||||
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
|
||||
return {
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
bodyText: document.body ? document.body.innerText || '' : '',
|
||||
offerTitle: model?.offerTitleModel?.subject ?? '',
|
||||
offerId: model?.tradeModel?.offerId ?? '',
|
||||
seller: toJson(model?.sellerModel),
|
||||
trade: toJson(model?.tradeModel),
|
||||
gallery: toJson(root.result?.data?.gallery?.fields ?? null),
|
||||
shipping: toJson(root.result?.data?.shippingServices?.fields ?? null),
|
||||
services: toJson(root.result?.data?.shippingServices?.fields?.protectionInfos ?? []),
|
||||
};
|
||||
})()
|
||||
`) as ItemBrowserPayload;
|
||||
|
||||
const resolvedOfferId = cleanText(String(payload.offerId ?? '')) || extractOfferId(cleanText(payload.href));
|
||||
if (!resolvedOfferId) {
|
||||
throw new CommandExecutionError(
|
||||
'1688 item page did not expose product context',
|
||||
'当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试',
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'item',
|
||||
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{
|
||||
name: 'input',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: '1688 商品 URL 或 offer ID(如 887904326744)',
|
||||
},
|
||||
],
|
||||
columns: ['offer_id', 'title', 'price_text', 'moq_text', 'seller_name', 'origin_place'],
|
||||
func: async (page, kwargs) => {
|
||||
const itemUrl = buildDetailUrl(String(kwargs.input ?? ''));
|
||||
const payload = await readItemPayload(page, itemUrl);
|
||||
return [normalizeItemPayload(payload)];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeItemPayload,
|
||||
normalizeVisibleAttributes,
|
||||
stripAlibabaSuffix,
|
||||
extractMoqText,
|
||||
extractDeliveryDaysText,
|
||||
extractKeywordLine,
|
||||
extractSalesText,
|
||||
extractStockQuantity,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './search.js';
|
||||
|
||||
describe('1688 search normalization', () => {
|
||||
it('normalizes search candidates into structured result rows', () => {
|
||||
const result = __test__.normalizeSearchCandidate({
|
||||
item_url: 'https://detail.1688.com/offer/887904326744.html',
|
||||
title: '宿舍置物架桌面加高架',
|
||||
container_text: '宿舍置物架桌面加高架 ¥56.00 2套起批 山东青岛 已售300+套',
|
||||
price_text: '¥ 56 .00',
|
||||
sales_text: '300+套',
|
||||
moq_text: '2套起批',
|
||||
tag_items: ['退货包运费', '回头率52%'],
|
||||
hover_items: ['验厂报告'],
|
||||
seller_name: '青岛沁澜衣品服装有限公司',
|
||||
seller_url: 'https://yinuoweierfushi.1688.com/page/index.html?spm=a123',
|
||||
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=置物架');
|
||||
|
||||
expect(result.rank).toBe(0);
|
||||
expect(result.offer_id).toBe('887904326744');
|
||||
expect(result.shop_id).toBe('yinuoweierfushi');
|
||||
expect(result.item_url).toBe('https://detail.1688.com/offer/887904326744.html');
|
||||
expect(result.seller_url).toBe('https://yinuoweierfushi.1688.com');
|
||||
expect(result.price_text).toBe('¥56.00');
|
||||
expect(result.price_min).toBe(56);
|
||||
expect(result.price_max).toBe(56);
|
||||
expect(result.moq_value).toBe(2);
|
||||
expect(result.location).toBe('山东青岛');
|
||||
expect(result.sales_text).toBe('300+套');
|
||||
expect(result.badges).toEqual(expect.arrayContaining(['退货包运费', '验厂报告']));
|
||||
expect(result.return_rate_text).toBe('回头率52%');
|
||||
});
|
||||
|
||||
it('does not use hover_price_text as MOQ source', () => {
|
||||
const result = __test__.normalizeSearchCandidate({
|
||||
item_url: 'https://detail.1688.com/offer/887904326744.html',
|
||||
title: 'test',
|
||||
container_text: 'test ¥56.00',
|
||||
price_text: '¥ 56 .00',
|
||||
hover_price_text: '¥56.00 3件起批',
|
||||
moq_text: null,
|
||||
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=test');
|
||||
// hover_price_text should not be used for MOQ extraction
|
||||
expect(result.moq_text).toBeNull();
|
||||
expect(result.moq_value).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts offer id from mobile detail search links', () => {
|
||||
const result = __test__.normalizeSearchCandidate({
|
||||
item_url: 'http://detail.m.1688.com/page/index.html?offerId=910933345396&sortType=&pageId=',
|
||||
title: '',
|
||||
container_text: '桌面书桌办公室工位收纳展示新中式博古架多层茶具厨房摆放置物架 ¥24.3 已售20+件',
|
||||
price_text: '¥ 14 .28',
|
||||
sales_text: '1500+件',
|
||||
moq_text: '≥2个',
|
||||
seller_name: '泰商国际贸易(宁阳)有限公司',
|
||||
seller_url: 'http://tsgjmy.1688.com/',
|
||||
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=桌面置物架');
|
||||
|
||||
expect(result.offer_id).toBe('910933345396');
|
||||
expect(result.shop_id).toBe('tsgjmy');
|
||||
expect(result.item_url).toBe('https://detail.1688.com/offer/910933345396.html');
|
||||
expect(result.title).toContain('桌面书桌办公室工位收纳展示');
|
||||
expect(result.price_text).toBe('¥14.28');
|
||||
expect(result.sales_text).toBe('1500+件');
|
||||
expect(result.moq_text).toBe('≥2个');
|
||||
expect(result.moq_value).toBe(2);
|
||||
});
|
||||
|
||||
it('prefers offer id and falls back to item url for dedupe key', () => {
|
||||
expect(__test__.buildDedupeKey({
|
||||
offer_id: '123456',
|
||||
item_url: 'https://detail.1688.com/offer/123456.html',
|
||||
})).toBe('offer:123456');
|
||||
expect(__test__.buildDedupeKey({
|
||||
offer_id: null,
|
||||
item_url: 'https://detail.1688.com/offer/123456.html',
|
||||
})).toBe('url:https://detail.1688.com/offer/123456.html');
|
||||
expect(__test__.buildDedupeKey({ offer_id: null, item_url: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
FACTORY_BADGE_PATTERNS,
|
||||
SERVICE_BADGE_PATTERNS,
|
||||
assertAuthenticatedState,
|
||||
buildProvenance,
|
||||
buildSearchUrl,
|
||||
canonicalizeItemUrl,
|
||||
canonicalizeSellerUrl,
|
||||
cleanText,
|
||||
extractBadges,
|
||||
extractLocation,
|
||||
extractMemberId,
|
||||
extractOfferId,
|
||||
extractShopId,
|
||||
gotoAndReadState,
|
||||
parseMoqText,
|
||||
parsePriceText,
|
||||
SEARCH_LIMIT_DEFAULT,
|
||||
SEARCH_LIMIT_MAX,
|
||||
parseSearchLimit,
|
||||
uniqueNonEmpty,
|
||||
} from './shared.js';
|
||||
|
||||
interface SearchPayload {
|
||||
href?: string;
|
||||
title?: string;
|
||||
bodyText?: string;
|
||||
next_url?: string;
|
||||
candidates?: Array<{
|
||||
item_url?: string;
|
||||
title?: string;
|
||||
container_text?: string;
|
||||
desc_rows?: string[];
|
||||
price_text?: string | null;
|
||||
sales_text?: string | null;
|
||||
hover_price_text?: string | null;
|
||||
moq_text?: string | null;
|
||||
tag_items?: string[];
|
||||
hover_items?: string[];
|
||||
seller_name?: string | null;
|
||||
seller_url?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SearchRow {
|
||||
rank: number;
|
||||
offer_id: string | null;
|
||||
member_id: string | null;
|
||||
shop_id: string | null;
|
||||
title: string | null;
|
||||
item_url: string | null;
|
||||
seller_name: string | null;
|
||||
seller_url: string | null;
|
||||
price_text: string | null;
|
||||
price_min: number | null;
|
||||
price_max: number | null;
|
||||
currency: string | null;
|
||||
moq_text: string | null;
|
||||
moq_value: number | null;
|
||||
location: string | null;
|
||||
badges: string[];
|
||||
sales_text: string | null;
|
||||
return_rate_text: string | null;
|
||||
source_url: string;
|
||||
fetched_at: string;
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
const SEARCH_ITEM_URL_PATTERNS = [
|
||||
'detail.1688.com/offer/',
|
||||
'detail.m.1688.com/page/index.html?offerId=',
|
||||
];
|
||||
const MAX_SEARCH_PAGES = 12;
|
||||
|
||||
function normalizeSearchCandidate(
|
||||
candidate: NonNullable<SearchPayload['candidates']>[number],
|
||||
sourceUrl: string,
|
||||
): SearchRow {
|
||||
const canonicalItemUrl = canonicalizeItemUrl(cleanText(candidate.item_url));
|
||||
const containerText = cleanText(candidate.container_text);
|
||||
const priceText = firstNonEmpty([
|
||||
normalizeInlineText(candidate.price_text),
|
||||
normalizeInlineText(extractPriceText(candidate.hover_price_text)),
|
||||
]);
|
||||
const priceRange = parsePriceText(priceText || containerText);
|
||||
const moq = parseMoqText(firstNonEmpty([
|
||||
normalizeInlineText(candidate.moq_text),
|
||||
normalizeInlineText(extractMoqText(containerText)),
|
||||
]));
|
||||
const canonicalSellerUrl = canonicalizeSellerUrl(cleanText(candidate.seller_url));
|
||||
const evidenceText = uniqueNonEmpty([
|
||||
containerText,
|
||||
...(candidate.desc_rows ?? []),
|
||||
...(candidate.tag_items ?? []),
|
||||
...(candidate.hover_items ?? []),
|
||||
]).join('\n');
|
||||
const badges = extractBadges(evidenceText, [...FACTORY_BADGE_PATTERNS, ...SERVICE_BADGE_PATTERNS]);
|
||||
const salesText = firstNonEmpty([
|
||||
extractSalesText(candidate.sales_text),
|
||||
extractSalesText(containerText),
|
||||
]);
|
||||
const returnRateText = extractReturnRateText([...(candidate.tag_items ?? []), ...(candidate.hover_items ?? [])]);
|
||||
const provenance = buildProvenance(sourceUrl);
|
||||
|
||||
return {
|
||||
rank: 0,
|
||||
offer_id: extractOfferId(canonicalItemUrl ?? '') ?? null,
|
||||
member_id: extractMemberId(canonicalSellerUrl ?? '') ?? null,
|
||||
shop_id: extractShopId(canonicalSellerUrl ?? '') ?? null,
|
||||
title: cleanText(candidate.title) || firstWord(containerText) || null,
|
||||
item_url: canonicalItemUrl,
|
||||
seller_name: cleanText(candidate.seller_name) || null,
|
||||
seller_url: canonicalSellerUrl,
|
||||
price_text: priceRange.price_text || null,
|
||||
price_min: priceRange.price_min,
|
||||
price_max: priceRange.price_max,
|
||||
currency: priceRange.currency,
|
||||
moq_text: moq.moq_text || null,
|
||||
moq_value: moq.moq_value,
|
||||
location: extractLocation(containerText),
|
||||
badges,
|
||||
sales_text: salesText || null,
|
||||
return_rate_text: returnRateText,
|
||||
source_url: provenance.source_url,
|
||||
fetched_at: provenance.fetched_at,
|
||||
strategy: provenance.strategy,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMoqText(text: string | null | undefined): string {
|
||||
const normalized = normalizeInlineText(text);
|
||||
return normalized.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/i)?.[0]
|
||||
?? normalized.match(/≥\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)?/i)?.[0]
|
||||
?? normalized.match(/\d+(?:\.\d+)?\s*(?:~|-|至|到)\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)/i)?.[0]
|
||||
?? '';
|
||||
}
|
||||
|
||||
function extractPriceText(text: string | null | undefined): string {
|
||||
const normalized = normalizeInlineText(text);
|
||||
return normalized.match(/[¥$€]\s*\d+(?:\.\d+)?/)?.[0] ?? '';
|
||||
}
|
||||
|
||||
function extractSalesText(text: string | null | undefined): string {
|
||||
const normalized = normalizeInlineText(text);
|
||||
if (!normalized) return '';
|
||||
if (/^\d+(?:\.\d+)?\+?\s*(件|套|个|单)$/.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
const match = normalized.match(/(?:已售|销量|售)\s*\d+(?:\.\d+)?\+?\s*(件|套|个|单)?/);
|
||||
return match ? cleanText(match[0]) : '';
|
||||
}
|
||||
|
||||
function firstWord(text: string): string {
|
||||
return text.split(/\s+/).find(Boolean) ?? '';
|
||||
}
|
||||
|
||||
function firstNonEmpty(values: Array<string | null | undefined>): string {
|
||||
return values.map((value) => cleanText(value)).find(Boolean) ?? '';
|
||||
}
|
||||
|
||||
function normalizeInlineText(text: string | null | undefined): string {
|
||||
return cleanText(text)
|
||||
.replace(/([¥$€])\s+(?=\d)/g, '$1')
|
||||
.replace(/(\d)\s*\.\s*(\d)/g, '$1.$2')
|
||||
.replace(/\s*([~-])\s*/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractReturnRateText(values: string[]): string | null {
|
||||
return uniqueNonEmpty(values.map((value) => normalizeInlineText(value)))
|
||||
.find((value) => /^回头率\s*\d+(?:\.\d+)?%$/.test(value))
|
||||
?? null;
|
||||
}
|
||||
|
||||
function buildDedupeKey(row: Pick<SearchRow, 'offer_id' | 'item_url'>): string | null {
|
||||
if (row.offer_id) return `offer:${row.offer_id}`;
|
||||
if (row.item_url) return `url:${row.item_url}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readSearchPayload(page: IPage, url: string): Promise<SearchPayload> {
|
||||
const state = await gotoAndReadState(page, url, 2500, 'search');
|
||||
assertAuthenticatedState(state, 'search');
|
||||
|
||||
const payload = await page.evaluate(`
|
||||
(() => {
|
||||
const normalizeText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const normalizeUrl = (href) => {
|
||||
if (!href) return '';
|
||||
try {
|
||||
return new URL(href, window.location.href).toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const isItemHref = (href) => ${JSON.stringify(SEARCH_ITEM_URL_PATTERNS)}
|
||||
.some((pattern) => (href || '').includes(pattern));
|
||||
const uniqueTexts = (values) => [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))];
|
||||
const collectTexts = (root, selector) => uniqueTexts(
|
||||
Array.from(root.querySelectorAll(selector)).map((node) => node.innerText || node.textContent || ''),
|
||||
);
|
||||
const firstText = (root, selectors) => {
|
||||
for (const selector of selectors) {
|
||||
const node = root.querySelector(selector);
|
||||
const value = normalizeText(node ? node.innerText || node.textContent || '' : '');
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const findMoqText = (values, priceText) => {
|
||||
const moqPattern = /(≥\\s*\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只)?)|(\\d+(?:\\.\\d+)?\\s*(?:~|-|至|到)\\s*\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只))|(\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只)\\s*起批)/i;
|
||||
return values.find((value) => moqPattern.test(value))
|
||||
|| normalizeText(priceText).match(moqPattern)?.[0]
|
||||
|| '';
|
||||
};
|
||||
const isSellerHref = (href) => {
|
||||
if (!href) return false;
|
||||
try {
|
||||
const url = new URL(href, window.location.href);
|
||||
const host = url.hostname || '';
|
||||
if (!host.endsWith('.1688.com')) return false;
|
||||
if (
|
||||
host === 's.1688.com'
|
||||
|| host === 'r.1688.com'
|
||||
|| host === 'air.1688.com'
|
||||
|| host === 'detail.1688.com'
|
||||
|| host === 'detail.m.1688.com'
|
||||
|| host === 'dj.1688.com'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const pickContainer = (anchor) => {
|
||||
let node = anchor;
|
||||
while (node && node !== document.body) {
|
||||
const text = normalizeText(node.innerText || node.textContent || '');
|
||||
if (text.length >= 40 && text.length <= 2000) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return anchor;
|
||||
};
|
||||
const collectCandidates = () => {
|
||||
const anchors = Array.from(document.querySelectorAll('a')).filter((anchor) => isItemHref(anchor.href || ''));
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const anchor of anchors) {
|
||||
const href = anchor.href || '';
|
||||
if (!href || seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
|
||||
const container = pickContainer(anchor);
|
||||
const tagItems = collectTexts(container, '.offer-tag-row .offer-desc-item');
|
||||
const hoverItems = collectTexts(container, '.offer-hover-wrapper .offer-desc-item');
|
||||
const sellerAnchor = Array.from(container.querySelectorAll('a'))
|
||||
.find((link) => isSellerHref(link.href || ''));
|
||||
const hoverPriceText = firstText(container, [
|
||||
'.offer-hover-wrapper .hover-price-item',
|
||||
'.offer-hover-wrapper .price-item',
|
||||
]);
|
||||
|
||||
items.push({
|
||||
item_url: href,
|
||||
title: firstText(container, ['.offer-title-row .title-text', '.offer-title-row'])
|
||||
|| normalizeText(anchor.innerText || anchor.textContent || ''),
|
||||
container_text: normalizeText(container.innerText || container.textContent || ''),
|
||||
desc_rows: collectTexts(container, '.offer-desc-row'),
|
||||
price_text: firstText(container, ['.offer-price-row .price-item']),
|
||||
sales_text: firstText(container, ['.offer-price-row .col-desc_after', '.offer-desc-row .col-desc_after']),
|
||||
hover_price_text: hoverPriceText,
|
||||
moq_text: findMoqText(hoverItems, hoverPriceText),
|
||||
tag_items: tagItems,
|
||||
hover_items: hoverItems,
|
||||
seller_name: sellerAnchor ? normalizeText(sellerAnchor.innerText || sellerAnchor.textContent || '') : null,
|
||||
seller_url: sellerAnchor ? sellerAnchor.href : null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
const findNextUrl = () => {
|
||||
const selectors = [
|
||||
'a.fui-next:not(.disabled)',
|
||||
'a.next-pagination-item:not(.disabled)',
|
||||
'a[rel="next"]:not(.disabled)',
|
||||
'a[data-role="next"]:not(.disabled)',
|
||||
];
|
||||
for (const selector of selectors) {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) continue;
|
||||
const href = normalizeUrl(node.getAttribute('href') || node.href || '');
|
||||
if (href) return href;
|
||||
}
|
||||
const textBased = Array.from(document.querySelectorAll('a'))
|
||||
.find((node) => /下一页|next/i.test(normalizeText(node.textContent || '')));
|
||||
if (!textBased) return '';
|
||||
return normalizeUrl(textBased.getAttribute('href') || textBased.href || '');
|
||||
};
|
||||
|
||||
return {
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
bodyText: document.body ? document.body.innerText || '' : '',
|
||||
next_url: findNextUrl(),
|
||||
candidates: collectCandidates(),
|
||||
};
|
||||
})()
|
||||
`) as SearchPayload;
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new CommandExecutionError(
|
||||
'1688 search page did not return a readable payload',
|
||||
'Open the same query in Chrome and verify the page is fully loaded before retrying.',
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function collectSearchRows(page: IPage, query: string, limit: number): Promise<SearchRow[]> {
|
||||
const rowsByKey = new Map<string, SearchRow>();
|
||||
const seenPages = new Set<string>();
|
||||
let nextUrl = buildSearchUrl(query);
|
||||
let pageCount = 0;
|
||||
|
||||
while (nextUrl && rowsByKey.size < limit && pageCount < MAX_SEARCH_PAGES) {
|
||||
if (seenPages.has(nextUrl)) break;
|
||||
seenPages.add(nextUrl);
|
||||
pageCount += 1;
|
||||
|
||||
const payload = await readSearchPayload(page, nextUrl);
|
||||
const sourceUrl = cleanText(payload.href) || nextUrl;
|
||||
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const row = normalizeSearchCandidate(candidate, sourceUrl);
|
||||
const dedupeKey = buildDedupeKey(row);
|
||||
if (!dedupeKey || rowsByKey.has(dedupeKey)) continue;
|
||||
rowsByKey.set(dedupeKey, row);
|
||||
if (rowsByKey.size >= limit) break;
|
||||
}
|
||||
|
||||
const candidateNextUrl = cleanText(payload.next_url);
|
||||
if (!candidateNextUrl || candidateNextUrl === sourceUrl) break;
|
||||
nextUrl = candidateNextUrl;
|
||||
}
|
||||
|
||||
if (rowsByKey.size === 0) {
|
||||
throw new EmptyResultError(
|
||||
'1688 search',
|
||||
'No visible results were extracted. Retry with a different query or open the same search page in Chrome first.',
|
||||
);
|
||||
}
|
||||
|
||||
return [...rowsByKey.values()]
|
||||
.slice(0, limit)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }));
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'search',
|
||||
description: '1688 商品搜索(结果候选、卖家链接、价格/MOQ/销量文本)',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{
|
||||
name: 'query',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: '搜索关键词,如 "置物架"',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'int',
|
||||
default: SEARCH_LIMIT_DEFAULT,
|
||||
help: `结果数量上限(默认 ${SEARCH_LIMIT_DEFAULT},最大 ${SEARCH_LIMIT_MAX})`,
|
||||
},
|
||||
],
|
||||
columns: ['rank', 'title', 'price_text', 'moq_text', 'seller_name', 'location'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = String(kwargs.query ?? '');
|
||||
const limit = parseSearchLimit(kwargs.limit);
|
||||
return collectSearchRows(page, query, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeSearchCandidate,
|
||||
extractMoqText,
|
||||
extractSalesText,
|
||||
firstWord,
|
||||
buildDedupeKey,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './shared.js';
|
||||
|
||||
describe('1688 shared helpers', () => {
|
||||
it('builds encoded search URLs and validates limit', () => {
|
||||
expect(__test__.buildSearchUrl('置物架')).toBe(
|
||||
'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=%E7%BD%AE%E7%89%A9%E6%9E%B6',
|
||||
);
|
||||
expect(() => __test__.buildSearchUrl(' ')).toThrowError(/cannot be empty/i);
|
||||
|
||||
expect(__test__.parseSearchLimit(3)).toBe(3);
|
||||
expect(__test__.parseSearchLimit('1000')).toBe(__test__.SEARCH_LIMIT_MAX);
|
||||
expect(() => __test__.parseSearchLimit('0')).toThrowError(/positive integer/i);
|
||||
});
|
||||
|
||||
it('extracts IDs and canonicalizes urls', () => {
|
||||
expect(__test__.extractOfferId('887904326744')).toBe('887904326744');
|
||||
expect(__test__.extractOfferId('https://detail.1688.com/offer/887904326744.html')).toBe('887904326744');
|
||||
expect(__test__.extractMemberId('https://winport.m.1688.com/page/index.html?memberId=b2b-1641351767')).toBe('b2b-1641351767');
|
||||
expect(__test__.extractMemberId('b2b-22154705262941f196')).toBe('b2b-22154705262941f196');
|
||||
expect(__test__.resolveStoreUrl('b2b-22154705262941f196')).toBe(
|
||||
'https://winport.m.1688.com/page/index.html?memberId=b2b-22154705262941f196',
|
||||
);
|
||||
expect(__test__.canonicalizeStoreUrl('https://yinuoweierfushi.1688.com/page/index.html?spm=foo')).toBe(
|
||||
'https://yinuoweierfushi.1688.com',
|
||||
);
|
||||
expect(__test__.canonicalizeItemUrl('http://detail.m.1688.com/page/index.html?offerId=910933345396&spm=x')).toBe(
|
||||
'https://detail.1688.com/offer/910933345396.html',
|
||||
);
|
||||
expect(__test__.canonicalizeSellerUrl('https://yinuoweierfushi.1688.com/page/contactinfo.html?tracelog=1')).toBe(
|
||||
'https://yinuoweierfushi.1688.com',
|
||||
);
|
||||
expect(__test__.extractShopId('https://yinuoweierfushi.1688.com/page/index.html')).toBe('yinuoweierfushi');
|
||||
});
|
||||
|
||||
it('parses price ranges and moq text', () => {
|
||||
expect(__test__.parsePriceText('¥96.00-98.00')).toEqual({
|
||||
price_text: '¥96.00-98.00',
|
||||
price_min: 96,
|
||||
price_max: 98,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
expect(__test__.parsePriceText('¥ 14 .28')).toEqual({
|
||||
price_text: '¥14.28',
|
||||
price_min: 14.28,
|
||||
price_max: 14.28,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
expect(__test__.parseMoqText('3套起批')).toEqual({
|
||||
moq_text: '3套起批',
|
||||
moq_value: 3,
|
||||
});
|
||||
|
||||
expect(__test__.parseMoqText('2~999个')).toEqual({
|
||||
moq_text: '2~999个',
|
||||
moq_value: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects captcha and login states', () => {
|
||||
expect(__test__.extractLocation('山东青岛 送至 江苏苏州')).toBe('山东青岛');
|
||||
expect(__test__.isCaptchaState({
|
||||
href: 'https://s.1688.com/_____tmd_____/punish',
|
||||
title: '验证码拦截',
|
||||
body_text: '请拖动下方滑块完成验证',
|
||||
})).toBe(true);
|
||||
expect(__test__.isLoginState({
|
||||
href: 'https://login.taobao.com/member/login.jhtml',
|
||||
title: '账号登录',
|
||||
body_text: '请登录后继续',
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
|
||||
export const SITE = '1688';
|
||||
export const HOME_URL = 'https://www.1688.com/';
|
||||
export const SEARCH_URL_PREFIX = 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=';
|
||||
export const DETAIL_URL_PREFIX = 'https://detail.1688.com/offer/';
|
||||
export const STORE_MOBILE_URL_PREFIX = 'https://winport.m.1688.com/page/index.html?memberId=';
|
||||
export const STRATEGY = 'cookie';
|
||||
export const SEARCH_LIMIT_DEFAULT = 20;
|
||||
export const SEARCH_LIMIT_MAX = 100;
|
||||
|
||||
const STORE_GENERIC_HOSTS = new Set(['www', 'detail', 's', 'winport', 'work', 'air', 'dj']);
|
||||
const TRACKING_QUERY_KEYS = new Set([
|
||||
'spm',
|
||||
'tracelog',
|
||||
'clickid',
|
||||
'source',
|
||||
'scene',
|
||||
'from',
|
||||
'src',
|
||||
'ns',
|
||||
'cna',
|
||||
'pvid',
|
||||
]);
|
||||
const CAPTCHA_URL_MARKER = '/_____tmd_____/punish';
|
||||
const CAPTCHA_TEXT_PATTERNS = [
|
||||
'请拖动下方滑块完成验证',
|
||||
'请按住滑块,拖动到最右边',
|
||||
'通过验证以确保正常访问',
|
||||
'验证码拦截',
|
||||
'访问验证',
|
||||
'滑动验证',
|
||||
];
|
||||
const LOGIN_TEXT_PATTERNS = [
|
||||
'请登录',
|
||||
'登录后',
|
||||
'账号登录',
|
||||
'手机登录',
|
||||
'立即登录',
|
||||
'扫码登录',
|
||||
'请先完成登录',
|
||||
'请先登录后查看',
|
||||
];
|
||||
const LOGIN_URL_PATTERNS = ['/member/login', 'passport', 'login.taobao.com', 'account.1688.com'];
|
||||
|
||||
export const FACTORY_BADGE_PATTERNS = [
|
||||
'源头工厂',
|
||||
'深度验厂',
|
||||
'实力工厂',
|
||||
'工厂档案',
|
||||
'加工专区',
|
||||
'验厂报告',
|
||||
'厂家直销',
|
||||
'生产厂家',
|
||||
'工厂直供',
|
||||
];
|
||||
export const SERVICE_BADGE_PATTERNS = [
|
||||
'延期必赔',
|
||||
'品质保障',
|
||||
'破损包赔',
|
||||
'退货包运费',
|
||||
'晚发必赔',
|
||||
'7*24小时响应',
|
||||
'48小时发货',
|
||||
'72小时发货',
|
||||
'后天达',
|
||||
'包邮',
|
||||
'闪电拿样',
|
||||
];
|
||||
|
||||
const CHINA_LOCATIONS = [
|
||||
'北京',
|
||||
'天津',
|
||||
'上海',
|
||||
'重庆',
|
||||
'河北',
|
||||
'山西',
|
||||
'辽宁',
|
||||
'吉林',
|
||||
'黑龙江',
|
||||
'江苏',
|
||||
'浙江',
|
||||
'安徽',
|
||||
'福建',
|
||||
'江西',
|
||||
'山东',
|
||||
'河南',
|
||||
'湖北',
|
||||
'湖南',
|
||||
'广东',
|
||||
'海南',
|
||||
'四川',
|
||||
'贵州',
|
||||
'云南',
|
||||
'陕西',
|
||||
'甘肃',
|
||||
'青海',
|
||||
'台湾',
|
||||
'内蒙古',
|
||||
'广西',
|
||||
'西藏',
|
||||
'宁夏',
|
||||
'新疆',
|
||||
'香港',
|
||||
'澳门',
|
||||
];
|
||||
|
||||
export interface ProvenanceFields {
|
||||
source_url: string;
|
||||
fetched_at: string;
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
export interface PageState {
|
||||
href: string;
|
||||
title: string;
|
||||
body_text: string;
|
||||
}
|
||||
|
||||
export interface PriceRange {
|
||||
price_text: string;
|
||||
price_min: number | null;
|
||||
price_max: number | null;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export interface MoqValue {
|
||||
moq_text: string;
|
||||
moq_value: number | null;
|
||||
}
|
||||
|
||||
export interface PriceTier {
|
||||
quantity_text: string;
|
||||
quantity_min: number | null;
|
||||
price_text: string;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export interface SearchCandidate {
|
||||
item_url: string;
|
||||
title: string;
|
||||
container_text: string;
|
||||
seller_name: string | null;
|
||||
seller_url: string | null;
|
||||
}
|
||||
|
||||
export function cleanText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
export function cleanMultilineText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '';
|
||||
}
|
||||
|
||||
export function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
||||
return [...new Set(values.map((value) => cleanText(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function parseSearchLimit(input: unknown): number {
|
||||
const parsed = Number.parseInt(String(input ?? SEARCH_LIMIT_DEFAULT), 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new ArgumentError(
|
||||
'1688 search --limit must be a positive integer',
|
||||
'Example: opencli 1688 search "桌面置物架" --limit 20',
|
||||
);
|
||||
}
|
||||
return Math.min(SEARCH_LIMIT_MAX, parsed);
|
||||
}
|
||||
|
||||
export function buildSearchUrl(query: string): string {
|
||||
const normalized = cleanText(query);
|
||||
if (!normalized) {
|
||||
throw new ArgumentError(
|
||||
'1688 search query cannot be empty',
|
||||
'Example: opencli 1688 search "桌面置物架" --limit 20',
|
||||
);
|
||||
}
|
||||
return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`;
|
||||
}
|
||||
|
||||
export function buildDetailUrl(input: string): string {
|
||||
const offerId = extractOfferId(input);
|
||||
if (!offerId) {
|
||||
throw new ArgumentError(
|
||||
'1688 item expects an offer URL or offer ID',
|
||||
'Example: opencli 1688 item 887904326744',
|
||||
);
|
||||
}
|
||||
return `${DETAIL_URL_PREFIX}${offerId}.html`;
|
||||
}
|
||||
|
||||
export function resolveStoreUrl(input: string): string {
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized) {
|
||||
throw new ArgumentError(
|
||||
'1688 store expects a store URL or member ID',
|
||||
'Example: opencli 1688 store https://yinuoweierfushi.1688.com/',
|
||||
);
|
||||
}
|
||||
|
||||
const memberId = extractMemberId(normalized);
|
||||
if (memberId) {
|
||||
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return canonicalizeStoreUrl(normalized);
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.1688.com')) {
|
||||
return canonicalizeStoreUrl(`https://${normalized}`);
|
||||
}
|
||||
|
||||
if (/^[a-z0-9-]+$/i.test(normalized)) {
|
||||
return canonicalizeStoreUrl(`https://${normalized}.1688.com`);
|
||||
}
|
||||
|
||||
throw new ArgumentError(
|
||||
'1688 store expects a store URL or member ID',
|
||||
'Example: opencli 1688 store b2b-22154705262941f196',
|
||||
);
|
||||
}
|
||||
|
||||
export function canonicalizeStoreUrl(input: string): string {
|
||||
const url = parse1688Url(input);
|
||||
const memberId = extractMemberId(url.toString());
|
||||
if (memberId) {
|
||||
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
|
||||
}
|
||||
|
||||
const host = normalizeStoreHost(url.hostname);
|
||||
if (!host) {
|
||||
throw new ArgumentError(
|
||||
'Invalid 1688 store URL',
|
||||
'Example: opencli 1688 store https://yinuoweierfushi.1688.com/',
|
||||
);
|
||||
}
|
||||
return `https://${host}`;
|
||||
}
|
||||
|
||||
export function canonicalizeItemUrl(input: string): string | null {
|
||||
const offerId = extractOfferId(input);
|
||||
if (offerId) {
|
||||
return `${DETAIL_URL_PREFIX}${offerId}.html`;
|
||||
}
|
||||
const url = parse1688UrlOrNull(input);
|
||||
if (!url) return null;
|
||||
stripTrackingParams(url);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function canonicalizeSellerUrl(input: string): string | null {
|
||||
const memberId = extractMemberId(input);
|
||||
if (memberId) {
|
||||
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
|
||||
}
|
||||
const url = parse1688UrlOrNull(input);
|
||||
if (!url) return null;
|
||||
const host = normalizeStoreHost(url.hostname);
|
||||
if (!host) return null;
|
||||
return `https://${host}`;
|
||||
}
|
||||
|
||||
export function extractOfferId(input: string): string | null {
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized) return null;
|
||||
const directId = normalized.match(/^\d{6,}$/)?.[0];
|
||||
if (directId) return directId;
|
||||
const detailMatch = normalized.match(/\/offer\/(\d{6,})\.html/i);
|
||||
if (detailMatch) return detailMatch[1];
|
||||
const queryMatch = normalized.match(/[?&]offerId=(\d{6,})/i);
|
||||
if (queryMatch) return queryMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractMemberId(input: string): string | null {
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized) return null;
|
||||
const direct = normalized.match(/\bb2b-[a-z0-9]+\b/i)?.[0];
|
||||
if (direct) return direct;
|
||||
const queryMatch = normalized.match(/[?&]memberId=(b2b-[a-z0-9]+)/i);
|
||||
if (queryMatch) return queryMatch[1];
|
||||
const mobileMatch = normalized.match(/\/winport\/(b2b-[a-z0-9]+)\.html/i);
|
||||
if (mobileMatch) return mobileMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractShopId(input: string): string | null {
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`);
|
||||
const host = normalizeStoreHost(url.hostname);
|
||||
if (!host) return null;
|
||||
return host.split('.')[0] ?? null;
|
||||
} catch {
|
||||
return /^[a-z0-9-]+$/i.test(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildProvenance(sourceUrl: string): ProvenanceFields {
|
||||
return {
|
||||
source_url: sourceUrl,
|
||||
fetched_at: new Date().toISOString(),
|
||||
strategy: STRATEGY,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePriceText(text: string): PriceRange {
|
||||
const normalized = normalizeNumericText(cleanText(text));
|
||||
const matches = normalized.match(/\d+(?:,\d{3})*(?:\.\d+)?/g) ?? [];
|
||||
const values = matches
|
||||
.map((value) => Number.parseFloat(value.replace(/,/g, '')))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
price_text: normalized,
|
||||
price_min: null,
|
||||
price_max: null,
|
||||
currency: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
price_text: normalized,
|
||||
price_min: values[0] ?? null,
|
||||
price_max: values[values.length - 1] ?? values[0] ?? null,
|
||||
currency: normalized.includes('¥') || normalized.includes('元') ? 'CNY' : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePriceTiers(
|
||||
rawTiers: Array<{ beginAmount?: unknown; price?: unknown }>,
|
||||
unit: string | null,
|
||||
): PriceTier[] {
|
||||
return rawTiers
|
||||
.map((tier) => {
|
||||
const quantityMin = toNumber(tier.beginAmount);
|
||||
const priceText = cleanText(tier.price);
|
||||
const price = toNumber(tier.price);
|
||||
return {
|
||||
quantity_text: quantityMin !== null ? `${quantityMin}${unit ?? ''}` : '',
|
||||
quantity_min: quantityMin,
|
||||
price_text: priceText,
|
||||
price,
|
||||
currency: priceText ? 'CNY' : null,
|
||||
};
|
||||
})
|
||||
.filter((tier) => tier.price_text);
|
||||
}
|
||||
|
||||
export function parseMoqText(text: string): MoqValue {
|
||||
const normalized = normalizeNumericText(cleanText(text));
|
||||
const match = normalized.match(/(\d+(?:\.\d+)?)\s*(件|个|套|箱|包|双|台|把|只|pcs|piece|pieces)?\s*起批/i)
|
||||
?? normalized.match(/≥\s*(\d+(?:\.\d+)?)/);
|
||||
const rangeMatch = normalized.match(
|
||||
/(\d+(?:\.\d+)?)\s*(?:~|-|至|到)\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只|pcs|piece|pieces)/i,
|
||||
);
|
||||
|
||||
if (!match && !rangeMatch) {
|
||||
return {
|
||||
moq_text: normalized,
|
||||
moq_value: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
moq_text: normalized,
|
||||
moq_value: Number.parseFloat((match ?? rangeMatch)![1]),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractLocation(text: string): string | null {
|
||||
const normalized = cleanMultilineText(text);
|
||||
const primaryRegion = normalized.split(/送至|发往/)[0] ?? normalized;
|
||||
const lines = primaryRegion.split('\n');
|
||||
for (const line of lines) {
|
||||
const compact = cleanText(line);
|
||||
if (!compact || compact.length > 16) continue;
|
||||
if (CHINA_LOCATIONS.some((location) => compact.startsWith(location))) {
|
||||
return compact;
|
||||
}
|
||||
}
|
||||
|
||||
const locationPattern = new RegExp(`(${CHINA_LOCATIONS.join('|')})[\\u4e00-\\u9fa5]{0,8}`);
|
||||
return primaryRegion.match(locationPattern)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractAddress(text: string): string | null {
|
||||
const normalized = cleanMultilineText(text);
|
||||
const lineMatch = normalized.match(/地址[::]\s*([^\n]+)/);
|
||||
if (lineMatch) return cleanText(lineMatch[1]);
|
||||
return normalized
|
||||
.split('\n')
|
||||
.map((line) => cleanText(line))
|
||||
.find((line) => line.includes('省') || line.includes('市') || line.includes('区') || line.includes('县'))
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function extractMetric(text: string, label: string): string | null {
|
||||
const normalized = cleanMultilineText(text);
|
||||
const direct = normalized.match(new RegExp(`(?:^|\\n)\\s*${escapeForRegex(label)}[::]?\\s*([^\\n]+)`));
|
||||
if (direct) return cleanText(direct[1]);
|
||||
|
||||
const lineBased = normalized.match(new RegExp(`(?:^|\\n)\\s*${escapeForRegex(label)}\\n([^\\n]+)`));
|
||||
return lineBased ? cleanText(lineBased[1]) : null;
|
||||
}
|
||||
|
||||
export function extractYearsOnPlatform(text: string): string | null {
|
||||
return text.match(/入驻\d+年/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractMainBusiness(text: string): string | null {
|
||||
const value = extractMetric(text, '主营');
|
||||
return value ? value.replace(/^:/, '').trim() : null;
|
||||
}
|
||||
|
||||
export function extractBadges(text: string, candidates: string[]): string[] {
|
||||
return uniqueNonEmpty(candidates.filter((candidate) => cleanMultilineText(text).includes(candidate)));
|
||||
}
|
||||
|
||||
export function guessTopCategories(text: string): string[] {
|
||||
const mainBusiness = extractMainBusiness(text);
|
||||
if (!mainBusiness) return [];
|
||||
return uniqueNonEmpty(mainBusiness.split(/[、,/|]/).map((value) => value.trim()));
|
||||
}
|
||||
|
||||
export function isCaptchaState(state: Partial<PageState>): boolean {
|
||||
const href = cleanText(state.href).toLowerCase();
|
||||
const title = cleanText(state.title);
|
||||
const bodyText = cleanMultilineText(state.body_text);
|
||||
if (href.includes(CAPTCHA_URL_MARKER)) return true;
|
||||
return CAPTCHA_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
|
||||
}
|
||||
|
||||
export function isLoginState(state: Partial<PageState>): boolean {
|
||||
const href = cleanText(state.href).toLowerCase();
|
||||
const title = cleanText(state.title);
|
||||
const bodyText = cleanMultilineText(state.body_text);
|
||||
if (LOGIN_URL_PATTERNS.some((pattern) => href.includes(pattern))) return true;
|
||||
return LOGIN_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
|
||||
}
|
||||
|
||||
export function buildCaptchaHint(action: string): string {
|
||||
return [
|
||||
`Open a clean 1688 ${action} page in the shared Chrome profile and finish any slider challenge first.`,
|
||||
'If you run opencli via CDP, set OPENCLI_CDP_TARGET=1688.com or a more specific 1688 host before retrying.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export async function readPageState(page: IPage): Promise<PageState> {
|
||||
const result = await page.evaluate(`
|
||||
(() => ({
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
body_text: document.body ? document.body.innerText || '' : '',
|
||||
}))()
|
||||
`) as Partial<PageState>;
|
||||
|
||||
return {
|
||||
href: cleanText(result.href),
|
||||
title: cleanText(result.title),
|
||||
body_text: cleanMultilineText(result.body_text),
|
||||
};
|
||||
}
|
||||
|
||||
export async function gotoAndReadState(
|
||||
page: IPage,
|
||||
url: string,
|
||||
settleMs: number = 2500,
|
||||
action: string = 'page',
|
||||
): Promise<PageState> {
|
||||
try {
|
||||
await page.goto(url, { settleMs });
|
||||
await page.wait(1.5);
|
||||
return readPageState(page);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message.includes('Inspected target navigated or closed')
|
||||
|| message.includes('Cannot find context with specified id')
|
||||
|| message.includes('Target closed')
|
||||
) {
|
||||
throw new CommandExecutionError(
|
||||
`1688 ${action} navigation lost the current browser target`,
|
||||
`${buildCaptchaHint(action)} If CDP is attached to a stale or blocked tab, open a fresh 1688 tab and point OPENCLI_CDP_TARGET at that tab.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensure1688Session(page: IPage): Promise<void> {
|
||||
const state = await gotoAndReadState(page, HOME_URL, 1500, 'homepage');
|
||||
assertAuthenticatedState(state, 'homepage');
|
||||
}
|
||||
|
||||
export function assertAuthenticatedState(state: PageState, action: string): void {
|
||||
if (!isCaptchaState(state) && !isLoginState(state)) return;
|
||||
throw new AuthRequiredError('1688.com', `请先在共享 Chrome 完成 1688 登录/验证,再重试(${action})`);
|
||||
}
|
||||
|
||||
export function assertNotCaptcha(state: PageState, action: string): void {
|
||||
assertAuthenticatedState(state, action);
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.replace(/,/g, '').trim();
|
||||
if (!normalized) return null;
|
||||
const parsed = Number.parseFloat(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function limitCandidates<T>(values: T[], limit: number): T[] {
|
||||
const normalizedLimit = Math.max(1, Math.trunc(limit) || 1);
|
||||
return values.slice(0, normalizedLimit);
|
||||
}
|
||||
|
||||
function normalizeNumericText(value: string): string {
|
||||
return value
|
||||
.replace(/([¥$€])\s+(?=\d)/g, '$1')
|
||||
.replace(/(\d)\s*\.\s*(\d)/g, '$1.$2')
|
||||
.replace(/\s*([~-])\s*/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeForRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parse1688Url(input: string): URL {
|
||||
const normalized = cleanText(input);
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
if (!url.hostname.endsWith('.1688.com') && url.hostname !== '1688.com' && url.hostname !== 'www.1688.com') {
|
||||
throw new Error('invalid-host');
|
||||
}
|
||||
stripTrackingParams(url);
|
||||
url.hash = '';
|
||||
return url;
|
||||
} catch {
|
||||
throw new ArgumentError(
|
||||
'Invalid 1688 URL',
|
||||
'Use a URL under 1688.com (for example: https://detail.1688.com/offer/887904326744.html)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parse1688UrlOrNull(input: string): URL | null {
|
||||
try {
|
||||
return parse1688Url(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoreHost(hostname: string): string | null {
|
||||
const lower = cleanText(hostname).toLowerCase();
|
||||
if (!lower.endsWith('.1688.com')) return null;
|
||||
const [subdomain] = lower.split('.');
|
||||
if (!subdomain || STORE_GENERIC_HOSTS.has(subdomain)) return null;
|
||||
return lower;
|
||||
}
|
||||
|
||||
function stripTrackingParams(url: URL): void {
|
||||
const keys = [...url.searchParams.keys()];
|
||||
for (const key of keys) {
|
||||
if (TRACKING_QUERY_KEYS.has(key) || key.toLowerCase().startsWith('utm_')) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
SEARCH_LIMIT_DEFAULT,
|
||||
SEARCH_LIMIT_MAX,
|
||||
parseSearchLimit,
|
||||
buildSearchUrl,
|
||||
buildDetailUrl,
|
||||
resolveStoreUrl,
|
||||
canonicalizeStoreUrl,
|
||||
canonicalizeItemUrl,
|
||||
canonicalizeSellerUrl,
|
||||
extractOfferId,
|
||||
extractMemberId,
|
||||
extractShopId,
|
||||
parsePriceText,
|
||||
normalizePriceTiers,
|
||||
parseMoqText,
|
||||
extractLocation,
|
||||
extractAddress,
|
||||
extractMetric,
|
||||
extractYearsOnPlatform,
|
||||
extractMainBusiness,
|
||||
extractBadges,
|
||||
guessTopCategories,
|
||||
isCaptchaState,
|
||||
isLoginState,
|
||||
cleanText,
|
||||
cleanMultilineText,
|
||||
uniqueNonEmpty,
|
||||
limitCandidates,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './store.js';
|
||||
|
||||
describe('1688 store normalization', () => {
|
||||
it('merges store contact text with seller seed data', () => {
|
||||
const result = __test__.normalizeStorePayload({
|
||||
resolvedUrl: 'https://yinuoweierfushi.1688.com/?offerId=887904326744',
|
||||
explicitMemberId: null,
|
||||
storePayload: {
|
||||
href: 'https://yinuoweierfushi.1688.com/page/index.html',
|
||||
bodyText: `
|
||||
青岛沁澜衣品服装有限公司
|
||||
联系方式
|
||||
地址:山东省青岛市即墨区环秀街道办事处湘江二路97号甲
|
||||
`,
|
||||
offerLinks: ['https://detail.1688.com/offer/887904326744.html'],
|
||||
},
|
||||
contactPayload: {
|
||||
href: 'https://yinuoweierfushi.1688.com/page/contactinfo.html',
|
||||
bodyText: `
|
||||
青岛沁澜衣品服装有限公司
|
||||
电话:86 0532 86655366
|
||||
手机:15963238678
|
||||
地址:山东省青岛市即墨区环秀街道办事处湘江二路97号甲
|
||||
`,
|
||||
},
|
||||
seed: {
|
||||
bodyText: `
|
||||
入驻13年
|
||||
主营:大码女装
|
||||
店铺回头率
|
||||
87%
|
||||
延期必赔
|
||||
品质保障
|
||||
`,
|
||||
seller: {
|
||||
companyName: '青岛沁澜衣品服装有限公司',
|
||||
memberId: 'b2b-1641351767',
|
||||
winportUrl: 'https://yinuoweierfushi.1688.com/page/index.html?spm=abc',
|
||||
},
|
||||
services: [{ serviceName: '延期必赔' }, { serviceName: '品质保障' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.member_id).toBe('b2b-1641351767');
|
||||
expect(result.store_url).toBe('https://yinuoweierfushi.1688.com');
|
||||
expect(result.company_url).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
|
||||
expect(result.years_on_platform_text).toBe('入驻13年');
|
||||
expect(result.location).toBe('山东省青岛市即墨区环秀街道办事处湘江二路97号甲');
|
||||
expect(result.return_rate_text).toContain('87%');
|
||||
expect(result.top_categories).toEqual(['大码女装']);
|
||||
expect(result.service_badges).toEqual(['延期必赔', '品质保障']);
|
||||
});
|
||||
|
||||
it('builds contact urls and extracts offer ids', () => {
|
||||
expect(__test__.safeCanonicalStoreUrl('https://yinuoweierfushi.1688.com/page/index.html?spm=foo')).toBe(
|
||||
'https://yinuoweierfushi.1688.com',
|
||||
);
|
||||
expect(__test__.buildContactUrl('https://yinuoweierfushi.1688.com')).toBe(
|
||||
'https://yinuoweierfushi.1688.com/page/contactinfo.html',
|
||||
);
|
||||
expect(__test__.firstOfferId([
|
||||
'https://detail.1688.com/offer/887904326744.html',
|
||||
])).toBe('887904326744');
|
||||
expect(__test__.firstContactUrl([
|
||||
'https://yinuoweierfushi.1688.com/page/contactinfo.html?spm=1',
|
||||
])).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
FACTORY_BADGE_PATTERNS,
|
||||
SERVICE_BADGE_PATTERNS,
|
||||
assertAuthenticatedState,
|
||||
buildDetailUrl,
|
||||
buildProvenance,
|
||||
canonicalizeSellerUrl,
|
||||
canonicalizeStoreUrl,
|
||||
cleanMultilineText,
|
||||
cleanText,
|
||||
extractAddress,
|
||||
extractBadges,
|
||||
extractMemberId,
|
||||
extractMetric,
|
||||
extractOfferId,
|
||||
extractShopId,
|
||||
extractYearsOnPlatform,
|
||||
gotoAndReadState,
|
||||
guessTopCategories,
|
||||
resolveStoreUrl,
|
||||
uniqueNonEmpty,
|
||||
} from './shared.js';
|
||||
|
||||
interface StoreBrowserPayload {
|
||||
href?: string;
|
||||
title?: string;
|
||||
bodyText?: string;
|
||||
offerLinks?: string[];
|
||||
contactLinks?: string[];
|
||||
}
|
||||
|
||||
interface StoreItemSeed {
|
||||
href?: string;
|
||||
bodyText?: string;
|
||||
seller?: {
|
||||
companyName?: string;
|
||||
memberId?: string;
|
||||
winportUrl?: string;
|
||||
sellerWinportUrlMap?: Record<string, string>;
|
||||
};
|
||||
services?: Array<{ serviceName?: string }>;
|
||||
}
|
||||
|
||||
function normalizeStorePayload(input: {
|
||||
resolvedUrl: string;
|
||||
storePayload: StoreBrowserPayload | null;
|
||||
contactPayload: StoreBrowserPayload | null;
|
||||
seed: StoreItemSeed | null;
|
||||
explicitMemberId: string | null;
|
||||
}): Record<string, unknown> {
|
||||
const storePayload = input.storePayload;
|
||||
const contactPayload = input.contactPayload;
|
||||
const seed = input.seed;
|
||||
|
||||
const contactText = cleanMultilineText(contactPayload?.bodyText);
|
||||
const storeText = cleanMultilineText(storePayload?.bodyText);
|
||||
const seedText = cleanMultilineText(seed?.bodyText);
|
||||
const combinedText = [contactText, storeText, seedText].filter(Boolean).join('\n');
|
||||
|
||||
const sellerUrlRaw = cleanText(
|
||||
seed?.seller?.winportUrl
|
||||
?? seed?.seller?.sellerWinportUrlMap?.defaultUrl
|
||||
?? storePayload?.href
|
||||
?? input.resolvedUrl,
|
||||
);
|
||||
const storeUrl = safeCanonicalStoreUrl(sellerUrlRaw || input.resolvedUrl) ?? input.resolvedUrl;
|
||||
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw) ?? storeUrl;
|
||||
const companyUrl = pickCompanyUrl(contactPayload?.href, storeUrl);
|
||||
const memberId = cleanText(seed?.seller?.memberId)
|
||||
|| input.explicitMemberId
|
||||
|| extractMemberId(input.resolvedUrl)
|
||||
|| extractMemberId(storePayload?.href ?? '')
|
||||
|| null;
|
||||
const shopId = extractShopId(sellerUrl) ?? extractShopId(storeUrl);
|
||||
const companyName = cleanText(seed?.seller?.companyName)
|
||||
|| firstNamedLine(contactText)
|
||||
|| firstNamedLine(storeText)
|
||||
|| null;
|
||||
const serviceBadges = uniqueNonEmpty([
|
||||
...extractBadges(combinedText, SERVICE_BADGE_PATTERNS),
|
||||
...((seed?.services ?? []).map((service) => cleanText(service.serviceName))),
|
||||
]);
|
||||
const factoryBadges = extractBadges(combinedText, FACTORY_BADGE_PATTERNS);
|
||||
|
||||
return {
|
||||
member_id: memberId,
|
||||
shop_id: shopId,
|
||||
store_name: companyName,
|
||||
store_url: storeUrl,
|
||||
company_name: companyName,
|
||||
company_url: companyUrl,
|
||||
business_model_text: firstMetric(combinedText, ['经营模式', '生产加工', '主营产品']),
|
||||
years_on_platform_text: extractYearsOnPlatform(combinedText),
|
||||
location: extractAddress(contactText) ?? extractAddress(storeText),
|
||||
staff_size_text: firstMetric(combinedText, ['员工人数', '员工总数']),
|
||||
factory_badges: factoryBadges,
|
||||
service_badges: serviceBadges,
|
||||
response_rate_text: firstMetric(combinedText, ['响应率', '回复率', '响应速度']),
|
||||
return_rate_text: extractReturnRate(combinedText),
|
||||
top_categories: guessTopCategories(combinedText),
|
||||
phone_text: extractMetric(contactText, '电话'),
|
||||
mobile_text: extractMetric(contactText, '手机'),
|
||||
...buildProvenance(cleanText(contactPayload?.href) || cleanText(storePayload?.href) || input.resolvedUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function safeCanonicalStoreUrl(url: string): string | null {
|
||||
try {
|
||||
return canonicalizeStoreUrl(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickCompanyUrl(contactHref: string | undefined, storeUrl: string): string | null {
|
||||
const fromPage = cleanText(contactHref);
|
||||
if (fromPage) {
|
||||
const normalized = buildContactUrl(fromPage);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return buildContactUrl(storeUrl);
|
||||
}
|
||||
|
||||
function buildContactUrl(storeUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(storeUrl);
|
||||
if (!parsed.hostname.endsWith('.1688.com')) return null;
|
||||
return `${parsed.protocol}//${parsed.hostname}/page/contactinfo.html`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function firstNamedLine(text: string): string | null {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => cleanText(line))
|
||||
.find((line) => line.includes('有限公司') || line.includes('商行') || line.includes('工厂'))
|
||||
?? null;
|
||||
}
|
||||
|
||||
function firstMetric(text: string, labels: string[]): string | null {
|
||||
for (const label of labels) {
|
||||
const value = extractMetric(text, label);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractReturnRate(text: string): string | null {
|
||||
const inline = text.match(/回头率\s*([0-9.]+%)/);
|
||||
if (inline) return cleanText(inline[0]);
|
||||
const multiline = text.match(/回头率\s*\n\s*([0-9.]+%)/);
|
||||
if (!multiline) return null;
|
||||
return `回头率${cleanText(multiline[1])}`;
|
||||
}
|
||||
|
||||
function firstOfferId(links: string[]): string | null {
|
||||
for (const link of links) {
|
||||
const offerId = extractOfferId(link);
|
||||
if (offerId) return offerId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstContactUrl(links: string[]): string | null {
|
||||
for (const link of links) {
|
||||
const url = buildContactUrl(link);
|
||||
if (url) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readStorePayload(page: IPage, url: string, action: string): Promise<StoreBrowserPayload> {
|
||||
const state = await gotoAndReadState(page, url, 2500, action);
|
||||
assertAuthenticatedState(state, action);
|
||||
|
||||
return await page.evaluate(`
|
||||
(() => ({
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
bodyText: document.body ? document.body.innerText || '' : '',
|
||||
offerLinks: Array.from(document.querySelectorAll('a[href*="detail.1688.com/offer/"], a[href*="offerId="]'))
|
||||
.map((anchor) => anchor.href)
|
||||
.filter(Boolean),
|
||||
contactLinks: Array.from(document.querySelectorAll('a[href*="contactinfo"]'))
|
||||
.map((anchor) => anchor.href)
|
||||
.filter(Boolean),
|
||||
}))()
|
||||
`) as StoreBrowserPayload;
|
||||
}
|
||||
|
||||
async function readItemSeed(page: IPage, offerId: string): Promise<StoreItemSeed> {
|
||||
const itemUrl = buildDetailUrl(offerId);
|
||||
const state = await gotoAndReadState(page, itemUrl, 2500, 'store seed item');
|
||||
assertAuthenticatedState(state, 'store seed item');
|
||||
|
||||
const seed = await page.evaluate(`
|
||||
(() => {
|
||||
const model = window.context?.result?.global?.globalData?.model ?? null;
|
||||
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
|
||||
return {
|
||||
href: window.location.href,
|
||||
bodyText: document.body ? document.body.innerText || '' : '',
|
||||
seller: toJson(model?.sellerModel),
|
||||
services: toJson(model?.shippingServices?.fields?.buyerProtectionModel ?? []),
|
||||
};
|
||||
})()
|
||||
`) as StoreItemSeed;
|
||||
|
||||
const hasSellerContext = !!cleanText(seed?.seller?.memberId) || !!cleanText(seed?.seller?.winportUrl);
|
||||
if (!hasSellerContext) {
|
||||
throw new CommandExecutionError(
|
||||
'1688 store seed item did not expose seller context',
|
||||
'当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试',
|
||||
);
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
|
||||
function hasAnyEvidence(
|
||||
storePayload: StoreBrowserPayload | null,
|
||||
contactPayload: StoreBrowserPayload | null,
|
||||
seed: StoreItemSeed | null,
|
||||
): boolean {
|
||||
return !!cleanText(storePayload?.bodyText)
|
||||
|| !!cleanText(contactPayload?.bodyText)
|
||||
|| !!cleanText(seed?.bodyText);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'store',
|
||||
description: '1688 店铺/供应商公开信息(联系方式、主营、入驻年限、公开服务信号)',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{
|
||||
name: 'input',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: '1688 店铺 URL 或 member ID(如 b2b-22154705262941f196)',
|
||||
},
|
||||
],
|
||||
columns: ['store_name', 'years_on_platform_text', 'location', 'return_rate_text'],
|
||||
func: async (page, kwargs) => {
|
||||
const rawInput = String(kwargs.input ?? '');
|
||||
const resolvedUrl = resolveStoreUrl(rawInput);
|
||||
const explicitMemberId = extractMemberId(rawInput);
|
||||
|
||||
const storePayload = await readStorePayload(page, resolvedUrl, 'store');
|
||||
const contactUrl = firstContactUrl(storePayload.contactLinks ?? []) || buildContactUrl(storePayload.href || resolvedUrl);
|
||||
const contactPayload = contactUrl ? await readStorePayload(page, contactUrl, 'store contact') : null;
|
||||
const offerId = extractOfferId(rawInput)
|
||||
|| firstOfferId(storePayload.offerLinks ?? [])
|
||||
|| firstOfferId(contactPayload?.offerLinks ?? []);
|
||||
|
||||
let seed: StoreItemSeed | null = null;
|
||||
if (offerId) {
|
||||
try {
|
||||
seed = await readItemSeed(page, offerId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof CommandExecutionError)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyEvidence(storePayload, contactPayload, seed)) {
|
||||
throw new EmptyResultError(
|
||||
'1688 store',
|
||||
'Store page is reachable but no visible fields were extracted. Open the store page in Chrome and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeStorePayload({
|
||||
resolvedUrl,
|
||||
storePayload,
|
||||
contactPayload,
|
||||
seed,
|
||||
explicitMemberId,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeStorePayload,
|
||||
safeCanonicalStoreUrl,
|
||||
buildContactUrl,
|
||||
firstNamedLine,
|
||||
firstMetric,
|
||||
extractReturnRate,
|
||||
firstOfferId,
|
||||
firstContactUrl,
|
||||
};
|
||||
@@ -3,9 +3,9 @@
|
||||
*
|
||||
* Fetches the full content of a 36kr article given its ID or URL.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
|
||||
/** Extract article ID from a full URL or a bare numeric ID string */
|
||||
function parseArticleId(input: string): string {
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* 36kr hot-list — INTERCEPT strategy.
|
||||
* 36kr hot-list — DOM scraping.
|
||||
*
|
||||
* Navigates to the 36kr hot-list page and scrapes rendered article links.
|
||||
* Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
renqi: '人气榜',
|
||||
@@ -34,7 +34,8 @@ cli({
|
||||
name: 'hot',
|
||||
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
|
||||
{
|
||||
@@ -58,9 +59,13 @@ cli({
|
||||
|
||||
const url = buildHotListUrl(listType);
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(url);
|
||||
await page.waitForCapture(6);
|
||||
// Poll DOM until article links appear (36kr renders client-side)
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await page.evaluate('document.querySelectorAll("a[href*=\\"/p/\\"]").length')) break;
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
// Scrape rendered article links from DOM (deduplicated)
|
||||
const domItems: any = await page.evaluate(`
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 36kr latest news — public RSS feed, no browser needed.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: '36kr',
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* 36kr article search — INTERCEPT strategy.
|
||||
* 36kr article search — DOM scraping.
|
||||
*
|
||||
* Navigates to the 36kr search results page and scrapes rendered articles.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'search',
|
||||
description: '搜索36氪文章',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
|
||||
@@ -22,9 +23,13 @@ cli({
|
||||
const count = Math.min(Number(args.limit) || 20, 50);
|
||||
const query = encodeURIComponent(String(args.query ?? ''));
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(`https://www.36kr.com/search/articles/${query}`);
|
||||
await page.waitForCapture(6);
|
||||
// Poll DOM until article links appear (36kr renders client-side)
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await page.evaluate('document.querySelectorAll("a[href*=\\"/p/\\"]").length')) break;
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
const domItems: any = await page.evaluate(`
|
||||
(() => {
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import type { CliOptions } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import type { CliOptions } from '@jackwener/opencli/registry';
|
||||
|
||||
/**
|
||||
* Factory: capture DOM HTML + accessibility snapshot.
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './bestsellers.js';
|
||||
import { __test__ } from './rankings.js';
|
||||
|
||||
describe('amazon bestsellers normalization', () => {
|
||||
it('normalizes bestseller cards and infers review counts from card text', () => {
|
||||
const result = __test__.normalizeBestsellerCandidate({
|
||||
const result = __test__.normalizeRankingCandidate({
|
||||
asin: 'B0DR31GC3D',
|
||||
title: '',
|
||||
href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs',
|
||||
@@ -11,7 +11,16 @@ describe('amazon bestsellers normalization', () => {
|
||||
rating_text: '4.3 out of 5 stars',
|
||||
review_count_text: '',
|
||||
card_text: 'Desk Shelves Desktop Organizer Shlef\n4.3 out of 5 stars\n435\n$25.92',
|
||||
}, 2, 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves', 'https://www.amazon.com/example');
|
||||
}, {
|
||||
listType: 'bestsellers',
|
||||
rankFallback: 2,
|
||||
listTitle: 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves',
|
||||
sourceUrl: 'https://www.amazon.com/example',
|
||||
categoryTitle: null,
|
||||
categoryUrl: 'https://www.amazon.com/example',
|
||||
categoryPath: [],
|
||||
visibleCategoryLinks: [],
|
||||
});
|
||||
|
||||
expect(result.rank).toBe(2);
|
||||
expect(result.asin).toBe('B0DR31GC3D');
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'bestsellers',
|
||||
listType: 'bestsellers',
|
||||
description: 'Amazon Best Sellers pages for category candidate discovery',
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
buildDiscussionUrl,
|
||||
buildProvenance,
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'movers-shakers',
|
||||
listType: 'movers_shakers',
|
||||
description: 'Amazon Movers & Shakers pages for short-term growth signals',
|
||||
}));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'new-releases',
|
||||
listType: 'new_releases',
|
||||
description: 'Amazon New Releases pages for early momentum discovery',
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
buildProductUrl,
|
||||
buildProvenance,
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
buildProductUrl,
|
||||
buildProvenance,
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './rankings.js';
|
||||
|
||||
describe('amazon rankings helpers', () => {
|
||||
it('normalizes ranking candidates with unified schema', () => {
|
||||
const result = __test__.normalizeRankingCandidate(
|
||||
{
|
||||
rank_text: '#3',
|
||||
asin: 'B0DR31GC3D',
|
||||
title: 'Desk Shelves Desktop Organizer',
|
||||
href: 'https://www.amazon.com/dp/B0DR31GC3D/ref=zg_bs',
|
||||
price_text: '$25.92',
|
||||
rating_text: '4.3 out of 5 stars',
|
||||
review_count_text: '435',
|
||||
},
|
||||
{
|
||||
listType: 'new_releases',
|
||||
rankFallback: 3,
|
||||
listTitle: 'Amazon New Releases',
|
||||
sourceUrl: 'https://www.amazon.com/gp/new-releases',
|
||||
categoryTitle: 'Home & Kitchen',
|
||||
categoryUrl: 'https://www.amazon.com/gp/new-releases/home-garden',
|
||||
categoryPath: ['Home & Kitchen'],
|
||||
visibleCategoryLinks: [{ title: 'Storage', url: 'https://www.amazon.com/gp/new-releases/storage', node_id: null }],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.list_type).toBe('new_releases');
|
||||
expect(result.rank).toBe(3);
|
||||
expect(result.asin).toBe('B0DR31GC3D');
|
||||
expect(result.product_url).toBe('https://www.amazon.com/dp/B0DR31GC3D');
|
||||
expect(result.category_title).toBe('Home & Kitchen');
|
||||
expect(result.visible_category_links).toEqual([
|
||||
{ title: 'Storage', url: 'https://www.amazon.com/gp/new-releases/storage', node_id: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates category links and parses rank fallback', () => {
|
||||
const links = __test__.normalizeVisibleCategoryLinks([
|
||||
{ title: 'Kitchen', url: '/gp/new-releases/home-garden' },
|
||||
{ title: 'Kitchen', url: 'https://www.amazon.com/gp/new-releases/home-garden' },
|
||||
{ title: 'Storage', url: '/gp/new-releases/storage', node_id: '1064954' },
|
||||
]);
|
||||
expect(links.length).toBe(2);
|
||||
expect(__test__.parseRank('N/A', 8)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { Strategy, type CliOptions } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
assertUsableState,
|
||||
buildProvenance,
|
||||
cleanText,
|
||||
extractAsin,
|
||||
extractCategoryNodeId,
|
||||
extractReviewCountFromCardText,
|
||||
firstMeaningfulLine,
|
||||
gotoAndReadState,
|
||||
isRankingPaginationUrl,
|
||||
normalizeProductUrl,
|
||||
parsePriceText,
|
||||
parseRatingValue,
|
||||
parseReviewCount,
|
||||
resolveRankingUrl,
|
||||
toAbsoluteAmazonUrl,
|
||||
uniqueNonEmpty,
|
||||
type AmazonRankingListType,
|
||||
} from './shared.js';
|
||||
|
||||
export interface RankingCardPayload {
|
||||
rank_text?: string | null;
|
||||
asin?: string | null;
|
||||
title?: string | null;
|
||||
href?: string | null;
|
||||
price_text?: string | null;
|
||||
rating_text?: string | null;
|
||||
review_count_text?: string | null;
|
||||
card_text?: string | null;
|
||||
}
|
||||
|
||||
interface RankingPagePayload {
|
||||
href?: string;
|
||||
title?: string;
|
||||
list_title?: string;
|
||||
category_title?: string;
|
||||
category_path?: string[];
|
||||
cards?: RankingCardPayload[];
|
||||
page_links?: string[];
|
||||
visible_category_links?: Array<{
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
node_id?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RankingCommandDefinition {
|
||||
commandName: string;
|
||||
listType: AmazonRankingListType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RankingNormalizeContext {
|
||||
listType: AmazonRankingListType;
|
||||
rankFallback: number;
|
||||
listTitle: string | null;
|
||||
sourceUrl: string;
|
||||
categoryTitle: string | null;
|
||||
categoryUrl: string | null;
|
||||
categoryPath: string[];
|
||||
visibleCategoryLinks: Array<{ title: string; url: string; node_id: string | null }>;
|
||||
}
|
||||
|
||||
function parseRank(rawRank: string | null | undefined, fallback: number): number {
|
||||
const normalized = cleanText(rawRank);
|
||||
const match = normalized.match(/(\d{1,4})/);
|
||||
if (!match) return fallback;
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeVisibleCategoryLinks(
|
||||
links: RankingPagePayload['visible_category_links'],
|
||||
): Array<{ title: string; url: string; node_id: string | null }> {
|
||||
const normalized = (links ?? [])
|
||||
.map((entry) => ({
|
||||
title: cleanText(entry?.title),
|
||||
url: toAbsoluteAmazonUrl(entry?.url) ?? '',
|
||||
node_id: cleanText(entry?.node_id) || extractCategoryNodeId(entry?.url) || null,
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.title) && Boolean(entry.url));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped: Array<{ title: string; url: string; node_id: string | null }> = [];
|
||||
for (const entry of normalized) {
|
||||
if (seen.has(entry.url)) continue;
|
||||
seen.add(entry.url);
|
||||
deduped.push(entry);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function normalizeRankingCandidate(
|
||||
candidate: RankingCardPayload,
|
||||
context: RankingNormalizeContext,
|
||||
): Record<string, unknown> {
|
||||
const productUrl = normalizeProductUrl(candidate.href);
|
||||
const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
|
||||
const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text);
|
||||
const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text);
|
||||
const ratingText = cleanText(candidate.rating_text) || null;
|
||||
const reviewCountText = cleanText(candidate.review_count_text)
|
||||
|| extractReviewCountFromCardText(candidate.card_text)
|
||||
|| null;
|
||||
const provenance = buildProvenance(context.sourceUrl);
|
||||
const categoryUrl = context.categoryUrl || context.sourceUrl;
|
||||
|
||||
return {
|
||||
list_type: context.listType,
|
||||
rank: parseRank(candidate.rank_text, context.rankFallback),
|
||||
asin,
|
||||
title: title || null,
|
||||
product_url: productUrl,
|
||||
price_text: price.price_text,
|
||||
price_value: price.price_value,
|
||||
currency: price.currency,
|
||||
rating_text: ratingText,
|
||||
rating_value: parseRatingValue(ratingText),
|
||||
review_count_text: reviewCountText,
|
||||
review_count: parseReviewCount(reviewCountText),
|
||||
list_title: context.listTitle,
|
||||
category_title: context.categoryTitle,
|
||||
category_url: categoryUrl,
|
||||
category_node_id: extractCategoryNodeId(categoryUrl),
|
||||
category_path: context.categoryPath,
|
||||
visible_category_links: context.visibleCategoryLinks,
|
||||
...provenance,
|
||||
};
|
||||
}
|
||||
|
||||
async function readRankingPage(
|
||||
page: IPage,
|
||||
listType: AmazonRankingListType,
|
||||
url: string,
|
||||
): Promise<RankingPagePayload> {
|
||||
const state = await gotoAndReadState(page, url, 2500, listType);
|
||||
assertUsableState(state, listType);
|
||||
|
||||
return await page.evaluate(`
|
||||
(() => ({
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
list_title:
|
||||
document.querySelector('#zg_banner_text')?.textContent
|
||||
|| document.querySelector('h1')?.textContent
|
||||
|| '',
|
||||
category_title:
|
||||
document.querySelector('#zg_browseRoot .zg_selected')?.textContent
|
||||
|| document.querySelector('#wayfinding-breadcrumbs_feature_div ul li:last-child')?.textContent
|
||||
|| document.querySelector('#wayfinding-breadcrumbs_container ul li:last-child')?.textContent
|
||||
|| '',
|
||||
category_path: Array.from(document.querySelectorAll(
|
||||
'#zg_browseRoot ul li a, #zg_browseRoot ul li span, ' +
|
||||
'#wayfinding-breadcrumbs_feature_div ul li a, #wayfinding-breadcrumbs_feature_div ul li span.a-list-item, ' +
|
||||
'#wayfinding-breadcrumbs_container ul li a, #wayfinding-breadcrumbs_container ul li span.a-list-item'
|
||||
))
|
||||
.map((entry) => (entry.textContent || '').trim())
|
||||
.filter(Boolean),
|
||||
cards: Array.from(document.querySelectorAll(
|
||||
'.p13n-sc-uncoverable-faceout, .zg-grid-general-faceout, [data-asin][class*="p13n"]'
|
||||
)).map((card) => ({
|
||||
rank_text:
|
||||
card.querySelector('.zg-bdg-text')?.textContent
|
||||
|| card.querySelector('[class*="rank"]')?.textContent
|
||||
|| '',
|
||||
asin:
|
||||
card.getAttribute('data-asin')
|
||||
|| card.getAttribute('id')
|
||||
|| '',
|
||||
title:
|
||||
card.querySelector('[class*="line-clamp"]')?.textContent
|
||||
|| card.querySelector('img')?.getAttribute('alt')
|
||||
|| card.querySelector('a[href*="/dp/"]')?.textContent
|
||||
|| '',
|
||||
href:
|
||||
card.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]')?.href
|
||||
|| '',
|
||||
price_text:
|
||||
card.querySelector('.a-price .a-offscreen')?.textContent
|
||||
|| card.querySelector('.a-color-price')?.textContent
|
||||
|| '',
|
||||
rating_text:
|
||||
card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label')
|
||||
|| '',
|
||||
review_count_text:
|
||||
card.querySelector('a[href*="#customerReviews"]')?.textContent
|
||||
|| card.querySelector('.a-size-small')?.textContent
|
||||
|| '',
|
||||
card_text: card.innerText || '',
|
||||
})),
|
||||
page_links: Array.from(document.querySelectorAll('.a-pagination a[href], li.a-normal a[href], li.a-selected a[href]'))
|
||||
.map((anchor) => anchor.href || '')
|
||||
.filter(Boolean),
|
||||
visible_category_links: Array.from(document.querySelectorAll(
|
||||
'#zg_browseRoot a[href], #zg-left-col a[href], [class*="zg-browse"] a[href]'
|
||||
)).map((anchor) => ({
|
||||
title: (anchor.textContent || '').trim(),
|
||||
url: anchor.href || '',
|
||||
node_id:
|
||||
anchor.getAttribute('data-node-id')
|
||||
|| anchor.dataset?.nodeid
|
||||
|| '',
|
||||
}))
|
||||
.filter((entry) => entry.title && entry.url),
|
||||
}))()
|
||||
`) as RankingPagePayload;
|
||||
}
|
||||
|
||||
function createEmptyResultHint(commandName: string): string {
|
||||
return [
|
||||
`Open the same Amazon ${commandName} page in shared Chrome and verify ranked items are visible.`,
|
||||
'If the page shows a robot check, clear it manually and retry.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function createRankingCliOptions(definition: RankingCommandDefinition): CliOptions {
|
||||
return {
|
||||
site: 'amazon',
|
||||
name: definition.commandName,
|
||||
description: definition.description,
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{
|
||||
name: 'input',
|
||||
positional: true,
|
||||
help: 'Ranking URL or supported Amazon path. Omit to use the list root.',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'int',
|
||||
default: 100,
|
||||
help: 'Maximum number of ranked items to return (default 100)',
|
||||
},
|
||||
],
|
||||
columns: ['list_type', 'rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = Math.max(1, Number(kwargs.limit) || 100);
|
||||
const initialUrl = resolveRankingUrl(definition.listType, typeof kwargs.input === 'string' ? kwargs.input : undefined);
|
||||
|
||||
const queue = [initialUrl];
|
||||
const visited = new Set<string>();
|
||||
const seenEntityKeys = new Set<string>();
|
||||
const results: Record<string, unknown>[] = [];
|
||||
let listTitle: string | null = null;
|
||||
|
||||
while (queue.length > 0 && results.length < limit) {
|
||||
const nextUrl = queue.shift()!;
|
||||
if (visited.has(nextUrl)) continue;
|
||||
visited.add(nextUrl);
|
||||
|
||||
const payload = await readRankingPage(page, definition.listType, nextUrl);
|
||||
const sourceUrl = cleanText(payload.href) || nextUrl;
|
||||
listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle;
|
||||
const categoryPath = uniqueNonEmpty(payload.category_path ?? []);
|
||||
const categoryTitle = cleanText(payload.category_title)
|
||||
|| (categoryPath.length > 0 ? categoryPath[categoryPath.length - 1] : '');
|
||||
const visibleCategoryLinks = normalizeVisibleCategoryLinks(payload.visible_category_links);
|
||||
const cards = payload.cards ?? [];
|
||||
|
||||
for (const card of cards) {
|
||||
const normalized = normalizeRankingCandidate(card, {
|
||||
listType: definition.listType,
|
||||
rankFallback: results.length + 1,
|
||||
listTitle,
|
||||
sourceUrl,
|
||||
categoryTitle: categoryTitle || null,
|
||||
categoryUrl: sourceUrl,
|
||||
categoryPath,
|
||||
visibleCategoryLinks,
|
||||
});
|
||||
|
||||
const dedupeKey = cleanText(String(normalized.asin ?? ''))
|
||||
|| cleanText(String(normalized.product_url ?? ''));
|
||||
if (dedupeKey && seenEntityKeys.has(dedupeKey)) continue;
|
||||
if (dedupeKey) seenEntityKeys.add(dedupeKey);
|
||||
|
||||
results.push(normalized);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
const pageLinks = uniqueNonEmpty(payload.page_links ?? []);
|
||||
for (const href of pageLinks) {
|
||||
const absolute = toAbsoluteAmazonUrl(href);
|
||||
if (!absolute || !isRankingPaginationUrl(definition.listType, absolute)) continue;
|
||||
if (!visited.has(absolute) && !queue.includes(absolute)) {
|
||||
queue.push(absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new CommandExecutionError(
|
||||
`amazon ${definition.commandName} did not expose any ranked items`,
|
||||
createEmptyResultHint(definition.commandName),
|
||||
);
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
parseRank,
|
||||
normalizeVisibleCategoryLinks,
|
||||
normalizeRankingCandidate,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import {
|
||||
buildProvenance,
|
||||
buildSearchUrl,
|
||||
@@ -34,4 +34,20 @@ describe('amazon shared helpers', () => {
|
||||
expect(__test__.resolveBestsellersUrl('/Best-Sellers/zgbs')).toBe('https://www.amazon.com/Best-Sellers/zgbs');
|
||||
expect(() => __test__.resolveBestsellersUrl('desk shelf organizer')).toThrow('amazon bestsellers expects a best sellers URL or /zgbs path');
|
||||
});
|
||||
|
||||
it('resolves and validates all ranking list URLs', () => {
|
||||
expect(__test__.resolveRankingUrl('new_releases')).toBe('https://www.amazon.com/gp/new-releases');
|
||||
expect(__test__.resolveRankingUrl('movers_shakers')).toBe('https://www.amazon.com/gp/movers-and-shakers');
|
||||
expect(__test__.resolveRankingUrl('new_releases', '/gp/new-releases/kitchen')).toBe('https://www.amazon.com/gp/new-releases/kitchen');
|
||||
expect(__test__.resolveRankingUrl(
|
||||
'bestsellers',
|
||||
'https://www.amazon.com/Best-Sellers/zgbs/ref=zg_bsnr_tab_bs',
|
||||
)).toBe('https://www.amazon.com/Best-Sellers/zgbs');
|
||||
expect(() => __test__.resolveRankingUrl('movers_shakers', 'https://example.com/gp/movers-and-shakers')).toThrow('Invalid Amazon URL');
|
||||
});
|
||||
|
||||
it('extracts category node id from URL best effort', () => {
|
||||
expect(__test__.extractCategoryNodeId('https://www.amazon.com/Best-Sellers-Home-Kitchen/zgbs/home-garden/3744371')).toBe('3744371');
|
||||
expect(__test__.extractCategoryNodeId('https://www.amazon.com/s?k=desk+organizer&rh=n%3A1064954')).toBe('1064954');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
|
||||
export const SITE = 'amazon';
|
||||
export const DOMAIN = 'amazon.com';
|
||||
export const HOME_URL = 'https://www.amazon.com/';
|
||||
export const BESTSELLERS_URL = 'https://www.amazon.com/Best-Sellers/zgbs';
|
||||
export const NEW_RELEASES_URL = 'https://www.amazon.com/gp/new-releases';
|
||||
export const MOVERS_SHAKERS_URL = 'https://www.amazon.com/gp/movers-and-shakers';
|
||||
export const SEARCH_URL_PREFIX = 'https://www.amazon.com/s?k=';
|
||||
export const PRODUCT_URL_PREFIX = 'https://www.amazon.com/dp/';
|
||||
export const DISCUSSION_URL_PREFIX = 'https://www.amazon.com/product-reviews/';
|
||||
@@ -28,6 +30,40 @@ const ROBOT_TEXT_PATTERNS = [
|
||||
'To discuss automated access to Amazon data please contact',
|
||||
];
|
||||
|
||||
export type AmazonRankingListType = 'bestsellers' | 'new_releases' | 'movers_shakers';
|
||||
|
||||
interface AmazonRankingSpec {
|
||||
commandName: string;
|
||||
rootUrl: string;
|
||||
pathPattern: RegExp;
|
||||
invalidInputMessage: string;
|
||||
invalidInputHint: string;
|
||||
}
|
||||
|
||||
const AMAZON_RANKING_SPECS: Record<AmazonRankingListType, AmazonRankingSpec> = {
|
||||
bestsellers: {
|
||||
commandName: 'bestsellers',
|
||||
rootUrl: BESTSELLERS_URL,
|
||||
pathPattern: /(?:^|\/)zgbs(?:\/|$)/i,
|
||||
invalidInputMessage: 'amazon bestsellers expects a best sellers URL or /zgbs path',
|
||||
invalidInputHint: 'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
|
||||
},
|
||||
new_releases: {
|
||||
commandName: 'new-releases',
|
||||
rootUrl: NEW_RELEASES_URL,
|
||||
pathPattern: /\/gp\/new-releases(?:\/|$)/i,
|
||||
invalidInputMessage: 'amazon new-releases expects a new releases URL or /gp/new-releases path',
|
||||
invalidInputHint: 'Example: opencli amazon new-releases https://www.amazon.com/gp/new-releases',
|
||||
},
|
||||
movers_shakers: {
|
||||
commandName: 'movers-shakers',
|
||||
rootUrl: MOVERS_SHAKERS_URL,
|
||||
pathPattern: /\/gp\/movers-and-shakers(?:\/|$)/i,
|
||||
invalidInputMessage: 'amazon movers-shakers expects a movers-and-shakers URL or /gp/movers-and-shakers path',
|
||||
invalidInputHint: 'Example: opencli amazon movers-shakers https://www.amazon.com/gp/movers-and-shakers',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProvenanceFields {
|
||||
source_url: string;
|
||||
fetched_at: string;
|
||||
@@ -115,23 +151,105 @@ export function buildDiscussionUrl(input: string): string {
|
||||
return `${DISCUSSION_URL_PREFIX}${asin}`;
|
||||
}
|
||||
|
||||
export function resolveBestsellersUrl(input?: string): string {
|
||||
function getRankingSpec(listType: AmazonRankingListType): AmazonRankingSpec {
|
||||
return AMAZON_RANKING_SPECS[listType];
|
||||
}
|
||||
|
||||
export function isSupportedRankingPath(listType: AmazonRankingListType, inputUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(inputUrl);
|
||||
return getRankingSpec(listType).pathPattern.test(url.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRankingUrl(listType: AmazonRankingListType, input?: string): string {
|
||||
const spec = getRankingSpec(listType);
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized) return BESTSELLERS_URL;
|
||||
if (normalized === 'root') return BESTSELLERS_URL;
|
||||
if (!normalized || normalized === 'root') return spec.rootUrl;
|
||||
|
||||
let candidateUrl: string;
|
||||
if (normalized.startsWith('/')) {
|
||||
return new URL(normalized, HOME_URL).toString();
|
||||
candidateUrl = new URL(normalized, HOME_URL).toString();
|
||||
} else if (/^https?:\/\//i.test(normalized)) {
|
||||
candidateUrl = canonicalizeAmazonUrl(normalized);
|
||||
} else if (normalized.includes('amazon.') && normalized.includes('/')) {
|
||||
candidateUrl = canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
|
||||
} else {
|
||||
throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
|
||||
}
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return canonicalizeAmazonUrl(normalized);
|
||||
|
||||
if (!isSupportedRankingPath(listType, candidateUrl)) {
|
||||
throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
|
||||
}
|
||||
if (normalized.includes('/zgbs/')) {
|
||||
return canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
|
||||
return normalizeRankingInputUrl(candidateUrl);
|
||||
}
|
||||
|
||||
function normalizeRankingInputUrl(inputUrl: string): string {
|
||||
try {
|
||||
const url = new URL(inputUrl);
|
||||
const normalizedPathSegments = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.filter((segment) => !/^ref=/i.test(segment));
|
||||
url.pathname = `/${normalizedPathSegments.join('/')}`;
|
||||
url.hash = '';
|
||||
// Ranking pages are frequently shared with tracking refs that can land on unstable variants.
|
||||
// Dropping ref keeps the canonical ranking path while preserving useful params (for example pg=2).
|
||||
url.searchParams.delete('ref');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return inputUrl;
|
||||
}
|
||||
throw new ArgumentError(
|
||||
'amazon bestsellers expects a best sellers URL or /zgbs path',
|
||||
'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
|
||||
);
|
||||
}
|
||||
|
||||
export function isRankingPaginationUrl(listType: AmazonRankingListType, inputUrl: string): boolean {
|
||||
const absolute = toAbsoluteAmazonUrl(inputUrl);
|
||||
if (!absolute || !isSupportedRankingPath(listType, absolute)) return false;
|
||||
|
||||
try {
|
||||
const url = new URL(absolute);
|
||||
const ref = cleanText(url.searchParams.get('ref')).toLowerCase();
|
||||
// pg= query param is the most reliable pagination indicator across all ranking lists
|
||||
return url.searchParams.has('pg')
|
||||
|| /(?:^|_)pg(?:_|$)/.test(ref)
|
||||
// Amazon ranking pagination refs: zg_bs_pg_ (bestsellers), zg_bsnr_pg_ (new releases), zg_bsms_pg_ (movers & shakers)
|
||||
|| /zg_bs(?:nr|ms)?_pg_/.test(ref);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractCategoryNodeId(inputUrl: string | null | undefined): string | null {
|
||||
const absolute = toAbsoluteAmazonUrl(inputUrl);
|
||||
if (!absolute) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(absolute);
|
||||
|
||||
for (const key of ['node', 'nodeid', 'nodeId', 'browseNode']) {
|
||||
const value = cleanText(url.searchParams.get(key));
|
||||
if (/^\d{4,}$/.test(value)) return value;
|
||||
}
|
||||
|
||||
const rhValue = cleanText(url.searchParams.get('rh'));
|
||||
const rhMatch = decodeURIComponent(rhValue).match(/(?:^|,)\s*n:(\d{4,})(?:,|$)/i);
|
||||
if (rhMatch) return rhMatch[1];
|
||||
|
||||
const pathMatches = [...url.pathname.matchAll(/\/(\d{4,})(?=\/|$)/g)];
|
||||
if (pathMatches.length > 0) {
|
||||
return pathMatches[pathMatches.length - 1][1];
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveBestsellersUrl(input?: string): string {
|
||||
return resolveRankingUrl('bestsellers', input);
|
||||
}
|
||||
|
||||
export function canonicalizeAmazonUrl(input: string): string {
|
||||
@@ -305,6 +423,10 @@ export const __test__ = {
|
||||
buildProductUrl,
|
||||
buildDiscussionUrl,
|
||||
resolveBestsellersUrl,
|
||||
resolveRankingUrl,
|
||||
isSupportedRankingPath,
|
||||
isRankingPaginationUrl,
|
||||
extractCategoryNodeId,
|
||||
parsePriceText,
|
||||
parseRatingValue,
|
||||
parseReviewCount,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export const dumpCommand = cli({
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const extractCodeCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -11,10 +11,10 @@
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { CDPBridge } from '../../browser/cdp.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { resolveElectronEndpoint } from '../../launcher.js';
|
||||
import { EXIT_CODES, getErrorMessage } from '../../errors.js';
|
||||
import { CDPBridge } from '@jackwener/opencli/browser/cdp';
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
import { resolveElectronEndpoint } from '@jackwener/opencli/launcher';
|
||||
import { EXIT_CODES, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
export const watchCommand = cli({
|
||||
site: 'antigravity',
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './search.js';
|
||||
import './top.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { itunesFetch, formatDuration, formatDate } from './utils.js';
|
||||
|
||||
cli({
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { itunesFetch } from './utils.js';
|
||||
|
||||
cli({
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
|
||||
// Apple Marketing Tools RSS API — public, no key required
|
||||
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
|
||||
@@ -5,7 +5,7 @@
|
||||
* https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
|
||||
*/
|
||||
|
||||
import { CliError } from '../../errors.js';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
|
||||
const BASE = 'https://itunes.apple.com';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, parseEntries } from './utils.js';
|
||||
|
||||
cli({
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, parseEntries } from './utils.js';
|
||||
|
||||
cli({
|
||||
@@ -5,7 +5,7 @@
|
||||
* https://info.arxiv.org/help/api/index.html
|
||||
*/
|
||||
|
||||
import { CliError } from '../../errors.js';
|
||||
import { CliError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
/**
|
||||
* band bands — List all Bands you belong to.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
/**
|
||||
* band mentions — Show Band notifications where you were @mentioned.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
import { formatCookieHeader } from '../../download/index.js';
|
||||
import { downloadMedia } from '../../download/media-download.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { formatCookieHeader } from '@jackwener/opencli/download';
|
||||
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
/**
|
||||
* band post — Export full content of a Band post: body, comments, and optional photo download.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
/**
|
||||
* band posts — List posts from a specific Band.
|
||||
@@ -3,7 +3,7 @@
|
||||
* Shows high volume/OI ratio trades that may indicate institutional activity.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
@@ -3,7 +3,7 @@
|
||||
* for near-the-money options on a given symbol.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
@@ -2,7 +2,7 @@
|
||||
* Barchart options chain — strike, bid/ask, volume, OI, greeks, IV.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
@@ -2,7 +2,7 @@
|
||||
* Barchart stock quote — price, volume, market cap, P/E, EPS, and key metrics.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user