Compare commits

..

1 Commits

Author SHA1 Message Date
jackwener e15094cd16 feat(external): register passthrough CLIs under ext namespace 2026-03-30 18:09:41 +08:00
981 changed files with 5104 additions and 55668 deletions
@@ -0,0 +1,249 @@
---
name: cross-project-adapter-migration
description: "Cross-project CLI command migration workflow for opencli. Use when importing commands from external CLI projects (python/node) like rdt-cli, twitter-cli, etc. Covers: source analysis → gap matrix → batch migration → README/SKILL.md update."
---
# Cross-Project Adapter Migration
> 从外部 CLI 项目(Python/Node/Go 等)批量迁移命令到 opencli 的标准化流程。
## When to Use
- 用户说"把 xxx-cli 的命令迁移过来"
- 用户说"看看 xxx 项目有什么可以借鉴的"
- 用户说"对齐 xxx-cli 的功能"
- 在为新平台扩展 opencli 时,发现已有第三方 CLI 工具
## Prerequisites
- 熟悉 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md)adapter 开发决策树)
- 熟悉 [SKILL.md](../../../SKILL.md)(命令参考 & 模板)
---
## Phase 1: 源项目分析
### 1.1 克隆 & 理解源项目
```bash
# 克隆源项目到 /tmp 做分析
git clone <source_repo_url> /tmp/<source-cli>
```
分析重点:
- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README
- **认证方式**CookieAPI KeyOAuth?浏览器自动化?
- **数据源**:公开 APIGraphQL?页面抓取?
- **输出字段**:每个命令返回哪些数据字段
### 1.2 生成命令清单
列出源项目所有命令,包括:
| 命令 | 类型 | API/方法 | 输出字段 |
|------|------|---------|---------|
| `xxx feed` | Read | `GET /api/feed` | title, author, time |
| `xxx post` | Write | `POST /api/tweet` | status, id |
---
## Phase 2: 功能对比矩阵
### 2.1 查看 opencli 现有命令
```bash
ls src/clis/<site>/ # 查看已有适配器
opencli list | grep <site> # 确认已注册命令
```
### 2.2 生成对比矩阵
对每个源项目命令,标注三种状态:
| 功能 | 源项目 | opencli 现有 | 行动 |
|------|--------|-------------|------|
| feed | ✅ `xxx feed` | ❌ 无 | ✅ **新增** |
| search | ✅ `xxx search` | ✅ `search.ts` | ❌ 已有,跳过 |
| hot | ✅ `xxx hot` | ⚠️ `hot.yaml`(不完整) | ✅ **增强** |
| like | ✅ `xxx like` | ✅ `like.ts` | ❌ 已有,跳过 |
### 2.3 筛选迁移目标
去掉已有的、低价值的,保留高价值缺失命令,按 Read/Write 分类:
**筛选原则**
- ✅ 高使用频率的命令优先
- ✅ 已有但不完整的命令标记为"增强"
- ❌ 源项目特有但 opencli 架构不支持的功能(如需要持久化存储的)跳过
- ❌ 与现有功能完全重复的跳过
---
## Phase 3: 批量实现
> [!IMPORTANT]
> 实现前必须查阅 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md) 确认策略选择。
### 3.1 选择实现方式
基于决策树分类:
| 类别 | 方式 | 适用条件 |
|------|------|---------|
| **Read + 简单 API** | YAML pipeline | 纯 fetch/select/map,无复杂 JS |
| **Read + GraphQL/分页/签名** | TypeScript adapter | 需要 JS 逻辑 |
| **Write 操作** | TypeScript + `Strategy.UI` | 点击/输入等 DOM 操作 |
| **Write + API** | TypeScript + `Strategy.COOKIE/HEADER` | 直接 POST API |
### 3.2 实现顺序
**先 Read 后 Write,先 YAML 后 TS**
1. **Phase A**: YAML Read 适配器(最快,通常每个 10-20 行)
2. **Phase B**: TS Read 适配器(需要 evaluate/intercept 的)
3. **Phase C**: TS Write 适配器(需 UI 自动化或 POST API
### 3.3 实现模板
#### YAML Read 适配器模板(Cookie 策略)
```yaml
site: <site>
name: <command>
description: <描述>
domain: www.<site>.com
strategy: cookie
browser: true
args:
limit:
type: int
default: 20
pipeline:
- navigate: https://www.<site>.com
- evaluate: |
(async () => {
const res = await fetch('<api_endpoint>', { credentials: 'include' });
const d = await res.json();
return (d.data?.items || []).map(item => ({
title: item.title,
// ... map source fields
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
- limit: ${{ args.limit }}
columns: [rank, title]
```
#### TS Write 适配器模板(UI 策略)
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: '<site>',
name: '<command>',
description: '<描述>',
strategy: Strategy.UI,
args: [{ name: 'target', required: true, help: '<参数说明>' }],
columns: ['status', 'message'],
func: async (page, kwargs) => {
await page.goto(`https://www.<site>.com/${kwargs.target}`);
await page.wait({ text: '<expected_text>', timeout: 10 });
// 获取 snapshot 找到目标按钮
const snapshot = await page.accessibility.snapshot();
// 点击按钮 ...
return [{ status: 'success', message: '<action> completed' }];
},
});
```
### 3.4 公共模式复用
迁移过程中如果发现多个适配器共享逻辑,考虑提取到 `src/clis/<site>/utils.ts` 工具文件:
```typescript
// src/clis/<site>/utils.ts
export async function fetchWithAuth(page, url) { ... }
export function parseItem(raw) { ... }
```
---
## Phase 4: 验证 & 发布
### 4.1 构建验证
```bash
npx tsc --noEmit # TypeScript 编译检查
opencli list | grep <site> # 确认所有命令已注册
```
### 4.2 运行验证(关键!)
每个新命令必须实际运行:
```bash
# Read 命令
opencli <site> <command> --limit 3 -f json
opencli <site> <command> --limit 3 -v # verbose 查看 pipeline
# Write 命令(谨慎!会实际操作)
opencli <site> <command> <test_target>
```
### 4.3 更新文档
迁移完成后必须更新以下文件:
1. **README.md** — 在对应平台区域添加新命令示例
2. **SKILL.md** — 在 Commands Reference 中添加新命令
### 4.4 提交 & 推送
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>
- Phase A: <N> YAML adapters (read operations)
- Phase B: <N> TS adapters (write operations)
- Source: <source_repo_url>"
git push
```
---
## Checklist
- [ ] 源项目命令清单已生成
- [ ] 对比矩阵已确认,高价值缺失命令已筛选
- [ ] 用户确认迁移范围
- [ ] Phase A: YAML Read 适配器已完成
- [ ] Phase B: TS Read 适配器已完成
- [ ] Phase C: TS Write 适配器已完成
- [ ] `npx tsc --noEmit` 编译通过
- [ ] 所有新命令已实际运行验证
- [ ] README.md 已更新
- [ ] SKILL.md 已更新
- [ ] 已 commit + push
## 实战案例参考
### rdt-cli → opencli Reddit2026-03-16
- **源项目**: `rdt-cli`25 个 Python 命令)
- **筛选结果**: 13 个高价值命令
- **实现**: 7 个 YAMLread + 6 个 TSwrite
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15+275%
### twitter-cli → opencli Twitter2026-03-16
- **源项目**: `twitter-cli`20+ Python 命令)
- **筛选结果**: 11 个待实现
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetchWrite 用 `Strategy.UI`
@@ -0,0 +1,54 @@
---
description: Migrate commands from an external CLI project into opencli adapters
---
// turbo-all
## Steps
1. Clone the source CLI project for analysis:
```bash
git clone <source_repo_url> /tmp/<source-cli>
```
2. Analyze source project: list all commands, auth method, API endpoints, and output fields.
3. Check existing opencli adapters for the target site:
```bash
ls src/clis/<site>/
opencli list | grep <site>
```
4. Generate a comparison matrix table (source commands vs opencli existing). Mark each as: ✅ **New** / ✅ **Enhance** / ❌ **Skip**. Ask user to confirm which commands to migrate.
5. Implement YAML Read adapters first (highest ROI, 10-20 lines each). Place files in `src/clis/<site>/<name>.yaml`.
6. Implement TS Read adapters for complex cases (GraphQL, pagination, signing). Place files in `src/clis/<site>/<name>.ts`.
7. Implement TS Write adapters using `Strategy.UI` or `Strategy.COOKIE`. Place files in `src/clis/<site>/<name>.ts`.
8. Verify build:
```bash
npx tsc --noEmit
```
9. Verify all commands are registered:
```bash
opencli list | grep <site>
```
10. Run each new command to verify it works:
```bash
opencli <site> <command> --limit 3 -f json
```
11. Update README.md with new command examples in the appropriate platform section.
12. Update SKILL.md Commands Reference with new commands.
13. Commit and push:
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>"
git push
```
-2
View File
@@ -22,5 +22,3 @@ docs/.vitepress/cache
# Database files
*.db
autoresearch/results/
autoresearch-results.tsv
-84
View File
@@ -1,89 +1,5 @@
# Changelog
## [1.6.1](https://github.com/jackwener/opencli/compare/v1.6.0...v1.6.1) (2026-04-02)
### Bug Fixes
* sync package-lock.json version with package.json ([#698](https://github.com/jackwener/opencli/issues/698))
## [1.6.0](https://github.com/jackwener/opencli/compare/v1.5.9...v1.6.0) (2026-04-02)
### Features
* **opencli-browser:** add browser control commands for Claude Code skill ([#614](https://github.com/jackwener/opencli/issues/614))
* **docs:** add tab completion to getting started guides ([#658](https://github.com/jackwener/opencli/issues/658))
### Bug Fixes
* **twitter:** resolve article ID to tweet ID before GraphQL query ([#688](https://github.com/jackwener/opencli/issues/688))
* **xiaohongshu:** clarify empty note shell hint ([#686](https://github.com/jackwener/opencli/issues/686))
* **skills:** add YAML frontmatter for discovery and improve descriptions ([#694](https://github.com/jackwener/opencli/issues/694))
### Refactoring
* centralize daemon transport client ([#692](https://github.com/jackwener/opencli/issues/692))
## [1.5.9](https://github.com/jackwener/opencli/compare/v1.5.8...v1.5.9) (2026-04-02)
### Features
* **amazon:** add browser adapter — bestsellers, search, product, offer, discussion ([#659](https://github.com/jackwener/opencli/issues/659))
* **skills:** create skills/ directory structure with opencli-usage, opencli-explorer, opencli-oneshot ([#670](https://github.com/jackwener/opencli/issues/670))
* **record:** add minimal record write candidates ([#665](https://github.com/jackwener/opencli/issues/665))
### Refactoring
* src cleanup — deduplicate errors, cache VM, extract BasePage, remove Playwright MCP legacy ([#667](https://github.com/jackwener/opencli/issues/667))
* remove bind-current, restore owned-only browser automation model ([#664](https://github.com/jackwener/opencli/issues/664))
### Chores
* remove .agents directory ([#668](https://github.com/jackwener/opencli/issues/668))
## [1.5.8](https://github.com/jackwener/opencli/compare/v1.5.7...v1.5.8) (2026-04-01)
### Bug Fixes
* **extension:** avoid mutating healthy tabs before debugger attach and add regression coverage ([#662](https://github.com/jackwener/opencli/issues/662))
## [1.5.7](https://github.com/jackwener/opencli/compare/v1.5.6...v1.5.7) (2026-04-01)
### Features
* **daemon:** replace 5min idle timeout with long-lived daemon model (4h default, dual-condition exit) ([#641](https://github.com/jackwener/opencli/issues/641))
* **daemon:** add `opencli daemon status/stop/restart` CLI commands ([#641](https://github.com/jackwener/opencli/issues/641))
* **youtube:** add search filters — `--type` shorts/video/channel, `--upload`, `--sort` ([#616](https://github.com/jackwener/opencli/issues/616))
* **notebooklm:** add read commands and compatibility layer ([#622](https://github.com/jackwener/opencli/issues/622))
* **instagram:** add media download command ([#623](https://github.com/jackwener/opencli/issues/623))
* **stealth:** harden CDP debugger detection countermeasures ([#644](https://github.com/jackwener/opencli/issues/644))
* **v2ex:** add id, node, url, content, member fields to topic output ([#646](https://github.com/jackwener/opencli/issues/646), [#648](https://github.com/jackwener/opencli/issues/648))
* **electron:** auto-launcher — zero-config CDP connection ([#653](https://github.com/jackwener/opencli/issues/653))
### Bug Fixes
* **douyin:** repair creator draft flow — switch from broken API pipeline to UI-driven approach ([#640](https://github.com/jackwener/opencli/issues/640))
* **douyin:** support current creator API response shapes for activities, profile, collections, hashtag, videos ([#618](https://github.com/jackwener/opencli/issues/618))
* **bilibili:** distinguish login-gated subtitles from empty results ([#645](https://github.com/jackwener/opencli/issues/645))
* **facebook:** avoid in-page redirect in search — use navigate step instead of window.location.href ([#642](https://github.com/jackwener/opencli/issues/642))
* **substack:** update selectors for DOM redesign ([#624](https://github.com/jackwener/opencli/issues/624))
* **weread:** recover book details from cached shelf fallback ([#628](https://github.com/jackwener/opencli/issues/628))
* **docs:** use relative links in adapter index ([#629](https://github.com/jackwener/opencli/issues/629))
## [1.4.1](https://github.com/jackwener/opencli/compare/v1.4.0...v1.4.1) (2026-03-25)
@@ -1,16 +1,10 @@
---
name: opencli-explorer
description: Use when creating a new OpenCLI adapter from scratch, adding support for a new website or platform, or exploring a site's API endpoints via browser DevTools. Covers API discovery workflow, authentication strategy selection, YAML/TS adapter writing, and testing.
tags: [opencli, adapter, browser, api-discovery, cli, web-scraping, automation]
---
# CLI-EXPLORER — 适配器探索式开发完全指南
> 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
> 从零到发布,覆盖 API 发现、方案选择、适配器编写、测试验证全流程。
> [!TIP]
> **只想为一个具体页面快速生成一个命令?** 看 [opencli-oneshot skill](../opencli-oneshot/SKILL.md)~150 行,4 步搞定)。
> **只想为一个具体页面快速生成一个命令?** 看 [CLI-ONESHOT.md](./CLI-ONESHOT.md)~150 行,4 步搞定)。
> 本文档适合从零探索一个新站点的完整流程。
---
@@ -189,8 +183,8 @@ opencli cascade https://api.example.com/hot
**不要从零开始写**。先看看同站点已有哪些适配器:
```bash
ls clis/<site>/ # 看看已有什么
cat clis/<site>/feed.ts # 读最相似的那个
ls src/clis/<site>/ # 看看已有什么
cat src/clis/<site>/feed.ts # 读最相似的那个
```
最高效的方式是 **复制最相似的适配器,然后改 3 个地方**
@@ -202,7 +196,7 @@ cat clis/<site>/feed.ts # 读最相似的那个
写 TS 适配器之前,先看看你的目标站点有没有**现成的 helper 函数**可以复用:
#### Bilibili (`clis/bilibili/utils.ts`)
#### Bilibili (`src/clis/bilibili/utils.ts`)
| 函数 | 用途 | 何时使用 |
|------|------|----------|
@@ -227,9 +221,9 @@ cat clis/<site>/feed.ts # 读最相似的那个
```
你的 pipeline 里有 evaluate 步骤(内嵌 JS 代码)?
→ ✅ 用 TypeScript (clis/<site>/<name>.ts),保存即自动动态注册
→ ✅ 用 TypeScript (src/clis/<site>/<name>.ts),保存即自动动态注册
→ ❌ 纯声明式(navigate + tap + map + limit)?
→ ✅ 用 YAML (clis/<site>/<name>.yaml),保存即自动注册
→ ✅ 用 YAML (src/clis/<site>/<name>.yaml),保存即自动注册
```
| 场景 | 选择 | 示例 |
@@ -266,12 +260,12 @@ func: async (page, kwargs) => {
### 方式 A: YAML Pipeline(声明式,推荐)
文件路径: `clis/<site>/<name>.yaml`,放入即自动注册。
文件路径: `src/clis/<site>/<name>.yaml`,放入即自动注册。
#### Tier 1 — 公开 API 模板
```yaml
# clis/v2ex/hot.yaml
# src/clis/v2ex/hot.yaml
site: v2ex
name: hot
description: V2EX 热门话题
@@ -301,7 +295,7 @@ columns: [rank, title, replies]
#### Tier 2 — Cookie 认证模板(最常用)
```yaml
# clis/zhihu/hot.yaml
# src/clis/zhihu/hot.yaml
site: zhihu
name: hot
description: 知乎热榜
@@ -342,7 +336,7 @@ columns: [rank, title, heat, answers]
#### 进阶 — 带搜索参数
```yaml
# clis/zhihu/search.yaml
# src/clis/zhihu/search.yaml
site: zhihu
name: search
description: 知乎搜索
@@ -394,7 +388,7 @@ columns: [rank, title, type, author, votes]
适用于 Vue + Pinia/Vuex 的网站(如小红书),无须手动写 XHR 拦截代码:
```yaml
# clis/xiaohongshu/notifications.yaml
# src/clis/xiaohongshu/notifications.yaml
site: xiaohongshu
name: notifications
description: "小红书通知"
@@ -449,13 +443,13 @@ pipeline:
适用于需要嵌入 JS 代码读取 Pinia state、XHR 拦截、GraphQL、分页、复杂数据转换等场景。
文件路径: `clis/<site>/<name>.ts`。文件将会在运行时被动态扫描并注册(切勿在 `index.ts` 中手动 `import`)。
文件路径: `src/clis/<site>/<name>.ts`。文件将会在运行时被动态扫描并注册(切勿在 `index.ts` 中手动 `import`)。
#### Tier 3 — Header 认证(Twitter
```typescript
// clis/twitter/search.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
// src/clis/twitter/search.ts
import { cli, Strategy } from '../../registry.js';
cli({
site: 'twitter',
@@ -495,8 +489,8 @@ cli({
#### Tier 4 — XHR/Fetch 双重拦截 (Twitter/小红书 通用模式)
```typescript
// clis/xiaohongshu/user.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
// src/clis/xiaohongshu/user.ts
import { cli, Strategy } from '../../registry.js';
cli({
site: 'xiaohongshu',
@@ -618,11 +612,11 @@ opencli mysite hot -f csv > data.csv # 确认 CSV 可导入
## Step 5: 提交发布
文件放入 `clis/<site>/` 即自动注册(YAML 或 TS 无需手动 import),然后:
文件放入 `src/clis/<site>/` 即自动注册(YAML 或 TS 无需手动 import),然后:
```bash
opencli list | grep mysite # 确认注册
git add clis/mysite/ && git commit -m "feat(mysite): add hot" && git push
git add src/clis/mysite/ && git commit -m "feat(mysite): add hot" && git push
```
> **架构理念**OpenCLI 内建 **Zero-Dependency jq** 数据流 — 所有解析在 `evaluate` 的原生 JS 内完成,外层 YAML 用 `select`/`map` 提取,无需依赖系统 `jq` 二进制。
@@ -636,8 +630,8 @@ git add clis/mysite/ && git commit -m "feat(mysite): add hot" && git push
### 模板代码
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { apiGet } from './utils.js'; // 复用平台 SDK
cli({
@@ -727,127 +721,4 @@ opencli synthesize mysite # 生成候选
opencli verify mysite/hot --smoke # 冒烟测试
```
生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `clis/mysite/` 并微调。
## Record Workflow
`record` 是为「无法用 `explore` 自动发现」的页面(需要登录操作、复杂交互、SPA 内路由)准备的手动录制方案。
### 工作原理
```
opencli record <url>
→ 打开 automation window 并导航到目标 URL
→ 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入)
→ 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区
→ 超时(默认 60s)或按 Enter 停止
→ 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML
```
**拦截器特性**
- 同时 patch `window.fetch``XMLHttpRequest`
- 只捕获 `Content-Type: application/json` 的响应
- 过滤纯对象少于 2 个 key 的响应(避免 tracking/ping
- 跨 tab 隔离:每个 tab 独立缓冲区,轮询时分别 drain
- 幂等注入:同一 tab 二次注入时先 restore 原始函数再重新 patch,不丢失已捕获数据
### 使用步骤
```bash
# 1. 启动录制(建议 --timeout 给足操作时间)
opencli record "https://example.com/page" --timeout 120000
# 2. 在弹出的 automation window 里正常操作页面:
# - 打开列表、搜索、点击条目、切换 Tab
# - 凡是触发网络请求的操作都会被捕获
# 3. 完成操作后按 Enter 停止(或等超时自动停止)
# 4. 查看结果
cat .opencli/record/<site>/captured.json # 原始捕获
ls .opencli/record/<site>/candidates/ # 候选 YAML
```
### 页面类型与捕获预期
| 页面类型 | 预期捕获量 | 说明 |
|---------|-----------|------|
| 列表/搜索页 | 多(5~20+) | 每次搜索/翻页都会触发新请求 |
| 详情页(只读) | 少(1~5) | 首屏数据一次性返回,后续操作走 form/redirect |
| SPA 内路由跳转 | 中等 | 路由切换会触发新接口,但首屏请求在注入前已发出 |
| 需要登录的页面 | 视操作而定 | 确保 Chrome 已登录目标网站 |
> **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。
> 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。
### 候选 YAML → TS CLI 转换
生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统):
**候选 YAML 结构**(自动生成):
```yaml
site: tae
name: getList # 从 URL path 推断的名称
strategy: cookie
browser: true
pipeline:
- navigate: https://...
- evaluate: |
(async () => {
const res = await fetch('/approval/getList.json?procInsId=...', { credentials: 'include' });
const data = await res.json();
return (data?.content?.operatorRecords || []).map(item => ({ ... }));
})()
```
**转换为 TS CLI**(参考 `clis/tae/add-expense.ts` 风格):
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'tae',
name: 'get-approval',
description: '查看报销单审批流程和操作记录',
domain: 'tae.alibaba-inc.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'proc_ins_id', type: 'string', required: true, positional: true, help: '流程实例 IDprocInsId' },
],
columns: ['step', 'operator', 'action', 'time'],
func: async (page, kwargs) => {
await page.goto('https://tae.alibaba-inc.com/expense/pc.html?_authType=SAML');
await page.wait(2);
const result = await page.evaluate(`(async () => {
const res = await fetch('/approval/getList.json?taskId=&procInsId=${kwargs.proc_ins_id}', {
credentials: 'include'
});
const data = await res.json();
return data?.content?.operatorRecords || [];
})()`);
return (result as any[]).map((r, i) => ({
step: i + 1,
operator: r.operatorName || r.userId,
action: r.operationType,
time: r.operateTime,
}));
},
});
```
**转换要点**
1. URL 中的动态 ID`procInsId``taskId` 等)提取为 `args`
2. `captured.json` 里的真实 body 结构用于确定正确的数据路径(如 `content.operatorRecords`
3. tae 系统统一用 `{ success, content, errorCode, errorMsg }` 外层包裹,取数据要走 `content.*`
4. 认证方式:cookie`credentials: 'include'`),不需要额外 header
5. 文件放入 `clis/<site>/`,无需手动注册,`npm run build` 后自动发现
### 故障排查
| 现象 | 原因 | 解法 |
|------|------|------|
| 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` |
| 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 |
| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI |
| 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` |
| 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 |
生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `src/clis/mysite/` 并微调。
@@ -1,13 +1,7 @@
---
name: opencli-oneshot
description: Use when quickly generating a single OpenCLI command from a specific URL and goal description. 4-step process — open page, capture API, write YAML adapter, test. For full site exploration, use opencli-explorer instead.
tags: [opencli, adapter, quick-start, yaml, cli, one-shot, automation]
---
# CLI-ONESHOT — 单点快速 CLI 生成
> 给一个 URL + 一句话描述,4 步生成一个 CLI 命令。
> 完整探索式开发请看 [opencli-explorer skill](../opencli-explorer/SKILL.md)。
> 完整探索式开发请看 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。
---
@@ -85,7 +79,7 @@ fetch(url, {credentials:'include'}) → Tier 2: cookie (YAML)
### YAML — Cookie/Public(最简)
```yaml
# clis/<site>/<name>.yaml
# src/clis/<site>/<name>.yaml
site: mysite
name: mycommand
description: "一句话描述"
@@ -123,8 +117,8 @@ columns: [rank, title, value]
### TS — Intercept(抓包模式)
```typescript
// clis/<site>/<name>.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
// src/clis/<site>/<name>.ts
import { cli, Strategy } from '../../registry.js';
cli({
site: 'mysite',
@@ -170,7 +164,7 @@ cli({
### TS — Header(如 Twitter GraphQL
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli, Strategy } from '../../registry.js';
cli({
site: 'twitter',
@@ -219,4 +213,4 @@ opencli mysite mycommand --limit 3 -v # 实际运行
## 就这样,没了
写完文件 → build → run → 提交。有问题再看 [opencli-explorer skill](../opencli-explorer/SKILL.md)。
写完文件 → build → run → 提交。有问题再看 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。
+5 -5
View File
@@ -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 `clis/<site>/<command>.yaml`:
Create a file like `src/clis/<site>/<command>.yaml`:
```yaml
site: mysite
@@ -66,14 +66,14 @@ pipeline:
columns: [rank, title, score, url]
```
See [`hackernews/top.yaml`](clis/hackernews/top.yaml) for a real example.
See [`hackernews/top.yaml`](src/clis/hackernews/top.yaml) for a real example.
### TypeScript Adapter (For complex browser interactions)
Create a file like `clis/<site>/<command>.ts`:
Create a file like `src/clis/<site>/<command>.ts`:
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli, Strategy } from '../../registry.js';
cli({
site: 'mysite',
@@ -109,7 +109,7 @@ cli({
});
```
Use `opencli explore <url>` to discover APIs and see [opencli-explorer skill](./skills/opencli-explorer/SKILL.md) if you need the full adapter workflow.
Use `opencli explore <url>` to discover APIs and see [CLI-EXPLORER.md](./CLI-EXPLORER.md) if you need the full adapter workflow.
### Validate Your Adapter
+96 -136
View File
@@ -1,186 +1,149 @@
# OpenCLI
> **Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.**
> Reuse your logged-in browser, automate live workflows, and crystallize repeated actions into reusable CLI commands.
> **Make any website, Electron App, or Local Tool your CLI.**
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
OpenCLI gives you one surface for three different kinds of automation:
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.
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
- **Drive a live browser directly** with `opencli browser` when an AI agent needs to click, type, extract, or inspect a page in real time.
- **Generate new adapters** from real browser behavior with `explore`, `synthesize`, `generate`, and `cascade`.
**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.
It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion.
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. External tools now have a canonical `opencli ext <tool> ...` namespace, while curated top-level aliases like `opencli gh ...` stay supported for convenience. Missing tools can still be auto-installed before passthrough execution.
## Why OpenCLI
**CLI for Electron Apps** — Turn any Electron application into a CLI tool. Recombine, script, and extend apps like Antigravity Ultra from the terminal. AI agents can now control other AI apps natively.
- **One mental model**: use the same CLI for websites, browser automation, Electron apps, and local tools.
- **Reuse real sessions**: browser-backed commands run against your existing Chrome/Chromium login state instead of reimplementing auth.
- **Deterministic outputs**: adapters return stable, scriptable structures that work well in shells, CI, and AI-agent tool use.
- **AI-agent ready**: `browser` handles live control, `explore` discovers APIs, `synthesize` drafts adapters, and `cascade` probes auth strategies.
- **Low runtime cost**: no model tokens are consumed when running existing commands.
- **Extensible by default**: keep built-ins, register local CLIs, or drop `.ts` / `.yaml` adapters into `clis/`.
---
## 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.
- **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.
- **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** — 50+ 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).
---
## Quick Start
### 1. Install OpenCLI
### 1. Install Browser Bridge Extension
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome 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).
3. Click **Load unpacked** and select the unzipped folder.
### 2. Install OpenCLI
**Install via npm (recommended)**
```bash
npm install -g @jackwener/opencli
```
### 2. Install the Browser Bridge Extension
OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extension plus a small local daemon. The daemon auto-starts when needed.
1. Download the latest `opencli-extension.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
2. Unzip it, open `chrome://extensions`, and enable **Developer mode**.
3. Click **Load unpacked** and select the unzipped folder.
### 3. Verify the setup
### 3. Verify & Try
```bash
opencli doctor
opencli daemon status
opencli doctor # Check extension + daemon connectivity
```
### 4. Run your first commands
**Try it out:**
```bash
opencli list
opencli hackernews top --limit 5
opencli bilibili hot --limit 5
opencli list # See all commands
opencli hackernews top --limit 5 # Public API, no browser needed
opencli bilibili hot --limit 5 # Browser command (requires Extension)
```
## For Humans
Use OpenCLI directly when you want a reliable command instead of a live browser session:
- `opencli list` shows every registered command.
- `opencli <site> <command>` runs a built-in or generated adapter.
- `opencli register mycli` exposes a local CLI through the same discovery surface.
- `opencli daemon status` and `opencli doctor` help diagnose browser connectivity.
## For AI Agents
Use two different entry points depending on the task:
- [`skills/opencli-generate/SKILL.md`](./skills/opencli-generate/SKILL.md): the task-level entry point for requests like "generate a CLI for this site".
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md): the low-level control surface for live browsing, debugging, and manual intervention.
Install the packaged skills with:
```bash
npx skills add jackwener/opencli
```
Or install only what you need:
```bash
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill opencli-generate
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-explorer
npx skills add jackwener/opencli --skill opencli-oneshot
```
In practice:
- start with `opencli-generate` when the agent needs a reusable command for a site
- use `opencli-browser` when the agent needs to inspect or steer the page directly
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, and `close`.
## Core Concepts
### `browser`: live control
Use `opencli browser` when the task is inherently interactive and the agent needs to operate the page directly.
### Built-in adapters: stable commands
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists and you want deterministic output.
### `explore` / `synthesize` / `generate`: create new CLIs
Use these commands when the site you need is not covered yet:
- `explore` inspects the page, network activity, and capability surface.
- `synthesize` turns exploration artifacts into evaluate-based YAML adapters.
- `generate` runs the verified generation path and returns either a usable command or a structured explanation of why completion was blocked or needs human review.
### `cascade`: auth strategy discovery
Use `cascade` to probe fallback auth paths such as public endpoints, cookies, and custom headers before you commit to an adapter design.
### CLI Hub and desktop adapters
OpenCLI is not only for websites. It can also:
- expose local binaries like `gh`, `docker`, `obsidian`, or custom tools through `opencli <tool> ...`
- control Electron desktop apps through dedicated adapters and CDP-backed integrations
## Prerequisites
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
- **Chrome or Chromium** running and logged into the target site for browser-backed commands
> **Important**: Browser-backed commands reuse your Chrome/Chromium login session. If you get empty data or permission-like failures, first confirm the site is already open and authenticated in Chrome/Chromium.
## Update
### Update
```bash
npm install -g @jackwener/opencli@latest
```
## For Developers
---
Install from source:
### For Developers
**Install from source**
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && npm run build && npm link
```
To load the source Browser Bridge extension:
**Load Source Browser Bridge Extension**
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select this repository's `extension/` directory.
1. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
2. Click **Load unpacked** and select the `extension/` directory from this repository.
---
## 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).
> **⚠️ 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.
## Built-in Commands
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **xiaohongshu** | `search` `feed` `user` `download` `publish` `comments` `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` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
| **1688** | `search` `item` `assets` `download` `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` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
79+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
## CLI Hub
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
External tools are registered in the unified command registry under the canonical `ext` namespace:
```bash
opencli ext gh pr list --limit 5
opencli ext docker ps
```
For convenience, curated top-level aliases still work:
```bash
opencli gh pr list --limit 5
opencli docker ps
```
| External CLI | Description | Example |
|--------------|-------------|---------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
@@ -225,7 +188,6 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **twitter** | Images, Videos | From user media tab or single tweet |
| **douban** | Images | Poster / still image lists |
| **pixiv** | Images | Original-quality illustrations, multi-page |
| **1688** | Images, Videos | Downloads page-visible product media from item pages |
| **zhihu** | Articles (Markdown) | Exports with optional image download |
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
@@ -235,7 +197,6 @@ For video downloads, install `yt-dlp` first: `brew install yt-dlp`
opencli xiaohongshu download abc123 --output ./xhs
opencli bilibili download BV1xxx --output ./bilibili
opencli twitter download elonmusk --limit 20 --output ./twitter
opencli 1688 download 841141931191 --output ./1688-downloads
```
## Output Formats
@@ -286,15 +247,14 @@ 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 [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — just a URL + one-line goal, 4 steps done.
> **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.
> **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.
> **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.
```bash
opencli explore https://example.com --site mysite # Discover APIs + capabilities
@@ -309,9 +269,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` in Chrome or Chromium.
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- **"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/Chromium login session may have expired. Navigate to the target site and log in again.
- **Empty data or 'Unauthorized' error** — Your Chrome 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`
+106 -145
View File
@@ -1,159 +1,119 @@
# OpenCLI
> **把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。**
> 复用浏览器登录态,先自动化真实操作,再把高频流程沉淀成可复用的 CLI 命令。
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 全能 CLI 枢纽
[![English](https://img.shields.io/badge/docs-English-1D4ED8?style=flat-square)](./README.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
OpenCLI 可以用同一套 CLI 做三类事情:
OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh``docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [79+ 站点](#内置命令) 开箱即用。
- **直接驱动浏览器**:用 `opencli browser` 让 AI Agent 实时点击、输入、提取、截图、检查页面状态。
- **把新网站生成成 CLI**:通过 `explore``synthesize``generate``cascade` 从真实页面行为推导出新的适配器。
**专为 AI Agent 打造**:只需在全局 `.cursorrules``AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`),AI 便能瞬间学会自动调用相应的本地工具。外部工具现在有统一的 `opencli ext <tool> ...` 命名空间,同时继续兼容 `opencli gh ...` 这类顶层 alias。
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
## 为什么是 OpenCLI
---
- **同一个心智模型**:网站、浏览器自动化、Electron 应用、本地 CLI 都走同一个入口。
- **复用真实会话**:浏览器命令直接使用你已经登录的 Chrome/Chromium,而不是重新造一套认证。
- **输出稳定**:适配器命令返回固定结构,适合 shell、脚本、CI 和 AI Agent 工具调用。
- **面向 AI Agent**`browser` 负责实时操作,`explore` 负责探索接口,`synthesize` 负责生成适配器,`cascade` 负责探测认证路径。
- **运行成本低**:已有命令运行时不消耗模型 token。
- **天然可扩展**:既能用内置能力,也能注册本地 CLI,或直接往 `clis/``.ts` / `.yaml` 适配器。
## 亮点
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **外部 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 的核心差异:**
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(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 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
### Browser Bridge 扩展配置
你可以选择以下任一方式安装扩展:
**方式一:下载构建好的安装包(推荐)**
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹。
**方式二:加载源码(针对开发者)**
1. 同样在 `chrome://extensions` 开启 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库代码树中的 `extension/` 文件夹。
完成!运行任何 opencli 浏览器命令时,后台微型 daemon 会自动启动与浏览器通信。无需配 API Token,零代码配置。
> **Tip**:后续诊断用 `opencli doctor`
> ```bash
> opencli doctor # 检查扩展和 daemon 连通性
> ```
## 快速开始
### 1. 安装 OpenCLI
### npm 全局安装(推荐)
```bash
npm install -g @jackwener/opencli
```
### 2. 安装 Browser Bridge 扩展
OpenCLI 通过轻量 Browser Bridge 扩展和本地微型 daemon 与 Chrome/Chromium 通信。daemon 会按需自动启动。
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后打开 `chrome://extensions`,启用 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的目录。
### 3. 验证环境
直接使用:
```bash
opencli doctor
opencli daemon status
opencli list # 查看所有命令
opencli list -f yaml # 以 YAML 列出所有命令
opencli hackernews top --limit 5 # 公共 API,无需浏览器
opencli bilibili hot --limit 5 # 浏览器命令
opencli zhihu hot -f json # JSON 输出
opencli zhihu hot -f yaml # YAML 输出
```
### 4. 跑第一个命令
### 从源码安装(面向开发者)
```bash
opencli list
opencli hackernews top --limit 5
opencli bilibili hot --limit 5
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link # 链接到全局环境
opencli list # 可以在任何地方使用了!
```
## 给人类用户
如果你只是想稳定地调用网站或桌面应用能力,主路径很简单:
- `opencli list` 查看当前所有命令
- `opencli <site> <command>` 调用内置或生成好的适配器
- `opencli register mycli` 把本地 CLI 接入同一发现入口
- `opencli doctor` / `opencli daemon status` 处理浏览器连通性问题
## 给 AI Agent
按任务类型,AI Agent 有两个不同入口:
- [`skills/opencli-generate/SKILL.md`](./skills/opencli-generate/SKILL.md):任务级入口,适合“帮我给这个网站生成 CLI”这类请求。
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md):底层控制入口,适合实时操作页面、debug 和人工介入。
安装全部 OpenCLI skills
```bash
npx skills add jackwener/opencli
```
或只装需要的 skill
```bash
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill opencli-generate
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-explorer
npx skills add jackwener/opencli --skill opencli-oneshot
```
实际使用上:
- 需要把某个站点收成可复用命令时,优先走 `opencli-generate`
- 需要直接检查页面、操作页面时,再走 `opencli-browser`
`browser` 可用命令包括:`open``state``click``type``select``keys``wait``get``screenshot``scroll``back``eval``network``init``verify``close`
## 核心概念
### `browser`:实时操作
当任务本身就是交互式页面操作时,使用 `opencli browser` 直接驱动浏览器。
### 内置适配器:稳定命令
当某个站点能力已经存在时,优先使用 `opencli hackernews top``opencli reddit hot` 这类稳定命令,而不是重新走一遍浏览器操作。
### `explore` / `synthesize` / `generate`:生成新的 CLI
当你需要的网站还没覆盖时:
- `explore` 负责观察页面、网络请求和能力边界
- `synthesize` 负责把探索结果转成 evaluate-based YAML 适配器
- `generate` 负责跑通 verified generation 主链路,最后要么给出可直接使用的命令,要么返回结构化的阻塞原因 / 人工介入结果
### `cascade`:认证策略探测
`cascade` 去判断某个能力应该优先走公开接口、Cookie 还是自定义 Header,而不是一开始就把适配器写死。
### CLI 枢纽与桌面端适配器
OpenCLI 不只是网站 CLI,还可以:
- 统一代理本地二进制工具,例如 `gh``docker``obsidian`
- 通过专门适配器和 CDP 集成控制 Electron 桌面应用
## 前置要求
- **Node.js**: >= 20.0.0
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
## 更新
### 更新
```bash
npm install -g @jackwener/opencli@latest
```
## 面向开发者
从源码安装:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
加载源码版 Browser Bridge 扩展:
1. 打开 `chrome://extensions` 并启用 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库里的 `extension/` 目录
## 内置命令
运行 `opencli list` 查看完整注册表。
@@ -163,7 +123,6 @@ npm link
| **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` | 桌面端 |
@@ -177,11 +136,9 @@ npm link
| **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` | 浏览器 |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
@@ -200,7 +157,7 @@ npm link
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **web** | `read` | 浏览器 |
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 |
| **weibo** | `hot` `search` | 浏览器 |
| **yahoo-finance** | `quote` | 浏览器 |
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
@@ -210,18 +167,14 @@ npm link
| **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** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 浏览器 |
| **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` `movers-shakers` `new-releases` | 浏览器 |
| **1688** | `search` `item` `assets` `download` `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` `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` | 公开 / 浏览器 |
@@ -233,16 +186,28 @@ npm link
| **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` | 浏览器 |
79+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
### 外部 CLI 枢纽
OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。
外部 CLI 现在会以统一的 `ext` 命名空间注册进命令系统:
```bash
opencli ext gh pr list --limit 5
opencli ext docker ps
```
为了兼容已有习惯,常用工具的顶层 alias 依然保留:
```bash
opencli gh pr list --limit 5
opencli docker ps
```
| 外部 CLI | 描述 | 示例 |
|----------|------|------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
@@ -290,7 +255,6 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
| **1688** | 图片、视频 | 下载商品页中可见的商品素材 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
@@ -325,9 +289,6 @@ opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./
# 下载豆瓣电影海报 / 剧照
opencli douban download 30382501 --output ./douban
# 下载 1688 商品页中的图片 / 视频素材
opencli 1688 download 841141931191 --output ./1688-downloads
# 导出知乎文章为 Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
@@ -406,9 +367,9 @@ opencli plugin uninstall my-tool # 卸载
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
> **快速模式**:只想为某个页面快速生成一个命令?看 [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — 给一个 URL + 一句话描述,4 步搞定。
> **快速模式**:只想为某个页面快速生成一个命令?看 [CLI-ONESHOT.md](./CLI-ONESHOT.md) — 给一个 URL + 一句话描述,4 步搞定。
> **完整模式**:在编写任何新代码前,先阅读 [opencli-explorer skill](./skills/opencli-explorer/SKILL.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
> **完整模式**:在编写任何新代码前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
```bash
# 1. Deep Explore — 网络拦截 → 响应分析 → 能力推理 → 框架检测
@@ -429,11 +390,11 @@ opencli cascade https://api.example.com/data
## 常见问题排查
- **"Extension not connected" 报错**
- 确保你当前的 Chrome 或 Chromium 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
- 其他 Chrome 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
- **返回空数据,或者报错 "Unauthorized"**
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
- 确保 Node.js 版本 `>= 20`
- **Daemon 问题**
+5 -5
View File
@@ -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 逻辑 | `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` |
| 站点 / 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` |
这些测试覆盖的重点包括:
@@ -94,7 +94,7 @@ find tests/smoke -name '*.test.ts' | sort
```bash
npm ci # 安装依赖
npm run build # 编译(E2E / smoke 测试需要 dist/src/main.js
npm run build # 编译(E2E / smoke 测试需要 dist/main.js
```
### 运行命令
@@ -110,7 +110,7 @@ npx vitest run tests/e2e/
npx vitest run tests/smoke/
# 单个测试文件
npx vitest run clis/apple-podcasts/commands.test.ts
npx vitest run src/clis/apple-podcasts/commands.test.ts
npx vitest run tests/e2e/management.test.ts
# 全部测试
@@ -123,7 +123,7 @@ npx vitest src/
### 浏览器命令本地测试须知
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/src/main.js`
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
@@ -132,7 +132,7 @@ npx vitest src/
## 如何添加新测试
### 新增 YAML Adapter(如 `clis/producthunt/trending.yaml`
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验
2. 根据 adapter 类型,在对应测试文件补一个 `it()` block
-1
View File
@@ -1 +0,0 @@
56/59
-1
View File
@@ -1 +0,0 @@
31/31
-686
View File
@@ -1,686 +0,0 @@
[
{
"name": "extract-title-example",
"steps": [
"opencli browser open https://example.com",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "Example Domain"
}
},
{
"name": "extract-title-iana",
"steps": [
"opencli browser open https://www.iana.org",
"opencli browser eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "extract-paragraph-wiki-js",
"steps": [
"opencli browser open https://en.wikipedia.org/wiki/JavaScript",
"opencli browser 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 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
"value": "programming language"
}
},
{
"name": "extract-paragraph-wiki-python",
"steps": [
"opencli browser open \"https://en.wikipedia.org/wiki/Python_(programming_language)\"",
"opencli browser 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 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
"value": "programming language"
}
},
{
"name": "extract-github-stars",
"steps": [
"opencli browser open https://github.com/browser-use/browser-use",
"opencli browser eval \"document.querySelector('#repo-stars-counter-star')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "extract-github-description",
"steps": [
"opencli browser open https://github.com/anthropics/claude-code",
"opencli browser eval \"document.querySelector('p.f4, [data-testid=about-description], .f4.my-3, .BorderGrid-cell p')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "extract-github-readme-heading",
"steps": [
"opencli browser open https://github.com/vercel/next.js",
"opencli browser 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"
}
},
{
"name": "extract-npm-downloads",
"steps": [
"opencli browser open https://www.npmjs.com/package/zod",
"opencli browser eval \"document.querySelector('[data-nosnippet]')?.textContent?.trim() || document.querySelector('p.f2874b88')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "extract-npm-description",
"steps": [
"opencli browser open https://www.npmjs.com/package/express",
"opencli browser wait time 2",
"opencli browser 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"
}
},
{
"name": "list-hn-top5",
"steps": [
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.titleline > a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-hn-top10",
"steps": [
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.athing')].slice(0,10).map(tr=>{const a=tr.querySelector('.titleline>a');const s=tr.nextElementSibling?.querySelector('.score');return{title:a?.textContent,score:parseInt(s?.textContent)||0}}))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 10
}
},
{
"name": "list-books-5",
"steps": [
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,5).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-books-10",
"steps": [
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,10).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 10
}
},
{
"name": "list-quotes-3",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser 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",
"minLength": 3
}
},
{
"name": "list-quotes-tags",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,5).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent,tags:[...el.querySelectorAll('.tag')].map(t=>t.textContent)})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-github-trending",
"steps": [
"opencli browser open https://github.com/trending",
"opencli browser 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",
"minLength": 3
}
},
{
"name": "list-github-trending-lang",
"steps": [
"opencli browser open https://github.com/trending/python",
"opencli browser 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",
"minLength": 5
}
},
{
"name": "list-jsonplaceholder-posts",
"steps": [
"opencli browser open https://jsonplaceholder.typicode.com/posts",
"opencli browser eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(p=>({id:p.id,title:p.title})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-jsonplaceholder-users",
"steps": [
"opencli browser open https://jsonplaceholder.typicode.com/users",
"opencli browser eval \"JSON.stringify(JSON.parse(document.body.innerText).map(u=>({name:u.name,email:u.email})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "search-google",
"steps": [
"opencli browser open https://www.google.com/search?q=opencli+github",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index 5 may vary"
},
{
"name": "search-ddg",
"steps": [
"opencli browser open https://duckduckgo.com",
"opencli browser state",
"opencli browser type 1 \"weather beijing\"",
"opencli browser keys Enter",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a]')].slice(0,3).map(a=>a.textContent))\""
],
"judge": {
"type": "nonEmpty"
},
"note": "index may vary"
},
{
"name": "search-ddg-tech",
"steps": [
"opencli browser open https://duckduckgo.com",
"opencli browser eval \"document.querySelector('input[name=q]').value='TypeScript tutorial';document.querySelector('form').submit();'submitted'\"",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index may vary"
},
{
"name": "search-wiki",
"steps": [
"opencli browser open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
"opencli browser wait time 3",
"opencli browser 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 ''; })()\""
],
"judge": {
"type": "contains",
"value": "programming language"
},
"note": "index may vary"
},
{
"name": "search-npm",
"steps": [
"opencli browser open https://www.npmjs.com/search?q=react",
"opencli browser wait time 3",
"opencli browser 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",
"minLength": 3
},
"note": "index may vary"
},
{
"name": "search-github",
"steps": [
"opencli browser open https://github.com/search?q=browser+automation&type=repositories",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.search-title a, [data-testid=results-list] a.Link--primary')].slice(0,3).map(a=>a.textContent?.trim().replace(/\\\\s+/g,' ')))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index may vary"
},
{
"name": "nav-click-link-example",
"steps": [
"opencli browser open https://example.com",
"opencli browser eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title + ' ' + location.href\""
],
"judge": {
"type": "contains",
"value": "IANA"
}
},
{
"name": "nav-click-hn-first",
"steps": [
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"document.querySelector('.titleline a')?.click();'clicked'\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-click-hn-comments",
"steps": [
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"document.querySelector('.subtext a:last-child')?.click(); 'clicked'\"",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-click-wiki-link",
"steps": [
"opencli browser open https://en.wikipedia.org/wiki/JavaScript",
"opencli browser eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
"opencli browser eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-click-github-tab",
"steps": [
"opencli browser open https://github.com/vercel/next.js",
"opencli browser eval \"document.querySelector('[data-tab-item=i1issues-tab] a, #issues-tab')?.click(); 'clicked'\"",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-go-back",
"steps": [
"opencli browser open https://example.com",
"opencli browser eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli browser wait time 2",
"opencli browser back",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "Example Domain"
}
},
{
"name": "nav-multi-step",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.next a')?.click(); 'clicked'\"",
"opencli browser eval \"document.querySelector('.quote .text')?.textContent\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "scroll-footer-quotes",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser scroll down",
"opencli browser scroll down",
"opencli browser eval \"document.querySelector('footer, .footer, .tags-box')?.textContent?.trim().slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "scroll-footer-books",
"steps": [
"opencli browser open https://books.toscrape.com",
"opencli browser scroll down",
"opencli browser scroll down",
"opencli browser eval \"document.querySelector('.pager .current')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "scroll-long-page",
"steps": [
"opencli browser open https://jsonplaceholder.typicode.com/posts",
"opencli browser eval \"JSON.parse(document.body.innerText).length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "scroll-find-element",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.next a')?.href\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "scroll-lazy-load",
"steps": [
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"document.querySelectorAll('article.product_pod').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "form-simple-name",
"steps": [
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var el=document.querySelector('[name=custname]');el.value='OpenCLI Test';el.dispatchEvent(new Event('input',{bubbles:true}));el.value\""
],
"judge": {
"type": "contains",
"value": "OpenCLI"
},
"note": "index may vary"
},
{
"name": "form-text-inputs",
"steps": [
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var n=document.querySelector('[name=custname]');n.value='Alice';n.dispatchEvent(new Event('input',{bubbles:true}));var t=document.querySelector('[name=custtel]');t.value='555-1234';t.dispatchEvent(new Event('input',{bubbles:true}));n.value+'|'+t.value\""
],
"judge": {
"type": "contains",
"value": "Alice"
},
"note": "index may vary"
},
{
"name": "form-radio-select",
"steps": [
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"document.querySelector('[value=medium]').checked=true;document.querySelector('[value=medium]').dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('[value=medium]').checked\""
],
"judge": {
"type": "contains",
"value": "true"
}
},
{
"name": "form-checkbox",
"steps": [
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var cb=document.querySelector('[value=cheese]');cb.checked=true;cb.dispatchEvent(new Event('change',{bubbles:true}));cb.checked\""
],
"judge": {
"type": "contains",
"value": "true"
}
},
{
"name": "form-textarea",
"steps": [
"opencli browser open https://httpbin.org/forms/post",
"opencli browser 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",
"value": "AutoResearch"
}
},
{
"name": "form-login-fake",
"steps": [
"opencli browser open https://the-internet.herokuapp.com/login",
"opencli browser eval \"var u=document.querySelector('#username');u.value='testuser';u.dispatchEvent(new Event('input',{bubbles:true}));var p=document.querySelector('#password');p.value='testpass';p.dispatchEvent(new Event('input',{bubbles:true}));u.value+'|'+p.value\""
],
"judge": {
"type": "contains",
"value": "testuser"
},
"note": "index may vary"
},
{
"name": "complex-wiki-toc",
"steps": [
"opencli browser open https://en.wikipedia.org/wiki/JavaScript",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.toc li a, #toc li a, .vector-toc-contents a')].slice(0,8).map(a=>a.textContent?.trim()))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "complex-books-detail",
"steps": [
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"document.querySelector('article.product_pod h3 a')?.click();'clicked'\"",
"opencli browser eval \"JSON.stringify({title:document.querySelector('h1')?.textContent,price:document.querySelector('.price_color')?.textContent})\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "complex-quotes-page2",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.next a')?.click();'clicked'\"",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "complex-github-repo-info",
"steps": [
"opencli browser open https://github.com/expressjs/express",
"opencli browser eval \"JSON.stringify({lang:document.querySelector('[itemprop=programmingLanguage]')?.textContent?.trim(),license:document.querySelector('[data-analytics-event*=license] span, .Layout-sidebar [href*=LICENSE]')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "complex-hn-story-comments",
"steps": [
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"document.querySelector('.subtext a:last-child')?.click();'clicked'\"",
"opencli browser eval \"document.querySelector('.fatitem .titleline a')?.textContent\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "complex-multi-extract",
"steps": [
"opencli browser open https://en.wikipedia.org/wiki/TypeScript",
"opencli browser eval \"JSON.stringify({title:document.title,firstParagraph:document.querySelector('#mw-content-text p')?.textContent?.slice(0,150)})\""
],
"judge": {
"type": "contains",
"value": "TypeScript"
}
},
{
"name": "bench-reddit-top5",
"steps": [
"opencli browser open https://old.reddit.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('#siteTable .thing .title a.title')].slice(0,5).map(a=>a.textContent))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
},
"set": "test"
},
{
"name": "bench-imdb-matrix",
"steps": [
"opencli browser open https://www.imdb.com/title/tt0133093/",
"opencli browser wait time 3",
"opencli browser 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",
"value": "1999"
},
"set": "test"
},
{
"name": "bench-npm-zod",
"steps": [
"opencli browser open https://www.npmjs.com/package/zod",
"opencli browser eval \"JSON.stringify({name:document.querySelector('h1 span, #top h2')?.textContent?.trim(),description:document.querySelector('[data-testid=package-description], p.package-description-redundant')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-wiki-search",
"steps": [
"opencli browser open https://en.wikipedia.org/wiki/Machine_learning",
"opencli browser 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 ''; })()\""
],
"judge": {
"type": "contains",
"value": "learning"
},
"set": "test"
},
{
"name": "bench-github-profile",
"steps": [
"opencli browser open https://github.com/torvalds",
"opencli browser eval \"JSON.stringify({name:document.querySelector('[itemprop=name]')?.textContent?.trim(),bio:document.querySelector('[data-bio-text]')?.textContent?.trim()||document.querySelector('.p-note')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-books-category",
"steps": [
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"document.querySelector('a[href*=science]')?.click();'clicked'\"",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.product_pod h3 a')].slice(0,3).map(a=>a.getAttribute('title')))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"set": "test"
},
{
"name": "bench-quotes-author",
"steps": [
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.author + a, a[href*=author]')?.click();'clicked'\"",
"opencli browser eval \"document.querySelector('.author-description, .author-details p')?.textContent?.slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-ddg-images",
"steps": [
"opencli browser open https://duckduckgo.com",
"opencli browser eval \"document.querySelector('input[name=q]').value='sunset';document.querySelector('form').submit();'submitted'\"",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>a.textContent))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"set": "test",
"note": "index may vary"
},
{
"name": "bench-httpbin-headers",
"steps": [
"opencli browser open https://httpbin.org/headers",
"opencli browser eval \"JSON.parse(document.body.innerText).headers['User-Agent']\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-jsonapi-todo",
"steps": [
"opencli browser open https://jsonplaceholder.typicode.com/todos",
"opencli browser eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(t=>({id:t.id,title:t.title,completed:t.completed})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
},
"set": "test"
}
]
-163
View File
@@ -1,163 +0,0 @@
#!/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 browser 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 browser 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 browser 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 browser close'); } catch {}
for (const step of task.steps) lastOutput = exec(step);
}
try { exec('opencli browser close'); } catch {}
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
}
main();
-146
View File
@@ -1,146 +0,0 @@
#!/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 {
// Pass prompt via stdin `input` option to avoid shell metacharacter expansion
const result = execSync(
'claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence',
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', input: prompt, 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();
-88
View File
@@ -1,88 +0,0 @@
#!/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();
-138
View File
@@ -1,138 +0,0 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch — Main autonomous iteration loop.
*
* Usage:
* npx tsx autoresearch/commands/run.ts --preset browser-reliability
* npx tsx autoresearch/commands/run.ts --preset browser-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();
-82
View File
@@ -1,82 +0,0 @@
/**
* 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 browser 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;
}
-363
View File
@@ -1,363 +0,0 @@
/**
* 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, execFileSync } 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 {
if (!this.config.scope.length) return null; // no scope = nothing to stage
// Stage only files matching scope globs (avoid staging unrelated changes)
// Use execFileSync to bypass shell glob expansion so git handles pathspecs directly
execFileSync('git', ['add', '--', ...this.config.scope], {
cwd: ROOT, timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
});
const diff = exec('git diff --cached --quiet; echo $?');
if (diff === '0') return null; // no changes
try {
execStrict(`git commit -m "experiment(browser): ${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;
}
}
-127
View File
@@ -1,127 +0,0 @@
#!/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();
-185
View File
@@ -1,185 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Layer 1: Deterministic Browse Command Testing
*
* Runs predefined opencli browser command sequences against real websites.
* No LLM involved — tests command reliability only.
*
* Usage:
* npx tsx autoresearch/eval-browse.ts # Run all tasks
* npx tsx autoresearch/eval-browse.ts --task hn-top5 # Run single task
*/
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, 'browse-tasks.json');
const RESULTS_DIR = join(__dirname, 'results');
const BASELINE_FILE = join(__dirname, 'baseline-browse.txt');
interface BrowseTask {
name: string;
steps: 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;
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': {
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): string {
try {
return execSync(cmd, {
cwd: join(__dirname, '..'),
timeout: 30000,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (err: any) {
return err.stdout?.trim() || err.stderr?.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, 100)}`,
set: task.set === 'test' ? 'test' : 'train',
};
} catch (err: any) {
return {
name: task.name,
passed: false,
duration: Date.now() - start,
error: err.message?.slice(0, 100),
set: task.set === 'test' ? 'test' : 'train',
};
}
}
function main() {
const args = process.argv.slice(2);
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
const allTasks: BrowseTask[] = 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 1: Browse Commands — ${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 browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli browser close'); } catch { /* ignore */ }
// 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 / 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.name}: ${f.error ?? 'unknown'}`);
}
}
console.log('');
// Save result
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('browse-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `browse-${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 / 60000)}min`,
tasks: results,
}, null, 2), 'utf-8');
console.log(` Results saved to: ${resultPath}`);
console.log(`\nSCORE=${totalPassed}/${results.length}`);
}
main();
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Layer 5: Publish Testing — end-to-end content creation via browser 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/src/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 browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli browser 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();
-249
View File
@@ -1,249 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Layer 4: Save as CLI Testing — "Save as CLI" Pipeline
*
* Tests the full browser init → write adapter → browser 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': {
// browser 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 the local built entrypoint 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/src/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 browser 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 browser verify
const verifyOutput = runCommand(
`opencli browser 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();
-254
View File
@@ -1,254 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
*
* Spawns Claude Code with the opencli-browser skill. Claude Code
* completes the task using browse commands AND judges its own result.
*
* Task format: YAML with judge_context (multi-criteria, like Browser Use)
*
* Usage:
* npx tsx autoresearch/eval-skill.ts # Run all
* npx tsx autoresearch/eval-skill.ts --task hn-top5 # Run single
*/
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 RESULTS_DIR = join(__dirname, 'results');
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-browser', 'SKILL.md');
// ── Types ──────────────────────────────────────────────────────────
interface SkillTask {
name: string;
task: string;
url?: string;
judge_context: string[];
max_steps?: number;
}
interface TaskResult {
name: string;
passed: boolean;
duration: number;
cost: number;
explanation: string;
}
// ── Task Definitions (inline, to avoid YAML dependency) ────────────
const TASKS: SkillTask[] = [
// Extract
{ name: "extract-title-example", task: "Extract the main heading text from this page", url: "https://example.com", judge_context: ["Output must contain 'Example Domain'"] },
{ name: "extract-paragraph-wiki", task: "Extract the first paragraph of the JavaScript article", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must mention 'programming language'", "Output must contain actual paragraph text, not just the title"] },
{ name: "extract-github-stars", task: "Find the number of stars on this repository", url: "https://github.com/browser-use/browser-use", judge_context: ["Output must contain a number (the star count)"] },
{ name: "extract-npm-downloads", task: "Find the weekly download count for this package", url: "https://www.npmjs.com/package/zod", judge_context: ["Output must contain a number (weekly downloads)"] },
// List extraction
{ name: "list-hn-top5", task: "Extract the top 5 stories with their titles", url: "https://news.ycombinator.com", judge_context: ["Output must contain 5 story titles", "Each title must be an actual HN story, not made up"] },
{ name: "list-books-5", task: "Extract the first 5 books with their title and price", url: "https://books.toscrape.com", judge_context: ["Output must contain 5 books", "Each book must have a title and a price"] },
{ name: "list-quotes-3", task: "Extract the first 3 quotes with their text and author", url: "https://quotes.toscrape.com", judge_context: ["Output must contain 3 quotes", "Each quote must have text and an author name"] },
{ name: "list-github-trending", task: "Extract the top 3 trending repositories with name and description", url: "https://github.com/trending", judge_context: ["Output must contain 3 repositories", "Each must have a repo name"] },
{ name: "list-jsonplaceholder", task: "Extract the first 5 posts with their title", url: "https://jsonplaceholder.typicode.com/posts", judge_context: ["Output must contain 5 posts", "Each post must have a title"] },
// Search
{ name: "search-ddg", task: "Search for 'TypeScript tutorial' and extract the first 3 result titles", url: "https://duckduckgo.com", judge_context: ["The agent must type a search query", "Output must contain at least 3 search result titles"] },
{ name: "search-npm", task: "Search for 'react' and extract the top 3 package names", url: "https://www.npmjs.com", judge_context: ["The agent must search for 'react'", "Output must contain at least 3 package names"] },
{ name: "search-wiki", task: "Search for 'Rust programming language' and extract the first sentence of the article", url: "https://en.wikipedia.org", judge_context: ["The agent must search and navigate to the article", "Output must mention 'programming language'"] },
// Navigation
{ name: "nav-click-link", task: "Click the 'More information...' link and extract the heading of the new page", url: "https://example.com", judge_context: ["The agent must click a link", "Output must contain 'IANA' or reference the new page"] },
{ name: "nav-click-hn", task: "Click on the first story link and tell me the title of the page you land on", url: "https://news.ycombinator.com", judge_context: ["The agent must click a story link", "Output must contain the title of the destination page"] },
{ name: "nav-go-back", task: "Click the 'More information...' link, then go back, and tell me the heading of the original page", url: "https://example.com", judge_context: ["The agent must click a link then go back", "Output must contain 'Example Domain'"] },
{ name: "nav-multi-step", task: "Click the Next page link at the bottom, then extract the first quote from page 2", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain a quote from page 2"] },
// Scroll
{ name: "scroll-footer", task: "Scroll to the bottom and extract the footer text", url: "https://quotes.toscrape.com", judge_context: ["The agent must scroll down", "Output must contain footer or bottom-of-page content"] },
{ name: "scroll-pagination", task: "Find the pagination info at the bottom of the page", url: "https://books.toscrape.com", judge_context: ["Output must contain page number or pagination info"] },
// Form
{ name: "form-fill-basic", task: "Fill the Customer Name with 'OpenCLI' and Telephone with '555-0100'. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must type 'OpenCLI' into a name field", "The agent must type '555-0100' into a phone field", "The form must NOT be submitted"] },
{ name: "form-radio", task: "Select the 'Medium' pizza size option. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must select a radio button for Medium size"] },
{ name: "form-login", task: "Fill the username with 'testuser' and password with 'testpass'. Do not submit.", url: "https://the-internet.herokuapp.com/login", judge_context: ["The agent must fill the username field", "The agent must fill the password field", "The form must NOT be submitted"] },
// Complex
{ name: "complex-wiki-toc", task: "Extract the table of contents headings", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must contain at least 5 section headings from the table of contents"] },
{ name: "complex-books-detail", task: "Click on the first book and extract its title and price from the detail page", url: "https://books.toscrape.com", judge_context: ["The agent must click on a book", "Output must contain the book title", "Output must contain a price"] },
{ name: "complex-quotes-page2", task: "Navigate to page 2 and extract the first 3 quotes with their authors", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain 3 quotes with authors"] },
{ name: "complex-multi-extract", task: "Extract both the page title and the first paragraph text", url: "https://en.wikipedia.org/wiki/TypeScript", judge_context: ["Output must contain 'TypeScript'", "Output must contain actual paragraph text"] },
// Bench (harder, real-world)
{ name: "bench-reddit", task: "Extract the titles of the top 5 posts", url: "https://old.reddit.com", judge_context: ["Output must contain 5 post titles", "Titles must be actual Reddit posts"] },
{ name: "bench-imdb", task: "Find the year and rating of The Matrix", url: "https://www.imdb.com/title/tt0133093/", judge_context: ["Output must contain '1999'", "Output must contain a rating number"] },
{ name: "bench-github-profile", task: "Extract the bio and number of public repositories", url: "https://github.com/torvalds", judge_context: ["Output must contain bio text or 'Linux'", "Output must contain a number for repos"] },
{ name: "bench-httpbin", task: "Extract the User-Agent header shown on this page", url: "https://httpbin.org/headers", judge_context: ["Output must contain a User-Agent string"] },
{ name: "bench-jsonapi-todo", task: "Extract the first 5 todo items with their title and completion status", url: "https://jsonplaceholder.typicode.com/todos", judge_context: ["Output must contain 5 todo items", "Each must have a title and completed status"] },
// Codex form (the real test)
{ name: "codex-form-fill", task: "Fill the basic information using 'opencli' as the identity (first name=open, last name=cli, email=opencli@example.com, GitHub username=opencli). Do NOT submit the form.", url: "https://openai.com/form/codex-for-oss/", judge_context: ["The agent must fill the first name field", "The agent must fill the last name field", "The agent must fill the email field", "The form must NOT be submitted"], max_steps: 15 },
];
// ── Run Task ───────────────────────────────────────────────────────
function runSkillTask(task: SkillTask): TaskResult {
const start = Date.now();
const skillContent = readFileSync(SKILL_PATH, 'utf-8');
const urlPart = task.url ? ` Start URL: ${task.url}` : '';
const criteria = task.judge_context.map((c, i) => `${i + 1}. ${c}`).join('\n');
const prompt = `Complete this browser task using opencli browser commands:
TASK: ${task.task}${urlPart}
After completing the task, evaluate your own result against these criteria:
${criteria}
At the very end of your response, output a JSON verdict on its own line:
{"success": true/false, "explanation": "brief explanation"}
Always close the browser with 'opencli browser close' when done.`;
try {
const output = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*)" --system-prompt ${JSON.stringify(skillContent)} --output-format json --no-session-persistence ${JSON.stringify(prompt)}`,
{
cwd: join(__dirname, '..'),
timeout: (task.max_steps ?? 10) * 15_000,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
}
);
const duration = Date.now() - start;
// Parse Claude Code output
let resultText = '';
let cost = 0;
try {
const parsed = JSON.parse(output);
resultText = parsed.result ?? output;
cost = parsed.total_cost_usd ?? 0;
} catch {
resultText = output;
}
// Extract verdict JSON from the result
const verdict = extractVerdict(resultText);
return {
name: task.name,
passed: verdict.success,
duration,
cost,
explanation: verdict.explanation,
};
} catch (err: any) {
return {
name: task.name,
passed: false,
duration: Date.now() - start,
cost: 0,
explanation: (err.stdout ?? err.message ?? 'timeout or crash').slice(0, 200),
};
}
}
function extractVerdict(text: string): { success: boolean; explanation: string } {
// 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
const lower = text.toLowerCase();
if (lower.includes('"success": true') || lower.includes('"success":true')) {
return { success: true, explanation: 'Parsed success from output' };
}
if (lower.includes('"success": false') || lower.includes('"success":false')) {
return { success: false, explanation: 'Parsed failure from output' };
}
// Final fallback: assume failure if we can't parse
return { success: false, explanation: 'Could not parse verdict from output' };
}
// ── Main ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
const tasks = singleTask ? TASKS.filter(t => t.name === singleTask) : TASKS;
if (tasks.length === 0) {
console.error(`Task "${singleTask}" not found. Available: ${TASKS.map(t => t.name).join(', ')}`);
process.exit(1);
}
console.log(`\n🔬 Layer 2: Skill E2E (LLM Judge) — ${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 = runSkillTask(task);
results.push(result);
const icon = result.passed ? '✓' : '✗';
const costStr = result.cost > 0 ? `, $${result.cost.toFixed(2)}` : '';
console.log(` ${icon} (${Math.round(result.duration / 1000)}s${costStr})`);
}
// Summary
const totalPassed = results.filter(r => r.passed).length;
const totalCost = results.reduce((s, r) => s + r.cost, 0);
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
console.log(`\n${'─'.repeat(50)}`);
console.log(` Score: ${totalPassed}/${results.length} (${Math.round(totalPassed / results.length * 100)}%)`);
console.log(` Cost: $${totalCost.toFixed(2)}`);
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.name}: ${f.explanation}`);
}
}
console.log('');
// Save
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('skill-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `skill-${roundNum}.json`);
writeFileSync(resultPath, JSON.stringify({
timestamp: new Date().toISOString(),
score: `${totalPassed}/${results.length}`,
totalCost,
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();
-220
View File
@@ -1,220 +0,0 @@
#!/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 browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli browser 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();
-230
View File
@@ -1,230 +0,0 @@
#!/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 browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli browser 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();
-69
View File
@@ -1,69 +0,0 @@
/**
* 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;
}
}
@@ -1,24 +0,0 @@
/**
* Preset: Browser Command Reliability
*
* Optimizes opencli browser 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 browserReliability: AutoResearchConfig = {
goal: 'Increase browser 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,
};
@@ -1,27 +0,0 @@
/**
* 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,
};
-23
View File
@@ -1,23 +0,0 @@
export { browserReliability } from './browser-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 { browserReliability } from './browser-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> = {
'browser-reliability': browserReliability,
'skill-quality': skillQuality,
'v2ex-reliability': v2exReliability,
'zhihu-reliability': zhihuReliability,
'combined': combinedReliability,
'save-reliability': saveReliability,
};
-26
View File
@@ -1,26 +0,0 @@
/**
* Preset: Save as CLI Reliability
*
* Optimizes the "Save as CLI" pipeline: browser 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: browser 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-browser/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,
};
-20
View File
@@ -1,20 +0,0 @@
/**
* Preset: Skill E2E Quality
*
* Optimizes the opencli-browser 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-browser/SKILL.md',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
guard: 'npm run build',
iterations: 20,
};
-24
View File
@@ -1,24 +0,0 @@
/**
* Preset: V2EX Command Reliability
*
* Optimizes opencli browser 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 browser 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,
};
-25
View File
@@ -1,25 +0,0 @@
/**
* Preset: Zhihu Command Reliability
*
* Optimizes opencli browser 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 browser 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,
};
-345
View File
@@ -1,345 +0,0 @@
[
{
"name": "twitter-fill-compose",
"platform": "twitter",
"type": "fill-only",
"description": "Navigate to tweet composer, fill in content (no publish)",
"steps": [
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 4",
"opencli browser eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
],
"judge": {
"type": "matchesPattern",
"pattern": "post|sent|Your post|X"
},
"cleanup": [
"opencli browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://news.ycombinator.com",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 4",
"opencli browser eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
],
"judge": {
"type": "matchesPattern",
"pattern": "post|sent|Your post|X"
},
"cleanup": [
"opencli browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/question/19550225",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 2",
"opencli browser 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>这段内容用于验证 browser 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser 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 browser open https://zhuanlan.zhihu.com/write",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://news.ycombinator.com",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
"opencli browser open https://www.zhihu.com/question/19550225",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://x.com/search?q=opencli&src=typed_query&f=live",
"opencli browser wait time 4",
"opencli browser eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/search?type=content&q=AI%20agent",
"opencli browser wait time 4",
"opencli browser 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 browser 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 browser wait time 4",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/question/19550225",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://zhuanlan.zhihu.com/write",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser wait time 3",
"opencli browser 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 browser state save zhihu_hot_title",
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser open https://x.com/explore/tabs/trending",
"opencli browser wait time 3",
"opencli browser 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 browser state save twitter_trending",
"opencli browser open https://zhuanlan.zhihu.com/write",
"opencli browser wait time 3",
"opencli browser 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 browser wait time 1",
"opencli browser 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 browser wait time 1",
"opencli browser 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"
}
]
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
# Layer 1: Deterministic browse command testing
set -e
cd "$(dirname "$0")/.."
echo "Building OpenCLI..."
npm run build > /dev/null 2>&1
echo "Build OK"
echo ""
npx tsx autoresearch/eval-browse.ts "$@"
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Layer 4: Save as CLI — test the full save pipeline
# Tests: browser init → write adapter → browser 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 "$@"
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
# Layer 2: Claude Code skill E2E testing
set -e
cd "$(dirname "$0")/.."
echo "Building OpenCLI..."
npm run build > /dev/null 2>&1
echo "Build OK"
echo ""
npx tsx autoresearch/eval-skill.ts "$@"
@@ -1,64 +0,0 @@
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,
}));
},
});
@@ -1,61 +0,0 @@
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;
},
});
@@ -1,62 +0,0 @@
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,
}));
},
});
@@ -1,52 +0,0 @@
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,
}));
},
});
@@ -1,57 +0,0 @@
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;
},
});
@@ -1,53 +0,0 @@
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,
}));
},
});
-281
View File
@@ -1,281 +0,0 @@
[
{
"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(/&quot;/g, '\"').replace(/&#039;/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"
}
]
-899
View File
@@ -1,899 +0,0 @@
[
{
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
},
{
"name": "v2ex-open-home",
"steps": [
"opencli browser open https://v2ex.com/"
],
"judge": {
"type": "contains",
"value": "Navigated to"
}
},
{
"name": "v2ex-state-home",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
"pattern": "\\[\\d+\\]"
}
},
{
"name": "v2ex-get-title",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "V2EX"
}
},
{
"name": "v2ex-click-tab",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser scroll down --amount 500",
"opencli browser eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
],
"judge": {
"type": "contains",
"value": "scrolled"
}
},
{
"name": "v2ex-get-first-topic-text",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-eval-extract-titles",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser get url"
],
"judge": {
"type": "contains",
"value": "v2ex.com"
}
},
{
"name": "v2ex-back-navigation",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser back",
"opencli browser get url"
],
"judge": {
"type": "matchesPattern",
"pattern": "v2ex\\.com/?$"
}
},
{
"name": "v2ex-wait-page-load",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser wait selector \"a[href^='/t/']\"",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser 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 browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
"opencli browser 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 browser open https://v2ex.com/go/python",
"opencli browser 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 browser open https://v2ex.com/go/programmer",
"opencli browser 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 browser open https://v2ex.com/?tab=jobs",
"opencli browser 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 browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
"opencli browser eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser open https://v2ex.com/member/Livid",
"opencli browser 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 browser open https://www.google.com/search?q=site:v2ex.com+TypeScript",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-click-author-profile",
"steps": [
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser back",
"opencli browser wait time 1",
"opencli browser get url"
],
"judge": {
"type": "matchesPattern",
"pattern": "v2ex\\.com/?$"
}
},
{
"name": "v2ex-tab-then-topic",
"steps": [
"opencli browser open https://v2ex.com/?tab=creative",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-scroll-find-more",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli browser scroll down --amount 1000",
"opencli browser scroll down --amount 1000",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "v2ex-node-to-topic-content",
"steps": [
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=tech",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
"opencli browser open https://v2ex.com/?tab=creative",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/new",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser open https://v2ex.com/go/python",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
"opencli browser open https://v2ex.com/go/go",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
"opencli browser open https://v2ex.com/go/programmer?p=2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser click 1"
],
"judge": {
"type": "contains",
"value": "Clicked"
}
},
{
"name": "v2ex-state-click-tab-tech",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser state",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
"pattern": "interactive: \\d+"
}
},
{
"name": "v2ex-state-scroll-state",
"steps": [
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser scroll down --amount 500",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
"pattern": "\\[\\d+\\]"
}
},
{
"name": "v2ex-type-search-box",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser state",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser screenshot /tmp/v2ex-test-screenshot.png"
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-get-html-selector",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser get html --selector h1"
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-keys-escape",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser keys Escape"
],
"judge": {
"type": "contains",
"value": "pressed"
}
},
{
"name": "v2ex-wait-text",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser wait text V2EX"
],
"judge": {
"type": "matchesPattern",
"pattern": "found|appeared"
}
},
{
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
},
{
"name": "v2ex-chain-3-pages",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.title\"",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"document.title\"",
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-navigate-extract-back",
"steps": [
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
"opencli browser back",
"opencli browser wait time 1",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "V2EX"
}
},
{
"name": "v2ex-chain-multi-node-scroll",
"steps": [
"opencli browser open https://v2ex.com/go/python",
"opencli browser scroll down --amount 500",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli browser open https://v2ex.com/go/go",
"opencli browser scroll down --amount 500",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "v2ex-chain-topic-replies-pagination",
"steps": [
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser wait time 2",
"opencli browser eval \"document.querySelectorAll('.reply_content').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "v2ex-chain-member-topics",
"steps": [
"opencli browser open https://v2ex.com/member/Livid",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()\"",
"opencli browser 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 browser open https://www.google.com/search?q=site:v2ex.com+Python",
"opencli browser wait time 2",
"opencli browser 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 browser wait time 3",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-tab-topic-author",
"steps": [
"opencli browser open https://v2ex.com/?tab=tech",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser wait time 2",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-node-page2-extract",
"steps": [
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli browser open https://v2ex.com/go/programmer?p=2",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli browser open https://v2ex.com/go/programmer?p=3",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser state",
"opencli browser eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelectorAll('.reply_content').length\"",
"opencli browser back",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"location.pathname\""
],
"judge": {
"type": "contains",
"value": "/go/python"
}
},
{
"name": "v2ex-eval-after-click",
"steps": [
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 1",
"opencli browser 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 browser open https://v2ex.com/go/programmer",
"opencli browser scroll down --amount 2000",
"opencli browser wait time 1",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser 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 browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser 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 browser open https://v2ex.com/",
"opencli browser state",
"opencli browser type 3 TypeScript",
"opencli browser get value 3"
],
"judge": {
"type": "contains",
"value": "TypeScript"
},
"note": "Types into search box using state index"
},
{
"name": "v2ex-agent-click-navigate-back",
"steps": [
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser 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 browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-agent-state-has-interactive",
"steps": [
"opencli browser open https://v2ex.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
"pattern": "interactive: \\d+"
}
},
{
"name": "v2ex-agent-state-after-scroll",
"steps": [
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser scroll down --amount 800",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
"pattern": "page_scroll: [\\d.]+↑"
}
}
]
-848
View File
@@ -1,848 +0,0 @@
[
{
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
},
{
"name": "zhihu-open-home",
"steps": [
"opencli browser open https://www.zhihu.com/"
],
"judge": {
"type": "contains",
"value": "Navigated to"
}
},
{
"name": "zhihu-get-title",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "知乎"
}
},
{
"name": "zhihu-state",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
"pattern": "\\[@?\\d+\\]"
}
},
{
"name": "zhihu-get-url",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser get url"
],
"judge": {
"type": "contains",
"value": "zhihu.com/hot"
}
},
{
"name": "zhihu-scroll-down",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser scroll down --amount 500",
"opencli browser eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
],
"judge": {
"type": "contains",
"value": "scrolled"
}
},
{
"name": "zhihu-click-tab-hot",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser back",
"opencli browser get url"
],
"judge": {
"type": "matchesPattern",
"pattern": "zhihu\\.com/?$"
}
},
{
"name": "zhihu-wait-page-load",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser wait text 推荐",
"opencli browser eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
],
"judge": {
"type": "contains",
"value": "loaded"
}
},
{
"name": "zhihu-keys-escape",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser keys Escape"
],
"judge": {
"type": "matchesPattern",
"pattern": "pressed|Pressed"
}
},
{
"name": "zhihu-screenshot",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser screenshot /tmp/zhihu-test.png"
],
"judge": {
"type": "nonEmpty"
}
},
{
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
},
{
"name": "zhihu-feed-titles",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-question-meta",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-feed-to-question",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-question-to-author",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-navigate",
"steps": [
"opencli browser open https://www.zhihu.com/search?type=content&q=TypeScript",
"opencli browser wait time 5",
"opencli browser scroll down --amount 300",
"opencli browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/topic/19552832/hot",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/people/excited-vczh",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser get url"
],
"judge": {
"type": "contains",
"value": "zhihu.com/hot"
}
},
{
"name": "zhihu-scroll-load-more",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli browser scroll down --amount 2000",
"opencli browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser 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 browser open https://www.zhihu.com/search?type=content&q=Python",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-question-scroll-answers",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser scroll down --amount 1000",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelectorAll('.RichContent-inner').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "zhihu-compare-tabs",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser 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 browser open https://www.zhihu.com/people/excited-vczh/answers",
"opencli browser wait time 4",
"opencli browser 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 browser open https://www.zhihu.com/topic/19552832/hot",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/search?type=content&q=AI",
"opencli browser wait time 5",
"opencli browser scroll down --amount 300",
"opencli browser wait time 1",
"opencli browser 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 browser open https://www.zhihu.com/search?type=people&q=Python",
"opencli browser wait time 3",
"opencli browser 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 browser open https://www.zhihu.com/search?type=topic&q=编程",
"opencli browser wait time 3",
"opencli browser 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 browser open https://www.zhihu.com/search?type=content&q=Rust编程",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-filter-answers",
"steps": [
"opencli browser open https://www.zhihu.com/search?type=content&q=Docker",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli browser wait time 5",
"opencli browser eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli browser wait time 3",
"opencli browser get url"
],
"judge": {
"type": "contains",
"value": "search"
}
},
{
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
},
{
"name": "zhihu-full-browse-workflow",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser 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 browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser 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 browser eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/search?type=content&q=Claude",
"opencli browser wait time 2",
"opencli browser 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 browser eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-3-page-chain",
"steps": [
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"document.title\"",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli browser open https://www.zhihu.com/people/excited-vczh",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-hot-scroll-deep-read",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser scroll down --amount 1000",
"opencli browser eval \"document.querySelectorAll('.HotItem-content a').length\"",
"opencli browser 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 browser wait time 2",
"opencli browser 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 browser open https://www.zhihu.com/",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser open https://www.zhihu.com/people/excited-vczh",
"opencli browser wait time 2",
"opencli browser eval \"location.pathname\""
],
"judge": {
"type": "contains",
"value": "/people/"
}
},
{
"name": "zhihu-hot-click-verify-url",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelectorAll('.RichContent-inner').length\"",
"opencli browser scroll down --amount 2000",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelectorAll('.RichContent-inner').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "zhihu-extract-structured",
"steps": [
"opencli browser open https://www.zhihu.com/hot",
"opencli browser 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 browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser 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+"
}
}
]
-615
View File
@@ -1,615 +0,0 @@
{
"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=="],
}
}
+82
View File
@@ -0,0 +1,82 @@
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$OpenCliArgs
)
$ErrorActionPreference = 'Stop'
$chatwiseExe = 'C:\Program Files\ChatWise\ChatWise.exe'
if (-not (Test-Path $chatwiseExe)) {
throw "ChatWise executable not found at $chatwiseExe"
}
$opencli = Get-Command opencli -ErrorAction SilentlyContinue
if (-not $opencli) {
throw 'opencli was not found in PATH'
}
function Clear-LocalProxyEnv {
$vars = 'http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY'
foreach ($name in $vars) {
Set-Item -Path "Env:$name" -Value ''
}
$noProxy = '127.0.0.1,localhost'
Set-Item -Path 'Env:NO_PROXY' -Value $noProxy
Set-Item -Path 'Env:no_proxy' -Value $noProxy
}
function Stop-ChatWiseTree {
$candidates = Get-CimInstance Win32_Process |
Where-Object { $_.Name -match '^ChatWise\.exe$|^chatwise\.exe$' }
foreach ($proc in $candidates) {
try {
Stop-Process -Id $proc.ProcessId -Force -ErrorAction Stop
} catch {}
}
Start-Sleep -Seconds 2
}
function Wait-ChatWiseDebugPort {
param(
[int]$Port = 9228,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri "http://127.0.0.1:$Port/json/version"
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
return
}
} catch {}
Start-Sleep -Milliseconds 500
}
throw "ChatWise debugging endpoint did not come up on 127.0.0.1:$Port"
}
Clear-LocalProxyEnv
Stop-ChatWiseTree
$proc = Start-Process -FilePath $chatwiseExe -ArgumentList '--remote-debugging-port=9228' -PassThru
Start-Sleep -Seconds 4
if ($proc.HasExited) {
throw "ChatWise exited early with code $($proc.ExitCode)"
}
Wait-ChatWiseDebugPort
$env:OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9228'
if (-not $OpenCliArgs -or $OpenCliArgs.Count -eq 0) {
& $opencli.Source 'chatwise' 'status'
exit $LASTEXITCODE
}
& $opencli.Source @OpenCliArgs
exit $LASTEXITCODE
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './assets.js';
import { __test__ as sharedTest } from './shared.js';
describe('1688 assets normalization', () => {
it('normalizes gallery and scanned assets into grouped media lists', () => {
const result = __test__.normalizeAssets({
href: 'https://detail.1688.com/offer/887904326744.html',
title: '测试商品 - 阿里巴巴',
offerTitle: '测试商品',
offerId: 887904326744,
gallery: {
mainImage: ['//img.example.com/main-1.jpg'],
offerImgList: ['https://img.example.com/main-2.jpg'],
wlImageInfos: [{ fullPathImageURI: 'https://img.example.com/main-3.jpg' }],
},
scannedAssets: [
{ type: 'image', group: 'sku', url: 'https://img.example.com/sku-1.png', source: 'dom:.sku' },
{ type: 'image', group: 'detail', url: 'https://img.example.com/detail-1.jpg', source: 'dom:.detail' },
{ type: 'video', group: 'video', url: 'https://video.example.com/demo.mp4', source: 'script' },
{ type: 'image', group: 'detail', url: 'blob:https://detail.1688.com/1', source: 'ignore' },
],
});
expect(result.offer_id).toBe('887904326744');
expect(result.main_images).toEqual([
'https://img.example.com/main-1.jpg',
'https://img.example.com/main-2.jpg',
'https://img.example.com/main-3.jpg',
]);
expect(result.sku_images).toEqual(['https://img.example.com/sku-1.png']);
expect(result.detail_images).toEqual(['https://img.example.com/detail-1.jpg']);
expect(result.videos).toEqual(['https://video.example.com/demo.mp4']);
expect(result.main_count).toBe(3);
expect(result.video_count).toBe(1);
});
it('normalizes media urls from style syntax and protocol-relative URLs', () => {
expect(sharedTest.normalizeMediaUrl('url("//img.example.com/1.jpg")')).toBe('https://img.example.com/1.jpg');
expect(sharedTest.normalizeMediaUrl('blob:https://detail.1688.com/1')).toBe('');
});
});
-257
View File
@@ -1,257 +0,0 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
assertAuthenticatedState,
buildDetailUrl,
buildProvenance,
cleanText,
extractOfferId,
gotoAndReadState,
type MediaSource,
uniqueMediaSources,
} from './shared.js';
interface AssetBrowserPayload {
href?: string;
title?: string;
offerTitle?: string;
offerId?: string | number;
gallery?: {
mainImage?: string[];
offerImgList?: string[];
wlImageInfos?: Array<{ fullPathImageURI?: string }>;
[key: string]: unknown;
};
scannedAssets?: MediaSource[];
}
export interface Normalized1688Assets {
offer_id: string | null;
title: string | null;
item_url: string;
main_images: string[];
sku_images: string[];
detail_images: string[];
videos: string[];
other_images: string[];
raw_assets: MediaSource[];
source: string[];
main_count: number;
sku_count: number;
detail_count: number;
video_count: number;
source_url: string;
fetched_at: string;
strategy: string;
}
function scriptToReadAssets(): string {
return `
(() => {
const root = window.context ?? {};
const model = root.result?.global?.globalData?.model ?? null;
const gallery = root.result?.data?.gallery?.fields ?? null;
const defaultSrcProps = ['data-lazyload-src', 'data-src', 'data-ks-lazyload', 'currentSrc', 'src'];
const groups = [
{ key: 'main', type: 'image', selectors: ['#dt-tab img', '.detail-gallery-turn img.detail-gallery-img', '.img-list-wrapper img.od-gallery-img', '.od-scroller-item span'] },
{ key: 'video', type: 'video', selectors: ['.lib-video video', 'video[src]', 'video source[src]'] },
{ key: 'sku', type: 'image', selectors: ['.pc-sku-wrapper .prop-item-inner-wrapper', '.sku-item-wrapper', '.specification-cell', '.sku-filter-button', '.expand-view-item', '.feature-item img'], srcProps: ['backgroundImage'] },
{ key: 'detail', type: 'image', selectors: ['.de-description-detail img', '#detailContentContainer img', '.html-description img', '.html-description source', '.desc-lazyload-container img'] },
];
const assets = [];
const seen = new Set();
const normalizeUrl = (value) => {
if (typeof value !== 'string') return '';
let next = value
.replace(/^url\\((.*)\\)$/i, '$1')
.replace(/^['"]|['"]$/g, '')
.replace(/\\\\u002F/g, '/')
.replace(/&amp;/g, '&')
.trim();
if (!next || next.startsWith('blob:') || next.startsWith('data:')) return '';
if (next.startsWith('//')) next = 'https:' + next;
try {
return new URL(next, location.href).toString();
} catch {
return '';
}
};
const push = (type, group, url, source) => {
const normalized = normalizeUrl(url);
if (!normalized) return;
const key = type + ':' + normalized;
if (seen.has(key)) return;
seen.add(key);
assets.push({ type, group, url: normalized, source });
};
const queryAllDeep = (selector) => {
const results = [];
const visitedRoots = new Set();
const walkRoots = (root, fn) => {
if (!root || visitedRoots.has(root)) return;
visitedRoots.add(root);
fn(root);
const childElements = root.querySelectorAll ? Array.from(root.querySelectorAll('*')) : [];
for (const child of childElements) {
if (child && child.shadowRoot) {
walkRoots(child.shadowRoot, fn);
}
}
};
walkRoots(document, (root) => {
if (root.querySelectorAll) {
results.push(...Array.from(root.querySelectorAll(selector)));
}
});
return results;
};
const valuesFromElement = (element, srcProps) => {
const values = [];
const props = srcProps && srcProps.length ? srcProps : defaultSrcProps;
for (const prop of props) {
try {
if (prop === 'backgroundImage') {
const bg = getComputedStyle(element).backgroundImage || '';
const matches = bg.match(/url\\(([^)]+)\\)/g) || [];
for (const match of matches) {
const clean = match.replace(/^url\\(/, '').replace(/\\)$/, '');
values.push(clean);
}
continue;
}
const direct = element[prop];
if (typeof direct === 'string' && direct) values.push(direct);
const attr = element.getAttribute ? element.getAttribute(prop) : '';
if (attr) values.push(attr);
} catch {}
}
if (element.tagName === 'SOURCE' && element.parentElement?.tagName === 'VIDEO') {
values.push(element.src || element.getAttribute('src') || '');
}
if (element.tagName === 'VIDEO') {
values.push(element.currentSrc || '');
values.push(element.src || '');
}
return values;
};
for (const group of groups) {
for (const selector of group.selectors) {
for (const element of queryAllDeep(selector)) {
for (const value of valuesFromElement(element, group.srcProps)) {
push(group.type, group.key, value, 'dom:' + selector);
}
}
}
}
const scriptTexts = Array.from(document.scripts).map((script) => script.textContent || '');
const videoRegex = /https?:\\/\\/[^"'\\s]+\\.(?:mp4|m3u8)(?:\\?[^"'\\s]*)?/gi;
for (const scriptText of scriptTexts) {
const matches = scriptText.match(videoRegex) || [];
for (const match of matches) {
push('video', 'video', match, 'script');
}
}
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
return {
href: window.location.href,
title: document.title || '',
offerTitle: model?.offerTitleModel?.subject ?? '',
offerId: model?.tradeModel?.offerId ?? '',
gallery: toJson(gallery),
scannedAssets: assets,
};
})()
`;
}
function normalizeAssets(payload: AssetBrowserPayload): Normalized1688Assets {
const offerId = cleanText(String(payload.offerId ?? '')) || extractOfferId(cleanText(payload.href)) || null;
const itemUrl = offerId ? buildDetailUrl(offerId) : cleanText(payload.href);
const seededAssets: MediaSource[] = [
...((payload.gallery?.mainImage ?? []).map((url) => ({ type: 'image' as const, group: 'main' as const, url, source: 'page_state:mainImage' }))),
...((payload.gallery?.offerImgList ?? []).map((url) => ({ type: 'image' as const, group: 'main' as const, url, source: 'page_state:offerImgList' }))),
...((payload.gallery?.wlImageInfos ?? []).map((item) => ({
type: 'image' as const,
group: 'main' as const,
url: item?.fullPathImageURI ?? '',
source: 'page_state:wlImageInfos',
}))),
];
const assets = uniqueMediaSources([...seededAssets, ...(payload.scannedAssets ?? [])]);
const mainImages = assets.filter((item) => item.type === 'image' && item.group === 'main').map((item) => item.url);
const skuImages = assets.filter((item) => item.type === 'image' && item.group === 'sku').map((item) => item.url);
const detailImages = assets.filter((item) => item.type === 'image' && item.group === 'detail').map((item) => item.url);
const videos = assets.filter((item) => item.type === 'video').map((item) => item.url);
const otherImages = assets
.filter((item) => item.type === 'image' && !['main', 'sku', 'detail'].includes(item.group))
.map((item) => item.url);
return {
offer_id: offerId,
title: cleanText(payload.offerTitle) || cleanText(payload.title) || null,
item_url: itemUrl,
main_images: mainImages,
sku_images: skuImages,
detail_images: detailImages,
videos,
other_images: otherImages,
raw_assets: assets,
source: [...new Set(assets.map((item) => cleanText(item.source)).filter(Boolean))],
main_count: mainImages.length,
sku_count: skuImages.length,
detail_count: detailImages.length,
video_count: videos.length,
...buildProvenance(cleanText(payload.href) || itemUrl),
};
}
async function readAssetsPayload(page: IPage, itemUrl: string): Promise<AssetBrowserPayload> {
const state = await gotoAndReadState(page, itemUrl, 2500, 'assets');
assertAuthenticatedState(state, 'assets');
await page.autoScroll({ times: 3, delayMs: 400 });
await page.wait(1);
return await page.evaluate(scriptToReadAssets()) as AssetBrowserPayload;
}
export async function extractAssetsForInput(page: IPage, input: string): Promise<Normalized1688Assets> {
const itemUrl = buildDetailUrl(String(input ?? ''));
const payload = await readAssetsPayload(page, itemUrl);
return normalizeAssets(payload);
}
cli({
site: '1688',
name: 'assets',
description: '列出 1688 商品页可提取的图片/视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 商品 URL 或 offer ID(如 887904326744',
},
],
columns: ['offer_id', 'title', 'main_count', 'sku_count', 'detail_count', 'video_count'],
func: async (page, kwargs) => {
return [await extractAssetsForInput(page, String(kwargs.input ?? ''))];
},
});
export const __test__ = {
normalizeAssets,
};
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './download.js';
describe('1688 download helpers', () => {
it('builds stable filenames for grouped assets', () => {
const items = __test__.toDownloadItems('887904326744', {
offer_id: '887904326744',
title: '测试商品',
item_url: 'https://detail.1688.com/offer/887904326744.html',
main_images: ['https://img.example.com/a.jpg'],
sku_images: ['https://img.example.com/b.png'],
detail_images: ['https://img.example.com/c.webp'],
videos: ['https://video.example.com/d.mp4'],
other_images: [],
raw_assets: [],
source: [],
main_count: 1,
sku_count: 1,
detail_count: 1,
video_count: 1,
source_url: 'https://detail.1688.com/offer/887904326744.html',
fetched_at: new Date().toISOString(),
strategy: 'cookie',
});
expect(items.map((item) => item.filename)).toEqual([
'887904326744_main_01.jpg',
'887904326744_sku_01.png',
'887904326744_detail_01.webp',
'887904326744_video_01.mp4',
]);
});
});
-83
View File
@@ -1,83 +0,0 @@
import * as path from 'node:path';
import { formatCookieHeader } from '@jackwener/opencli/download';
import { downloadMedia, type MediaItem } from '@jackwener/opencli/download/media-download';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cleanText } from './shared.js';
import { extractAssetsForInput } from './assets.js';
function extFromUrl(url: string, fallback: string): string {
try {
const ext = path.extname(new URL(url).pathname).toLowerCase();
if (ext && ext.length <= 8) return ext;
} catch {
// ignore
}
return fallback;
}
function toDownloadItems(offerId: string, assets: Awaited<ReturnType<typeof extractAssetsForInput>>): MediaItem[] {
const items: MediaItem[] = [];
const pushImages = (urls: string[], prefix: string) => {
urls.forEach((url, index) => {
items.push({
type: 'image',
url,
filename: `${offerId}_${prefix}_${String(index + 1).padStart(2, '0')}${extFromUrl(url, '.jpg')}`,
});
});
};
pushImages(assets.main_images, 'main');
pushImages(assets.sku_images, 'sku');
pushImages(assets.detail_images, 'detail');
pushImages(assets.other_images, 'other');
assets.videos.forEach((url, index) => {
items.push({
type: 'video',
url,
filename: `${offerId}_video_${String(index + 1).padStart(2, '0')}${extFromUrl(url, '.mp4')}`,
});
});
return items;
}
cli({
site: '1688',
name: 'download',
description: '批量下载 1688 商品页可提取的图片和视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 商品 URL 或 offer ID(如 887904326744',
},
{ name: 'output', default: './1688-downloads', help: '输出目录' },
],
columns: ['index', 'type', 'status', 'size'],
func: async (page, kwargs) => {
const assets = await extractAssetsForInput(page, String(kwargs.input ?? ''));
const offerId = cleanText(assets.offer_id) || '1688';
const items = toDownloadItems(offerId, assets);
const browserCookies = await page.getCookies({ domain: '1688.com' });
return downloadMedia(items, {
output: String(kwargs.output || './1688-downloads'),
subdir: offerId,
cookies: formatCookieHeader(browserCookies),
browserCookies,
filenamePrefix: offerId,
timeout: 60000,
});
},
});
export const __test__ = {
extFromUrl,
toDownloadItems,
};
-69
View File
@@ -1,69 +0,0 @@
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: '莫代尔纤维' },
]);
});
});
-282
View File
@@ -1,282 +0,0 @@
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,
};
-81
View File
@@ -1,81 +0,0 @@
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();
});
});
-402
View File
@@ -1,402 +0,0 @@
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,
};
-75
View File
@@ -1,75 +0,0 @@
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);
});
});
-672
View File
@@ -1,672 +0,0 @@
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 interface MediaSource {
type: 'image' | 'video';
group: 'main' | 'sku' | 'detail' | 'video' | 'unknown';
url: string;
source?: string;
}
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);
}
export function normalizeMediaUrl(input: unknown): string {
const raw = cleanText(input);
if (!raw) return '';
let value = raw
.replace(/^url\((.*)\)$/i, '$1')
.replace(/^['"]|['"]$/g, '')
.replace(/\\u002F/g, '/')
.replace(/&amp;/g, '&')
.trim();
if (!value || value.startsWith('data:') || value.startsWith('blob:')) return '';
if (value.startsWith('//')) value = `https:${value}`;
try {
const url = new URL(value);
return url.toString();
} catch {
return '';
}
}
export function uniqueMediaSources(values: MediaSource[]): MediaSource[] {
const seen = new Set<string>();
const result: MediaSource[] = [];
for (const value of values) {
const url = normalizeMediaUrl(value.url);
if (!url) continue;
const key = `${value.type}:${url}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
...value,
url,
source: cleanText(value.source) || undefined,
});
}
return result;
}
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,
normalizeMediaUrl,
uniqueMediaSources,
limitCandidates,
};
-69
View File
@@ -1,69 +0,0 @@
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');
});
});
-300
View File
@@ -1,300 +0,0 @@
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,
};
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './rankings.js';
describe('amazon bestsellers normalization', () => {
it('normalizes bestseller cards and infers review counts from card text', () => {
const result = __test__.normalizeRankingCandidate({
asin: 'B0DR31GC3D',
title: '',
href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs',
price_text: '$25.92',
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',
}, {
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');
expect(result.title).toBe('Desk Shelves Desktop Organizer Shlef');
expect(result.review_count).toBe(435);
expect(result.list_title).toBe('Amazon Best Sellers: Best Desktop & Off-Surface Shelves');
});
});
-8
View File
@@ -1,8 +0,0 @@
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',
}));
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './discussion.js';
describe('amazon discussion normalization', () => {
it('normalizes review summary and sample reviews', () => {
const result = __test__.normalizeDiscussionPayload({
href: 'https://www.amazon.com/product-reviews/B0FJS72893',
average_rating_text: '3.9 out of 5',
total_review_count_text: '27 global ratings',
qa_links: [],
review_samples: [
{
title: '5.0 out of 5 stars Great value and quality',
rating_text: '5.0 out of 5 stars',
author: 'GTreader2',
date_text: 'Reviewed in the United States on February 21, 2026',
body: 'Small but mighty.',
verified: true,
},
],
});
expect(result.asin).toBe('B0FJS72893');
expect(result.average_rating_value).toBe(3.9);
expect(result.total_review_count).toBe(27);
expect(result.review_samples).toEqual([
{
title: 'Great value and quality',
rating_text: '5.0 out of 5 stars',
rating_value: 5,
author: 'GTreader2',
date_text: 'Reviewed in the United States on February 21, 2026',
body: 'Small but mighty.',
verified_purchase: true,
},
]);
});
});
-131
View File
@@ -1,131 +0,0 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
buildDiscussionUrl,
buildProvenance,
cleanText,
extractAsin,
normalizeProductUrl,
parseRatingValue,
parseReviewCount,
trimRatingPrefix,
uniqueNonEmpty,
assertUsableState,
gotoAndReadState,
} from './shared.js';
interface DiscussionPayload {
href?: string;
title?: string;
average_rating_text?: string | null;
total_review_count_text?: string | null;
qa_links?: string[];
review_samples?: Array<{
title?: string | null;
rating_text?: string | null;
author?: string | null;
date_text?: string | null;
body?: string | null;
verified?: boolean;
}>;
}
function normalizeDiscussionPayload(payload: DiscussionPayload): Record<string, unknown> {
const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
const asin = extractAsin(payload.href ?? '') ?? null;
const averageRatingText = cleanText(payload.average_rating_text) || null;
const totalReviewCountText = cleanText(payload.total_review_count_text) || null;
const provenance = buildProvenance(sourceUrl);
return {
asin,
product_url: asin ? normalizeProductUrl(asin) : null,
discussion_url: sourceUrl,
...provenance,
average_rating_text: averageRatingText,
average_rating_value: parseRatingValue(averageRatingText),
total_review_count_text: totalReviewCountText,
total_review_count: parseReviewCount(totalReviewCountText),
qa_urls: uniqueNonEmpty(payload.qa_links ?? []),
review_samples: (payload.review_samples ?? []).map((sample) => ({
title: trimRatingPrefix(sample.title) || null,
rating_text: cleanText(sample.rating_text) || null,
rating_value: parseRatingValue(sample.rating_text),
author: cleanText(sample.author) || null,
date_text: cleanText(sample.date_text) || null,
body: cleanText(sample.body) || null,
verified_purchase: sample.verified === true,
})),
};
}
async function readDiscussionPayload(page: IPage, input: string, limit: number): Promise<DiscussionPayload> {
const url = buildDiscussionUrl(input);
const state = await gotoAndReadState(page, url, 2500, 'discussion');
assertUsableState(state, 'discussion');
return await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
average_rating_text: document.querySelector('[data-hook="rating-out-of-text"]')?.textContent || '',
total_review_count_text: document.querySelector('[data-hook="total-review-count"]')?.textContent || '',
qa_links: Array.from(document.querySelectorAll('a[href*="ask/questions"]')).map((anchor) => anchor.href || ''),
review_samples: Array.from(document.querySelectorAll('[data-hook="review"]')).slice(0, ${limit}).map((card) => ({
title: card.querySelector('[data-hook="review-title"]')?.textContent || '',
rating_text:
card.querySelector('[data-hook="review-star-rating"]')?.textContent
|| card.querySelector('[data-hook="cmps-review-star-rating"]')?.textContent
|| '',
author: card.querySelector('.a-profile-name')?.textContent || '',
date_text: card.querySelector('[data-hook="review-date"]')?.textContent || '',
body: card.querySelector('[data-hook="review-body"]')?.textContent || '',
verified: !!card.querySelector('[data-hook="avp-badge"]'),
})),
}))()
`) as DiscussionPayload;
}
cli({
site: 'amazon',
name: 'discussion',
description: 'Amazon review summary and sample customer discussion from product review pages',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: 'ASIN or product URL, for example B0FJS72893',
},
{
name: 'limit',
type: 'int',
default: 10,
help: 'Maximum number of review samples to return (default 10)',
},
],
columns: ['asin', 'average_rating_value', 'total_review_count'],
func: async (page, kwargs) => {
const input = String(kwargs.input ?? '');
const limit = Math.max(1, Number(kwargs.limit) || 10);
const payload = await readDiscussionPayload(page, input, limit);
const normalized = normalizeDiscussionPayload(payload);
if (!normalized.average_rating_text && !normalized.total_review_count_text) {
throw new CommandExecutionError(
'amazon discussion page did not expose review summary',
'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.',
);
}
return [normalized];
},
});
export const __test__ = {
normalizeDiscussionPayload,
};
-8
View File
@@ -1,8 +0,0 @@
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',
}));
-8
View File
@@ -1,8 +0,0 @@
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',
}));
-35
View File
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './offer.js';
describe('amazon offer normalization', () => {
it('extracts sold-by and fulfillment facts from product offer text', () => {
const result = __test__.normalizeOfferPayload({
href: 'https://www.amazon.com/dp/B0FJS72893',
price_text: '$15.99',
merchant_info: '',
sold_by: 'KUATUDIRECT',
ships_from_text: 'Ships from Amazon',
offer_link: null,
review_url: 'https://www.amazon.com/dp/B0FJS72893#customerReviews',
qa_url: null,
});
expect(result.asin).toBe('B0FJS72893');
expect(result.sold_by).toBe('KUATUDIRECT');
expect(result.ships_from).toBe('Amazon');
expect(result.is_amazon_sold).toBe(false);
expect(result.is_amazon_fulfilled).toBe(true);
});
it('parses merchant info fallback text', () => {
expect(__test__.extractSoldBy('Sold by Example Seller and Fulfilled by Amazon.')).toBe('Example Seller');
expect(__test__.extractShipsFrom('Ships from Amazon')).toBe('Amazon');
});
it('detects delivery-location blocking in the buy box text', () => {
expect(__test__.isDeliveryLocationBlocked(
'This item cannot be shipped to your selected delivery location. Similar items shipping to Hong Kong',
)).toBe(true);
expect(__test__.isDeliveryLocationBlocked('Ships from Amazon')).toBe(false);
});
});
-185
View File
@@ -1,185 +0,0 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
buildProductUrl,
buildProvenance,
cleanText,
extractAsin,
isAmazonEntity,
normalizeProductUrl,
PRIMARY_PRICE_SELECTORS,
parsePriceText,
assertUsableState,
gotoAndReadState,
} from './shared.js';
interface OfferPayload {
href?: string;
title?: string;
price_text?: string | null;
merchant_info?: string | null;
sold_by?: string | null;
ships_from_text?: string | null;
offer_link?: string | null;
review_url?: string | null;
qa_url?: string | null;
buybox_text?: string | null;
}
const OFFER_FACT_SELECTOR = [
'#sellerProfileTriggerId',
'#shipsFromSoldByInsideBuyBox_feature_div',
'#fulfillerInfoFeature_feature_div',
'#merchantInfoFeature_feature_div',
'#tabular-buybox-container',
'#merchant-info',
].join(', ');
function collapseAdjacentWords(text: string): string {
const parts = cleanText(text).split(' ').filter(Boolean);
const deduped: string[] = [];
for (const part of parts) {
if (deduped[deduped.length - 1] === part) continue;
deduped.push(part);
}
return deduped.join(' ');
}
function extractShipsFrom(text: string): string | null {
const normalized = cleanText(text);
const match = normalized.match(/Ships from\s+(.+?)(?=Sold by|and Fulfilled by|$)/i);
return match ? collapseAdjacentWords(match[1].replace(/Ships from/ig, '')) : null;
}
function extractSoldBy(text: string): string | null {
const normalized = cleanText(text);
const match = normalized.match(/Sold by\s+(.+?)(?=and Fulfilled by|Ships from|$)/i);
return match ? collapseAdjacentWords(match[1]) : null;
}
function isDeliveryLocationBlocked(text: string | null | undefined): boolean {
const normalized = cleanText(text).toLowerCase();
return normalized.includes('cannot be shipped to your selected delivery location')
|| normalized.includes('similar items shipping to')
|| normalized.includes('deliver to hong kong');
}
function normalizeOfferPayload(payload: OfferPayload): Record<string, unknown> {
const asin = extractAsin(payload.href ?? '') ?? null;
const sourceUrl = cleanText(payload.href) || buildProductUrl(payload.href ?? '');
const price = parsePriceText(payload.price_text);
const merchantInfo = cleanText(payload.merchant_info) || null;
const soldBy = cleanText(payload.sold_by)
|| extractSoldBy(payload.ships_from_text ?? '')
|| extractSoldBy(merchantInfo ?? '')
|| null;
const shipsFrom = extractShipsFrom(payload.ships_from_text ?? '')
|| extractShipsFrom(merchantInfo ?? '')
|| cleanText(payload.ships_from_text)
|| null;
const provenance = buildProvenance(sourceUrl);
return {
asin,
product_url: normalizeProductUrl(payload.href),
...provenance,
price_text: price.price_text,
price_value: price.price_value,
currency: price.currency,
merchant_info_text: merchantInfo,
sold_by: soldBy,
ships_from: shipsFrom,
offer_listing_url: cleanText(payload.offer_link) || null,
review_url: cleanText(payload.review_url) || null,
qa_url: cleanText(payload.qa_url) || null,
is_amazon_sold: isAmazonEntity(soldBy),
is_amazon_fulfilled: isAmazonEntity(shipsFrom) || /fulfilled by amazon/i.test(merchantInfo ?? ''),
};
}
async function readOfferPayload(page: IPage, input: string): Promise<OfferPayload> {
const url = buildProductUrl(input);
const state = await gotoAndReadState(page, url, 2500, 'offer');
assertUsableState(state, 'offer');
// Reconnecting to an existing Amazon target can surface the product page
// before the buy-box / merchant blocks are reattached to the DOM.
await page.wait({ selector: OFFER_FACT_SELECTOR, timeout: 6 }).catch(() => {});
return await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
price_text: (() => {
const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)};
for (const selector of selectors) {
const text = document.querySelector(selector)?.textContent || '';
if (text.trim()) return text;
}
return '';
})(),
merchant_info: document.querySelector('#merchant-info')?.textContent || '',
sold_by: document.querySelector('#sellerProfileTriggerId')?.textContent || '',
ships_from_text:
document.querySelector('#shipsFromSoldByInsideBuyBox_feature_div')?.textContent
|| document.querySelector('#fulfillerInfoFeature_feature_div')?.textContent
|| document.querySelector('#merchantInfoFeature_feature_div')?.textContent
|| document.querySelector('#tabular-buybox-container')?.textContent
|| '',
offer_link: document.querySelector('a[href*="/gp/offer-listing/"]')?.href || '',
review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '',
qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '',
buybox_text:
document.querySelector('#desktop_qualifiedBuyBox')?.textContent
|| document.querySelector('#buybox')?.textContent
|| '',
}))()
`) as OfferPayload;
}
cli({
site: 'amazon',
name: 'offer',
description: 'Amazon seller, buy box, and fulfillment facts from the product page',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: 'ASIN or product URL, for example B0FJS72893',
},
],
columns: ['asin', 'price_text', 'sold_by', 'ships_from', 'is_amazon_sold', 'is_amazon_fulfilled'],
func: async (page, kwargs) => {
const input = String(kwargs.input ?? '');
const payload = await readOfferPayload(page, input);
const normalized = normalizeOfferPayload(payload);
if (!normalized.sold_by && !normalized.ships_from && !normalized.merchant_info_text) {
if (isDeliveryLocationBlocked(payload.buybox_text)) {
throw new CommandExecutionError(
'amazon offer buy box is blocked by the current delivery location',
'The shared Chrome profile is not set to the target US delivery address. Switch Amazon delivery location to the requested US destination, reopen the product page, and retry.',
);
}
throw new CommandExecutionError(
'amazon offer surface did not expose seller or fulfillment facts',
'The product page may have changed. Open the product page in Chrome, make sure the buy box is visible, and retry.',
);
}
return [normalized];
},
});
export const __test__ = {
extractShipsFrom,
extractSoldBy,
isDeliveryLocationBlocked,
normalizeOfferPayload,
};
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './product.js';
describe('amazon product normalization', () => {
it('normalizes product facts from the product page', () => {
const result = __test__.normalizeProductPayload({
href: 'https://www.amazon.com/dp/B0FJS72893',
title: 'Amazon.com: KVTUKIAIT Desktop Shelf Organizer',
product_title: 'White Desktop Shelf Organizer for Top of Desk',
byline: 'Visit the KVTUKIAIT Store',
price_text: '$15.99',
rating_text: '3.9 out of 5 stars',
review_count_text: '27 ratings',
review_url: 'https://www.amazon.com/dp/B0FJS72893#customerReviews',
qa_url: null,
bullets: ['SPACE-SAVING DESK SHELF ORGANIZER', 'SMALL AND STYLISH AESTHETIC DECOR'],
breadcrumbs: ['Office Products', 'Desktop & Off-Surface Shelves'],
});
expect(result.asin).toBe('B0FJS72893');
expect(result.price_value).toBe(15.99);
expect(result.rating_value).toBe(3.9);
expect(result.review_count).toBe(27);
expect(result.breadcrumbs).toEqual(['Office Products', 'Desktop & Off-Surface Shelves']);
});
});
-131
View File
@@ -1,131 +0,0 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
buildProductUrl,
buildProvenance,
cleanText,
extractAsin,
PRIMARY_PRICE_SELECTORS,
parsePriceText,
parseRatingValue,
parseReviewCount,
normalizeProductUrl,
uniqueNonEmpty,
assertUsableState,
gotoAndReadState,
} from './shared.js';
interface ProductPayload {
href?: string;
title?: string;
product_title?: string | null;
byline?: string | null;
price_text?: string | null;
rating_text?: string | null;
review_count_text?: string | null;
review_url?: string | null;
qa_url?: string | null;
bullets?: string[];
breadcrumbs?: string[];
}
const PRODUCT_TITLE_SELECTOR = '#productTitle, #title span, [data-feature-name="title"] h1 span';
const BYLINE_SELECTOR = '#bylineInfo, [data-feature-name="bylineInfo"] #bylineInfo';
function normalizeProductPayload(payload: ProductPayload): Record<string, unknown> {
const sourceUrl = cleanText(payload.href) || buildProductUrl(cleanText(payload.product_title) || cleanText(payload.href));
const asin = extractAsin(payload.href ?? '') ?? null;
const price = parsePriceText(payload.price_text);
const ratingText = cleanText(payload.rating_text) || null;
const reviewCountText = cleanText(payload.review_count_text) || null;
const provenance = buildProvenance(sourceUrl);
return {
asin,
title: cleanText(payload.product_title) || cleanText(payload.title) || null,
product_url: normalizeProductUrl(payload.href),
...provenance,
brand_text: cleanText(payload.byline) || null,
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),
review_url: cleanText(payload.review_url) || null,
qa_url: cleanText(payload.qa_url) || null,
breadcrumbs: uniqueNonEmpty(payload.breadcrumbs ?? []),
bullet_points: uniqueNonEmpty(payload.bullets ?? []),
};
}
async function readProductPayload(page: IPage, input: string): Promise<ProductPayload> {
const url = buildProductUrl(input);
const state = await gotoAndReadState(page, url, 2500, 'product');
assertUsableState(state, 'product');
// Amazon can report a "stable" DOM before the product title block hydrates,
// especially when reconnecting to an existing shared CDP target.
await page.wait({ selector: PRODUCT_TITLE_SELECTOR, timeout: 6 }).catch(() => {});
return await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
product_title: document.querySelector(${JSON.stringify(PRODUCT_TITLE_SELECTOR)})?.textContent || '',
byline: document.querySelector(${JSON.stringify(BYLINE_SELECTOR)})?.textContent || '',
price_text: (() => {
const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)};
for (const selector of selectors) {
const text = document.querySelector(selector)?.textContent || '';
if (text.trim()) return text;
}
return '';
})(),
rating_text:
document.querySelector('#acrPopover')?.getAttribute('title')
|| document.querySelector('#acrPopover')?.textContent
|| '',
review_count_text: document.querySelector('#acrCustomerReviewText')?.textContent || '',
review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '',
qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '',
bullets: Array.from(document.querySelectorAll('#feature-bullets li .a-list-item')).map((node) => node.textContent || ''),
breadcrumbs: Array.from(document.querySelectorAll('#wayfinding-breadcrumbs_feature_div a')).map((node) => node.textContent || ''),
}))()
`) as ProductPayload;
}
cli({
site: 'amazon',
name: 'product',
description: 'Amazon product page facts for candidate validation',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: 'ASIN or product URL, for example B0FJS72893',
},
],
columns: ['asin', 'title', 'price_text', 'rating_value', 'review_count'],
func: async (page, kwargs) => {
const input = String(kwargs.input ?? '');
const payload = await readProductPayload(page, input);
if (!cleanText(payload.product_title)) {
throw new CommandExecutionError(
'amazon product page did not expose product content',
'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.',
);
}
return [normalizeProductPayload(payload)];
},
});
export const __test__ = {
normalizeProductPayload,
};
-47
View File
@@ -1,47 +0,0 @@
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);
});
});
-312
View File
@@ -1,312 +0,0 @@
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,
};
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './search.js';
describe('amazon search normalization', () => {
it('normalizes search cards into research-friendly fields', () => {
const result = __test__.normalizeSearchCandidate({
asin: 'B0FJS72893',
title: 'White Desktop Shelf Organizer for Top of Desk',
href: 'https://www.amazon.com/KVTUKIAIT-White-Desktop-Shelf-Organizer/dp/B0FJS72893/ref=sr_1_1',
price_text: '$15.99',
rating_text: '3.9 out of 5 stars, rating details',
review_count_text: '(27)',
sponsored: false,
badge_texts: ['Limited time deal'],
}, 1, 'https://www.amazon.com/s?k=desk+shelf+organizer');
expect(result.asin).toBe('B0FJS72893');
expect(result.product_url).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(result.price_value).toBe(15.99);
expect(result.rating_value).toBe(3.9);
expect(result.review_count).toBe(27);
expect(result.badges).toEqual(['Limited time deal']);
});
});
-128
View File
@@ -1,128 +0,0 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
buildProvenance,
buildSearchUrl,
cleanText,
extractAsin,
normalizeProductUrl,
parsePriceText,
parseRatingValue,
parseReviewCount,
assertUsableState,
gotoAndReadState,
} from './shared.js';
interface SearchPayload {
href?: string;
cards?: Array<{
asin?: string;
title?: string;
href?: string;
price_text?: string | null;
rating_text?: string | null;
review_count_text?: string | null;
sponsored?: boolean;
badge_texts?: string[];
}>;
}
function normalizeSearchCandidate(
candidate: NonNullable<SearchPayload['cards']>[number],
rank: number,
sourceUrl: string,
): Record<string, unknown> {
const productUrl = normalizeProductUrl(candidate.href);
const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
const price = parsePriceText(candidate.price_text);
const ratingText = cleanText(candidate.rating_text) || null;
const reviewCountText = cleanText(candidate.review_count_text) || null;
const provenance = buildProvenance(sourceUrl);
return {
rank,
asin,
title: cleanText(candidate.title) || null,
product_url: productUrl,
...provenance,
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),
is_sponsored: candidate.sponsored === true,
badges: (candidate.badge_texts ?? []).map((value) => cleanText(value)).filter(Boolean),
};
}
async function readSearchPayload(page: IPage, query: string): Promise<SearchPayload> {
const url = buildSearchUrl(query);
const state = await gotoAndReadState(page, url, 2500, 'search');
assertUsableState(state, 'search');
return await page.evaluate(`
(() => ({
href: window.location.href,
cards: Array.from(document.querySelectorAll('[data-component-type="s-search-result"]'))
.map((card) => ({
asin: card.getAttribute('data-asin') || '',
title: card.querySelector('h2')?.textContent || '',
href: card.querySelector('a.a-link-normal[href*="/dp/"]')?.href || '',
price_text: card.querySelector('.a-price .a-offscreen')?.textContent || '',
rating_text: card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label') || '',
review_count_text: card.querySelector('a[href*="#customerReviews"]')?.textContent || '',
sponsored: /sponsored/i.test(card.innerText || ''),
badge_texts: Array.from(card.querySelectorAll('.a-badge-text')).map((node) => node.textContent || ''),
})),
}))()
`) as SearchPayload;
}
cli({
site: 'amazon',
name: 'search',
description: 'Amazon search results for product discovery and coarse filtering',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'query',
required: true,
positional: true,
help: 'Search query, for example "desk shelf organizer"',
},
{
name: 'limit',
type: 'int',
default: 20,
help: 'Maximum number of results to return (default 20)',
},
],
columns: ['rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
func: async (page, kwargs) => {
const query = String(kwargs.query ?? '');
const limit = Math.max(1, Number(kwargs.limit) || 20);
const payload = await readSearchPayload(page, query);
const sourceUrl = cleanText(payload.href) || buildSearchUrl(query);
const cards = (payload.cards ?? [])
.filter((card) => cleanText(card.asin) && cleanText(card.title))
.slice(0, limit);
if (cards.length === 0) {
throw new CommandExecutionError(
'amazon search did not expose any product cards',
'The search page may have changed or hit a robot check. Open the same query in Chrome, verify the page is visible, and retry.',
);
}
return cards.map((card, index) => normalizeSearchCandidate(card, index + 1, sourceUrl));
},
});
export const __test__ = {
normalizeSearchCandidate,
};
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './shared.js';
describe('amazon shared helpers', () => {
it('builds canonical product and discussion URLs from ASINs and product URLs', () => {
expect(__test__.buildProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(__test__.buildProductUrl('https://www.amazon.com/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('https://www.amazon.com/dp/B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
});
it('parses price, rating, and review-count text', () => {
expect(__test__.parsePriceText('1 offer from $34.11')).toEqual({
price_text: '$34.11',
price_value: 34.11,
currency: 'USD',
});
expect(__test__.parseRatingValue('3.9 out of 5 stars, rating details')).toBe(3.9);
expect(__test__.parseReviewCount('27 global ratings')).toBe(27);
expect(__test__.parseReviewCount('(2.9K)')).toBe(2900);
expect(__test__.parseReviewCount('1.2M global ratings')).toBe(1200000);
expect(__test__.extractReviewCountFromCardText('Desk Shelf\n4.3 out of 5 stars\n435\n$25.92')).toBe('435');
});
it('recognizes robot checks and Amazon-owned merchants', () => {
expect(__test__.isAmazonEntity('Ships from Amazon')).toBe(true);
expect(__test__.trimRatingPrefix('5.0 out of 5 stars Great value and quality')).toBe('Great value and quality');
expect(__test__.isRobotState({
title: 'Robot Check',
body_text: 'Sorry, we just need to make sure you\'re not a robot',
})).toBe(true);
});
it('requires a real best-sellers URL or path', () => {
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');
});
});
-438
View File
@@ -1,438 +0,0 @@
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/';
export const STRATEGY = 'cookie';
export const PRIMARY_PRICE_SELECTORS = [
'#corePrice_feature_div .a-offscreen',
'#corePriceDisplay_desktop_feature_div .a-offscreen',
'#corePrice_desktop .a-offscreen',
'#apex_desktop .a-offscreen',
'#newAccordionRow_0 .a-offscreen',
'#price_inside_buybox',
'#priceblock_ourprice',
'#priceblock_dealprice',
'#tp_price_block_total_price_ww',
];
const ROBOT_TEXT_PATTERNS = [
'Sorry, we just need to make sure you\'re not a robot',
'Enter the characters you see below',
'Type the characters you see in this image',
'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;
strategy: string;
}
export interface PageState {
href: string;
title: string;
body_text: string;
}
export interface PriceValue {
price_text: string | null;
price_value: number | null;
currency: 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 buildProvenance(sourceUrl: string): ProvenanceFields {
return {
source_url: sourceUrl,
fetched_at: new Date().toISOString(),
strategy: STRATEGY,
};
}
export function buildSearchUrl(query: string): string {
const normalized = cleanText(query);
if (!normalized) {
throw new ArgumentError('amazon search query cannot be empty');
}
return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`;
}
export function extractAsin(input: string): string | null {
const normalized = cleanText(input);
if (!normalized) return null;
if (/^[A-Z0-9]{10}$/i.test(normalized)) {
return normalized.toUpperCase();
}
const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i);
return match ? match[1].toUpperCase() : null;
}
export function buildProductUrl(input: string): string {
const asin = extractAsin(input);
if (!asin) {
throw new ArgumentError(
'amazon product expects an ASIN or product URL',
'Example: opencli amazon product B0FJS72893',
);
}
return `${PRODUCT_URL_PREFIX}${asin}`;
}
export function buildDiscussionUrl(input: string): string {
const asin = extractAsin(input);
if (!asin) {
throw new ArgumentError(
'amazon discussion expects an ASIN or product URL',
'Example: opencli amazon discussion B0FJS72893',
);
}
return `${DISCUSSION_URL_PREFIX}${asin}`;
}
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 || normalized === 'root') return spec.rootUrl;
let candidateUrl: string;
if (normalized.startsWith('/')) {
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 (!isSupportedRankingPath(listType, candidateUrl)) {
throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
}
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;
}
}
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 {
try {
const url = new URL(input);
if (!url.hostname.endsWith(DOMAIN)) {
throw new Error('not-amazon');
}
return url.toString();
} catch {
throw new ArgumentError('Invalid Amazon URL');
}
}
export function toAbsoluteAmazonUrl(value: string | null | undefined): string | null {
const normalized = cleanText(value);
if (!normalized) return null;
try {
return new URL(normalized, HOME_URL).toString();
} catch {
return null;
}
}
export function normalizeProductUrl(value: string | null | undefined): string | null {
const normalized = cleanText(value);
const asin = extractAsin(normalized);
if (asin) return buildProductUrl(asin);
return toAbsoluteAmazonUrl(normalized);
}
export function parsePriceText(text: string | null | undefined): PriceValue {
const normalized = cleanText(text);
const match = normalized.match(/([$€£])\s*(\d+(?:,\d{3})*(?:\.\d+)?)/);
if (!match) {
return {
price_text: normalized || null,
price_value: null,
currency: null,
};
}
const currencyMap: Record<string, string> = {
'$': 'USD',
'€': 'EUR',
'£': 'GBP',
};
return {
price_text: `${match[1]}${match[2]}`,
price_value: Number.parseFloat(match[2].replace(/,/g, '')),
currency: currencyMap[match[1]] ?? null,
};
}
export function parseRatingValue(text: string | null | undefined): number | null {
const normalized = cleanText(text);
const match = normalized.match(/(\d+(?:\.\d+)?)\s*out of 5/i);
return match ? Number.parseFloat(match[1]) : null;
}
export function parseReviewCount(text: string | null | undefined): number | null {
const normalized = cleanText(text);
const compactMatch = normalized.match(/(\d+(?:\.\d+)?)\s*([kKmM])/);
if (compactMatch) {
const value = Number.parseFloat(compactMatch[1]);
const multiplier = /m/i.test(compactMatch[2]) ? 1_000_000 : 1_000;
return Number.isFinite(value) ? Math.round(value * multiplier) : null;
}
const match = normalized.match(/([\d,]+)/);
return match ? Number.parseInt(match[1].replace(/,/g, ''), 10) : null;
}
export function extractReviewCountFromCardText(text: string | null | undefined): string | null {
const normalized = cleanMultilineText(text);
const match = normalized.match(/out of 5 stars(?:, rating details)?\s*([\d,]+)/i);
if (match) return match[1];
const numericLine = normalized
.split('\n')
.map((line) => cleanText(line))
.find((line) => /^[\d,]+$/.test(line));
return numericLine ?? null;
}
export function isAmazonEntity(text: string | null | undefined): boolean {
const normalized = cleanText(text).toLowerCase();
return normalized.includes('amazon');
}
export function firstMeaningfulLine(text: string | null | undefined): string {
return cleanMultilineText(text)
.split('\n')
.map((line) => cleanText(line))
.find(Boolean)
?? '';
}
export function trimRatingPrefix(text: string | null | undefined): string | null {
const normalized = cleanText(text);
if (!normalized) return null;
return normalized.replace(/^\d+(?:\.\d+)?\s*out of 5 stars\s*/i, '').trim() || normalized;
}
export function isRobotState(state: Partial<PageState>): boolean {
const title = cleanText(state.title);
const bodyText = cleanMultilineText(state.body_text);
return ROBOT_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
}
export function buildChallengeHint(action: string): string {
return [
`Open a clean Amazon ${action} page in the shared Chrome profile and clear any robot check first.`,
'If you are using CDP, set OPENCLI_CDP_TARGET=amazon.com and avoid parallel Amazon commands against the same browser target.',
].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 await 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(
`amazon ${action} navigation lost the current browser target`,
`${buildChallengeHint(action)} If CDP is attached to a stale tab, open a fresh Amazon tab and retry.`,
);
}
throw error;
}
}
export function assertUsableState(state: PageState, action: string): void {
if (!isRobotState(state)) return;
throw new CommandExecutionError(
`amazon ${action} hit a robot check`,
buildChallengeHint(action),
);
}
export const __test__ = {
buildSearchUrl,
extractAsin,
buildProductUrl,
buildDiscussionUrl,
resolveBestsellersUrl,
resolveRankingUrl,
isSupportedRankingPath,
isRankingPaginationUrl,
extractCategoryNodeId,
parsePriceText,
parseRatingValue,
parseReviewCount,
extractReviewCountFromCardText,
isAmazonEntity,
trimRatingPrefix,
isRobotState,
PRIMARY_PRICE_SELECTORS,
};
-61
View File
@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./utils.js')>()),
apiGet: mockApiGet,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './subtitle.js';
describe('bilibili subtitle', () => {
const command = getRegistry().get('bilibili/subtitle');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
} as any;
beforeEach(() => {
mockApiGet.mockReset();
page.goto.mockClear();
page.evaluate.mockReset();
});
it('throws AuthRequiredError when bilibili hides subtitles behind login', async () => {
page.evaluate.mockResolvedValueOnce(123456);
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: true,
subtitle: {
subtitles: [],
},
},
});
await expect(command!.func!(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy((err: Error) =>
err instanceof AuthRequiredError && /login|登录/i.test(err.message),
);
});
it('throws EmptyResultError when a video truly has no subtitles', async () => {
page.evaluate.mockResolvedValueOnce(123456);
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
need_login_subtitle: false,
subtitle: {
subtitles: [],
},
},
});
await expect(command!.func!(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
});
});
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveBvid } from './utils.js';
describe('resolveBvid', () => {
it('passes through a valid BV ID', async () => {
expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
});
it('passes through BV ID with surrounding whitespace', async () => {
expect(await resolveBvid(' BV1MV9NBtENN ')).toBe('BV1MV9NBtENN');
});
it('handles non-string input via String() coercion', async () => {
expect(await resolveBvid('BV123abc' as any)).toBe('BV123abc');
});
it('rejects invalid input that cannot be resolved', async () => {
// A random string that b23.tv won't resolve — should timeout or fail
await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
});
});
-73
View File
@@ -1,73 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './search.js';
describe('ctrip search', () => {
const command = getRegistry().get('ctrip/search');
beforeEach(() => {
vi.unstubAllGlobals();
});
it('maps live endpoint results into ranked rows', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
Response: {
searchResults: [
{
displayName: '苏州, 江苏, 中国',
displayType: '城市',
commentScore: 0,
price: '',
},
{
word: '姑苏区',
type: '行政区',
cStar: 4.8,
minPrice: 320,
},
],
},
}), { status: 200 })));
const result = await command!.func!(null as any, { query: '苏州', limit: 3 });
expect(result).toEqual([
{
rank: 1,
name: '苏州, 江苏, 中国',
type: '城市',
score: 0,
price: '',
url: '',
},
{
rank: 2,
name: '姑苏区',
type: '行政区',
score: 4.8,
price: 320,
url: '',
},
]);
});
it('rejects empty queries', async () => {
await expect(command!.func!(null as any, { query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
});
it('surfaces fetch failures as CliError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 503 })));
await expect(command!.func!(null as any, { query: '苏州', limit: 3 })).rejects.toMatchObject({
code: 'FETCH_ERROR',
message: 'ctrip search failed with status 503',
});
});
it('surfaces empty results as EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
Response: { searchResults: [] },
}), { status: 200 })));
await expect(command!.func!(null as any, { query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
});
});
-111
View File
@@ -1,111 +0,0 @@
/**
* 携程旅行搜索 — public destination and hotel suggestion lookup.
*/
import { ArgumentError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
interface CtripSearchResultItem {
displayName?: string;
word?: string;
cityName?: string;
displayType?: string;
type?: string;
commentScore?: number | string;
cStar?: number | string;
price?: number | string;
minPrice?: number | string;
}
function clampLimit(raw: unknown, fallback = 15): number {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(1, Math.min(Math.floor(parsed), 50));
}
function mapSearchResults(results: unknown[], limit: number) {
return results
.filter((item): item is CtripSearchResultItem => !!item && typeof item === 'object')
.slice(0, limit)
.map((item, index) => ({
rank: index + 1,
name: String(item.displayName || item.word || item.cityName || '').replace(/\s+/g, ' ').trim(),
type: String(item.displayType || item.type || '').replace(/\s+/g, ' ').trim(),
score: item.commentScore ?? item.cStar ?? '',
price: item.price ?? item.minPrice ?? '',
url: '',
}))
.filter((item) => item.name);
}
cli({
site: 'ctrip',
name: 'search',
description: '搜索携程目的地、景区和酒店联想结果',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', required: true, positional: true, help: 'Search keyword (city or attraction)' },
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
],
columns: ['rank', 'name', 'type', 'score', 'price', 'url'],
func: async (_page, kwargs) => {
const query = String(kwargs.query || '').trim();
if (!query) {
throw new ArgumentError('Search keyword cannot be empty');
}
const limit = clampLimit(kwargs.limit);
const response = await fetch('https://m.ctrip.com/restapi/soa2/21881/json/gaHotelSearchEngine', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
keyword: query,
searchType: 'D',
platform: 'online',
pageID: '102001',
head: {
Locale: 'zh-CN',
LocaleController: 'zh_cn',
Currency: 'CNY',
PageId: '102001',
clientID: 'opencli-ctrip-search',
group: 'ctrip',
Frontend: {
sessionID: 1,
pvid: 1,
},
HotelExtension: {
group: 'CTRIP',
WebpSupport: false,
},
},
}),
});
if (!response.ok) {
throw new CliError(
'FETCH_ERROR',
`ctrip search failed with status ${response.status}`,
'Retry the command or verify ctrip.com is reachable',
);
}
const payload = await response.json();
const rawResults = Array.isArray(payload?.Response?.searchResults) ? payload.Response.searchResults : [];
const results = mapSearchResults(rawResults, limit);
if (!results.length) {
throw new EmptyResultError(
'ctrip search',
'Try a destination, scenic spot, or hotel keyword such as "苏州" or "朱家尖"',
);
}
return results;
},
});
export const __test__ = {
clampLimit,
mapSearchResults,
};
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { getSts2Credentials } from './sts2.js';
describe('douyin sts2 credentials', () => {
it('accepts top-level credential fields returned by creator center', async () => {
const page = {
evaluate: async () => ({
access_key_id: 'ak',
secret_access_key: 'sk',
session_token: 'token',
expired_time: 1_234_567_890,
}),
};
await expect(getSts2Credentials(page as never)).resolves.toEqual({
access_key_id: 'ak',
secret_access_key: 'sk',
session_token: 'token',
expired_time: 1_234_567_890,
});
});
it('still rejects responses without credential fields', async () => {
const page = {
evaluate: async () => ({ status_code: 8 }),
};
await expect(getSts2Credentials(page as never)).rejects.toBeInstanceOf(AuthRequiredError);
});
});
-65
View File
@@ -1,65 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { browserFetchMock } = vi.hoisted(() => ({
browserFetchMock: vi.fn(),
}));
vi.mock('./_shared/browser-fetch.js', () => ({
browserFetch: browserFetchMock,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './activities.js';
describe('douyin activities registration', () => {
beforeEach(() => {
browserFetchMock.mockReset();
});
it('registers the activities command', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
expect(cmd).toBeDefined();
});
it('has expected columns', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
expect(cmd?.columns).toContain('activity_id');
expect(cmd?.columns).toContain('title');
expect(cmd?.columns).toContain('end_time');
});
it('uses COOKIE strategy', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
expect(cmd?.strategy).toBe('cookie');
});
it('maps the current activity payload shape returned by creator center', async () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'activities');
expect(cmd?.func).toBeDefined();
if (!cmd?.func) throw new Error('douyin activities command not registered');
browserFetchMock.mockResolvedValueOnce({
activity_list: [
{
activity_id: '200',
activity_name: '超会玩派对',
show_end_time: '2026.05.31',
},
],
});
const rows = await cmd.func({} as never, {});
expect(rows).toEqual([
{
activity_id: '200',
title: '超会玩派对',
end_time: '2026.05.31',
},
]);
});
});
-59
View File
@@ -1,59 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { browserFetchMock } = vi.hoisted(() => ({
browserFetchMock: vi.fn(),
}));
vi.mock('./_shared/browser-fetch.js', () => ({
browserFetch: browserFetchMock,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './collections.js';
describe('douyin collections', () => {
beforeEach(() => {
browserFetchMock.mockReset();
});
it('registers the collections command', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
expect(cmd).toBeDefined();
expect(cmd?.args.some(a => a.name === 'limit')).toBe(true);
});
it('has expected columns', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
expect(cmd?.columns).toContain('mix_id');
expect(cmd?.columns).toContain('name');
expect(cmd?.columns).toContain('item_count');
});
it('uses COOKIE strategy', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
expect(cmd?.strategy).toBe('cookie');
});
it('uses the current mix list request shape', async () => {
const registry = getRegistry();
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'collections');
expect(command?.func).toBeDefined();
if (!command?.func) throw new Error('douyin collections command not registered');
browserFetchMock.mockResolvedValueOnce({
mix_list: [],
});
const rows = await command.func({} as any, { limit: 12 });
expect(browserFetchMock).toHaveBeenCalledWith(
{},
'GET',
'https://creator.douyin.com/web/api/mix/list/?status=0,1,2,3,6&count=12&cursor=0&should_query_new_mix=1&device_platform=web&aid=1128',
);
expect(rows).toEqual([]);
});
});
-452
View File
@@ -1,452 +0,0 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { wrapForEval } from '@jackwener/opencli/browser/utils';
import { getRegistry } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { buildCoverCheckPanelTextJs } from './draft.js';
// ─── Shared test helpers ────────────────────────────────────────────
const tempDirs: string[] = [];
function createTempVideo(name = 'demo.mp4'): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-douyin-draft-'));
tempDirs.push(dir);
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, Buffer.from([0, 0, 0, 20, 102, 116, 121, 112]));
return filePath;
}
function createTempCover(videoPath: string, name = 'cover.jpg'): string {
const filePath = path.join(path.dirname(videoPath), name);
fs.writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
return filePath;
}
function getDraftCommand() {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'draft');
if (!cmd?.func) throw new Error('douyin draft command not registered');
return cmd as typeof cmd & { func: NonNullable<typeof cmd.func> };
}
afterAll(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
type FakeNode = {
textContent: string;
parentElement: FakeNode | null;
querySelectorAll: (_selector: string) => FakeNode[];
};
function createFakeTree(text: string, children: FakeNode[] = []): FakeNode {
const node: FakeNode = {
textContent: text,
parentElement: null,
querySelectorAll: () => [],
};
node.querySelectorAll = () => {
const descendants: FakeNode[] = [];
for (const child of children) {
descendants.push(child, ...child.querySelectorAll('*'));
}
return descendants;
};
for (const child of children) {
child.parentElement = node;
}
return node;
}
function createPageMock(
evaluateResults: unknown[],
overrides: Partial<IPage> = {},
): IPage {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
}
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate,
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
setFileInput: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('douyin draft registration', () => {
it('registers the draft command', () => {
const registry = getRegistry();
const values = [...registry.values()];
const cmd = values.find(c => c.site === 'douyin' && c.name === 'draft');
expect(cmd).toBeDefined();
});
it('extracts the higher quick-check panel instead of stopping at a header-only ancestor', () => {
const marker = createFakeTree('快速检测');
const state = createFakeTree('重新检测');
const header = createFakeTree('快速检测', [marker]);
const status = createFakeTree('重新检测', [state]);
const panel = createFakeTree('快速检测重新检测', [header, status]);
const body = createFakeTree('body', [panel]);
const g = globalThis as unknown as {
document?: { body: FakeNode; querySelectorAll: (_selector: string) => FakeNode[] };
};
const originalDocument = g.document;
g.document = {
body,
querySelectorAll: () => [marker, state],
};
try {
expect((eval(buildCoverCheckPanelTextJs()) as () => string)()).toBe('快速检测重新检测');
} finally {
g.document = originalDocument;
}
});
it('returns empty when only header text exists and no exact quick-check state node is present', () => {
const marker = createFakeTree('快速检测');
const note = createFakeTree('检测说明');
const header = createFakeTree('快速检测检测说明', [marker, note]);
const body = createFakeTree('body', [header]);
const g = globalThis as unknown as {
document?: { body: FakeNode; querySelectorAll: (_selector: string) => FakeNode[] };
};
const originalDocument = g.document;
g.document = {
body,
querySelectorAll: () => [marker, note],
};
try {
expect((eval(buildCoverCheckPanelTextJs()) as () => string)()).toBe('');
} finally {
g.document = originalDocument;
}
});
it('extracts the quick-check panel when busy state is rendered as a single 封面检测中 node', () => {
const marker = createFakeTree('快速检测');
const busy = createFakeTree('封面检测中');
const header = createFakeTree('快速检测', [marker]);
const status = createFakeTree('封面检测中', [busy]);
const panel = createFakeTree('快速检测封面检测中', [header, status]);
const body = createFakeTree('body', [panel]);
const g = globalThis as unknown as {
document?: { body: FakeNode; querySelectorAll: (_selector: string) => FakeNode[] };
};
const originalDocument = g.document;
g.document = {
body,
querySelectorAll: () => [marker, busy],
};
try {
expect((eval(buildCoverCheckPanelTextJs()) as () => string)()).toBe('快速检测封面检测中');
} finally {
g.document = originalDocument;
}
});
it('uploads through the official creator draft page and saves the draft session', async () => {
const cmd = getDraftCommand();
const videoPath = createTempVideo('demo.mp4');
const page = createPageMock(
[
undefined,
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
undefined,
true,
true,
true,
{ ok: true, text: '暂存离开', creationId: 'creation-001' },
{
href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish',
bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃',
},
],
);
const rows = await cmd.func(page, {
video: videoPath,
title: '最小修复验证',
caption: 'opencli draft e2e',
cover: '',
visibility: 'friends',
});
expect(page.goto).toHaveBeenCalledWith(
'https://creator.douyin.com/creator-micro/content/upload',
);
expect(page.wait).toHaveBeenCalledWith({
selector: 'input[type="file"]',
timeout: 20,
});
expect(page.setFileInput).toHaveBeenCalledWith([videoPath], 'input[type="file"]');
const evaluateCalls = (page.evaluate as ReturnType<typeof vi.fn>).mock.calls.map(
(args: unknown[]) => String(args[0]),
);
expect(evaluateCalls.some((code: string) => code.includes('填写作品标题'))).toBe(true);
expect(evaluateCalls.some((code: string) => code.includes('好友可见'))).toBe(true);
expect(evaluateCalls.some((code: string) => code.includes('暂存离开'))).toBe(true);
expect(rows).toEqual([
{
status: '✅ 草稿已保存,可在创作中心继续编辑',
draft_id: 'creation-001',
},
]);
});
it('waits for the composer when upload processing is slower than the first few polls', async () => {
const cmd = getDraftCommand();
const videoPath = createTempVideo('slow.mp4');
const page = createPageMock(
[
undefined,
{ href: 'https://creator.douyin.com/creator-micro/content/upload', ready: false, bodyText: '上传中 42%' },
{ href: 'https://creator.douyin.com/creator-micro/content/upload', ready: false, bodyText: '转码中' },
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
undefined,
true,
true,
{ ok: true, text: '暂存离开', creationId: 'creation-slow' },
{
href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish',
bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃',
},
],
);
const rows = await cmd.func(page, {
video: videoPath,
title: '慢上传验证',
caption: '',
cover: '',
visibility: 'public',
});
expect(rows).toEqual([
{
status: '✅ 草稿已保存,可在创作中心继续编辑',
draft_id: 'creation-slow',
},
]);
expect(page.wait).toHaveBeenCalledWith({ time: 0.5 });
const shortWaitCalls = (page.wait as ReturnType<typeof vi.fn>).mock.calls.filter(
([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 }),
);
expect(shortWaitCalls).toHaveLength(2);
});
it('fails fast when the save action does not expose a draft creation id', async () => {
const cmd = getDraftCommand();
const videoPath = createTempVideo('missing-id.mp4');
const page = createPageMock(
[
undefined,
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
undefined,
true,
true,
{ ok: true, text: '暂存离开', creationId: '' },
],
);
await expect(
cmd.func(page, {
video: videoPath,
title: '缺失 creation id',
caption: '',
cover: '',
visibility: 'public',
}),
).rejects.toThrow('点击草稿按钮失败: creation-id-missing');
});
it('uses the dedicated cover upload input when a custom cover is provided', async () => {
const cmd = getDraftCommand();
const videoPath = createTempVideo('demo.mp4');
const coverPath = createTempCover(videoPath);
const page = createPageMock(
[
undefined,
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
undefined,
1,
{ ok: false, reason: 'cover-input-pending' },
{ ok: true, selector: '[data-opencli-cover-input="1"]' },
'快速检测检测中',
'快速检测重新检测',
true,
true,
{ ok: true, text: '暂存离开', creationId: 'creation-002' },
{
href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish',
bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃',
},
],
);
const rows = await cmd.func(page, {
video: videoPath,
title: '封面上传验证',
caption: '',
cover: coverPath,
visibility: 'public',
});
expect(page.setFileInput).toHaveBeenNthCalledWith(1, [videoPath], 'input[type="file"]');
expect(page.setFileInput).toHaveBeenNthCalledWith(2, [coverPath], '[data-opencli-cover-input="1"]');
const shortWaitCalls = (page.wait as ReturnType<typeof vi.fn>).mock.calls.filter(
([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 }),
);
expect(shortWaitCalls).toHaveLength(2);
const evaluateCalls = (page.evaluate as ReturnType<typeof vi.fn>).mock.calls.map(
(args: unknown[]) => String(args[0]),
);
expect(evaluateCalls.some((code: string) => code.includes('上传新封面'))).toBe(true);
expect(evaluateCalls.some((code: string) => code.includes("text.includes('快速检测检测')"))).toBe(false);
expect(() => {
for (const code of evaluateCalls) {
new Function(wrapForEval(code));
}
}).not.toThrow();
expect(rows).toEqual([
{
status: '✅ 草稿已保存,可在创作中心继续编辑',
draft_id: 'creation-002',
},
]);
});
it('waits for a late cover-section update before treating the custom cover as ready', async () => {
const cmd = getDraftCommand();
const videoPath = createTempVideo('cover-race.mp4');
const coverPath = createTempCover(videoPath, 'cover-race.jpg');
const page = createPageMock(
[
undefined,
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
undefined,
1,
{ ok: true, selector: '[data-opencli-cover-input="1"]' },
'快速检测重新检测',
'快速检测重新检测',
'快速检测重新检测',
'快速检测检测中',
'快速检测横/竖双封面缺失',
true,
true,
{ ok: true, text: '暂存离开', creationId: 'creation-cover-race' },
{
href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish',
bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃',
},
],
);
const rows = await cmd.func(page, {
video: videoPath,
title: '封面竞态验证',
caption: '',
cover: coverPath,
visibility: 'public',
});
expect(rows).toEqual([
{
status: '✅ 草稿已保存,可在创作中心继续编辑',
draft_id: 'creation-cover-race',
},
]);
const shortWaitCalls = (page.wait as ReturnType<typeof vi.fn>).mock.calls.filter(
([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 }),
);
expect(shortWaitCalls).toHaveLength(4);
});
it('accepts the same ready label after cover busy state when the quick-check panel actually transitioned', async () => {
const cmd = getDraftCommand();
const videoPath = createTempVideo('cover-same-ready.mp4');
const coverPath = createTempCover(videoPath, 'cover-same-ready.jpg');
const page = createPageMock(
[
undefined,
{ href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' },
undefined,
1,
{ ok: true, selector: '[data-opencli-cover-input="1"]' },
'快速检测重新检测',
'快速检测重新检测',
'快速检测检测中',
'快速检测重新检测',
true,
true,
{ ok: true, text: '暂存离开', creationId: 'creation-cover-same-ready' },
{
href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish',
bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃',
},
],
);
const rows = await cmd.func(page, {
video: videoPath,
title: '封面同文案验证',
caption: '',
cover: coverPath,
visibility: 'public',
});
expect(rows).toEqual([
{
status: '✅ 草稿已保存,可在创作中心继续编辑',
draft_id: 'creation-cover-same-ready',
},
]);
const shortWaitCalls = (page.wait as ReturnType<typeof vi.fn>).mock.calls.filter(
([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 }),
);
expect(shortWaitCalls).toHaveLength(3);
});
});
-446
View File
@@ -1,446 +0,0 @@
/**
* Douyin draft — upload through the official creator page and save as draft.
*
* The previous API pipeline relied on an old pre-upload endpoint that no longer
* matches creator center's live upload flow. This command now drives the
* official upload page directly so it stays aligned with the site.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import type { IPage } from '@jackwener/opencli/types';
const VISIBILITY_LABELS: Record<string, string> = {
public: '公开',
friends: '好友可见',
private: '仅自己可见',
};
const DRAFT_UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload';
const COMPOSER_WAIT_ATTEMPTS = 120;
const COVER_INPUT_WAIT_ATTEMPTS = 20;
const COVER_READY_WAIT_ATTEMPTS = 20;
interface DraftComposerState {
href: string;
ready: boolean;
bodyText: string;
}
interface DraftSaveResult {
text: string;
creationId: string;
}
interface DraftResumeState {
href: string;
bodyText: string;
}
/**
* Best-effort dismissal for coach marks and upload tips that can block clicks.
*/
async function dismissKnownModals(page: IPage): Promise<void> {
await page.evaluate(`() => {
const targets = ['我知道了', '知道了', '关闭'];
for (const text of targets) {
const btn = Array.from(document.querySelectorAll('button,[role="button"]'))
.find((el) => (el.textContent || '').trim() === text);
if (btn instanceof HTMLElement) btn.click();
}
}`);
}
/**
* Wait until Douyin finishes uploading and lands on the post-video composer.
*/
async function waitForDraftComposer(page: IPage): Promise<void> {
let lastState: DraftComposerState = {
href: '',
ready: false,
bodyText: '',
};
for (let attempt = 0; attempt < COMPOSER_WAIT_ATTEMPTS; attempt += 1) {
lastState = (await page.evaluate(`() => ({
href: location.href,
ready: !!Array.from(document.querySelectorAll('input')).find(
(el) => (el.placeholder || '').includes('填写作品标题')
) && !!Array.from(document.querySelectorAll('button')).find(
(el) => (el.textContent || '').includes('暂存离开')
),
bodyText: document.body?.innerText || ''
})`)) as DraftComposerState;
if (lastState.ready) return;
await page.wait({ time: 0.5 });
}
throw new CommandExecutionError(
'等待抖音草稿编辑页超时',
`当前页面: ${lastState.href || 'unknown'}`,
);
}
/**
* Fill title, caption and visibility controls on the live composer page.
*/
async function fillDraftComposer(
page: IPage,
options: { title: string; caption: string; visibilityLabel: string },
): Promise<void> {
const titleOk = (await page.evaluate(`() => {
const titleInput = Array.from(document.querySelectorAll('input')).find(
(el) => (el.placeholder || '').includes('填写作品标题')
);
if (!(titleInput instanceof HTMLInputElement)) return false;
const propKey = Object.keys(titleInput).find((key) => key.startsWith('__reactProps$'));
const props = propKey ? titleInput[propKey] : null;
if (props?.onChange) {
props.onChange({
target: { value: ${JSON.stringify(options.title)} },
currentTarget: { value: ${JSON.stringify(options.title)} },
});
} else {
titleInput.focus();
titleInput.value = ${JSON.stringify(options.title)};
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
titleInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (props?.onBlur) {
props.onBlur({
target: titleInput,
currentTarget: titleInput,
relatedTarget: null,
});
} else {
titleInput.dispatchEvent(new Event('blur', { bubbles: true }));
}
return true;
}`)) as boolean;
if (!titleOk) {
throw new CommandExecutionError(
'填写抖音草稿表单失败: title-input-missing',
);
}
if (options.caption) {
const captionOk = (await page.evaluate(`() => {
const editor = document.querySelector('[contenteditable="true"]');
if (!(editor instanceof HTMLElement)) return false;
editor.focus();
editor.textContent = '';
document.execCommand('selectAll', false);
document.execCommand('insertText', false, ${JSON.stringify(options.caption)});
editor.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}`)) as boolean;
if (!captionOk) {
throw new CommandExecutionError(
'填写抖音草稿表单失败: caption-editor-missing',
);
}
}
const visibilityOk = (await page.evaluate(`() => {
const visibility = Array.from(document.querySelectorAll('label')).find(
(el) => (el.textContent || '').includes(${JSON.stringify(options.visibilityLabel)})
);
if (!(visibility instanceof HTMLElement)) return false;
visibility.click();
return true;
}`)) as boolean;
if (!visibilityOk) {
throw new CommandExecutionError(
'填写抖音草稿表单失败: visibility-missing',
);
}
}
/**
* Switch the composer into custom-cover mode and expose the cover input with a
* stable selector for CDP file injection.
*/
async function prepareCustomCoverInput(page: IPage): Promise<string> {
let lastReason = 'cover-input-missing';
const baselineCount = (await page.evaluate(
`() => Array.from(document.querySelectorAll('input[type="file"]')).length`,
)) as number;
for (let attempt = 0; attempt < COVER_INPUT_WAIT_ATTEMPTS; attempt += 1) {
const result = (await page.evaluate(`() => {
const coverLabel = Array.from(document.querySelectorAll('label')).find(
(el) => (el.textContent || '').includes('上传新封面')
);
if (coverLabel instanceof HTMLElement) {
coverLabel.click();
}
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
const target = inputs
.slice(${JSON.stringify(baselineCount)})
.find((el) => el instanceof HTMLInputElement && !el.disabled);
if (!(target instanceof HTMLInputElement)) {
return { ok: false, reason: 'cover-input-pending' };
}
document
.querySelectorAll('[data-opencli-cover-input="1"]')
.forEach((el) => el.removeAttribute('data-opencli-cover-input'));
target.setAttribute('data-opencli-cover-input', '1');
return { ok: true, selector: '[data-opencli-cover-input="1"]' };
}`)) as { ok?: boolean; reason?: string; selector?: string };
if (result?.ok && result.selector) {
return result.selector;
}
lastReason = result?.reason || lastReason;
await page.wait({ time: 0.5 });
}
throw new CommandExecutionError(
`准备抖音自定义封面输入框失败: ${lastReason}`,
);
}
/**
* Read the local quick-check panel text that reflects cover validation state.
*/
export function buildCoverCheckPanelTextJs(): string {
return `() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const stateTexts = ['检测', '检测中', '封面检测中', '重新检测', '横/竖双封面缺失'];
const marker = Array.from(document.querySelectorAll('div,span,p,button')).find(
(el) => normalize(el.textContent) === '快速检测'
);
let root = marker?.parentElement || null;
while (root && root !== document.body) {
const descendants = Array.from(root.querySelectorAll('div,span,p,button'))
.map((el) => normalize(el.textContent));
const hasMarkerText = descendants.includes('快速检测');
const hasStateText = descendants.some((text) => stateTexts.includes(text));
if (hasMarkerText && hasStateText) {
return normalize(root.textContent).slice(0, 400);
}
root = root.parentElement;
}
return '';
}`;
}
async function getCoverCheckPanelText(page: IPage): Promise<string> {
return ((await page.evaluate(buildCoverCheckPanelTextJs())) as string) || '';
}
/**
* Wait for Douyin's cover-detection pipeline to expose a post-upload signal.
* In the live creator page, custom cover upload first shows `封面检测中`, then
* lands on a ready state such as `重新检测` or the warning copy for missing
* horizontal/vertical covers.
*/
async function waitForCoverReady(
page: IPage,
): Promise<void> {
let lastPanelText = '';
let sawBusy = false;
for (let attempt = 0; attempt < COVER_READY_WAIT_ATTEMPTS; attempt += 1) {
const panelText = await getCoverCheckPanelText(page);
const busy = panelText.includes('检测中');
const ready = (
panelText.includes('重新检测')
|| panelText.includes('横/竖双封面缺失')
);
if (busy) {
sawBusy = true;
}
if (sawBusy && ready && !busy) {
return;
}
lastPanelText = panelText;
await page.wait({ time: 0.5 });
}
throw new CommandExecutionError(
'等待抖音封面处理完成超时',
lastPanelText || 'unknown',
);
}
/**
* Click the draft button on the composer page and extract the current creation id.
*/
async function clickSaveDraft(page: IPage): Promise<DraftSaveResult> {
const result = (await page.evaluate(`() => {
const extractCreationId = () => {
const titleInput = Array.from(document.querySelectorAll('input')).find(
(el) => (el.placeholder || '').includes('填写作品标题')
);
if (!(titleInput instanceof HTMLInputElement)) return '';
const fiberKey = Object.keys(titleInput).find((key) => key.startsWith('__reactFiber$'));
let fiber = fiberKey ? titleInput[fiberKey] : null;
while (fiber) {
const props = fiber.memoizedProps;
if (typeof props?.creation_id === 'string' && props.creation_id) {
return props.creation_id;
}
fiber = fiber.return;
}
return '';
};
const btn = Array.from(document.querySelectorAll('button')).find(
(el) => (el.textContent || '').includes('暂存离开')
);
if (!(btn instanceof HTMLButtonElement)) {
return { ok: false, reason: 'draft-button-missing' };
}
const creationId = extractCreationId();
const propKey = Object.keys(btn).find((key) => key.startsWith('__reactProps$'));
const props = propKey ? btn[propKey] : null;
if (props?.onClick) {
props.onClick({
preventDefault() {},
stopPropagation() {},
nativeEvent: null,
target: btn,
currentTarget: btn,
});
} else {
btn.click();
}
return {
ok: true,
text: (btn.textContent || '').trim(),
creationId,
};
}`)) as { ok?: boolean; reason?: string; text?: string; creationId?: string };
if (!result?.ok) {
throw new CommandExecutionError(
`点击草稿按钮失败: ${result?.reason || 'unknown'}`,
);
}
if (!result.creationId) {
throw new CommandExecutionError(
'点击草稿按钮失败: creation-id-missing',
);
}
return {
text: result.text || '暂存离开',
creationId: result.creationId,
};
}
/**
* Wait until creator center shows the resumable-draft prompt after saving.
*/
async function waitForDraftResult(
page: IPage,
creationId: string,
): Promise<string> {
let lastState: DraftResumeState = { href: '', bodyText: '' };
for (let attempt = 0; attempt < 20; attempt += 1) {
lastState = (await page.evaluate(`() => ({
href: location.href,
bodyText: document.body?.innerText || ''
})`)) as { href: string; bodyText: string };
if (
lastState.href.includes('/creator-micro/content/upload')
&& /继续编辑/.test(lastState.bodyText)
) {
return creationId;
}
await page.wait({ time: 1 });
}
throw new CommandExecutionError(
'未检测到抖音草稿恢复提示',
`当前页面: ${lastState.href || 'unknown'}`,
);
}
cli({
site: 'douyin',
name: 'draft',
description: '上传视频并保存为草稿',
domain: 'creator.douyin.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{ name: 'video', required: true, positional: true, help: '视频文件路径' },
{ name: 'title', required: true, help: '视频标题(≤30字)' },
{ name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
{ name: 'cover', default: '', help: '封面图片路径' },
{ name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
],
columns: ['status', 'draft_id'],
func: async (page: IPage, kwargs) => {
const videoPath = path.resolve(kwargs.video as string);
if (!fs.existsSync(videoPath)) {
throw new ArgumentError(`视频文件不存在: ${videoPath}`);
}
const ext = path.extname(videoPath).toLowerCase();
if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) {
throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm`);
}
const title = kwargs.title as string;
if (title.length > 30) {
throw new ArgumentError('标题不能超过 30 字');
}
const caption = (kwargs.caption as string) || '';
if (caption.length > 1000) {
throw new ArgumentError('正文不能超过 1000 字');
}
const coverPath = kwargs.cover as string;
if (coverPath) {
if (!fs.existsSync(path.resolve(coverPath))) {
throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
}
}
if (!page.setFileInput) {
throw new CommandExecutionError(
'当前浏览器适配器不支持文件注入',
'请使用 Browser Bridge 或支持 setFileInput 的浏览器模式',
);
}
const visibilityLabel = VISIBILITY_LABELS[kwargs.visibility as string] ?? VISIBILITY_LABELS.public;
await page.goto(DRAFT_UPLOAD_URL);
await page.wait({ selector: 'input[type="file"]', timeout: 20 });
await dismissKnownModals(page);
await page.setFileInput([videoPath], 'input[type="file"]');
await waitForDraftComposer(page);
await dismissKnownModals(page);
if (coverPath) {
const coverSelector = await prepareCustomCoverInput(page);
await page.setFileInput([path.resolve(coverPath)], coverSelector);
await waitForCoverReady(page);
}
await fillDraftComposer(page, { title, caption, visibilityLabel });
await page.wait({ time: 1 });
const saveResult = await clickSaveDraft(page);
const draftId = await waitForDraftResult(page, saveResult.creationId);
return [
{
status: '✅ 草稿已保存,可在创作中心继续编辑',
draft_id: draftId,
},
];
},
});
-68
View File
@@ -1,68 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { browserFetchMock } = vi.hoisted(() => ({
browserFetchMock: vi.fn(),
}));
vi.mock('./_shared/browser-fetch.js', () => ({
browserFetch: browserFetchMock,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './hashtag.js';
describe('douyin hashtag', () => {
beforeEach(() => {
browserFetchMock.mockReset();
});
it('registers the hashtag command', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
expect(cmd).toBeDefined();
expect(cmd?.args.some(a => a.name === 'action')).toBe(true);
});
it('has all expected args', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
const argNames = cmd?.args.map(a => a.name) ?? [];
expect(argNames).toContain('action');
expect(argNames).toContain('keyword');
expect(argNames).toContain('cover');
expect(argNames).toContain('limit');
});
it('uses COOKIE strategy', () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
expect(cmd?.strategy).toBe('cookie');
});
it('parses the current hotspot recommendation shape', async () => {
const registry = getRegistry();
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'hashtag');
expect(command?.func).toBeDefined();
if (!command?.func) throw new Error('douyin hashtag command not registered');
browserFetchMock.mockResolvedValueOnce({
all_sentences: [
{
word: '在公园花海里大晒一场',
hot_value: 12141172,
sentence_id: '2448416',
},
],
});
const rows = await command.func({} as any, { action: 'hot', keyword: '', limit: 5 });
expect(rows).toEqual([
{
name: '在公园花海里大晒一场',
id: '2448416',
view_count: 12141172,
},
]);
});
});
-54
View File
@@ -1,54 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { browserFetchMock } = vi.hoisted(() => ({
browserFetchMock: vi.fn(),
}));
vi.mock('./_shared/browser-fetch.js', () => ({
browserFetch: browserFetchMock,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './profile.js';
describe('douyin profile registration', () => {
beforeEach(() => {
browserFetchMock.mockReset();
});
it('registers the profile command', () => {
const registry = getRegistry();
const values = [...registry.values()];
const cmd = values.find(c => c.site === 'douyin' && c.name === 'profile');
expect(cmd).toBeDefined();
});
it('maps the current user payload shape returned by creator center', async () => {
const registry = getRegistry();
const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'profile');
expect(cmd?.func).toBeDefined();
if (!cmd?.func) throw new Error('douyin profile command not registered');
browserFetchMock.mockResolvedValueOnce({
user: {
uid: '100',
nickname: 'creator',
follower_count: 12,
following_count: 3,
aweme_count: 7,
},
});
const rows = await cmd.func({} as never, {});
expect(rows).toEqual([
{
uid: '100',
nickname: 'creator',
follower_count: 12,
following_count: 3,
aweme_count: 7,
},
]);
});
});
-62
View File
@@ -1,62 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { browserFetchMock } = vi.hoisted(() => ({
browserFetchMock: vi.fn(),
}));
vi.mock('./_shared/browser-fetch.js', () => ({
browserFetch: browserFetchMock,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './videos.js';
describe('douyin videos', () => {
beforeEach(() => {
browserFetchMock.mockReset();
});
it('registers the videos command', () => {
const registry = getRegistry();
const values = [...registry.values()];
const cmd = values.find(c => c.site === 'douyin' && c.name === 'videos');
expect(cmd).toBeDefined();
});
it('parses the current creator work_list api shape', async () => {
const registry = getRegistry();
const command = [...registry.values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'videos');
expect(command?.func).toBeDefined();
if (!command?.func) throw new Error('douyin videos command not registered');
browserFetchMock.mockResolvedValueOnce({
aweme_list: [
{
aweme_id: '7000000000000000001',
desc: '测试视频标题',
create_time: 1581571130,
statistics: {
play_count: 0,
digg_count: 12,
},
status: {
is_private: true,
},
},
],
});
const rows = await command.func({} as any, { limit: 5, page: 1, status: 'all' });
expect(rows).toEqual([
{
aweme_id: '7000000000000000001',
title: '测试视频标题',
status: 'private',
play_count: 0,
digg_count: 12,
create_time: new Date(1581571130 * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
},
]);
});
});
-83
View File
@@ -1,83 +0,0 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { browserFetch } from './_shared/browser-fetch.js';
import type { IPage } from '@jackwener/opencli/types';
interface LegacyWorkItem {
aweme_id: string;
desc: string;
status: number;
public_time: number;
create_time: number;
statistics: { play_count: number; digg_count: number };
}
interface CurrentWorkItem {
aweme_id: string;
desc?: string;
status?: {
in_reviewing?: boolean;
is_private?: boolean;
is_delete?: boolean;
is_prohibited?: boolean;
};
public_time?: number;
create_time?: number;
statistics?: { play_count?: number; digg_count?: number };
}
function normalizeVideoStatus(
status: number | {
in_reviewing?: boolean;
is_private?: boolean;
is_delete?: boolean;
is_prohibited?: boolean;
} | undefined,
publicTime: number | undefined,
): number | string {
if (typeof status === 'number') return status;
if (!status) return publicTime && publicTime > Date.now() / 1000 ? 'scheduled' : 'published';
if (status.is_delete) return 'deleted';
if (status.is_prohibited) return 'prohibited';
if (status.in_reviewing) return 'reviewing';
if (status.is_private) return 'private';
if (publicTime && publicTime > Date.now() / 1000) return 'scheduled';
return 'published';
}
cli({
site: 'douyin',
name: 'videos',
description: '获取作品列表',
domain: 'creator.douyin.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 20, help: '每页数量' },
{ name: 'page', type: 'int', default: 1, help: '页码' },
{ name: 'status', default: 'all', choices: ['all', 'published', 'reviewing', 'scheduled'] },
],
columns: ['aweme_id', 'title', 'status', 'play_count', 'digg_count', 'create_time'],
func: async (page: IPage, kwargs) => {
const statusMap: Record<string, number> = { all: 0, published: 1, reviewing: 3, scheduled: 0 };
const statusNum = statusMap[kwargs.status as string] ?? 0;
const url = `https://creator.douyin.com/janus/douyin/creator/pc/work_list?page_size=${kwargs.limit}&page_num=${kwargs.page}&status=${statusNum}`;
const res = (await browserFetch(page, 'GET', url)) as {
data?: { work_list?: LegacyWorkItem[] };
aweme_list?: CurrentWorkItem[];
};
let items: Array<LegacyWorkItem | CurrentWorkItem> = res.data?.work_list ?? res.aweme_list ?? [];
// The API has a bug with status=16 for scheduled, so filter client-side
if (kwargs.status === 'scheduled') {
items = items.filter((v) => (v.public_time ?? 0) > Date.now() / 1000);
}
return items.map((v) => ({
aweme_id: v.aweme_id,
title: v.desc ?? '',
status: normalizeVideoStatus(v.status, v.public_time),
play_count: v.statistics?.play_count ?? 0,
digg_count: v.statistics?.digg_count ?? 0,
create_time: new Date((v.create_time ?? v.public_time ?? 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
}));
},
});
-68
View File
@@ -1,68 +0,0 @@
/**
* Regression test for issue #625.
* Facebook search must navigate in the pipeline before DOM extraction.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { describe, expect, it, vi } from 'vitest';
import { executePipeline } from '@jackwener/opencli/pipeline';
import type { IPage } from '@jackwener/opencli/types';
interface YamlCommand {
pipeline?: Array<Record<string, unknown>>;
}
/**
* Minimal browser mock for pipeline execution tests.
* Only methods touched by this adapter path are implemented.
*/
function createMockPage(): IPage {
return {
goto: vi.fn(),
evaluate: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(''),
click: vi.fn(),
typeText: vi.fn(),
pressKey: vi.fn(),
scrollTo: vi.fn(),
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn(),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn(),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue(''),
scroll: vi.fn(),
autoScroll: vi.fn(),
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
};
}
describe('facebook search pipeline', () => {
it('navigates to search results before extracting DOM data', async () => {
// Load the YAML adapter directly so the regression test covers the shipped command definition.
const filePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'search.yaml');
const raw = fs.readFileSync(filePath, 'utf8');
const command = yaml.load(raw) as YamlCommand;
const pipeline = command.pipeline ?? [];
const page = createMockPage();
await executePipeline(page, pipeline, {
args: { query: 'AI agent', limit: 3 },
});
expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.facebook.com');
expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.facebook.com/search/top?q=AI%20agent', {
waitUntil: undefined,
settleMs: 4000,
});
expect(page.evaluate).toHaveBeenCalledTimes(1);
expect(String((page.evaluate as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] ?? '')).not.toContain('window.location.href');
});
});
-116
View File
@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IPage } from '@jackwener/opencli/types';
const baseline = {
turns: [{ Role: 'Assistant', Text: '旧回答' }],
transcriptLines: ['baseline'],
composerHasText: true,
isGenerating: false,
structuredTurnsTrusted: true,
};
const submission = {
snapshot: {
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请只回复:OK' },
],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
},
preSendAssistantCount: 1,
userAnchorTurn: { Role: 'User', Text: '请只回复:OK' },
reason: 'user_turn' as const,
};
const mocks = vi.hoisted(() => ({
readGeminiSnapshot: vi.fn(),
sendGeminiMessage: vi.fn(),
startNewGeminiChat: vi.fn(),
waitForGeminiSubmission: vi.fn(),
waitForGeminiResponse: vi.fn(),
}));
vi.mock('./utils.js', async () => {
const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
return {
...actual,
readGeminiSnapshot: mocks.readGeminiSnapshot,
sendGeminiMessage: mocks.sendGeminiMessage,
startNewGeminiChat: mocks.startNewGeminiChat,
waitForGeminiSubmission: mocks.waitForGeminiSubmission,
waitForGeminiResponse: mocks.waitForGeminiResponse,
};
});
import { askCommand } from './ask.js';
function createPageMock(): IPage {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
nativeType: vi.fn().mockResolvedValue(undefined),
nativeKeyPress: vi.fn().mockResolvedValue(undefined),
} as unknown as IPage;
}
describe('gemini ask orchestration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('captures baseline, sends, waits for confirmed submission, then waits with the remaining timeout', async () => {
vi.spyOn(Date, 'now')
.mockReturnValueOnce(0)
.mockReturnValueOnce(2000);
const page = createPageMock();
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
const result = await askCommand.func!(page, { prompt: '请只回复:OK', timeout: '20', new: 'false' });
expect(mocks.readGeminiSnapshot).toHaveBeenCalledWith(page);
expect(mocks.waitForGeminiSubmission).toHaveBeenCalledWith(page, baseline, 20);
expect(mocks.waitForGeminiResponse).toHaveBeenCalledWith(page, submission, '请只回复:OK', 18);
expect(result).toEqual([{ response: '💬 OK' }]);
});
it('does not spend extra response wait time after submission has already consumed the full timeout budget', async () => {
vi.spyOn(Date, 'now')
.mockReturnValueOnce(0)
.mockReturnValueOnce(20000);
const page = createPageMock();
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
mocks.waitForGeminiResponse.mockResolvedValueOnce('');
await askCommand.func!(page, { prompt: '请只回复:OK', timeout: '20', new: 'false' });
expect(mocks.waitForGeminiResponse).toHaveBeenCalledWith(page, submission, '请只回复:OK', 0);
});
});
-53
View File
@@ -1,53 +0,0 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { GEMINI_DOMAIN, readGeminiSnapshot, sendGeminiMessage, startNewGeminiChat, waitForGeminiResponse, waitForGeminiSubmission } from './utils.js';
function normalizeBooleanFlag(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
const NO_RESPONSE_PREFIX = '[NO RESPONSE]';
export const askCommand = cli({
site: 'gemini',
name: 'ask',
description: 'Send a prompt to Gemini and return only the assistant response',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
timeoutSeconds: 180,
args: [
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
{ name: 'timeout', required: false, help: 'Max seconds to wait (default: 60)', default: '60' },
{ name: 'new', required: false, help: 'Start a new chat first (true/false, default: false)', default: 'false' },
],
columns: ['response'],
func: async (page: IPage, kwargs: any) => {
const prompt = kwargs.prompt as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 60;
const startFresh = normalizeBooleanFlag(kwargs.new);
if (startFresh) await startNewGeminiChat(page);
const before = await readGeminiSnapshot(page);
await sendGeminiMessage(page, prompt);
const submissionStartedAt = Date.now();
const submitted = await waitForGeminiSubmission(page, before, timeout);
if (!submitted) {
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
}
const remainingTimeoutSeconds = Math.max(0, timeout - Math.ceil((Date.now() - submissionStartedAt) / 1000));
const response = await waitForGeminiResponse(page, submitted, prompt, remainingTimeoutSeconds);
if (!response) {
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
}
return [{ response: `💬 ${response}` }];
},
});
-206
View File
@@ -1,206 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IPage } from '@jackwener/opencli/types';
const {
mockClickGeminiConversationByTitle,
mockExportGeminiDeepResearchReport,
mockGetGeminiConversationList,
mockGetGeminiPageState,
mockGetLatestGeminiAssistantResponse,
mockReadGeminiSnapshot,
mockResolveGeminiConversationForQuery,
mockWaitForGeminiTranscript,
} = vi.hoisted(() => ({
mockClickGeminiConversationByTitle: vi.fn(),
mockExportGeminiDeepResearchReport: vi.fn(),
mockGetGeminiConversationList: vi.fn(),
mockGetGeminiPageState: vi.fn(),
mockGetLatestGeminiAssistantResponse: vi.fn(),
mockReadGeminiSnapshot: vi.fn(),
mockResolveGeminiConversationForQuery: vi.fn(),
mockWaitForGeminiTranscript: vi.fn(),
}));
vi.mock('./utils.js', () => ({
GEMINI_DOMAIN: 'gemini.google.com',
clickGeminiConversationByTitle: mockClickGeminiConversationByTitle,
exportGeminiDeepResearchReport: mockExportGeminiDeepResearchReport,
getGeminiConversationList: mockGetGeminiConversationList,
getGeminiPageState: mockGetGeminiPageState,
getLatestGeminiAssistantResponse: mockGetLatestGeminiAssistantResponse,
readGeminiSnapshot: mockReadGeminiSnapshot,
parseGeminiConversationUrl: (value: unknown) => {
const raw = String(value ?? '').trim();
return raw.startsWith('https://gemini.google.com/app/') ? raw : null;
},
parseGeminiTitleMatchMode: (value: unknown) => {
const raw = String(value ?? 'contains').trim().toLowerCase();
if (raw === 'contains' || raw === 'exact') return raw;
return null;
},
resolveGeminiConversationForQuery: mockResolveGeminiConversationForQuery,
waitForGeminiTranscript: mockWaitForGeminiTranscript,
}));
import { deepResearchResultCommand } from './deep-research-result.js';
describe('gemini/deep-research-result', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
} as unknown as IPage;
beforeEach(() => {
vi.clearAllMocks();
mockGetGeminiPageState.mockResolvedValue({ isSignedIn: true });
mockGetGeminiConversationList.mockResolvedValue([{ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }]);
mockResolveGeminiConversationForQuery.mockReturnValue({ Title: 'A title', Url: 'https://gemini.google.com/app/abc' });
mockClickGeminiConversationByTitle.mockResolvedValue(true);
mockWaitForGeminiTranscript.mockResolvedValue(['line']);
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: 'https://files.example.com/report.md', source: 'network' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('Final answer');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
});
it('uses latest conversation when query is empty', async () => {
const result = await deepResearchResultCommand.func!(page, { query: ' ' });
expect(page.goto).toHaveBeenCalledWith('https://gemini.google.com/app/abc', { waitUntil: 'load', settleMs: 2500 });
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});
it('falls back to current page response when query is empty and sidebar has no conversations', async () => {
mockGetGeminiConversationList.mockResolvedValue([]);
mockResolveGeminiConversationForQuery.mockReturnValue(null);
const result = await deepResearchResultCommand.func!(page, { query: '' });
expect(page.goto).not.toHaveBeenCalled();
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});
it('returns a validation message when match mode is invalid', async () => {
const result = await deepResearchResultCommand.func!(page, { query: 'A', match: 'prefix' });
expect(result).toEqual([{ response: 'Invalid match mode. Use contains or exact.' }]);
});
it('returns a signed-out message when Gemini page state indicates logged out', async () => {
mockGetGeminiPageState.mockResolvedValue({ isSignedIn: false });
const result = await deepResearchResultCommand.func!(page, { query: 'A' });
expect(result).toEqual([{ response: 'Not signed in to Gemini.' }]);
});
it('opens matched conversation by URL and returns exported report url', async () => {
const result = await deepResearchResultCommand.func!(page, { query: 'A title', match: 'exact' });
expect(page.goto).toHaveBeenCalledWith('https://gemini.google.com/app/abc', { waitUntil: 'load', settleMs: 2500 });
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});
it('accepts a direct conversation URL and reads response from that page', async () => {
const url = 'https://gemini.google.com/app/direct-id';
const result = await deepResearchResultCommand.func!(page, { query: url, match: 'contains' });
expect(page.goto).toHaveBeenCalledWith(url, { waitUntil: 'load', settleMs: 2500 });
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});
it('passes query and mode into resolveGeminiConversationForQuery', async () => {
const result = await deepResearchResultCommand.func!(page, { query: 'title', match: 'contains' });
expect(mockResolveGeminiConversationForQuery).toHaveBeenCalledWith(
[{ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }],
'title',
'contains',
);
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});
it('falls back to click-by-title and returns not-found when click fails', async () => {
mockResolveGeminiConversationForQuery.mockReturnValue(null);
mockClickGeminiConversationByTitle.mockResolvedValue(false);
const result = await deepResearchResultCommand.func!(page, { query: 'missing', match: 'contains' });
expect(result).toEqual([{ response: 'No conversation matched: missing' }]);
});
it('returns pending message when export url is unavailable and completion is not confirmed', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
const result = await deepResearchResultCommand.func!(page, { query: 'A title' });
expect(result).toEqual([{ response: 'Deep Research may still be running or preparing export. Please wait and retry later.' }]);
});
it('returns waiting message when deep research is still generating', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
});
const result = await deepResearchResultCommand.func!(page, { query: 'A title' });
expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]);
});
it('returns waiting message when assistant response indicates research in progress', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('正在研究中,请稍候。');
const result = await deepResearchResultCommand.func!(page, { query: 'A title' });
expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]);
});
it('returns waiting message when transcript indicates in-progress status', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: ['生成研究计划中,请稍候。'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await deepResearchResultCommand.func!(page, { query: 'A title' });
expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]);
});
it('returns no-docs message when text indicates completed state', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('Researching websites... Completed');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await deepResearchResultCommand.func!(page, { query: 'A title' });
expect(result).toEqual([{ response: 'No Docs URL found. Please check Share & Export -> Export to Docs in Gemini UI.' }]);
});
it('returns pending message when assistant response is empty', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('');
const result = await deepResearchResultCommand.func!(page, { query: 'A title' });
expect(result).toEqual([{ response: 'Deep Research may still be running or preparing export. Please wait and retry later.' }]);
});
});
-116
View File
@@ -1,116 +0,0 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
GEMINI_DOMAIN,
clickGeminiConversationByTitle,
exportGeminiDeepResearchReport,
getLatestGeminiAssistantResponse,
getGeminiPageState,
parseGeminiConversationUrl,
parseGeminiTitleMatchMode,
readGeminiSnapshot,
resolveGeminiConversationForQuery,
waitForGeminiTranscript,
getGeminiConversationList,
} from './utils.js';
const DEEP_RESEARCH_WAITING_MESSAGE = 'Deep Research is still running. Please wait and retry later.';
const DEEP_RESEARCH_NO_DOCS_MESSAGE = 'No Docs URL found. Please check Share & Export -> Export to Docs in Gemini UI.';
const DEEP_RESEARCH_PENDING_MESSAGE = 'Deep Research may still be running or preparing export. Please wait and retry later.';
function isDeepResearchInProgress(text: string): boolean {
return /\bresearching(?:\s+websites?)?\b|research in progress|working on your research|generating research plan|gathering sources|creating report|planning research|正在研究|研究中|调研中|生成研究计划|搜集资料|请稍候|稍候|请等待/i.test(text);
}
function isDeepResearchCompleted(text: string): boolean {
return /\bcompleted\b|research complete|completed research|report completed|已完成|研究完成|完成了研究|报告已完成/i.test(text);
}
async function resolveDeepResearchExportResponse(page: IPage, timeoutSeconds: number): Promise<string> {
const exported = await exportGeminiDeepResearchReport(page, timeoutSeconds);
if (exported.url) return exported.url;
const snapshot = await readGeminiSnapshot(page).catch(() => null);
if (snapshot?.isGenerating) return DEEP_RESEARCH_WAITING_MESSAGE;
const latest = await getLatestGeminiAssistantResponse(page).catch(() => '');
const turnTail = Array.isArray(snapshot?.turns)
? snapshot.turns.slice(-6).map((turn) => String(turn?.Text ?? '')).join('\n')
: '';
const transcriptTail = Array.isArray(snapshot?.transcriptLines)
? snapshot.transcriptLines.slice(-30).join('\n')
: '';
const statusText = [latest, turnTail, transcriptTail]
.map((value) => String(value ?? '').trim())
.filter(Boolean)
.join('\n');
if (statusText && isDeepResearchInProgress(statusText) && !isDeepResearchCompleted(statusText)) {
return DEEP_RESEARCH_WAITING_MESSAGE;
}
if (statusText && isDeepResearchCompleted(statusText)) {
return DEEP_RESEARCH_NO_DOCS_MESSAGE;
}
return DEEP_RESEARCH_PENDING_MESSAGE;
}
export const deepResearchResultCommand = cli({
site: 'gemini',
name: 'deep-research-result',
description: 'Export Deep Research report URL from a Gemini conversation',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
args: [
{ name: 'query', positional: true, required: false, help: 'Conversation title or URL (optional; defaults to latest conversation)' },
{ name: 'match', required: false, default: 'contains', choices: ['contains', 'exact'], help: 'Match mode' },
{ name: 'timeout', type: 'int', required: false, default: 120, help: 'Max seconds to wait for Docs export (default: 120)' },
],
columns: ['response'],
func: async (page: IPage, kwargs: any) => {
const query = String(kwargs.query ?? '').trim();
const matchMode = parseGeminiTitleMatchMode(kwargs.match);
const timeoutRaw = Number.parseInt(String(kwargs.timeout ?? ''), 10);
const timeoutSeconds = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 120;
if (!matchMode) {
return [{ response: 'Invalid match mode. Use contains or exact.' }];
}
const state = await getGeminiPageState(page);
if (state.isSignedIn === false) {
return [{ response: 'Not signed in to Gemini.' }];
}
const conversationUrl = parseGeminiConversationUrl(query);
if (conversationUrl) {
await page.goto(conversationUrl, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
await waitForGeminiTranscript(page);
return [{ response: await resolveDeepResearchExportResponse(page, timeoutSeconds) }];
}
const conversations = await getGeminiConversationList(page);
const picked = resolveGeminiConversationForQuery(conversations, query, matchMode);
if (picked?.Url) {
await page.goto(picked.Url, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
await waitForGeminiTranscript(page);
} else if (query) {
if (matchMode === 'exact') {
return [{ response: `No conversation matched: ${query}` }];
}
const clicked = await clickGeminiConversationByTitle(page, query);
if (!clicked) {
return [{ response: `No conversation matched: ${query}` }];
}
await waitForGeminiTranscript(page);
}
return [{ response: await resolveDeepResearchExportResponse(page, timeoutSeconds) }];
},
});
-242
View File
@@ -1,242 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IPage } from '@jackwener/opencli/types';
const {
mockGetCurrentGeminiUrl,
mockReadGeminiSnapshot,
mockSelectGeminiTool,
mockSendGeminiMessage,
mockStartNewGeminiChat,
mockWaitForGeminiSubmission,
mockWaitForGeminiConfirmButton,
mockGetLatestGeminiAssistantResponse,
} = vi.hoisted(() => ({
mockGetCurrentGeminiUrl: vi.fn<() => Promise<string>>(),
mockReadGeminiSnapshot: vi.fn<() => Promise<any>>(),
mockSelectGeminiTool: vi.fn<() => Promise<string>>(),
mockSendGeminiMessage: vi.fn<() => Promise<void>>(),
mockStartNewGeminiChat: vi.fn<() => Promise<void>>(),
mockWaitForGeminiSubmission: vi.fn<() => Promise<any>>(),
mockWaitForGeminiConfirmButton: vi.fn<() => Promise<string>>(),
mockGetLatestGeminiAssistantResponse: vi.fn<() => Promise<string>>(),
}));
vi.mock('./utils.js', () => ({
GEMINI_DOMAIN: 'gemini.google.com',
GEMINI_APP_URL: 'https://gemini.google.com/app',
GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS: ['Deep Research', 'Deep research', '\u6df1\u5ea6\u7814\u7a76'],
GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS: [
'Start research',
'Start Research',
'Start deep research',
'Start Deep Research',
'Generate research plan',
'Generate Research Plan',
'Generate deep research plan',
'Generate Deep Research Plan',
'\u5f00\u59cb\u7814\u7a76',
'\u5f00\u59cb\u6df1\u5ea6\u7814\u7a76',
'\u5f00\u59cb\u8c03\u7814',
'\u751f\u6210\u7814\u7a76\u8ba1\u5212',
'\u751f\u6210\u8c03\u7814\u8ba1\u5212',
],
parseGeminiPositiveInt: (value: unknown, fallback: number) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
},
resolveGeminiLabels: (value: unknown, fallback: string[]) => {
const label = String(value ?? '').trim();
return label ? [label] : fallback;
},
getCurrentGeminiUrl: mockGetCurrentGeminiUrl,
getLatestGeminiAssistantResponse: mockGetLatestGeminiAssistantResponse,
readGeminiSnapshot: mockReadGeminiSnapshot,
selectGeminiTool: mockSelectGeminiTool,
sendGeminiMessage: mockSendGeminiMessage,
startNewGeminiChat: mockStartNewGeminiChat,
waitForGeminiSubmission: mockWaitForGeminiSubmission,
waitForGeminiConfirmButton: mockWaitForGeminiConfirmButton,
}));
import { deepResearchCommand } from './deep-research.js';
describe('gemini/deep-research', () => {
const page = {} as IPage;
beforeEach(() => {
vi.clearAllMocks();
mockGetCurrentGeminiUrl.mockResolvedValue('https://gemini.google.com/app/chat');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
mockSelectGeminiTool.mockResolvedValue('Deep Research');
mockSendGeminiMessage.mockResolvedValue();
mockStartNewGeminiChat.mockResolvedValue();
mockWaitForGeminiSubmission.mockResolvedValue({
snapshot: { turns: [], transcriptLines: [], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true },
preSendAssistantCount: 0,
userAnchorTurn: null,
reason: 'user_turn',
});
mockWaitForGeminiConfirmButton.mockResolvedValue('Start research');
mockGetLatestGeminiAssistantResponse.mockResolvedValue('');
});
it('starts a new chat by default, then sends prompt and confirms deep research', async () => {
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(mockStartNewGeminiChat).toHaveBeenCalledTimes(1);
expect(mockSelectGeminiTool).toHaveBeenCalledTimes(1);
expect(mockSendGeminiMessage).toHaveBeenCalledWith(page, 'research this topic');
expect(mockWaitForGeminiSubmission).toHaveBeenCalledTimes(1);
expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledWith(
page,
expect.arrayContaining(['Start research', 'Start deep research', 'Generate research plan', '\u751f\u6210\u7814\u7a76\u8ba1\u5212']),
30,
);
expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/chat' }]);
});
it('returns tool-not-found when the tool cannot be selected', async () => {
mockSelectGeminiTool.mockResolvedValue('');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(result).toEqual([{ status: 'tool-not-found', url: 'https://gemini.google.com/app/chat' }]);
expect(mockSendGeminiMessage).not.toHaveBeenCalled();
expect(mockWaitForGeminiSubmission).not.toHaveBeenCalled();
expect(mockWaitForGeminiConfirmButton).not.toHaveBeenCalled();
});
it('retries send once when first submission cannot be confirmed', async () => {
mockWaitForGeminiSubmission.mockResolvedValueOnce(null).mockResolvedValueOnce({
snapshot: { turns: [], transcriptLines: [], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true },
preSendAssistantCount: 0,
userAnchorTurn: null,
reason: 'user_turn',
});
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2);
expect(mockReadGeminiSnapshot).toHaveBeenCalledTimes(2);
expect(mockSendGeminiMessage).toHaveBeenCalledTimes(2);
expect(mockWaitForGeminiSubmission).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/chat' }]);
});
it('returns submit-not-found when submission cannot be confirmed after retry', async () => {
mockWaitForGeminiSubmission.mockResolvedValue(null);
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2);
expect(mockSendGeminiMessage).toHaveBeenCalledTimes(2);
expect(mockWaitForGeminiConfirmButton).not.toHaveBeenCalled();
expect(result).toEqual([{ status: 'submit-not-found', url: 'https://gemini.google.com/app/chat' }]);
});
it('returns confirm-not-found when no confirm button is found', async () => {
mockWaitForGeminiConfirmButton.mockResolvedValue('');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(result).toEqual([{ status: 'confirm-not-found', url: 'https://gemini.google.com/app/chat' }]);
});
it('returns started when confirm is missing but research appears to be running', async () => {
mockWaitForGeminiConfirmButton.mockResolvedValue('');
mockGetCurrentGeminiUrl.mockResolvedValue('https://gemini.google.com/app/abc123');
mockGetLatestGeminiAssistantResponse.mockResolvedValue('Researching websites now');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/abc123' }]);
});
it('does not treat conversation url alone as started when confirm is missing', async () => {
mockWaitForGeminiConfirmButton.mockResolvedValue('');
mockGetCurrentGeminiUrl.mockResolvedValue('https://gemini.google.com/app/abc999');
mockGetLatestGeminiAssistantResponse.mockResolvedValue('I drafted a plan. Start research');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' });
expect(result).toEqual([{ status: 'confirm-not-found', url: 'https://gemini.google.com/app/abc999' }]);
});
it('retries once when stuck on root app URL and starts successfully on second confirm', async () => {
mockWaitForGeminiConfirmButton.mockResolvedValueOnce('').mockResolvedValueOnce('Start research');
mockGetCurrentGeminiUrl.mockResolvedValueOnce('https://gemini.google.com/app').mockResolvedValueOnce('https://gemini.google.com/app/retry123');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' });
expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2);
expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1);
expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/retry123' }]);
});
it('treats root-url confirm as false-positive and retries', async () => {
mockWaitForGeminiConfirmButton.mockResolvedValueOnce('Start research').mockResolvedValueOnce('Start research');
mockGetCurrentGeminiUrl.mockResolvedValueOnce('https://gemini.google.com/app').mockResolvedValueOnce('https://gemini.google.com/app/retry456');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' });
expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2);
expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1);
expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/retry456' }]);
});
it('does not resend prompt during root-url retry to avoid duplicate chats', async () => {
mockWaitForGeminiConfirmButton.mockResolvedValueOnce('Start research').mockResolvedValueOnce('');
mockGetCurrentGeminiUrl.mockResolvedValueOnce('https://gemini.google.com/app').mockResolvedValueOnce('https://gemini.google.com/app');
mockGetLatestGeminiAssistantResponse.mockResolvedValue('');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' });
expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2);
expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1);
expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ status: 'confirm-not-found', url: 'https://gemini.google.com/app' }]);
});
it('attempts one more confirm click when still waiting for start research', async () => {
mockWaitForGeminiConfirmButton
.mockResolvedValueOnce('')
.mockResolvedValueOnce('Start research');
mockGetCurrentGeminiUrl
.mockResolvedValueOnce('https://gemini.google.com/app/xyz123')
.mockResolvedValueOnce('https://gemini.google.com/app/xyz123');
mockGetLatestGeminiAssistantResponse
.mockResolvedValueOnce('I drafted a plan. Start research')
.mockResolvedValueOnce('Researching websites now');
const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' });
expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledTimes(2);
expect(mockWaitForGeminiConfirmButton).toHaveBeenNthCalledWith(
2,
page,
expect.arrayContaining(['Start research', 'Start deep research', '开始研究', '开始深度研究']),
8,
);
expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1);
expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/xyz123' }]);
});
it('uses custom tool/confirm labels when provided', async () => {
await deepResearchCommand.func!(page, {
prompt: 'research this topic',
tool: 'Custom Tool',
confirm: 'Custom Confirm',
timeout: '42',
});
expect(mockSelectGeminiTool).toHaveBeenCalledWith(page, ['Custom Tool']);
expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledWith(page, ['Custom Confirm'], 42);
});
});
-124
View File
@@ -1,124 +0,0 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS,
GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS,
GEMINI_APP_URL,
GEMINI_DOMAIN,
getCurrentGeminiUrl,
getLatestGeminiAssistantResponse,
parseGeminiPositiveInt,
readGeminiSnapshot,
resolveGeminiLabels,
selectGeminiTool,
sendGeminiMessage,
startNewGeminiChat,
waitForGeminiSubmission,
waitForGeminiConfirmButton,
} from './utils.js';
function isGeminiRootAppUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.origin + parsed.pathname.replace(/\/+$/, '') === GEMINI_APP_URL;
} catch {
return false;
}
}
function parseDeepResearchProgress(text: string): { isResearching: boolean; waitingForStart: boolean } {
const isResearching = /\bresearching(?:\s+websites?)?\b|research in progress|working on your research|正在研究|研究中/i.test(text);
const waitingForStart = /\bstart(?:\s+deep)?\s+research\b|begin\s+research|generate(?:\s+deep)?\s+research\s+plan|开始研究|开始深度研究|开始调研|生成研究计划|生成调研计划|try again without deep research/i.test(text);
return { isResearching, waitingForStart };
}
export const deepResearchCommand = cli({
site: 'gemini',
name: 'deep-research',
description: 'Start a Gemini Deep Research run and confirm it',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
timeoutSeconds: 180,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for confirm (default: 30)', default: 30 },
{ name: 'tool', required: false, help: 'Override tool label (default: Deep Research)' },
{ name: 'confirm', required: false, help: 'Override confirm button label (default: Start research)' },
],
columns: ['status', 'url'],
func: async (page: IPage, kwargs: any) => {
const prompt = kwargs.prompt as string;
const timeout = parseGeminiPositiveInt(kwargs.timeout, 30);
const submitTimeout = Math.min(Math.max(timeout, 6), 20);
await startNewGeminiChat(page);
const toolLabels = resolveGeminiLabels(kwargs.tool, GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS);
const confirmLabels = resolveGeminiLabels(kwargs.confirm, GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS);
const toolMatched = await selectGeminiTool(page, toolLabels);
if (!toolMatched) {
const url = await getCurrentGeminiUrl(page);
return [{ status: 'tool-not-found', url }];
}
let baseline = await readGeminiSnapshot(page);
await sendGeminiMessage(page, prompt);
let submitted = await waitForGeminiSubmission(page, baseline, submitTimeout);
if (!submitted) {
// Retry once when submit did not stick (e.g. composer swallowed Enter/click in this UI state).
await selectGeminiTool(page, toolLabels);
baseline = await readGeminiSnapshot(page);
await sendGeminiMessage(page, prompt);
submitted = await waitForGeminiSubmission(page, baseline, submitTimeout);
}
if (!submitted) {
const url = await getCurrentGeminiUrl(page);
return [{ status: 'submit-not-found', url }];
}
const confirmed = await waitForGeminiConfirmButton(page, confirmLabels, timeout);
let url = await getCurrentGeminiUrl(page);
if (confirmed && !isGeminiRootAppUrl(url)) {
return [{ status: 'started', url }];
}
// false-positive confirm click can happen on generic buttons while still at /app root.
{
// Retry once when we are still at the root app URL, which usually means submit did not stick.
if (isGeminiRootAppUrl(url)) {
await selectGeminiTool(page, toolLabels);
// Avoid resending prompt here: it can create a duplicate conversation thread.
const confirmedRetry = await waitForGeminiConfirmButton(page, confirmLabels, timeout);
url = await getCurrentGeminiUrl(page);
if (confirmedRetry && !isGeminiRootAppUrl(url)) {
return [{ status: 'started', url }];
}
}
let response = await getLatestGeminiAssistantResponse(page);
let { isResearching, waitingForStart } = parseDeepResearchProgress(response);
// Some UIs render the plan card first; click confirm one more time without resending prompt.
if (!isResearching && waitingForStart) {
const fallbackConfirmLabels = Array.from(new Set([
...confirmLabels,
...GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS,
]));
const confirmedFallback = await waitForGeminiConfirmButton(page, fallbackConfirmLabels, Math.min(timeout, 8));
if (confirmedFallback) {
url = await getCurrentGeminiUrl(page);
response = await getLatestGeminiAssistantResponse(page);
({ isResearching, waitingForStart } = parseDeepResearchProgress(response));
}
}
if (isResearching && !waitingForStart) {
return [{ status: 'started', url }];
}
return [{ status: 'confirm-not-found', url }];
}
},
});
-115
View File
@@ -1,115 +0,0 @@
import * as os from 'node:os';
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { saveBase64ToFile } from '@jackwener/opencli/utils';
import { GEMINI_DOMAIN, exportGeminiImages, getGeminiVisibleImageUrls, sendGeminiMessage, startNewGeminiChat, waitForGeminiImages } from './utils.js';
function extFromMime(mime: string): string {
if (mime.includes('png')) return '.png';
if (mime.includes('webp')) return '.webp';
if (mime.includes('gif')) return '.gif';
return '.jpg';
}
function normalizeBooleanFlag(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
function displayPath(filePath: string): string {
const home = os.homedir();
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
}
function buildImagePrompt(prompt: string, options: {
ratio?: string;
style?: string;
}): string {
const extras: string[] = [];
if (options.ratio) extras.push(`aspect ratio ${options.ratio}`);
if (options.style) extras.push(`style ${options.style}`);
if (extras.length === 0) return prompt;
return `${prompt}
Image requirements: ${extras.join(', ')}.`;
}
function normalizeRatio(value: string): string {
const normalized = value.trim();
const allowed = new Set(['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3']);
return allowed.has(normalized) ? normalized : '1:1';
}
async function currentGeminiLink(page: IPage): Promise<string> {
const url = await page.evaluate('window.location.href').catch(() => '');
return typeof url === 'string' && url ? url : 'https://gemini.google.com/app';
}
export const imageCommand = cli({
site: 'gemini',
name: 'image',
description: 'Generate images with Gemini web and save them locally',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
timeoutSeconds: 240,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to Gemini' },
{ name: 'rt', default: '1:1', help: 'Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)' },
{ name: 'st', default: '', help: 'Style shorthand, e.g. anime, icon, watercolor' },
{ name: 'op', default: path.join(os.homedir(), 'tmp', 'gemini-images'), help: 'Output directory shorthand' },
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show Gemini page link' },
],
columns: ['status', 'file', 'link'],
func: async (page: IPage, kwargs: any) => {
const prompt = kwargs.prompt as string;
const ratio = normalizeRatio(String(kwargs.rt ?? '1:1'));
const style = String(kwargs.st ?? '').trim();
const outputDir = (kwargs.op as string) || path.join(os.homedir(), 'tmp', 'gemini-images');
const timeout = 120;
const startFresh = true;
const skipDownloadRaw = kwargs.sd;
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
const effectivePrompt = buildImagePrompt(prompt, {
ratio,
style: style || undefined,
});
if (startFresh) await startNewGeminiChat(page);
const beforeUrls = await getGeminiVisibleImageUrls(page);
await sendGeminiMessage(page, effectivePrompt);
const urls = await waitForGeminiImages(page, beforeUrls, timeout);
const link = await currentGeminiLink(page);
if (!urls.length) {
return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
}
if (skipDownload) {
return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }];
}
const assets = await exportGeminiImages(page, urls);
if (!assets.length) {
return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
}
const stamp = Date.now();
const results = [];
for (let index = 0; index < assets.length; index += 1) {
const asset = assets[index];
const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
const suffix = assets.length > 1 ? `_${index + 1}` : '';
const filePath = path.join(outputDir, `gemini_${stamp}${suffix}${extFromMime(asset.mimeType)}`);
await saveBase64ToFile(base64, filePath);
results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
}
return results;
},
});
-22
View File
@@ -1,22 +0,0 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { GEMINI_DOMAIN, startNewGeminiChat } from './utils.js';
export const newCommand = cli({
site: 'gemini',
name: 'new',
description: 'Start a new conversation in Gemini web chat',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Status', 'Action'],
func: async (page: IPage) => {
const action = await startNewGeminiChat(page);
return [{
Status: 'Success',
Action: action === 'navigate' ? 'Reloaded /app as fallback' : 'Clicked New chat',
}];
},
});
-742
View File
@@ -1,742 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { IPage } from '@jackwener/opencli/types';
import type { GeminiSnapshot } from './utils.js';
import { __test__, waitForGeminiResponse, waitForGeminiSubmission } from './utils.js';
function snapshot(overrides: Partial<GeminiSnapshot> = {}): GeminiSnapshot {
return {
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
...overrides,
};
}
function createPageMock(): IPage {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
nativeType: vi.fn().mockResolvedValue(undefined),
nativeKeyPress: vi.fn().mockResolvedValue(undefined),
} as unknown as IPage;
}
describe('Gemini snapshot diff helpers', () => {
it('reports appended trusted turns when the current snapshot extends the baseline', () => {
const before = snapshot({
turns: [{ Role: 'Assistant', Text: '旧回答' }],
});
const current = snapshot({
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请只回复:OK' },
],
});
expect(__test__.diffTrustedStructuredTurns(before, current)).toEqual({
appendedTurns: [{ Role: 'User', Text: '请只回复:OK' }],
hasTrustedAppend: true,
hasNewUserTurn: true,
hasNewAssistantTurn: false,
});
});
it('treats restored structured turns as untrusted when the pre-send snapshot had no trustworthy turns', () => {
const before = snapshot({
turns: [],
transcriptLines: ['旧问题', '旧回答'],
structuredTurnsTrusted: false,
});
const current = snapshot({
turns: [
{ Role: 'User', Text: '旧问题' },
{ Role: 'Assistant', Text: '旧回答' },
],
transcriptLines: ['旧问题', '旧回答'],
structuredTurnsTrusted: true,
});
expect(__test__.diffTrustedStructuredTurns(before, current)).toEqual({
appendedTurns: [],
hasTrustedAppend: false,
hasNewUserTurn: false,
hasNewAssistantTurn: false,
});
});
it('keeps transcript delta lines raw for later conservative fallback checks', () => {
const before = snapshot({
transcriptLines: ['baseline'],
});
const current = snapshot({
transcriptLines: ['baseline', '关于“请只回复:OK”,这里是解释。'],
structuredTurnsTrusted: false,
});
expect(__test__.diffTranscriptLines(before, current)).toEqual([
'关于“请只回复:OK”,这里是解释。',
]);
});
});
describe('Gemini submission state', () => {
it('confirms submission from a trusted appended user turn', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请只回复:OK' },
],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiSubmission(page, snapshot({
turns: [{ Role: 'Assistant', Text: '旧回答' }],
transcriptLines: ['baseline'],
composerHasText: true,
structuredTurnsTrusted: true,
}), 4);
expect(result).toEqual({
snapshot: {
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请只回复:OK' },
],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
},
preSendAssistantCount: 1,
userAnchorTurn: { Role: 'User', Text: '请只回复:OK' },
reason: 'user_turn',
});
});
it('confirms submission from composer cleared plus generating even when transcript has not changed yet', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: false,
});
const result = await waitForGeminiSubmission(page, snapshot({
transcriptLines: ['baseline'],
composerHasText: true,
structuredTurnsTrusted: false,
}), 2);
expect(result).toEqual({
snapshot: {
turns: [],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: false,
},
preSendAssistantCount: 0,
userAnchorTurn: null,
reason: 'composer_generating',
});
});
it('confirms submission from generating state even when the pre-send baseline composer was empty', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'User', Text: '你说\n\n请只回复:DBG2' },
{ Role: 'User', Text: '请只回复:DBG2' },
],
transcriptLines: ['baseline', '请只回复:DBG2'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiSubmission(page, snapshot({
turns: [{ Role: 'Assistant', Text: '需要我为你做些什么?' }],
transcriptLines: ['baseline'],
composerHasText: false,
structuredTurnsTrusted: true,
}), 2);
expect(result).toEqual({
snapshot: {
turns: [
{ Role: 'User', Text: '你说\n\n请只回复:DBG2' },
{ Role: 'User', Text: '请只回复:DBG2' },
],
transcriptLines: ['baseline', '请只回复:DBG2'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
},
preSendAssistantCount: 1,
userAnchorTurn: { Role: 'User', Text: '请只回复:DBG2' },
reason: 'composer_generating',
});
});
it('confirms submission from composer cleared plus transcript growth when generation state is unavailable', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
// This transcript delta may be only a prompt echo. It is allowed to confirm
// submission only because the composer has already cleared, and it must never
// be reused later as reply ownership evidence.
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: false,
});
const result = await waitForGeminiSubmission(page, snapshot({
transcriptLines: ['baseline'],
composerHasText: true,
structuredTurnsTrusted: false,
}), 2);
expect(result).toEqual({
snapshot: {
turns: [],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: false,
},
preSendAssistantCount: 0,
userAnchorTurn: null,
reason: 'composer_transcript',
});
});
it('does not confirm submission when old structured turns only reappear after an untrusted pre-send snapshot', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'User', Text: '旧问题' },
{ Role: 'Assistant', Text: '旧回答' },
],
transcriptLines: ['旧问题', '旧回答'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'User', Text: '旧问题' },
{ Role: 'Assistant', Text: '旧回答' },
],
transcriptLines: ['旧问题', '旧回答'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiSubmission(page, snapshot({
turns: [],
transcriptLines: ['旧问题', '旧回答'],
composerHasText: true,
structuredTurnsTrusted: false,
}), 2);
expect(result).toBeNull();
});
it('does not confirm submission from transcript growth alone when the composer never clears', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: true,
isGenerating: false,
structuredTurnsTrusted: false,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: true,
isGenerating: false,
structuredTurnsTrusted: false,
});
const result = await waitForGeminiSubmission(page, snapshot({
transcriptLines: ['baseline'],
composerHasText: true,
structuredTurnsTrusted: false,
}), 2);
expect(result).toBeNull();
});
it('does not confirm transcript-only submission while url remains /app root', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
url: 'https://gemini.google.com/app',
turns: [],
transcriptLines: ['baseline', 'prompt'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: false,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
url: 'https://gemini.google.com/app',
turns: [],
transcriptLines: ['baseline', 'prompt'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: false,
});
const result = await waitForGeminiSubmission(page, snapshot({
url: 'https://gemini.google.com/app',
transcriptLines: ['baseline'],
composerHasText: true,
structuredTurnsTrusted: false,
}), 2);
expect(result).toBeNull();
});
it('keeps polling past ten seconds when the overall timeout budget still allows submission confirmation', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
for (let index = 0; index < 10; index += 1) {
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [],
transcriptLines: ['baseline'],
composerHasText: true,
isGenerating: false,
structuredTurnsTrusted: false,
});
}
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [],
transcriptLines: ['baseline', '请只回复:OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: false,
});
const result = await waitForGeminiSubmission(page, snapshot({
transcriptLines: ['baseline'],
composerHasText: true,
structuredTurnsTrusted: false,
}), 12);
expect(result?.reason).toBe('composer_transcript');
});
});
describe('Gemini reply state', () => {
it('does not reuse an older identical reply when the submission baseline has no structured user anchor', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'Assistant', Text: 'OK' }],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: 'OK' },
{ Role: 'Assistant', Text: 'OK' },
],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: 'OK' },
{ Role: 'Assistant', Text: 'OK' },
],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [{ Role: 'Assistant', Text: 'OK' }],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 1,
userAnchorTurn: null,
reason: 'composer_generating',
}, '请只回复:OK', 6);
expect(result).toBe('OK');
});
it('does not treat prepended older history as the current round reply when reply ownership has no user anchor', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '更早的问题' },
{ Role: 'Assistant', Text: '旧回答' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '更早的问题' },
{ Role: 'Assistant', Text: '旧回答' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '更早的问题' },
{ Role: 'Assistant', Text: '旧回答' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [{ Role: 'Assistant', Text: '旧回答' }],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 1,
userAnchorTurn: null,
reason: 'composer_generating',
}, '请只回复:OK', 6);
expect(result).toBe('');
});
it('accepts a reply when the submission snapshot contains only the current round user turns and later appends a new assistant', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'User', Text: '你说\n\n请只回复:DBGREG' },
{ Role: 'User', Text: '请只回复:DBGREG' },
{ Role: 'Assistant', Text: 'DBGREG' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'User', Text: '你说\n\n请只回复:DBGREG' },
{ Role: 'User', Text: '请只回复:DBGREG' },
{ Role: 'Assistant', Text: 'DBGREG' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [
{ Role: 'User', Text: '你说\n\n请只回复:DBGREG' },
{ Role: 'User', Text: '请只回复:DBGREG' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 1,
userAnchorTurn: { Role: 'User', Text: '请只回复:DBGREG' },
reason: 'composer_generating',
}, '请只回复:DBGREG', 6);
expect(result).toBe('DBGREG');
});
it('does not trust an assistant-only submission snapshot without a stable post-submission owner', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'Assistant', Text: '完整回答' }],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'Assistant', Text: '完整回答' }],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [{ Role: 'Assistant', Text: '半截回答' }],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 0,
userAnchorTurn: null,
reason: 'composer_generating',
}, '请解释', 4);
expect(result).toBe('');
});
it('accepts an assistant reply that appears after a structured user anchor only after it stabilizes and generation stops', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请解释' },
{ Role: 'Assistant', Text: '半截回答' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请解释' },
{ Role: 'Assistant', Text: '完整回答' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请解释' },
{ Role: 'Assistant', Text: '完整回答' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [
{ Role: 'Assistant', Text: '旧回答' },
{ Role: 'User', Text: '请解释' },
],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 1,
userAnchorTurn: { Role: 'User', Text: '请解释' },
reason: 'user_turn',
}, '请解释', 6);
expect(result).toBe('完整回答');
});
it('uses transcript fallback only after two identical post-submission deltas and after generation stops', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 0,
userAnchorTurn: { Role: 'User', Text: '请只回复:OK' },
reason: 'user_turn',
}, '请只回复:OK', 6);
expect(result).toBe('OK');
});
it('ignores transcript lines that appeared before submission confirmation and only accepts post-submission transcript deltas', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', '早到的提示词回声', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', '早到的提示词回声', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
})
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', '早到的提示词回声', 'OK'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
const result = await waitForGeminiResponse(page, {
snapshot: snapshot({
turns: [{ Role: 'User', Text: '请只回复:OK' }],
transcriptLines: ['baseline', '早到的提示词回声'],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
}),
preSendAssistantCount: 0,
userAnchorTurn: { Role: 'User', Text: '请只回复:OK' },
reason: 'composer_transcript',
}, '请只回复:OK', 6);
expect(result).toBe('OK');
});
});

Some files were not shown because too many files have changed in this diff Show More