Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90971d4c1a | |||
| 4945f10ac8 | |||
| 454964010d |
@@ -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](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)(adapter 开发决策树)
|
||||
- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 源项目分析
|
||||
|
||||
### 1.1 克隆 & 理解源项目
|
||||
|
||||
```bash
|
||||
# 克隆源项目到 /tmp 做分析
|
||||
git clone <source_repo_url> /tmp/<source-cli>
|
||||
```
|
||||
|
||||
分析重点:
|
||||
- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README)
|
||||
- **认证方式**:Cookie?API Key?OAuth?浏览器自动化?
|
||||
- **数据源**:公开 API?GraphQL?页面抓取?
|
||||
- **输出字段**:每个命令返回哪些数据字段
|
||||
|
||||
### 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](file:///Users/jakevin/code/opencli/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 Reddit(2026-03-16)
|
||||
|
||||
- **源项目**: `rdt-cli`(25 个 Python 命令)
|
||||
- **筛选结果**: 13 个高价值命令
|
||||
- **实现**: 7 个 YAML(read) + 6 个 TS(write)
|
||||
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15(+275%)
|
||||
|
||||
### twitter-cli → opencli Twitter(2026-03-16)
|
||||
|
||||
- **源项目**: `twitter-cli`(20+ Python 命令)
|
||||
- **筛选结果**: 11 个待实现
|
||||
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetch,Write 用 `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
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Setup Chrome
|
||||
description: Install real Chrome for browser testing (with xvfb on Linux)
|
||||
name: Setup Chrome + xvfb
|
||||
description: Install real Chrome and xvfb virtual display for headed browser testing
|
||||
|
||||
outputs:
|
||||
chrome-path:
|
||||
@@ -19,9 +19,8 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}"
|
||||
"${{ steps.setup-chrome.outputs.chrome-path }}" --version
|
||||
${{ steps.setup-chrome.outputs.chrome-path }} --version
|
||||
|
||||
- name: Install xvfb (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
- name: Install xvfb for headed mode
|
||||
shell: bash
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
@@ -24,10 +24,8 @@ Related issue:
|
||||
- [ ] Added doc page under `docs/adapters/` (if new adapter)
|
||||
- [ ] Updated `docs/adapters/index.md` table (if new adapter)
|
||||
- [ ] Updated sidebar in `docs/.vitepress/config.mts` (if new adapter)
|
||||
- [ ] Updated `README.md` / `README.zh-CN.md` when command discoverability changed
|
||||
- [ ] Used positional args for the command's primary subject unless a named flag is clearly better
|
||||
- [ ] Normalized expected adapter failures to `CliError` subclasses instead of raw `Error`
|
||||
|
||||
## Screenshots / Output
|
||||
|
||||
<!-- If applicable, paste CLI output or screenshots here. -->
|
||||
|
||||
|
||||
@@ -4,14 +4,8 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*.*.*" ]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.github/workflows/build-extension.yml'
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.github/workflows/build-extension.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -39,8 +33,12 @@ jobs:
|
||||
working-directory: extension
|
||||
|
||||
- name: Prepare extension package
|
||||
run: npm run package:release -- --out ../extension-package
|
||||
working-directory: extension
|
||||
run: |
|
||||
rm -rf extension-package
|
||||
mkdir -p extension-package
|
||||
cp extension/manifest.json extension-package/
|
||||
cp -R extension/dist extension-package/
|
||||
cp -R extension/icons extension-package/
|
||||
|
||||
- name: Create Extension ZIP
|
||||
run: |
|
||||
|
||||
@@ -16,11 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Fast gate: typecheck + build ──
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -39,15 +35,12 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
|
||||
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
|
||||
unit-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["ubuntu-latest"]') }}
|
||||
node-version: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["20","22"]') || fromJSON('["22"]') }}
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -63,28 +56,6 @@ jobs:
|
||||
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
|
||||
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
# ── Bun compatibility check ──
|
||||
bun-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests under Bun
|
||||
run: bun vitest run --project unit --reporter=verbose
|
||||
|
||||
# Adapter tests are pure unit tests — OS doesn't affect results.
|
||||
adapter-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
@@ -106,13 +77,7 @@ jobs:
|
||||
smoke-test:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
needs: build
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
|
||||
# Chrome MSI installation on Windows runners (known issue).
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -124,24 +89,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run smoke tests (Linux, via xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/smoke/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
- name: Run smoke tests (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
run: npx vitest run tests/smoke/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -3,28 +3,8 @@ name: E2E Headed Chrome
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- 'src/browser/**'
|
||||
- 'src/daemon.ts'
|
||||
- 'src/execution.ts'
|
||||
- 'src/interceptor.ts'
|
||||
- 'tests/e2e/**'
|
||||
- 'tests/smoke/**'
|
||||
- '.github/actions/setup-chrome/**'
|
||||
- '.github/workflows/e2e-headed.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- 'src/browser/**'
|
||||
- 'src/daemon.ts'
|
||||
- 'src/execution.ts'
|
||||
- 'src/interceptor.ts'
|
||||
- 'tests/e2e/**'
|
||||
- 'tests/smoke/**'
|
||||
- '.github/actions/setup-chrome/**'
|
||||
- '.github/workflows/e2e-headed.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -33,13 +13,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
e2e-headed:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
|
||||
# Chrome MSI installation on Windows runners (known issue).
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -52,23 +26,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests (Linux, via xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
- name: Run E2E tests (headed Chrome + xvfb)
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/e2e/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
- name: Run E2E tests (macOS / Windows)
|
||||
if: runner.os != 'Linux'
|
||||
run: npx vitest run tests/e2e/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish Any Commit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish
|
||||
@@ -31,3 +31,6 @@ jobs:
|
||||
|
||||
- name: npm audit (production)
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx --yes audit-ci@^7 --high --skip-dev
|
||||
|
||||
@@ -22,5 +22,3 @@ docs/.vitepress/cache
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
autoresearch/results/
|
||||
autoresearch-results.tsv
|
||||
|
||||
-162
@@ -1,167 +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-operate:** 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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **douyin:** add Douyin creator center adapter — 14 commands, 8-phase publish pipeline ([#416](https://github.com/jackwener/opencli/issues/416))
|
||||
* **weibo,youtube:** add Weibo commands and YouTube channel/comments ([#418](https://github.com/jackwener/opencli/issues/418))
|
||||
* **twitter:** add filter option for search ([#410](https://github.com/jackwener/opencli/issues/410))
|
||||
* **extension:** add popup UI, privacy policy, and CSP for Chrome Web Store ([#415](https://github.com/jackwener/opencli/issues/415))
|
||||
* add url field to 9 search adapters (67% -> 97% coverage) ([#414](https://github.com/jackwener/opencli/issues/414))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **extension:** improve UX when daemon is not running — show hint in popup, reduce reconnect noise ([#424](https://github.com/jackwener/opencli/issues/424))
|
||||
* remove incorrect gws and readwise external CLI entries ([#419](https://github.com/jackwener/opencli/issues/419), [#420](https://github.com/jackwener/opencli/issues/420))
|
||||
|
||||
|
||||
### CI
|
||||
|
||||
* limit default e2e to bilibili/zhihu/v2ex, gate extended browser tests ([#421](https://github.com/jackwener/opencli/issues/421), [#423](https://github.com/jackwener/opencli/issues/423))
|
||||
|
||||
|
||||
## [1.4.0](https://github.com/jackwener/opencli/compare/v1.3.3...v1.4.0) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **pixiv:** add Pixiv adapter — ranking, search, user illusts, detail, download ([#403](https://github.com/jackwener/opencli/issues/403))
|
||||
* **plugin:** add lifecycle hooks API — onStartup, onBeforeExecute, onAfterExecute ([#376](https://github.com/jackwener/opencli/issues/376))
|
||||
* **plugin:** validate plugin structure on install and update ([#364](https://github.com/jackwener/opencli/issues/364))
|
||||
* **xueqiu:** add Danjuan fund account commands — fund-holdings, fund-snapshot ([#391](https://github.com/jackwener/opencli/issues/391))
|
||||
* **tiktok:** add video URL to search results ([#404](https://github.com/jackwener/opencli/issues/404))
|
||||
* **linkedin:** add timeline feed command ([#342](https://github.com/jackwener/opencli/issues/342))
|
||||
* **jd:** add JD.com product details adapter ([#344](https://github.com/jackwener/opencli/issues/344))
|
||||
* **web:** add generic `web read` command for any URL → Markdown ([#343](https://github.com/jackwener/opencli/issues/343))
|
||||
* **dictionary:** add dictionary search, synonyms, and examples adapters ([#241](https://github.com/jackwener/opencli/issues/241))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analysis:** fix hasLimit using wrong Set (SEARCH_PARAMS → LIMIT_PARAMS) ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* **pipeline:** remove phantom scroll step — declared but never registered ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* **validate:** add missing download step to KNOWN_STEP_NAMES ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* **extension:** security hardening — tab isolation, URL validation, cookie scope ([#409](https://github.com/jackwener/opencli/issues/409))
|
||||
* **sort:** use localeCompare with natural numeric sort by default ([#306](https://github.com/jackwener/opencli/issues/306))
|
||||
* **pipeline:** evaluate chained || in template engine ([#305](https://github.com/jackwener/opencli/issues/305))
|
||||
* **pipeline:** check HTTP status in fetch step ([#384](https://github.com/jackwener/opencli/issues/384))
|
||||
* **plugin:** resolve Windows path and symlink issues ([#400](https://github.com/jackwener/opencli/issues/400))
|
||||
* **download:** scope cookies to target domain ([#385](https://github.com/jackwener/opencli/issues/385))
|
||||
* **extension:** fix same-url navigation timeout ([#380](https://github.com/jackwener/opencli/issues/380))
|
||||
* fix ChatWise Windows connect ([#405](https://github.com/jackwener/opencli/issues/405))
|
||||
* resolve 6 critical + 11 important bugs from deep code review ([#337](https://github.com/jackwener/opencli/issues/337), [#340](https://github.com/jackwener/opencli/issues/340))
|
||||
* harden security-sensitive execution paths ([#335](https://github.com/jackwener/opencli/issues/335))
|
||||
* **stealth:** harden anti-detection against advanced fingerprinting ([#357](https://github.com/jackwener/opencli/issues/357))
|
||||
|
||||
|
||||
### Code Quality
|
||||
|
||||
* replace all `catch (err: any)` with typed `getErrorMessage()` across 13 files ([#412](https://github.com/jackwener/opencli/issues/412))
|
||||
* adopt CliError subclasses in social and desktop adapters ([#367](https://github.com/jackwener/opencli/issues/367), [#372](https://github.com/jackwener/opencli/issues/372), [#375](https://github.com/jackwener/opencli/issues/375))
|
||||
* simplify codebase with type dedup, shared analysis module, and consistent naming ([#373](https://github.com/jackwener/opencli/issues/373))
|
||||
* **ci:** add cross-platform CI matrix (Linux/macOS/Windows) ([#402](https://github.com/jackwener/opencli/issues/402))
|
||||
|
||||
|
||||
## [1.3.3](https://github.com/jackwener/opencli/compare/v1.3.2...v1.3.3) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **browser:** add stealth anti-detection for CDP and daemon modes ([#319](https://github.com/jackwener/opencli/issues/319))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **stealth:** review fixes — guard plugins, rewrite stack trace cleanup ([#320](https://github.com/jackwener/opencli/issues/320))
|
||||
|
||||
|
||||
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
# Privacy Policy — OpenCLI Browser Extension
|
||||
|
||||
**Last updated**: 2026-03-25
|
||||
|
||||
## What the extension does
|
||||
|
||||
The OpenCLI Browser Extension is a bridge between the [OpenCLI](https://github.com/jackwener/opencli) command-line tool and your Chrome browser. It receives commands from a **locally running daemon** process via WebSocket (`localhost` only) and executes them in **isolated Chrome windows** that are separate from your normal browsing session.
|
||||
|
||||
## Data collection
|
||||
|
||||
The extension does **NOT** collect, store, transmit, or sell any personal data. Specifically:
|
||||
|
||||
- **No analytics or telemetry** — no data is sent to any remote server.
|
||||
- **No user tracking** — no cookies, identifiers, or fingerprints are created.
|
||||
- **No external network requests** — all communication is strictly `localhost` (WebSocket to `ws://localhost:19825`).
|
||||
|
||||
## Permissions explained
|
||||
|
||||
| Permission | Why it's needed |
|
||||
|------------|----------------|
|
||||
| `debugger` | Required to use Chrome DevTools Protocol (CDP) for browser automation — executing JavaScript, capturing page content, and taking screenshots in isolated windows. |
|
||||
| `tabs` | Required to create and manage isolated automation windows and tabs, separate from the user's browsing session. |
|
||||
| `cookies` | Required to read site-specific cookies (scoped by domain) so CLI commands can authenticate with websites the user is already logged into. Cookies are **never written, modified, or transmitted externally**. |
|
||||
| `activeTab` | Required to identify the currently active tab for context-aware commands. |
|
||||
| `alarms` | Required to maintain the WebSocket connection to the local daemon via periodic keepalive checks. |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
User's terminal (opencli CLI)
|
||||
↓ (spawns)
|
||||
Local daemon process (localhost:19825)
|
||||
↓ (WebSocket, localhost only)
|
||||
Chrome Extension (this extension)
|
||||
↓ (Chrome APIs)
|
||||
Isolated Chrome automation window
|
||||
```
|
||||
|
||||
All data stays on the user's machine. No data leaves `localhost`.
|
||||
|
||||
## Cookie access
|
||||
|
||||
The extension reads cookies **only** when explicitly requested by a CLI command, and **only** for the specific domain the command targets. It cannot and does not dump all cookies. Cookie data is returned to the local daemon process and is never sent to any external server.
|
||||
|
||||
## Third-party services
|
||||
|
||||
This extension does not integrate with, send data to, or receive data from any third-party service.
|
||||
|
||||
## Open source
|
||||
|
||||
This extension is fully open source. You can audit the complete source code at:
|
||||
https://github.com/jackwener/opencli/tree/main/extension
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy questions or concerns, please open an issue at:
|
||||
https://github.com/jackwener/opencli/issues
|
||||
@@ -1,165 +1,207 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> Zero risk · Reuse Chrome/Chromium login · AI-powered discovery · Universal CLI Hub
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
|
||||
|
||||
[中文文档](./README.zh-CN.md)
|
||||
|
||||
[](./README.zh-CN.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
**Built for AI Agents** — Load the [`opencli-operate` skill](./skills/opencli-operate/SKILL.md) to give any AI agent (Claude Code, Cursor) direct browser control. Operate any website, then crystallize those interactions into reusable CLI commands. Configure `opencli list` in your `AGENT.md` or `.cursorrules` so the AI auto-discovers all available tools.
|
||||
**Built for AI Agents**: Simply configure an instruction in your global `AGENT.md` or `.cursorrules` guiding the AI to execute `opencli list` via Bash to discover available tools. Register your favorite local CLIs (`opencli register mycli`), and the AI will automatically learn how to invoke all your tools perfectly!
|
||||
|
||||
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
|
||||
|
||||
**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.
|
||||
**CLI All Electron Apps! The Most Powerful Update Has Arrived!**
|
||||
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **Browser Automation** — `operate` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 70+ pre-built adapters, or crystallize your own with `opencli record`.
|
||||
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
|
||||
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `operate` controls the browser directly.
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **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** — 73+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
- **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
|
||||
## Prerequisites
|
||||
|
||||
### 1. Install Browser Bridge Extension
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
|
||||
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome/Chromium extension + micro-daemon (zero config, auto-start).
|
||||
> **⚠️ 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.
|
||||
|
||||
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
### Browser Bridge Extension Setup
|
||||
|
||||
You can install the extension via either method:
|
||||
|
||||
**Method 1: Download Pre-built Release (Recommended)**
|
||||
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
|
||||
**Method 2: Load Source (For Developers)**
|
||||
1. Open `chrome://extensions` and enable **Developer mode**.
|
||||
2. Click **Load unpacked** and select the `extension/` directory from this repository.
|
||||
|
||||
**Install via npm (recommended)**
|
||||
That's it! The daemon auto-starts when you run any browser command. No tokens, no manual configuration.
|
||||
|
||||
> **Tip**: Use `opencli doctor` for ongoing diagnosis:
|
||||
> ```bash
|
||||
> opencli doctor # Check extension + daemon connectivity
|
||||
> ```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install via npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
### 3. Verify & Try
|
||||
Then use directly:
|
||||
|
||||
```bash
|
||||
opencli doctor # Check extension + daemon connectivity
|
||||
opencli daemon status # Check daemon state (PID, uptime, memory)
|
||||
opencli list # See all commands
|
||||
opencli list -f yaml # List commands as YAML
|
||||
opencli hackernews top --limit 5 # Public API, no browser
|
||||
opencli bilibili hot --limit 5 # Browser command
|
||||
opencli zhihu hot -f json # JSON output
|
||||
opencli zhihu hot -f yaml # YAML output
|
||||
```
|
||||
|
||||
**Try it out:**
|
||||
### Install from source (for developers)
|
||||
|
||||
```bash
|
||||
opencli list # See all commands
|
||||
opencli hackernews top --limit 5 # Public API, no browser needed
|
||||
opencli bilibili hot --limit 5 # Browser command (requires Extension)
|
||||
git clone git@github.com:jackwener/opencli.git
|
||||
cd opencli
|
||||
npm install
|
||||
npm run build
|
||||
npm link # Link binary globally
|
||||
opencli list # Now you can use it anywhere!
|
||||
```
|
||||
|
||||
### 4. Browser Automation — Make Websites Accessible for AI Agents
|
||||
|
||||
Point your AI agent (Claude Code, Cursor) to [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md). It has everything needed — full command reference, examples, and workflow.
|
||||
|
||||
Available commands: `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, `close`.
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
### Install AI Skills
|
||||
|
||||
OpenCLI provides [skills](./skills/) for AI agents (Claude Code, etc.):
|
||||
|
||||
```bash
|
||||
# Install all OpenCLI skills
|
||||
npx skills add jackwener/opencli
|
||||
|
||||
# Or install specific skills
|
||||
npx skills add jackwener/opencli --skill opencli-usage # Command reference
|
||||
npx skills add jackwener/opencli --skill opencli-operate # Browser automation for AI agents
|
||||
npx skills add jackwener/opencli --skill opencli-explorer # Adapter development guide
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot # Quick command reference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### For Developers
|
||||
|
||||
**Install from source**
|
||||
|
||||
```bash
|
||||
git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && npm run build && npm link
|
||||
```
|
||||
|
||||
**Load Source Browser Bridge Extension**
|
||||
|
||||
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 or Chromium** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome/Chromium login session. You must be logged into the target website in Chrome or Chromium before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
|
||||
| **tieba** | `hot` `posts` `search` `read` |
|
||||
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
|
||||
| **gemini** | `new` `ask` `image` |
|
||||
| **yuanbao** | `new` `ask` |
|
||||
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
|
||||
| **xianyu** | `search` `item` `chat` |
|
||||
Run `opencli list` for the live registry.
|
||||
|
||||
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **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` | Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` | Browser |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
|
||||
| **zhihu** | `hot` `search` `question` `download` | Browser |
|
||||
| **weixin** | `download` | Browser |
|
||||
| **youtube** | `search` `video` `transcript` | Browser |
|
||||
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
|
||||
| **coupang** | `search` `add-to-cart` | Browser |
|
||||
| **bbc** | `news` | Public |
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
|
||||
| **ctrip** | `search` | Browser |
|
||||
| **devto** | `top` `tag` `user` | Public |
|
||||
| **dictionary** | `search` `synonyms` `examples` | Public |
|
||||
| **arxiv** | `search` `paper` | Public |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
||||
| **linkedin** | `search` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
| **smzdm** | `search` | Browser |
|
||||
| **weibo** | `hot` `search` | Browser |
|
||||
| **yahoo-finance** | `quote` | Browser |
|
||||
| **sinafinance** | `news` | 🌐 Public |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | Browser |
|
||||
| **chaoxing** | `assignments` `exams` | Browser |
|
||||
| **grok** | `ask` | Browser |
|
||||
| **hf** | `top` | Public |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | Browser |
|
||||
| **jimeng** | `generate` `history` | Browser |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | Browser |
|
||||
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | Public |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
|
||||
| **steam** | `top-sellers` | Public |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
|
||||
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
|
||||
| **google** | `news` `search` `suggest` `trends` | Public |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | Public |
|
||||
| **medium** | `feed` `search` `user` | Browser |
|
||||
| **sinablog** | `hot` `search` `article` `user` | Browser |
|
||||
| **substack** | `feed` `search` `publication` | Browser |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
|
||||
|
||||
## 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 CLI Hub
|
||||
|
||||
| External CLI | Description | Example |
|
||||
|--------------|-------------|---------|
|
||||
OpenCLI acts as a universal hub for your existing command-line tools. It provides unified discovery, automatic installation, and pure passthrough execution.
|
||||
|
||||
| External CLI | Description | Commands Example |
|
||||
|--------------|-------------|------------------|
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker | `opencli docker ps` |
|
||||
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
|
||||
| **dingtalk** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dingtalk msg send --to user "hello"` |
|
||||
| **wecom** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom msg send --to user "hello"` |
|
||||
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
|
||||
| **docker** | Docker command-line interface | `opencli docker ps` |
|
||||
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
|
||||
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
|
||||
**Zero Configuration**: OpenCLI purely passes your inputs to the underlying binary via standard I/O streams. The external CLI works exactly as it naturally would, maintaining its standard output formats.
|
||||
|
||||
**Auto-Installation**: If you run `opencli gh ...` and `gh` is not installed on your system, OpenCLI will automatically try to install it using your system's package manager (e.g., `brew install gh`) before seamlessly re-running the command.
|
||||
|
||||
**Register Your Own**:
|
||||
Add any local CLI to your OpenCLI registry so AI agents can automatically discover it via the `opencli list` command.
|
||||
```bash
|
||||
opencli register mycli
|
||||
```
|
||||
|
||||
### Desktop App Adapters
|
||||
|
||||
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
|
||||
Each desktop adapter has its own detailed documentation with commands reference, setup guide, and examples:
|
||||
|
||||
| App | Description | Doc |
|
||||
|-----|-------------|-----|
|
||||
@@ -172,71 +214,83 @@ Control Electron desktop apps directly from the terminal. Each adapter has its o
|
||||
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
|
||||
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
|
||||
|
||||
To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
|
||||
|
||||
## Download Support
|
||||
|
||||
OpenCLI supports downloading images, videos, and articles from supported platforms.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Content Types | Notes |
|
||||
|----------|---------------|-------|
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | From user media tab or single tweet |
|
||||
| **douban** | Images | Poster / still image lists |
|
||||
| **pixiv** | Images | Original-quality illustrations, multi-page |
|
||||
| **zhihu** | Articles (Markdown) | Exports with optional image download |
|
||||
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
|
||||
For video downloads, install `yt-dlp` first: `brew install yt-dlp`
|
||||
### Prerequisites
|
||||
|
||||
For video downloads from streaming platforms, you need to install `yt-dlp`:
|
||||
|
||||
```bash
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
# Install yt-dlp
|
||||
pip install yt-dlp
|
||||
# or
|
||||
brew install yt-dlp
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Download images/videos from Xiaohongshu note
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
|
||||
# Download Bilibili video (requires yt-dlp)
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
opencli bilibili download BV1xxx --quality 1080p # Specify quality
|
||||
|
||||
# Download Twitter media from user
|
||||
opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# Export with local images
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
|
||||
# Export WeChat article to Markdown
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Output Formats
|
||||
|
||||
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
|
||||
All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
|
||||
The `list` command supports the same format options, and keeps `--json` for backward compatibility.
|
||||
|
||||
```bash
|
||||
opencli bilibili hot -f json # Pipe to jq or LLMs
|
||||
opencli bilibili hot -f csv # Spreadsheet-friendly
|
||||
opencli list -f yaml # Command registry as YAML
|
||||
opencli bilibili hot -f table # Default: rich terminal table
|
||||
opencli bilibili hot -f json # JSON (pipe to jq or LLMs)
|
||||
opencli bilibili hot -f yaml # YAML (human-readable structured output)
|
||||
opencli bilibili hot -f md # Markdown
|
||||
opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # Verbose: show pipeline debug steps
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
opencli follows Unix `sysexits.h` conventions so it integrates naturally with shell pipelines and CI scripts:
|
||||
|
||||
| Code | Meaning | When |
|
||||
|------|---------|------|
|
||||
| `0` | Success | Command completed normally |
|
||||
| `1` | Generic error | Unexpected / unclassified failure |
|
||||
| `2` | Usage error | Bad arguments or unknown command |
|
||||
| `66` | Empty result | No data returned (`EX_NOINPUT`) |
|
||||
| `69` | Service unavailable | Browser Bridge not connected (`EX_UNAVAILABLE`) |
|
||||
| `75` | Temporary failure | Command timed out — retry (`EX_TEMPFAIL`) |
|
||||
| `77` | Auth required | Not logged in to target site (`EX_NOPERM`) |
|
||||
| `78` | Config error | Missing credentials or bad config (`EX_CONFIG`) |
|
||||
| `130` | Interrupted | Ctrl-C / SIGINT |
|
||||
|
||||
```bash
|
||||
opencli spotify status || echo "exit $?" # 69 if browser not running
|
||||
opencli github issues 2>/dev/null
|
||||
[ $? -eq 77 ] && opencli github auth # auto-auth if not logged in
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Extend OpenCLI with community-contributed adapters:
|
||||
Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS format as built-in commands and are automatically discovered at startup.
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugin-my-tool
|
||||
opencli plugin list
|
||||
opencli plugin update --all
|
||||
opencli plugin uninstall my-tool
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # Install
|
||||
opencli plugin list # List installed
|
||||
opencli plugin update my-tool # Update to latest
|
||||
opencli plugin uninstall my-tool # Remove
|
||||
```
|
||||
|
||||
| Plugin | Type | Description |
|
||||
@@ -244,39 +298,58 @@ 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)
|
||||
|
||||
If you are an AI assistant tasked with creating a new command adapter for `opencli`, please follow the AI Agent workflow below:
|
||||
|
||||
> **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 [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
|
||||
opencli synthesize mysite # Generate YAML adapters
|
||||
opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
|
||||
opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
|
||||
# 1. Deep Explore — discover APIs, infer capabilities, detect framework
|
||||
opencli explore https://example.com --site mysite
|
||||
|
||||
# 2. Synthesize — generate YAML adapters from explore artifacts
|
||||
opencli synthesize mysite
|
||||
|
||||
# 3. Generate — one-shot: explore → synthesize → register
|
||||
opencli generate https://example.com --goal "hot"
|
||||
|
||||
# 4. Strategy Cascade — auto-probe: PUBLIC → COOKIE → HEADER
|
||||
opencli cascade https://api.example.com/data
|
||||
```
|
||||
|
||||
Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, capabilities.json, auth.json).
|
||||
|
||||
## Testing
|
||||
|
||||
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.
|
||||
- **"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.
|
||||
- **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`
|
||||
- **"Extension not connected"**
|
||||
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"**
|
||||
- Another Chrome extension (e.g. youmind, New Tab Override, or AI assistant extensions) may be interfering. Try **disabling other extensions** temporarily, then retry.
|
||||
- **Empty data returns or 'Unauthorized' error**
|
||||
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
|
||||
- **Node API errors**
|
||||
- Make sure you are using Node.js >= 20. Some dependencies require modern Node APIs.
|
||||
- **Daemon issues**
|
||||
- Check daemon status: `curl localhost:19825/status`
|
||||
- View extension logs: `curl localhost:19825/logs`
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#jackwener/opencli&Date)
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[Apache-2.0](./LICENSE)
|
||||
|
||||
+42
-99
@@ -1,16 +1,17 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
|
||||
> 零风控 · 复用 Chrome/Chromium 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
[](./README.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh`、`docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
**专为 AI Agent 打造**:加载 [`opencli-operate` skill](./skills/opencli-operate/SKILL.md),赋予 AI Agent(Claude Code、Cursor 等)直接操控浏览器的能力——操作任意网站,并将这些交互沉淀为可复用的 CLI 命令。在 `AGENT.md` 或 `.cursorrules` 中配置 `opencli list`,AI 即可自动发现并调用所有可用工具。
|
||||
**专为 AI Agent 打造**:只需在全局 `.cursorrules` 或 `AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`),AI 便能瞬间学会自动调用相应的本地工具!
|
||||
|
||||
**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
|
||||
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
|
||||
@@ -22,24 +23,41 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
## 亮点
|
||||
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **浏览器自动化** — `operate` 赋予 AI Agent 直接操控浏览器的能力:点击、输入、提取、截图,任意交互皆可脚本化
|
||||
- **网页转 CLI** — 将任意网站变成确定性命令行工具:73+ 预置适配器,或用 `opencli record` 沉淀自己的操作
|
||||
- **多站点覆盖** — 73+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
- **零风控** — 复用 Chrome/Chromium 登录态,无需存储任何凭证
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略,`operate` 直接控制浏览器
|
||||
- **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 或 Chromium** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com)
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
|
||||
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome/Chromium 登录状态。运行命令前,你必须已在 Chrome 或 Chromium 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
|
||||
### Browser Bridge 扩展配置
|
||||
|
||||
@@ -47,7 +65,7 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 da
|
||||
|
||||
**方式一:下载构建好的安装包(推荐)**
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`。
|
||||
2. 解压后在 Chrome 或 Chromium 中打开 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹。
|
||||
|
||||
**方式二:加载源码(针对开发者)**
|
||||
@@ -56,11 +74,9 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 da
|
||||
|
||||
完成!运行任何 opencli 浏览器命令时,后台微型 daemon 会自动启动与浏览器通信。无需配 API Token,零代码配置。
|
||||
|
||||
> **Tip**:后续诊断和 daemon 管理:
|
||||
> **Tip**:后续诊断用 `opencli doctor`:
|
||||
> ```bash
|
||||
> opencli doctor # 检查扩展和 daemon 连通性
|
||||
> opencli daemon status # 查看 daemon 状态
|
||||
> opencli daemon stop # 停止 daemon
|
||||
> ```
|
||||
|
||||
## 快速开始
|
||||
@@ -99,27 +115,6 @@ opencli list # 可以在任何地方使用了!
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
### 浏览器自动化 — 让 AI Agent 直接控制浏览器
|
||||
|
||||
将 [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) 指向你的 AI Agent(Claude Code、Cursor),即可开箱即用,内含完整命令参考与使用示例。
|
||||
|
||||
可用命令:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`screenshot`、`scroll`、`back`、`eval`、`network`、`init`、`verify`、`close`。
|
||||
|
||||
### 安装 AI Skills
|
||||
|
||||
OpenCLI 提供 [skills](./skills/) 供 AI Agent(Claude Code 等)使用:
|
||||
|
||||
```bash
|
||||
# 安装所有 OpenCLI skills
|
||||
npx skills add jackwener/opencli
|
||||
|
||||
# 或安装特定 skill
|
||||
npx skills add jackwener/opencli --skill opencli-usage # 命令参考
|
||||
npx skills add jackwener/opencli --skill opencli-operate # 浏览器自动化(AI Agent 专用)
|
||||
npx skills add jackwener/opencli --skill opencli-explorer # 适配器开发指南
|
||||
npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参考
|
||||
```
|
||||
|
||||
## 内置命令
|
||||
|
||||
运行 `opencli list` 查看完整注册表。
|
||||
@@ -128,19 +123,18 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
|------|------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
|
||||
| **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` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` | 浏览器 |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
@@ -155,14 +149,11 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **devto** | `top` `tag` `user` | 公开 |
|
||||
| **dictionary** | `search` `synonyms` `examples` | 公开 |
|
||||
| **arxiv** | `search` `paper` | 公开 |
|
||||
| **paperreview** | `submit` `review` `feedback` | 公开 |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
| **jd** | `item` | 浏览器 |
|
||||
| **linkedin** | `search` `timeline` | 浏览器 |
|
||||
| **linkedin** | `search` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **web** | `read` | 浏览器 |
|
||||
| **weibo** | `hot` `search` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
| **sinafinance** | `news` | 🌐 公开 |
|
||||
@@ -173,33 +164,20 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
|
||||
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 浏览器 |
|
||||
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | 公开 |
|
||||
| **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` | 浏览器 |
|
||||
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
|
||||
| **google** | `news` `search` `suggest` `trends` | 公开 |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` | 浏览器 |
|
||||
| **gemini** | `new` `ask` `image` | 浏览器 |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
|
||||
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 浏览器 |
|
||||
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
|
||||
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
|
||||
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
|
||||
| **medium** | `feed` `search` `user` | 浏览器 |
|
||||
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
|
||||
| **substack** | `feed` `search` `publication` | 浏览器 |
|
||||
| **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` | 浏览器 |
|
||||
|
||||
73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
@@ -210,10 +188,8 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker 命令行工具 | `opencli docker ps` |
|
||||
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
|
||||
| **dingtalk** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dingtalk msg send --to user "hello"` |
|
||||
| **wecom** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom msg send --to user "hello"` |
|
||||
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
|
||||
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
|
||||
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
|
||||
|
||||
@@ -251,10 +227,8 @@ OpenCLI 支持从各平台下载图片、视频和文章。
|
||||
| **小红书** | 图片、视频 | 下载笔记中的所有媒体文件 |
|
||||
| **B站** | 视频 | 需要安装 `yt-dlp` |
|
||||
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
|
||||
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
|
||||
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
|
||||
| **微信公众号** | 文章(Markdown) | 导出微信公众号文章为 Markdown |
|
||||
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
|
||||
|
||||
### 前置依赖
|
||||
|
||||
@@ -283,9 +257,6 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
# 下载单条推文的媒体
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# 下载豆瓣电影海报 / 剧照
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# 导出知乎文章为 Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
@@ -313,31 +284,6 @@ opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # 详细模式:展示管线执行步骤调试信息
|
||||
```
|
||||
|
||||
## 退出码
|
||||
|
||||
opencli 遵循 Unix `sysexits.h` 惯例,可无缝接入 shell 管道和 CI 脚本:
|
||||
|
||||
| 退出码 | 含义 | 触发场景 |
|
||||
|--------|------|----------|
|
||||
| `0` | 成功 | 命令正常完成 |
|
||||
| `1` | 通用错误 | 未分类的意外错误 |
|
||||
| `2` | 用法错误 | 参数错误或未知命令 |
|
||||
| `66` | 无数据 | 命令返回空结果(`EX_NOINPUT`) |
|
||||
| `69` | 服务不可用 | Browser Bridge 未连接(`EX_UNAVAILABLE`) |
|
||||
| `75` | 临时失败 | 命令超时,可重试(`EX_TEMPFAIL`) |
|
||||
| `77` | 需要认证 | 未登录目标网站(`EX_NOPERM`) |
|
||||
| `78` | 配置错误 | 凭证缺失或配置有误(`EX_CONFIG`) |
|
||||
| `130` | 中断 | Ctrl-C / SIGINT |
|
||||
|
||||
```bash
|
||||
opencli bilibili hot 2>/dev/null
|
||||
case $? in
|
||||
0) echo "ok" ;;
|
||||
69) echo "请先启动 Browser Bridge" ;;
|
||||
77) echo "请先登录 bilibili.com" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
## 插件
|
||||
|
||||
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
|
||||
@@ -346,12 +292,9 @@ esac
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # 安装
|
||||
opencli plugin list # 查看已安装
|
||||
opencli plugin update my-tool # 更新到最新
|
||||
opencli plugin update --all # 更新全部已安装插件
|
||||
opencli plugin uninstall my-tool # 卸载
|
||||
```
|
||||
|
||||
当 plugin 的版本被记录到 `~/.opencli/plugins.lock.json` 后,`opencli plugin list` 也会显示对应的短 commit hash。
|
||||
|
||||
| 插件 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
|
||||
@@ -387,11 +330,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 问题**
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 1.3.1
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
|
||||
---
|
||||
|
||||
# OpenCLI
|
||||
|
||||
> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
|
||||
|
||||
> [!CAUTION]
|
||||
> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)!**
|
||||
> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
|
||||
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
|
||||
|
||||
## Install & Run
|
||||
|
||||
```bash
|
||||
# npm global install (recommended)
|
||||
npm install -g @jackwener/opencli
|
||||
opencli <command>
|
||||
|
||||
# Or from source
|
||||
cd ~/code/opencli && npm install
|
||||
npx tsx src/main.ts <command>
|
||||
|
||||
# Update to latest
|
||||
npm update -g @jackwener/opencli
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Browser commands require:
|
||||
1. Chrome browser running **(logged into target sites)**
|
||||
2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`)
|
||||
3. No further setup needed — the daemon auto-starts on first browser command
|
||||
|
||||
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
|
||||
|
||||
Public API commands (`hackernews`, `v2ex`) need no browser.
|
||||
|
||||
## Commands Reference
|
||||
|
||||
### Data Commands
|
||||
|
||||
```bash
|
||||
# Bilibili (browser)
|
||||
opencli bilibili hot --limit 10 # B站热门视频
|
||||
opencli bilibili search "rust" # 搜索视频 (query positional)
|
||||
opencli bilibili me # 我的信息
|
||||
opencli bilibili favorite # 我的收藏
|
||||
opencli bilibili history --limit 20 # 观看历史
|
||||
opencli bilibili feed --limit 10 # 动态时间线
|
||||
opencli bilibili user-videos --uid 12345 # 用户投稿
|
||||
opencli bilibili subtitle --bvid BV1xxx # 获取视频字幕 (支持 --lang zh-CN)
|
||||
opencli bilibili dynamic --limit 10 # 动态
|
||||
opencli bilibili ranking --limit 10 # 排行榜
|
||||
opencli bilibili following --limit 20 # 我的关注列表 (支持 --uid 查看他人)
|
||||
|
||||
# 知乎 (browser)
|
||||
opencli zhihu hot --limit 10 # 知乎热榜
|
||||
opencli zhihu search "AI" # 搜索 (query positional)
|
||||
opencli zhihu question 34816524 # 问题详情和回答 (id positional)
|
||||
|
||||
# 小红书 (browser)
|
||||
opencli xiaohongshu search "美食" # 搜索笔记 (query positional)
|
||||
opencli xiaohongshu notifications # 通知(mentions/likes/connections)
|
||||
opencli xiaohongshu feed --limit 10 # 推荐 Feed
|
||||
opencli xiaohongshu user xxx # 用户主页 (id positional)
|
||||
opencli xiaohongshu creator-notes --limit 10 # 创作者笔记列表
|
||||
opencli xiaohongshu creator-note-detail --note-id xxx # 笔记详情
|
||||
opencli xiaohongshu creator-notes-summary # 笔记数据概览
|
||||
opencli xiaohongshu creator-profile # 创作者资料
|
||||
opencli xiaohongshu creator-stats # 创作者数据统计
|
||||
|
||||
# 雪球 Xueqiu (browser)
|
||||
opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜
|
||||
opencli xueqiu stock --symbol SH600519 # 查看股票实时行情
|
||||
opencli xueqiu watchlist # 获取自选股/持仓列表
|
||||
opencli xueqiu feed # 我的关注 timeline
|
||||
opencli xueqiu hot --limit 10 # 雪球热榜
|
||||
opencli xueqiu search "特斯拉" # 搜索 (query positional)
|
||||
|
||||
# GitHub (via gh External CLI)
|
||||
opencli gh repo list # 列出仓库 (passthrough to gh)
|
||||
opencli gh pr list --limit 5 # PR 列表
|
||||
opencli gh issue list # Issue 列表
|
||||
|
||||
# Twitter/X (browser)
|
||||
opencli twitter trending --limit 10 # 热门话题
|
||||
opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
|
||||
opencli twitter search "AI" # 搜索推文 (query positional)
|
||||
opencli twitter profile elonmusk # 用户资料
|
||||
opencli twitter timeline --limit 20 # 时间线
|
||||
opencli twitter thread 1234567890 # 推文 thread(原文 + 回复)
|
||||
opencli twitter article 1891511252174299446 # 推文长文内容
|
||||
opencli twitter follow elonmusk # 关注用户
|
||||
opencli twitter unfollow elonmusk # 取消关注
|
||||
opencli twitter bookmark https://x.com/... # 收藏推文
|
||||
opencli twitter unbookmark https://x.com/... # 取消收藏
|
||||
|
||||
# Reddit (browser)
|
||||
opencli reddit hot --limit 10 # 热门帖子
|
||||
opencli reddit hot --subreddit programming # 指定子版块
|
||||
opencli reddit frontpage --limit 10 # 首页 /r/all
|
||||
opencli reddit popular --limit 10 # /r/popular 热门
|
||||
opencli reddit search "AI" --sort top --time week # 搜索(支持排序+时间过滤)
|
||||
opencli reddit subreddit rust --sort top --time month # 子版块浏览(支持时间过滤)
|
||||
opencli reddit read --post-id 1abc123 # 阅读帖子 + 评论
|
||||
opencli reddit user spez # 用户资料(karma、注册时间)
|
||||
opencli reddit user-posts spez # 用户发帖历史
|
||||
opencli reddit user-comments spez # 用户评论历史
|
||||
opencli reddit upvote --post-id xxx --direction up # 投票(up/down/none)
|
||||
opencli reddit save --post-id xxx # 收藏帖子
|
||||
opencli reddit comment --post-id xxx "Great!" # 发表评论 (text positional)
|
||||
opencli reddit subscribe --subreddit python # 订阅子版块
|
||||
opencli reddit saved --limit 10 # 我的收藏
|
||||
opencli reddit upvoted --limit 10 # 我的赞
|
||||
|
||||
# V2EX (public + browser)
|
||||
opencli v2ex hot --limit 10 # 热门话题
|
||||
opencli v2ex latest --limit 10 # 最新话题
|
||||
opencli v2ex topic 1024 # 主题详情 (id positional)
|
||||
opencli v2ex daily # 每日签到 (browser)
|
||||
opencli v2ex me # 我的信息 (browser)
|
||||
opencli v2ex notifications --limit 10 # 通知 (browser)
|
||||
|
||||
# Hacker News (public)
|
||||
opencli hackernews top --limit 10 # Top stories
|
||||
|
||||
# BBC (public)
|
||||
opencli bbc news --limit 10 # BBC News RSS headlines
|
||||
|
||||
# 微博 (browser)
|
||||
opencli weibo hot --limit 10 # 微博热搜
|
||||
|
||||
# BOSS直聘 (browser)
|
||||
opencli boss search "AI agent" # 搜索职位 (query positional)
|
||||
opencli boss detail --security-id xxx # 职位详情
|
||||
opencli boss recommend --limit 10 # 推荐职位
|
||||
opencli boss joblist --limit 10 # 职位列表
|
||||
opencli boss greet --security-id xxx # 打招呼
|
||||
opencli boss batchgreet --job-id xxx # 批量打招呼
|
||||
opencli boss send --uid xxx "消息内容" # 发消息 (text positional)
|
||||
opencli boss chatlist --limit 10 # 聊天列表
|
||||
opencli boss chatmsg --security-id xxx # 聊天记录
|
||||
opencli boss invite --security-id xxx # 邀请沟通
|
||||
opencli boss mark --security-id xxx # 标记管理
|
||||
opencli boss exchange --security-id xxx # 交换联系方式
|
||||
opencli boss resume # 简历管理
|
||||
opencli boss stats # 数据统计
|
||||
|
||||
# YouTube (browser)
|
||||
opencli youtube search "rust" # 搜索视频 (query positional)
|
||||
opencli youtube video "https://www.youtube.com/watch?v=xxx" # 视频元数据
|
||||
opencli youtube transcript "https://www.youtube.com/watch?v=xxx" # 获取视频字幕/转录
|
||||
opencli youtube transcript "xxx" --lang zh-Hans --mode raw # 指定语言 + 原始时间戳模式
|
||||
|
||||
# Yahoo Finance (browser)
|
||||
opencli yahoo-finance quote --symbol AAPL # 股票行情
|
||||
|
||||
# Sina Finance
|
||||
opencli sinafinance news --limit 10 --type 1 # 7x24实时快讯 (0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它)
|
||||
|
||||
# Reuters (browser)
|
||||
opencli reuters search "AI" # 路透社搜索 (query positional)
|
||||
|
||||
# 什么值得买 (browser)
|
||||
opencli smzdm search "耳机" # 搜索好价 (query positional)
|
||||
|
||||
# 携程 (browser)
|
||||
opencli ctrip search "三亚" # 搜索目的地 (query positional)
|
||||
|
||||
# Antigravity (Electron/CDP)
|
||||
opencli antigravity status # 检查 CDP 连接
|
||||
opencli antigravity send "hello" # 发送文本到当前 agent 聊天框
|
||||
opencli antigravity read # 读取整个聊天记录面板
|
||||
opencli antigravity new # 清空聊天、开启新对话
|
||||
opencli antigravity dump # 导出 DOM 和快照调试信息
|
||||
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
|
||||
opencli antigravity model claude # 切换底层模型
|
||||
opencli antigravity watch # 流式监听增量消息
|
||||
|
||||
# Barchart (browser)
|
||||
opencli barchart quote --symbol AAPL # 股票行情
|
||||
opencli barchart options --symbol AAPL # 期权链
|
||||
opencli barchart greeks --symbol AAPL # 期权 Greeks
|
||||
opencli barchart flow --limit 20 # 异常期权活动
|
||||
|
||||
# Jike 即刻 (browser)
|
||||
opencli jike feed --limit 10 # 动态流
|
||||
opencli jike search "AI" # 搜索 (query positional)
|
||||
opencli jike create "内容" # 发布动态 (text positional)
|
||||
opencli jike like xxx # 点赞 (id positional)
|
||||
opencli jike comment xxx "评论" # 评论 (id + text positional)
|
||||
opencli jike repost xxx # 转发 (id positional)
|
||||
opencli jike notifications # 通知
|
||||
|
||||
# Linux.do (public)
|
||||
opencli linux-do hot --limit 10 # 热门话题
|
||||
opencli linux-do latest --limit 10 # 最新话题
|
||||
opencli linux-do search "rust" # 搜索 (query positional)
|
||||
opencli linux-do topic 1024 # 主题详情 (id positional)
|
||||
|
||||
# StackOverflow (public)
|
||||
opencli stackoverflow hot --limit 10 # 热门问题
|
||||
opencli stackoverflow search "typescript" # 搜索 (query positional)
|
||||
opencli stackoverflow bounties --limit 10 # 悬赏问题
|
||||
|
||||
# WeRead 微信读书 (browser)
|
||||
opencli weread shelf --limit 10 # 书架
|
||||
opencli weread search "AI" # 搜索图书 (query positional)
|
||||
opencli weread book xxx # 图书详情 (book-id positional)
|
||||
opencli weread highlights xxx # 划线笔记 (book-id positional)
|
||||
opencli weread notes xxx # 想法笔记 (book-id positional)
|
||||
opencli weread ranking --limit 10 # 排行榜
|
||||
|
||||
# Jimeng 即梦 AI (browser)
|
||||
opencli jimeng generate --prompt "描述" # AI 生图
|
||||
opencli jimeng history --limit 10 # 生成历史
|
||||
|
||||
# Yollomi yollomi.com (browser — 需在 Chrome 登录 yollomi.com,复用站点 session)
|
||||
opencli yollomi models --type image # 列出图像模型与积分
|
||||
opencli yollomi generate "提示词" --model z-image-turbo # 文生图
|
||||
opencli yollomi video "提示词" --model kling-2-1 # 视频
|
||||
opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用
|
||||
opencli yollomi remove-bg <image-url> # 去背景(免费)
|
||||
opencli yollomi edit <image-url> "改成油画风格" # Qwen 图像编辑
|
||||
|
||||
# Grok (default + explicit web)
|
||||
opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径)
|
||||
opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路径
|
||||
|
||||
# HuggingFace (public)
|
||||
opencli hf top --limit 10 # 热门模型
|
||||
|
||||
# 超星学习通 (browser)
|
||||
opencli chaoxing assignments # 作业列表
|
||||
opencli chaoxing exams # 考试列表
|
||||
|
||||
# Douban 豆瓣 (browser)
|
||||
opencli douban search "三体" # 搜索 (query positional)
|
||||
opencli douban top250 # 豆瓣 Top 250
|
||||
opencli douban subject 1234567 # 条目详情 (id positional)
|
||||
opencli douban marks --limit 10 # 我的标记
|
||||
opencli douban reviews --limit 10 # 短评
|
||||
|
||||
# Facebook (browser)
|
||||
opencli facebook feed --limit 10 # 动态流
|
||||
opencli facebook profile username # 用户资料 (id positional)
|
||||
opencli facebook search "AI" # 搜索 (query positional)
|
||||
opencli facebook friends # 好友列表
|
||||
opencli facebook groups # 群组
|
||||
opencli facebook events # 活动
|
||||
opencli facebook notifications # 通知
|
||||
opencli facebook memories # 回忆
|
||||
opencli facebook add-friend username # 添加好友 (id positional)
|
||||
opencli facebook join-group groupid # 加入群组 (id positional)
|
||||
|
||||
# Instagram (browser)
|
||||
opencli instagram explore # 探索
|
||||
opencli instagram profile username # 用户资料 (id positional)
|
||||
opencli instagram search "AI" # 搜索 (query positional)
|
||||
opencli instagram user username # 用户详情 (id positional)
|
||||
opencli instagram followers username # 粉丝 (id positional)
|
||||
opencli instagram following username # 关注 (id positional)
|
||||
opencli instagram follow username # 关注用户 (id positional)
|
||||
opencli instagram unfollow username # 取消关注 (id positional)
|
||||
opencli instagram like postid # 点赞 (id positional)
|
||||
opencli instagram unlike postid # 取消点赞 (id positional)
|
||||
opencli instagram comment postid "评论" # 评论 (id + text positional)
|
||||
opencli instagram save postid # 收藏 (id positional)
|
||||
opencli instagram unsave postid # 取消收藏 (id positional)
|
||||
opencli instagram saved # 已收藏列表
|
||||
|
||||
# TikTok (browser)
|
||||
opencli tiktok explore # 探索
|
||||
opencli tiktok search "AI" # 搜索 (query positional)
|
||||
opencli tiktok profile username # 用户资料 (id positional)
|
||||
opencli tiktok user username # 用户详情 (id positional)
|
||||
opencli tiktok following username # 关注列表 (id positional)
|
||||
opencli tiktok follow username # 关注 (id positional)
|
||||
opencli tiktok unfollow username # 取消关注 (id positional)
|
||||
opencli tiktok like videoid # 点赞 (id positional)
|
||||
opencli tiktok unlike videoid # 取消点赞 (id positional)
|
||||
opencli tiktok comment videoid "评论" # 评论 (id + text positional)
|
||||
opencli tiktok save videoid # 收藏 (id positional)
|
||||
opencli tiktok unsave videoid # 取消收藏 (id positional)
|
||||
opencli tiktok live # 直播
|
||||
opencli tiktok notifications # 通知
|
||||
opencli tiktok friends # 朋友
|
||||
|
||||
# Medium (browser)
|
||||
opencli medium feed --limit 10 # 动态流
|
||||
opencli medium search "AI" # 搜索 (query positional)
|
||||
opencli medium user username # 用户主页 (id positional)
|
||||
|
||||
# Substack (browser)
|
||||
opencli substack feed --limit 10 # 订阅动态
|
||||
opencli substack search "AI" # 搜索 (query positional)
|
||||
opencli substack publication name # 出版物详情 (id positional)
|
||||
|
||||
# Sinablog 新浪博客 (browser)
|
||||
opencli sinablog hot --limit 10 # 热门
|
||||
opencli sinablog search "AI" # 搜索 (query positional)
|
||||
opencli sinablog article url # 文章详情
|
||||
opencli sinablog user username # 用户主页 (id positional)
|
||||
|
||||
# Lobsters (public)
|
||||
opencli lobsters hot --limit 10 # 热门
|
||||
opencli lobsters newest --limit 10 # 最新
|
||||
opencli lobsters active --limit 10 # 活跃
|
||||
opencli lobsters tag rust # 按标签筛选 (tag positional)
|
||||
|
||||
# Google (public)
|
||||
opencli google news --limit 10 # 新闻
|
||||
opencli google search "AI" # 搜索 (query positional)
|
||||
opencli google suggest "AI" # 搜索建议 (query positional)
|
||||
opencli google trends # 趋势
|
||||
|
||||
# DEV.to (public)
|
||||
opencli devto top --limit 10 # 热门文章
|
||||
opencli devto tag javascript --limit 10 # 按标签 (tag positional)
|
||||
opencli devto user username # 用户文章 (username positional)
|
||||
|
||||
# Steam (public)
|
||||
opencli steam top-sellers --limit 10 # 热销游戏
|
||||
|
||||
# Wikipedia (public)
|
||||
opencli wikipedia search "AI" # 搜索 (query positional)
|
||||
opencli wikipedia summary "Python" # 摘要 (title positional)
|
||||
```
|
||||
|
||||
### Desktop Adapter Commands
|
||||
|
||||
```bash
|
||||
# Cursor (desktop — CDP via Electron)
|
||||
opencli cursor status # 检查连接
|
||||
opencli cursor send "message" # 发送消息
|
||||
opencli cursor read # 读取回复
|
||||
opencli cursor new # 新建对话
|
||||
opencli cursor dump # 导出 DOM 调试信息
|
||||
opencli cursor composer # Composer 模式
|
||||
opencli cursor model claude # 切换模型
|
||||
opencli cursor extract-code # 提取代码块
|
||||
opencli cursor ask "question" # 一键提问并等回复
|
||||
opencli cursor screenshot # 截图
|
||||
opencli cursor history # 对话历史
|
||||
opencli cursor export # 导出对话
|
||||
|
||||
# Codex (desktop — headless CLI agent)
|
||||
opencli codex status # 检查连接
|
||||
opencli codex send "message" # 发送消息
|
||||
opencli codex read # 读取回复
|
||||
opencli codex new # 新建对话
|
||||
opencli codex dump # 导出调试信息
|
||||
opencli codex extract-diff # 提取 diff
|
||||
opencli codex model gpt-4 # 切换模型
|
||||
opencli codex ask "question" # 一键提问并等回复
|
||||
opencli codex screenshot # 截图
|
||||
opencli codex history # 对话历史
|
||||
opencli codex export # 导出对话
|
||||
|
||||
# ChatGPT (desktop — macOS AppleScript/CDP)
|
||||
opencli chatgpt status # 检查应用状态
|
||||
opencli chatgpt new # 新建对话
|
||||
opencli chatgpt send "message" # 发送消息
|
||||
opencli chatgpt read # 读取回复
|
||||
opencli chatgpt ask "question" # 一键提问并等回复
|
||||
|
||||
# ChatWise (desktop — multi-LLM client)
|
||||
opencli chatwise status # 检查连接
|
||||
opencli chatwise new # 新建对话
|
||||
opencli chatwise send "message" # 发送消息
|
||||
opencli chatwise read # 读取回复
|
||||
opencli chatwise ask "question" # 一键提问并等回复
|
||||
opencli chatwise model claude # 切换模型
|
||||
opencli chatwise history # 对话历史
|
||||
opencli chatwise export # 导出对话
|
||||
opencli chatwise screenshot # 截图
|
||||
|
||||
# Notion (desktop — CDP via Electron)
|
||||
opencli notion status # 检查连接
|
||||
opencli notion search "keyword" # 搜索页面
|
||||
opencli notion read # 读取当前页面
|
||||
opencli notion new # 新建页面
|
||||
opencli notion write "content" # 写入内容
|
||||
opencli notion sidebar # 侧边栏导航
|
||||
opencli notion favorites # 收藏列表
|
||||
opencli notion export # 导出
|
||||
|
||||
# Discord App (desktop — CDP via Electron)
|
||||
opencli discord-app status # 检查连接
|
||||
opencli discord-app send "message" # 发送消息
|
||||
opencli discord-app read # 读取消息
|
||||
opencli discord-app channels # 频道列表
|
||||
opencli discord-app servers # 服务器列表
|
||||
opencli discord-app search "keyword" # 搜索
|
||||
opencli discord-app members # 成员列表
|
||||
|
||||
# Doubao App 豆包桌面版 (desktop — CDP via Electron)
|
||||
opencli doubao-app status # 检查连接
|
||||
opencli doubao-app new # 新建对话
|
||||
opencli doubao-app send "message" # 发送消息
|
||||
opencli doubao-app read # 读取回复
|
||||
opencli doubao-app ask "question" # 一键提问并等回复
|
||||
opencli doubao-app screenshot # 截图
|
||||
opencli doubao-app dump # 导出 DOM 调试信息
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
|
||||
```bash
|
||||
opencli list # List all commands (including External CLIs)
|
||||
opencli list --json # JSON output
|
||||
opencli list -f yaml # YAML output
|
||||
opencli install <name> # Auto-install an external CLI (e.g., gh, obsidian)
|
||||
opencli register <name> # Register a local custom CLI for unified discovery
|
||||
opencli validate # Validate all CLI definitions
|
||||
opencli validate bilibili # Validate specific site
|
||||
opencli doctor # Diagnose browser bridge (auto-starts daemon, includes live test)
|
||||
```
|
||||
|
||||
### AI Agent Workflow
|
||||
|
||||
```bash
|
||||
# Deep Explore: network intercept → response analysis → capability inference
|
||||
opencli explore <url> --site <name>
|
||||
|
||||
# Synthesize: generate evaluate-based YAML pipelines from explore artifacts
|
||||
opencli synthesize <site>
|
||||
|
||||
# Generate: one-shot explore → synthesize → register
|
||||
opencli generate <url> --goal "hot"
|
||||
|
||||
# Record: YOU operate the page, opencli captures every API call → YAML candidates
|
||||
# Opens the URL in automation window, injects fetch/XHR interceptor into ALL tabs,
|
||||
# polls every 2s, auto-stops after 60s (or press Enter to stop early).
|
||||
opencli record <url> # 录制,site name 从域名推断
|
||||
opencli record <url> --site mysite # 指定 site name
|
||||
opencli record <url> --timeout 120000 # 自定义超时(毫秒,默认 60000)
|
||||
opencli record <url> --poll 1000 # 缩短轮询间隔(毫秒,默认 2000)
|
||||
opencli record <url> --out .opencli/record/x # 自定义输出目录
|
||||
# Output:
|
||||
# .opencli/record/<site>/captured.json ← 原始捕获数据(带 url/method/body)
|
||||
# .opencli/record/<site>/candidates/*.yaml ← 高置信度候选适配器(score ≥ 8,有 array 结果)
|
||||
|
||||
# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
|
||||
opencli cascade <api-url>
|
||||
|
||||
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
|
||||
opencli explore <url> --auto --click "字幕,CC,评论"
|
||||
|
||||
# Validate: validate adapter definitions
|
||||
opencli validate
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
|
||||
The `list` command supports the same formats and also keeps `--json` as a compatibility alias.
|
||||
|
||||
```bash
|
||||
opencli list -f yaml # YAML command registry
|
||||
opencli bilibili hot -f table # Default: rich table
|
||||
opencli bilibili hot -f json # JSON (pipe to jq, feed to AI agent)
|
||||
opencli bilibili hot -f yaml # YAML (readable structured output)
|
||||
opencli bilibili hot -f md # Markdown
|
||||
opencli bilibili hot -f csv # CSV
|
||||
```
|
||||
|
||||
## Verbose Mode
|
||||
|
||||
```bash
|
||||
opencli bilibili hot -v # Show each pipeline step and data flow
|
||||
```
|
||||
|
||||
## 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**(参考 `src/clis/tae/add-expense.ts` 风格):
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
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: '流程实例 ID(procInsId)' },
|
||||
],
|
||||
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. 文件放入 `src/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 | 无需处理 |
|
||||
|
||||
## Creating Adapters
|
||||
|
||||
> [!TIP]
|
||||
> **快速模式**:如果你只想为一个具体页面生成一个命令,直接看 [CLI-ONESHOT.md](./CLI-ONESHOT.md)。
|
||||
> 只需要一个 URL + 一句话描述,4 步搞定。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **完整模式 — 在写任何代码之前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。**
|
||||
> 它包含:① AI Agent 浏览器探索工作流 ② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
|
||||
> **下方仅为简化模板参考,直接使用极易踩坑。**
|
||||
|
||||
### YAML Pipeline (declarative, recommended)
|
||||
|
||||
Create `src/clis/<site>/<name>.yaml`:
|
||||
|
||||
```yaml
|
||||
site: mysite
|
||||
name: hot
|
||||
description: Hot topics
|
||||
domain: www.mysite.com
|
||||
strategy: cookie # public | cookie | header | intercept | ui
|
||||
browser: true
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
description: Number of items
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.mysite.com
|
||||
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const res = await fetch('/api/hot', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return d.data.items.map(item => ({
|
||||
title: item.title,
|
||||
score: item.score,
|
||||
}));
|
||||
})()
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
score: ${{ item.score }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, score]
|
||||
```
|
||||
|
||||
For public APIs (no browser):
|
||||
|
||||
```yaml
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: https://api.example.com/hot.json
|
||||
- select: data.items
|
||||
- map:
|
||||
title: ${{ item.title }}
|
||||
- limit: ${{ args.limit }}
|
||||
```
|
||||
|
||||
### TypeScript Adapter (programmatic)
|
||||
|
||||
Create `src/clis/<site>/<name>.ts`. It will be automatically dynamically loaded (DO NOT manually import it in `index.ts`):
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
name: 'search',
|
||||
strategy: Strategy.INTERCEPT, // Or COOKIE
|
||||
args: [{ name: 'query', required: true, positional: true }],
|
||||
columns: ['rank', 'title', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://www.mysite.com/search');
|
||||
|
||||
// Inject native XHR/Fetch interceptor hook
|
||||
await page.installInterceptor('/api/search');
|
||||
|
||||
// Auto scroll down to trigger lazy loading
|
||||
await page.autoScroll({ times: 3, delayMs: 2000 });
|
||||
|
||||
// Retrieve intercepted JSON payloads
|
||||
const requests = await page.getInterceptedRequests();
|
||||
|
||||
let results = [];
|
||||
for (const req of requests) {
|
||||
results.push(...req.data.items);
|
||||
}
|
||||
return results.map((item, i) => ({
|
||||
rank: i + 1, title: item.title, url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**When to use TS**: XHR interception (`page.installInterceptor`), infinite scrolling (`page.autoScroll`), cookie extraction, complex data transforms (like GraphQL unwrapping).
|
||||
|
||||
## Pipeline Steps
|
||||
|
||||
| Step | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `navigate` | Go to URL | `navigate: https://example.com` |
|
||||
| `fetch` | HTTP request (browser cookies) | `fetch: { url: "...", params: { q: "..." } }` |
|
||||
| `evaluate` | Run JavaScript in page | `evaluate: \| (async () => { ... })()` |
|
||||
| `select` | Extract JSON path | `select: data.items` |
|
||||
| `map` | Map fields | `map: { title: "${{ item.title }}" }` |
|
||||
| `filter` | Filter items | `filter: item.score > 100` |
|
||||
| `sort` | Sort items | `sort: { by: score, order: desc }` |
|
||||
| `limit` | Cap result count | `limit: ${{ args.limit }}` |
|
||||
| `intercept` | Declarative XHR capture | `intercept: { trigger: "navigate:...", capture: "api/hot" }` |
|
||||
| `tap` | Store action + XHR capture | `tap: { store: "feed", action: "fetchFeeds", capture: "homefeed" }` |
|
||||
| `snapshot` | Page accessibility tree | `snapshot: { interactive: true }` |
|
||||
| `click` | Click element | `click: ${{ ref }}` |
|
||||
| `type` | Type text | `type: { ref: "@1", text: "hello" }` |
|
||||
| `wait` | Wait for time/text | `wait: 2` or `wait: { text: "loaded" }` |
|
||||
| `press` | Press key | `press: Enter` |
|
||||
|
||||
## Template Syntax
|
||||
|
||||
```yaml
|
||||
# Arguments with defaults
|
||||
${{ args.query }}
|
||||
${{ args.limit | default(20) }}
|
||||
|
||||
# Current item (in map/filter)
|
||||
${{ item.title }}
|
||||
${{ item.data.nested.field }}
|
||||
|
||||
# Index (0-based)
|
||||
${{ index }}
|
||||
${{ index + 1 }}
|
||||
```
|
||||
|
||||
## 5-Tier Authentication Strategy
|
||||
|
||||
| Tier | Name | Method | Example |
|
||||
|------|------|--------|---------|
|
||||
| 1 | `public` | No auth, Node.js fetch | Hacker News, V2EX |
|
||||
| 2 | `cookie` | Browser fetch with `credentials: include` | Bilibili, Zhihu |
|
||||
| 3 | `header` | Custom headers (ct0, Bearer) | Twitter GraphQL |
|
||||
| 4 | `intercept` | XHR interception + store mutation | 小红书 Pinia |
|
||||
| 5 | `ui` | Full UI automation (click/type/scroll) | Last resort |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
|
||||
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
|
||||
| `OPENCLI_VERBOSE` | — | Show daemon/extension logs |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `npx not found` | Install Node.js: `brew install node` |
|
||||
| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension |
|
||||
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
|
||||
| Empty table data | Check if evaluate returns correct data path |
|
||||
| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs |
|
||||
@@ -68,7 +68,6 @@ src/
|
||||
| `tests/e2e/browser-auth.test.ts` | `bilibili`、`twitter`、`v2ex`、`xueqiu`、`linux-do`、`xiaohongshu` 的需登录命令 graceful failure |
|
||||
| `tests/e2e/management.test.ts` | `list`、`validate`、`verify`、`--version`、`--help`、unknown command |
|
||||
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
|
||||
| `tests/e2e/plugin-management.test.ts` | `plugin install` / `list` / `update` / `uninstall` 全生命周期 |
|
||||
|
||||
### 烟雾测试(1 个文件)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
56/59
|
||||
@@ -1 +0,0 @@
|
||||
31/31
|
||||
@@ -1,686 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "extract-title-example",
|
||||
"steps": [
|
||||
"opencli operate open https://example.com",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Example Domain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-title-iana",
|
||||
"steps": [
|
||||
"opencli operate open https://www.iana.org",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-paragraph-wiki-js",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "programming language"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-paragraph-wiki-python",
|
||||
"steps": [
|
||||
"opencli operate open \"https://en.wikipedia.org/wiki/Python_(programming_language)\"",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "programming language"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-github-stars",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/browser-use/browser-use",
|
||||
"opencli operate eval \"document.querySelector('#repo-stars-counter-star')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-github-description",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/anthropics/claude-code",
|
||||
"opencli operate 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 operate open https://github.com/vercel/next.js",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=readme] h1, [data-testid=readme] h2, #readme h1, #readme h2, article h1, article h2, .markdown-body h1, .markdown-body h2')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-npm-downloads",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/package/zod",
|
||||
"opencli operate eval \"document.querySelector('[data-nosnippet]')?.textContent?.trim() || document.querySelector('p.f2874b88')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extract-npm-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/package/express",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list-hn-top5",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate 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 operate open https://news.ycombinator.com",
|
||||
"opencli operate 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 operate open https://books.toscrape.com",
|
||||
"opencli operate 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 operate open https://books.toscrape.com",
|
||||
"opencli operate 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 operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote, [class*=quote]')].slice(0,3).map(el=>({text:(el.querySelector('.text, [class*=text]')?.textContent)||(el.querySelector('span')?.textContent),author:(el.querySelector('.author, [class*=author]')?.textContent)||(el.querySelector('small')?.textContent)})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list-quotes-tags",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate 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 operate open https://github.com/trending",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,3).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim()),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list-github-trending-lang",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,5).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim())})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list-jsonplaceholder-posts",
|
||||
"steps": [
|
||||
"opencli operate open https://jsonplaceholder.typicode.com/posts",
|
||||
"opencli operate 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 operate open https://jsonplaceholder.typicode.com/users",
|
||||
"opencli operate 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 operate open https://www.google.com/search?q=opencli+github",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "index 5 may vary"
|
||||
},
|
||||
{
|
||||
"name": "search-ddg",
|
||||
"steps": [
|
||||
"opencli operate open https://duckduckgo.com",
|
||||
"opencli operate state",
|
||||
"opencli operate type 1 \"weather beijing\"",
|
||||
"opencli operate keys Enter",
|
||||
"opencli operate 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 operate open https://duckduckgo.com",
|
||||
"opencli operate eval \"document.querySelector('input[name=q]').value='TypeScript tutorial';document.querySelector('form').submit();'submitted'\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate 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 operate open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "programming language"
|
||||
},
|
||||
"note": "index may vary"
|
||||
},
|
||||
{
|
||||
"name": "search-npm",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/search?q=react",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3, .package-list-item h3, a[class*=package] h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "index may vary"
|
||||
},
|
||||
{
|
||||
"name": "search-github",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/search?q=browser+automation&type=repositories",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate 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 operate open https://example.com",
|
||||
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title + ' ' + location.href\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "IANA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nav-click-hn-first",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate eval \"document.querySelector('.titleline a')?.click();'clicked'\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nav-click-hn-comments",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate eval \"document.querySelector('.subtext a:last-child')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nav-click-wiki-link",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
|
||||
"opencli operate eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nav-click-github-tab",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/vercel/next.js",
|
||||
"opencli operate eval \"document.querySelector('[data-tab-item=i1issues-tab] a, #issues-tab')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nav-go-back",
|
||||
"steps": [
|
||||
"opencli operate open https://example.com",
|
||||
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Example Domain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nav-multi-step",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"document.querySelector('.next a')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('.quote .text')?.textContent\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scroll-footer-quotes",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate scroll down",
|
||||
"opencli operate scroll down",
|
||||
"opencli operate eval \"document.querySelector('footer, .footer, .tags-box')?.textContent?.trim().slice(0,100)\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scroll-footer-books",
|
||||
"steps": [
|
||||
"opencli operate open https://books.toscrape.com",
|
||||
"opencli operate scroll down",
|
||||
"opencli operate scroll down",
|
||||
"opencli operate eval \"document.querySelector('.pager .current')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scroll-long-page",
|
||||
"steps": [
|
||||
"opencli operate open https://jsonplaceholder.typicode.com/posts",
|
||||
"opencli operate eval \"JSON.parse(document.body.innerText).length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scroll-find-element",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"document.querySelector('.next a')?.href\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scroll-lazy-load",
|
||||
"steps": [
|
||||
"opencli operate open https://books.toscrape.com",
|
||||
"opencli operate eval \"document.querySelectorAll('article.product_pod').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "form-simple-name",
|
||||
"steps": [
|
||||
"opencli operate open https://httpbin.org/forms/post",
|
||||
"opencli operate 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 operate open https://httpbin.org/forms/post",
|
||||
"opencli operate 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 operate open https://httpbin.org/forms/post",
|
||||
"opencli operate 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 operate open https://httpbin.org/forms/post",
|
||||
"opencli operate 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 operate open https://httpbin.org/forms/post",
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments], textarea[name=delivery], textarea');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoResearch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "form-login-fake",
|
||||
"steps": [
|
||||
"opencli operate open https://the-internet.herokuapp.com/login",
|
||||
"opencli operate 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 operate open https://en.wikipedia.org/wiki/JavaScript",
|
||||
"opencli operate 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 operate open https://books.toscrape.com",
|
||||
"opencli operate eval \"document.querySelector('article.product_pod h3 a')?.click();'clicked'\"",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent,price:document.querySelector('.price_color')?.textContent})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "complex-quotes-page2",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"document.querySelector('.next a')?.click();'clicked'\"",
|
||||
"opencli operate 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 operate open https://github.com/expressjs/express",
|
||||
"opencli operate 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 operate open https://news.ycombinator.com",
|
||||
"opencli operate eval \"document.querySelector('.subtext a:last-child')?.click();'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('.fatitem .titleline a')?.textContent\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "complex-multi-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org/wiki/TypeScript",
|
||||
"opencli operate 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 operate open https://old.reddit.com",
|
||||
"opencli operate 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 operate open https://www.imdb.com/title/tt0133093/",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "1999"
|
||||
},
|
||||
"set": "test"
|
||||
},
|
||||
{
|
||||
"name": "bench-npm-zod",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/package/zod",
|
||||
"opencli operate 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 operate open https://en.wikipedia.org/wiki/Machine_learning",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "learning"
|
||||
},
|
||||
"set": "test"
|
||||
},
|
||||
{
|
||||
"name": "bench-github-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/torvalds",
|
||||
"opencli operate 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 operate open https://books.toscrape.com",
|
||||
"opencli operate eval \"document.querySelector('a[href*=science]')?.click();'clicked'\"",
|
||||
"opencli operate 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 operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"document.querySelector('.author + a, a[href*=author]')?.click();'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('.author-description, .author-details p')?.textContent?.slice(0,100)\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"set": "test"
|
||||
},
|
||||
{
|
||||
"name": "bench-ddg-images",
|
||||
"steps": [
|
||||
"opencli operate open https://duckduckgo.com",
|
||||
"opencli operate eval \"document.querySelector('input[name=q]').value='sunset';document.querySelector('form').submit();'submitted'\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate 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 operate open https://httpbin.org/headers",
|
||||
"opencli operate eval \"JSON.parse(document.body.innerText).headers['User-Agent']\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"set": "test"
|
||||
},
|
||||
{
|
||||
"name": "bench-jsonapi-todo",
|
||||
"steps": [
|
||||
"opencli operate open https://jsonplaceholder.typicode.com/todos",
|
||||
"opencli operate 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"
|
||||
}
|
||||
]
|
||||
@@ -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 operate close'); } catch {}
|
||||
let lastOutput = '';
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
const passed = lastOutput.trim().length > 0; // simplified check
|
||||
if (!passed) console.error(` ✗ ${task.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const task = tasks.find(t => t.name === taskName);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${taskName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
|
||||
console.log(` Steps: ${task.steps.length}`);
|
||||
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
|
||||
console.log(` Max iterations: ${maxIterations}\n`);
|
||||
|
||||
initLog();
|
||||
|
||||
// Phase 1: Gather — run the task and capture output
|
||||
console.log('Phase 1: Gathering symptoms...');
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
|
||||
lastOutput = exec(step);
|
||||
if (i < task.steps.length - 1) {
|
||||
console.log(` → ${lastOutput.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
|
||||
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
|
||||
|
||||
// Phase 2: Hypothesize + investigate via Claude Code
|
||||
for (let iter = 1; iter <= maxIterations; iter++) {
|
||||
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
|
||||
|
||||
const prompt = `You are debugging a failing browser automation task.
|
||||
|
||||
## Task: ${taskName}
|
||||
Steps:
|
||||
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
## Judge criteria
|
||||
${JSON.stringify(task.judge)}
|
||||
|
||||
## Last output
|
||||
${lastOutput.slice(0, 500)}
|
||||
|
||||
## Instructions
|
||||
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
|
||||
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
|
||||
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
|
||||
4. If CONFIRMED: describe the root cause and suggest a fix
|
||||
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
|
||||
|
||||
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
|
||||
// Extract hypothesis and result
|
||||
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
|
||||
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
|
||||
|
||||
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
|
||||
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
|
||||
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
|
||||
|
||||
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
|
||||
console.log(` Verdict: ${verdict} — ${description.slice(0, 100)}`);
|
||||
|
||||
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
|
||||
|
||||
if (verdict === 'CONFIRMED') {
|
||||
console.log(`\n✅ Root cause found at iteration ${iter}!`);
|
||||
console.log(` ${description}`);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(` Error: ${err.message?.slice(0, 100)}`);
|
||||
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
|
||||
}
|
||||
|
||||
// Re-run task for fresh output
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
}
|
||||
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,145 +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 {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await engine.run();
|
||||
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
|
||||
if (finalMetric === 0) {
|
||||
console.log('\n✅ All errors fixed!\n');
|
||||
} else {
|
||||
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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();
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch — Main autonomous iteration loop.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
|
||||
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
|
||||
*
|
||||
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
|
||||
* Engine handles commit, verify, guard, keep/discard, and logging.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, type AutoResearchConfig } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
|
||||
const recent = ctx.recentLog.slice(-10).map(r =>
|
||||
` ${r.status.padEnd(12)} ${r.description}`
|
||||
).join('\n');
|
||||
|
||||
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
|
||||
|
||||
## Goal
|
||||
${config.goal}
|
||||
|
||||
## Current State
|
||||
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
|
||||
- Iteration: ${ctx.iteration}
|
||||
- Consecutive discards: ${ctx.consecutiveDiscards}
|
||||
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
|
||||
|
||||
## Recent History
|
||||
${recent || ' (no history yet)'}
|
||||
|
||||
## Git Log (recent experiments)
|
||||
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
|
||||
|
||||
## Scope (files you can modify)
|
||||
${ctx.scopeFiles.join('\n')}
|
||||
|
||||
## Rules
|
||||
1. Make ONE atomic change (one logical intent, even if multiple files)
|
||||
2. Read the failing test output or code BEFORE modifying
|
||||
3. DO NOT modify test files or the verify command
|
||||
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
|
||||
5. If previous approach was discarded, try something DIFFERENT
|
||||
6. Focus on the specific failures — read error messages carefully`;
|
||||
}
|
||||
|
||||
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
|
||||
const prompt = buildModifyPrompt(ctx, config);
|
||||
|
||||
console.log(' Claude Code making a change...');
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{
|
||||
cwd: ROOT,
|
||||
timeout: 300_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}
|
||||
).trim();
|
||||
|
||||
// Extract description from Claude's response (last non-empty line or summary)
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
|
||||
return desc.slice(0, 120);
|
||||
} catch (err: any) {
|
||||
console.error(' Claude Code failed:', err.message?.slice(0, 100));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Resolve config from preset or CLI args
|
||||
let config: AutoResearchConfig;
|
||||
if (args.preset) {
|
||||
config = PRESETS[args.preset];
|
||||
if (!config) {
|
||||
console.error(`Unknown preset: ${args.preset}`);
|
||||
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Allow CLI overrides
|
||||
if (args.iterations != null) config = { ...config, iterations: args.iterations };
|
||||
if (args.guard != null) config = { ...config, guard: args.guard };
|
||||
} else if (args.goal && args.verify) {
|
||||
config = {
|
||||
goal: args.goal,
|
||||
scope: args.scope ?? ['src/**/*.ts'],
|
||||
metric: args.metric ?? 'score',
|
||||
direction: args.direction ?? 'higher',
|
||||
verify: args.verify,
|
||||
guard: args.guard,
|
||||
iterations: args.iterations,
|
||||
minDelta: args.minDelta,
|
||||
};
|
||||
} else {
|
||||
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
|
||||
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
|
||||
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 AutoResearch: ${config.goal}`);
|
||||
console.log(` Metric: ${config.metric} (${config.direction})`);
|
||||
console.log(` Verify: ${config.verify}`);
|
||||
console.log(` Guard: ${config.guard ?? '(none)'}`);
|
||||
console.log(` Iterations: ${config.iterations ?? '∞'}`);
|
||||
console.log('');
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: (ctx) => modify(ctx, config),
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
await engine.run();
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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 operate pass rate to 59/59" */
|
||||
goal: string;
|
||||
/** Glob patterns for files the agent can modify */
|
||||
scope: string[];
|
||||
/** What the metric measures, e.g. "pass_count" */
|
||||
metric: string;
|
||||
/** Whether improvement means the number goes up or down */
|
||||
direction: 'higher' | 'lower';
|
||||
/** Shell command that outputs a number (the metric value) */
|
||||
verify: string;
|
||||
/** Optional guard command — must pass for a keep decision */
|
||||
guard?: string;
|
||||
/** Max iterations (undefined = unbounded) */
|
||||
iterations?: number;
|
||||
/** Minimum delta to count as real improvement (noise filter) */
|
||||
minDelta?: number;
|
||||
}
|
||||
|
||||
export type IterationStatus =
|
||||
| 'baseline'
|
||||
| 'keep'
|
||||
| 'keep (reworked)'
|
||||
| 'discard'
|
||||
| 'crash'
|
||||
| 'no-op'
|
||||
| 'hook-blocked';
|
||||
|
||||
export interface IterationResult {
|
||||
iteration: number;
|
||||
commit: string;
|
||||
metric: number;
|
||||
delta: number;
|
||||
guard: 'pass' | 'fail' | '-';
|
||||
status: IterationStatus;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
|
||||
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
|
||||
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (arg) {
|
||||
case '--preset': config.preset = next; i++; break;
|
||||
case '--goal': config.goal = next; i++; break;
|
||||
case '--scope': config.scope = next?.split(','); i++; break;
|
||||
case '--metric': config.metric = next; i++; break;
|
||||
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
|
||||
case '--verify': config.verify = next; i++; break;
|
||||
case '--guard': config.guard = next; i++; break;
|
||||
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
|
||||
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
|
||||
case '--task': config.task = next; i++; break;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Extract a number from command output using common patterns */
|
||||
export function extractMetric(output: string): number | null {
|
||||
// Try: last line that looks like a number
|
||||
const lines = output.trim().split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
|
||||
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
|
||||
if (scoreMatch) return parseFloat(scoreMatch[1]);
|
||||
const numMatch = line.match(/^[\d.]+$/);
|
||||
if (numMatch) return parseFloat(numMatch[0]);
|
||||
}
|
||||
// Fallback: first number in output
|
||||
const fallback = output.match(/(\d+(?:\.\d+)?)/);
|
||||
return fallback ? parseFloat(fallback[1]) : null;
|
||||
}
|
||||
@@ -1,359 +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 } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
export interface EngineCallbacks {
|
||||
/** Called at Phase 2-3: review context, ideate, and make ONE change.
|
||||
* Return a one-sentence description of what was changed, or null to skip. */
|
||||
modify(context: ModifyContext): Promise<string | null>;
|
||||
|
||||
/** Called when engine needs to report status */
|
||||
onStatus?(msg: string): void;
|
||||
}
|
||||
|
||||
export interface ModifyContext {
|
||||
iteration: number;
|
||||
bestMetric: number;
|
||||
currentMetric: number;
|
||||
recentLog: IterationResult[];
|
||||
gitLog: string;
|
||||
scopeFiles: string[];
|
||||
consecutiveDiscards: number;
|
||||
stuckHint: string | null;
|
||||
}
|
||||
|
||||
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
|
||||
|
||||
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: opts?.cwd ?? ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function execStrict(cmd: string, opts?: { timeout?: number }): string {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private config: AutoResearchConfig;
|
||||
private logger: Logger;
|
||||
private callbacks: EngineCallbacks;
|
||||
private bestMetric: number = 0;
|
||||
private currentMetric: number = 0;
|
||||
private iteration: number = 0;
|
||||
|
||||
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
|
||||
this.config = config;
|
||||
this.logger = new Logger(logPath);
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
private log(msg: string): void {
|
||||
this.callbacks.onStatus?.(msg);
|
||||
}
|
||||
|
||||
/** Phase 0: Precondition checks */
|
||||
private checkPreconditions(): void {
|
||||
// Git repo exists
|
||||
try { execStrict('git rev-parse --git-dir'); }
|
||||
catch { throw new Error('Not a git repository'); }
|
||||
|
||||
// Clean working tree
|
||||
const status = exec('git status --porcelain');
|
||||
if (status) throw new Error(`Working tree not clean:\n${status}`);
|
||||
|
||||
// No stale locks
|
||||
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
|
||||
throw new Error('Stale .git/index.lock found — remove it first');
|
||||
}
|
||||
|
||||
// Not detached HEAD
|
||||
try { execStrict('git symbolic-ref HEAD'); }
|
||||
catch { throw new Error('Detached HEAD — checkout a branch first'); }
|
||||
}
|
||||
|
||||
/** Phase 5: Run verify command and extract metric */
|
||||
private runVerify(): number | null {
|
||||
this.log(' verify...');
|
||||
const output = exec(this.config.verify, { timeout: 300_000 });
|
||||
return extractMetric(output);
|
||||
}
|
||||
|
||||
/** Phase 5.5: Run guard command */
|
||||
private runGuard(): boolean {
|
||||
if (!this.config.guard) return true;
|
||||
this.log(' guard...');
|
||||
try {
|
||||
execStrict(this.config.guard, { timeout: 300_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 4: Commit changes */
|
||||
private commit(description: string): string | null {
|
||||
// Stage all changes in scope (but not untracked outside scope)
|
||||
exec('git add -A');
|
||||
const diff = exec('git diff --cached --quiet; echo $?');
|
||||
if (diff === '0') return null; // no changes
|
||||
|
||||
try {
|
||||
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
|
||||
return exec('git rev-parse --short HEAD');
|
||||
} catch {
|
||||
// Hook failure
|
||||
exec('git reset HEAD');
|
||||
return 'hook-blocked';
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 6: Rollback */
|
||||
private safeRevert(): void {
|
||||
try {
|
||||
execStrict('git revert HEAD --no-edit');
|
||||
} catch {
|
||||
exec('git revert --abort');
|
||||
exec('git reset --hard HEAD~1');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get stuck hint when >5 consecutive discards */
|
||||
private getStuckHint(discards: number): string | null {
|
||||
if (discards < 5) return null;
|
||||
const hints = [
|
||||
'Re-read ALL scope files from scratch. Try a completely different approach.',
|
||||
'Review entire results log — what worked before? Try combining successful changes.',
|
||||
'Try the OPPOSITE of what has been failing.',
|
||||
'Try a radical architectural change instead of incremental tweaks.',
|
||||
'Simplify — remove complexity rather than adding it.',
|
||||
];
|
||||
return hints[Math.min(discards - 5, hints.length - 1)];
|
||||
}
|
||||
|
||||
/** Run the main loop */
|
||||
async run(): Promise<IterationResult[]> {
|
||||
const results: IterationResult[] = [];
|
||||
|
||||
// Phase 0: Preconditions
|
||||
this.log('Phase 0: Precondition checks...');
|
||||
this.checkPreconditions();
|
||||
|
||||
// Initialize logger
|
||||
this.logger.init(this.config);
|
||||
|
||||
// Baseline measurement
|
||||
this.log('Measuring baseline...');
|
||||
const baseline = this.runVerify();
|
||||
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
|
||||
this.bestMetric = baseline;
|
||||
this.currentMetric = baseline;
|
||||
|
||||
const baselineCommit = exec('git rev-parse --short HEAD');
|
||||
const baselineResult: IterationResult = {
|
||||
iteration: 0,
|
||||
commit: baselineCommit,
|
||||
metric: baseline,
|
||||
delta: 0,
|
||||
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
|
||||
status: 'baseline',
|
||||
description: `initial state — ${this.config.metric} ${baseline}`,
|
||||
};
|
||||
this.logger.append(baselineResult);
|
||||
results.push(baselineResult);
|
||||
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
|
||||
|
||||
// Main loop
|
||||
const maxIter = this.config.iterations ?? Infinity;
|
||||
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
|
||||
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
|
||||
|
||||
// Phase 1: Review
|
||||
const gitLog = exec('git log --oneline -20');
|
||||
const recentLog = this.logger.readLast(20);
|
||||
const scopeFiles = this.config.scope;
|
||||
const consecutiveDiscards = this.logger.consecutiveDiscards();
|
||||
|
||||
// Phase 2-3: Ideate + Modify (delegated to callback)
|
||||
const context: ModifyContext = {
|
||||
iteration: this.iteration,
|
||||
bestMetric: this.bestMetric,
|
||||
currentMetric: this.currentMetric,
|
||||
recentLog,
|
||||
gitLog,
|
||||
scopeFiles,
|
||||
consecutiveDiscards,
|
||||
stuckHint: this.getStuckHint(consecutiveDiscards),
|
||||
};
|
||||
|
||||
let description: string | null;
|
||||
try {
|
||||
description = await this.callbacks.modify(context);
|
||||
} catch (err: any) {
|
||||
this.log(` modify error: ${err.message}`);
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `modify crashed: ${err.message?.slice(0, 80)}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: 'no changes made',
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 4: Commit
|
||||
this.log(` commit: ${description}`);
|
||||
const commitHash = this.commit(description);
|
||||
if (!commitHash) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: `no diff after: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
if (commitHash === 'hook-blocked') {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'hook-blocked',
|
||||
description: `hook rejected: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 5: Verify
|
||||
const metric = this.runVerify();
|
||||
if (metric == null) {
|
||||
this.log(' verify crashed — reverting');
|
||||
this.safeRevert();
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `verify crashed: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
const improved = this.config.direction === 'higher'
|
||||
? metric > this.bestMetric
|
||||
: metric < this.bestMetric;
|
||||
const delta = +(metric - this.bestMetric).toFixed(4);
|
||||
const absDelta = Math.abs(delta);
|
||||
const minDelta = this.config.minDelta ?? 0;
|
||||
|
||||
// Phase 5.5: Guard
|
||||
let guardResult: 'pass' | 'fail' | '-' = '-';
|
||||
if (this.config.guard && improved && absDelta >= minDelta) {
|
||||
guardResult = this.runGuard() ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
// Phase 6: Decide
|
||||
let status: IterationStatus;
|
||||
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
|
||||
status = 'keep';
|
||||
this.bestMetric = metric;
|
||||
this.currentMetric = metric;
|
||||
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
} else if (improved && guardResult === 'fail') {
|
||||
this.log(' guard failed — reverting');
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
this.log(` ✗ DISCARD (guard) — ${description}`);
|
||||
} else {
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
|
||||
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
}
|
||||
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: status === 'keep' ? commitHash : '-',
|
||||
metric,
|
||||
delta,
|
||||
guard: guardResult,
|
||||
status,
|
||||
description,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
|
||||
const discards = results.filter(r => r.status === 'discard');
|
||||
this.log(`\n${'━'.repeat(50)}`);
|
||||
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
|
||||
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 1: Deterministic Browse Command Testing
|
||||
*
|
||||
* Runs predefined opencli operate 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 operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate 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();
|
||||
@@ -1,238 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 5: Publish Testing — end-to-end content creation via operate commands
|
||||
*
|
||||
* Tests the full chain: read content → navigate to platform → fill title+body → (optionally) publish → verify → cleanup
|
||||
*
|
||||
* Task types:
|
||||
* fill-only: navigate + fill fields + verify content was entered (safe, no side effects)
|
||||
* publish: full publish + verify + cleanup (deletes the post after verification)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-publish.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-publish.ts --task twitter-fill # Run single task
|
||||
* npx tsx autoresearch/eval-publish.ts --type fill-only # Run only fill tasks (safe)
|
||||
* npx tsx autoresearch/eval-publish.ts --type publish # Run only publish tasks (destructive)
|
||||
* npx tsx autoresearch/eval-publish.ts --platform twitter # Run only twitter tasks
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const TASKS_FILE = join(__dirname, 'publish-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface PublishTask {
|
||||
name: string;
|
||||
platform: string;
|
||||
type: 'fill-only' | 'publish';
|
||||
description: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
cleanup?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
platform: string;
|
||||
taskType: 'fill-only' | 'publish';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
cleanupResult?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern, 'i').test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: PublishTask): TaskResult {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Run main steps
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
process.stderr.write(` step ${i + 1}/${task.steps.length}: ${step.slice(0, 60)}...\n`);
|
||||
lastOutput = runCommand(step, 45000);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
// Run cleanup steps (if publish type and cleanup defined)
|
||||
let cleanupResult: string | undefined;
|
||||
if (task.cleanup && task.cleanup.length > 0) {
|
||||
process.stderr.write(` cleanup: ${task.cleanup.length} steps...\n`);
|
||||
let cleanupOutput = '';
|
||||
for (const step of task.cleanup) {
|
||||
cleanupOutput = runCommand(step, 30000);
|
||||
}
|
||||
cleanupResult = cleanupOutput.slice(0, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
cleanupResult,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const filterType = args.includes('--type') ? args[args.indexOf('--type') + 1] : null;
|
||||
const filterPlatform = args.includes('--platform') ? args[args.indexOf('--platform') + 1] : null;
|
||||
|
||||
const allTasks: PublishTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
let tasks = allTasks;
|
||||
|
||||
if (singleTask) tasks = tasks.filter(t => t.name === singleTask);
|
||||
if (filterType) tasks = tasks.filter(t => t.type === filterType);
|
||||
if (filterPlatform) tasks = tasks.filter(t => t.platform === filterPlatform);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`No tasks matched filters: task=${singleTask}, type=${filterType}, platform=${filterPlatform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fillTasks = tasks.filter(t => t.type === 'fill-only');
|
||||
const publishTasks = tasks.filter(t => t.type === 'publish');
|
||||
|
||||
console.log(`\n📝 Layer 5: Publish Testing — ${tasks.length} tasks`);
|
||||
console.log(` fill-only: ${fillTasks.length} | publish: ${publishTasks.length}`);
|
||||
console.log(` platforms: ${[...new Set(tasks.map(t => t.platform))].join(', ')}\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const icon = task.type === 'publish' ? '🚀' : '📋';
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${icon} ${task.name} (${task.platform})...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const status = result.passed ? '✓' : '✗';
|
||||
const cleanup = result.cleanupResult ? ` [cleanup: ${result.cleanupResult.slice(0, 30)}]` : '';
|
||||
console.log(` ${status} (${(result.duration / 1000).toFixed(1)}s)${cleanup}`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const fillPassed = results.filter(r => r.taskType === 'fill-only' && r.passed).length;
|
||||
const publishPassed = results.filter(r => r.taskType === 'publish' && r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
const fillTotal = results.filter(r => r.taskType === 'fill-only').length;
|
||||
const publishTotal = results.filter(r => r.taskType === 'publish').length;
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length}`);
|
||||
console.log(` fill-only: ${fillPassed}/${fillTotal}`);
|
||||
console.log(` publish: ${publishPassed}/${publishTotal}`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
// Platform breakdown
|
||||
const platforms = [...new Set(results.map(r => r.platform))];
|
||||
for (const p of platforms) {
|
||||
const pr = results.filter(r => r.platform === p);
|
||||
const pp = pr.filter(r => r.passed).length;
|
||||
console.log(` ${p}: ${pp}/${pr.length}`);
|
||||
}
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.platform}/${f.taskType}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('publish-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `publish-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
fillScore: `${fillPassed}/${fillTotal}`,
|
||||
publishScore: `${publishPassed}/${publishTotal}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 4: Save as CLI Testing — "Save as CLI" Pipeline
|
||||
*
|
||||
* Tests the full operate init → write adapter → operate verify flow.
|
||||
* Validates that browser exploration can be crystallized into reusable CLI adapters.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-save.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-save.ts --task hn-top # Run single task
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'save-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const USER_CLIS_DIR = join(homedir(), '.opencli', 'clis');
|
||||
|
||||
interface SaveTask {
|
||||
name: string;
|
||||
site: string;
|
||||
command: string;
|
||||
/** Inline adapter code (simple tasks) */
|
||||
adapter?: string;
|
||||
/** Path to adapter file relative to autoresearch/ dir (complex tasks — avoids JSON escape issues) */
|
||||
adapterFile?: string;
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
phase: 'init' | 'write' | 'verify' | 'judge';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
set: 'train' | 'test';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
// operate verify outputs table text; try JSON parse first, then count non-empty lines
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON — try line counting */ }
|
||||
// Table output: count data rows (skip header, separator, empty lines)
|
||||
const lines = output.split('\n').filter(l => l.trim() && !l.startsWith('─') && !l.startsWith('┌') && !l.startsWith('└') && !l.startsWith('├'));
|
||||
// Subtract header row
|
||||
const dataLines = lines.length > 1 ? lines.length - 1 : 0;
|
||||
return dataLines >= criteria.minLength;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
|
||||
/** Run a command, using local dist/main.js instead of global opencli for consistency */
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
// Use local build so tests always run against the current source
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupAdapter(site: string, command: string): void {
|
||||
const siteDir = join(USER_CLIS_DIR, site);
|
||||
const filePath = join(siteDir, `${command}.ts`);
|
||||
try {
|
||||
if (existsSync(filePath)) rmSync(filePath);
|
||||
// Remove site dir if empty
|
||||
if (existsSync(siteDir)) {
|
||||
const remaining = readdirSync(siteDir);
|
||||
if (remaining.length === 0) rmSync(siteDir, { recursive: true });
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function runTask(task: SaveTask): TaskResult {
|
||||
const start = Date.now();
|
||||
const { site, command } = task;
|
||||
const adapterDir = join(USER_CLIS_DIR, site);
|
||||
const adapterPath = join(adapterDir, `${command}.ts`);
|
||||
|
||||
// Cleanup any leftover from previous runs
|
||||
cleanupAdapter(site, command);
|
||||
|
||||
try {
|
||||
// Phase 1: init — create scaffold
|
||||
const initOutput = runCommand(`opencli operate init ${site}/${command}`);
|
||||
if (!existsSync(adapterPath)) {
|
||||
return {
|
||||
name: task.name, phase: 'init', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: `init failed: file not created. Output: ${initOutput.slice(0, 100)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: write — overwrite scaffold with real adapter code
|
||||
if (task.adapterFile) {
|
||||
// Read from file (complex adapters — avoids JSON string escape issues)
|
||||
const srcPath = join(__dirname, task.adapterFile);
|
||||
const code = readFileSync(srcPath, 'utf-8');
|
||||
writeFileSync(adapterPath, code, 'utf-8');
|
||||
} else if (task.adapter) {
|
||||
writeFileSync(adapterPath, task.adapter, 'utf-8');
|
||||
}
|
||||
|
||||
// Phase 3: verify — run the adapter via operate verify
|
||||
const verifyOutput = runCommand(
|
||||
`opencli operate verify ${site}/${command}`,
|
||||
45000, // longer timeout for network calls
|
||||
);
|
||||
|
||||
if (verifyOutput.includes('✗ Adapter failed')) {
|
||||
return {
|
||||
name: task.name, phase: 'verify', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: `verify failed: ${verifyOutput.slice(0, 200)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 4: judge — check output quality
|
||||
const passed = judge(task.judge, verifyOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
phase: 'judge',
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Judge failed on output: ${verifyOutput.slice(0, 150)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name, phase: 'verify', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
} finally {
|
||||
// Always cleanup test adapters
|
||||
cleanupAdapter(site, command);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
|
||||
const allTasks: SaveTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const tasks = singleTask ? allTasks.filter(t => t.name === singleTask) : allTasks;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`Task "${singleTask}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🧪 Layer 4: Save as CLI — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
const phase = result.passed ? '' : ` (${result.phase})`;
|
||||
console.log(` ${icon}${phase} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const trainResults = results.filter(r => r.set === 'train');
|
||||
const testResults = results.filter(r => r.set === 'test');
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const trainPassed = trainResults.filter(r => r.passed).length;
|
||||
const testPassed = testResults.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length} (train: ${trainPassed}/${trainResults.length}, test: ${testPassed}/${testResults.length})`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.phase}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('save-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `save-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
trainScore: `${trainPassed}/${trainResults.length}`,
|
||||
testScore: `${testPassed}/${testResults.length}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
|
||||
*
|
||||
* Spawns Claude Code with the opencli-operate 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-operate', '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 operate 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 operate 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();
|
||||
@@ -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 operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('v2ex-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `v2ex-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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 operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('zhihu-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `zhihu-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
export { operateReliability } from './operate-reliability.js';
|
||||
export { skillQuality } from './skill-quality.js';
|
||||
export { v2exReliability } from './v2ex-reliability.js';
|
||||
export { zhihuReliability } from './zhihu-reliability.js';
|
||||
export { combinedReliability } from './combined-reliability.js';
|
||||
export { saveReliability } from './save-reliability.js';
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
import { operateReliability } from './operate-reliability.js';
|
||||
import { skillQuality } from './skill-quality.js';
|
||||
import { v2exReliability } from './v2ex-reliability.js';
|
||||
import { zhihuReliability } from './zhihu-reliability.js';
|
||||
import { combinedReliability } from './combined-reliability.js';
|
||||
import { saveReliability } from './save-reliability.js';
|
||||
|
||||
export const PRESETS: Record<string, AutoResearchConfig> = {
|
||||
'operate-reliability': operateReliability,
|
||||
'skill-quality': skillQuality,
|
||||
'v2ex-reliability': v2exReliability,
|
||||
'zhihu-reliability': zhihuReliability,
|
||||
'combined': combinedReliability,
|
||||
'save-reliability': saveReliability,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Preset: Operate Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
|
||||
* Metric: number of passing browse-tasks (out of 59).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const operateReliability: AutoResearchConfig = {
|
||||
goal: 'Increase operate command pass rate to 59/59 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Preset: Save as CLI Reliability
|
||||
*
|
||||
* Optimizes the "Save as CLI" pipeline: operate init → write adapter → run.
|
||||
* Covers PUBLIC (no auth) and COOKIE (browser session) strategies.
|
||||
* Metric: number of passing save-tasks.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const saveReliability: AutoResearchConfig = {
|
||||
goal: 'Increase "Save as CLI" pipeline pass rate to 100%. The flow is: operate init creates a scaffold, user writes adapter code, opencli discovers and runs it. Covers both PUBLIC (fetch API) and COOKIE (browser session) strategies. Focus on: init template correctness, user CLI discovery, adapter loading, verify command robustness, and browser session handling.',
|
||||
scope: [
|
||||
'src/cli.ts',
|
||||
'src/discovery.ts',
|
||||
'src/registry.ts',
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
'autoresearch/save-tasks.json',
|
||||
'autoresearch/save-adapters/*.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-save.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Preset: Skill E2E Quality
|
||||
*
|
||||
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Metric: number of passing skill-tasks (out of 35).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const skillQuality: AutoResearchConfig = {
|
||||
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
|
||||
scope: [
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 20,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Preset: V2EX Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the V2EX-specific test suite.
|
||||
* 40 tasks across 5 difficulty layers (atomic → complex chain).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const v2exReliability: AutoResearchConfig = {
|
||||
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-v2ex.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Preset: Zhihu Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Zhihu test suite.
|
||||
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
|
||||
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const zhihuReliability: AutoResearchConfig = {
|
||||
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-zhihu.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -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 operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval - fill only test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "3-step: open compose → paste text via ClipboardEvent → verify text in composer"
|
||||
},
|
||||
{
|
||||
"name": "twitter-post-and-delete",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Post a tweet, verify success, then delete it",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "6-step chain: open compose → paste text → click post → wait → verify toast → cleanup: find tweet → menu → delete → confirm"
|
||||
},
|
||||
{
|
||||
"name": "twitter-read-hn-then-post",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Read HN top story title, compose a tweet about it, post, then delete",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const title = document.title || 'HN Story'; const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Interesting from HN: ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "9-step cross-site chain: read HN title → navigate to twitter compose → paste content → post → verify → cleanup delete"
|
||||
},
|
||||
{
|
||||
"name": "twitter-reply-to-own-tweet",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to own profile, find latest tweet, open reply box, fill reply text",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: home → find first tweet → click reply → fill reply text → verify content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to a popular question, open answer editor, fill in answer content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这是一个 OpenCLI 发文测试,时间戳: ' + Date.now() + '</p><p>这段内容用于验证 operate 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to question → click '写回答' → find editor → fill rich content (title + body) → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-article",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhihu article editor (zhuanlan), fill title + body (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ta = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'); if (!ta) return 'no-title-input'; ta.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(ta, '[AutoTest] OpenCLI 发文能力验证 ' + Date.now()); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是 OpenCLI autoresearch 发文测试集的一部分。</p><p>测试链路:导航 → 填写标题 → 填写正文 → 验证内容。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'))?.value || ''; const body = document.querySelector('[contenteditable=true]')?.textContent || ''; return JSON.stringify({ title: title.slice(0, 50), body: body.slice(0, 50) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to zhuanlan editor → fill title textarea → fill rich text body → verify both title and body content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-hn-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Read HN top story, then navigate to zhihu question and fill an answer about it",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || document.querySelector('[data-zop-retarget=\\\"answer\\\"]'); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 分享一个来自 Hacker News 的有趣内容</p><p>这是一个跨平台内容搬运测试,时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step cross-site chain: read HN title → navigate zhihu question → click 写回答 → fill answer with HN content → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-thread-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to compose, type first tweet, add thread tweet, type second tweet, verify both",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 1 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'first-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const addBtn = document.querySelector('[data-testid=\\\"addButton\\\"]') || document.querySelector('[aria-label=\\\"Add post\\\"]'); if (addBtn) { addBtn.click(); return 'thread-added'; } return 'no-add-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const box = boxes[boxes.length - 1]; if (!box) return 'no-second-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 2 - continuation'); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'second-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const t1 = boxes[0]?.textContent || ''; const t2 = boxes[boxes.length - 1]?.textContent || ''; return JSON.stringify({ tweet1: t1, tweet2: t2 }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Thread tweet 2"
|
||||
},
|
||||
"note": "10-step thread compose: open composer → fill tweet 1 → click add thread → fill tweet 2 → verify both tweets present"
|
||||
},
|
||||
{
|
||||
"name": "twitter-quote-retweet-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to home, find first tweet, open retweet menu, select Quote, fill quote text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const retweet = tweet.querySelector('[data-testid=\\\"retweet\\\"]'); if (retweet) { retweet.click(); return 'retweet-menu-opened'; } return 'no-retweet-btn'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Quote') || item.textContent?.includes('引用')) { item.click(); return 'quote-selected'; } } return 'no-quote-option'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Quote retweet test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'quote-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Quote retweet test"
|
||||
},
|
||||
"note": "8-step quote retweet: home → find tweet → click retweet → select Quote → fill quote text → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-search-then-reply-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'opencli' on twitter, find first result, click reply, fill reply text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/search?q=opencli&src=typed_query&f=live",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply from search result ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'reply-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Reply from search"
|
||||
},
|
||||
"note": "8-step search-then-reply: navigate to search URL → verify results → click reply on first → fill reply → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'AI agent' on zhihu, click first question result, click 写回答, fill answer, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI%20agent",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-question-links'; const link = links[0]; const href = link.getAttribute('href'); return 'found: ' + href; })()\"",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-links'; const link = links[0]; const href = link.getAttribute('href'); const match = href.match(/\\\\/question\\\\/(\\\\d+)/); if (match) { window.location.href = 'https://www.zhihu.com/question/' + match[1]; return 'navigating-to-question'; } link.click(); return 'clicked-link'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || Array.from(document.querySelectorAll('a')).find(a => a.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] AI agent 搜索后回答测试 ' + Date.now() + '</p><p>这是通过搜索 → 进入问题 → 填写回答的完整链路测试。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "9-step search-then-answer: search zhihu → find question link → navigate → click 写回答 → fill answer → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-question-fill-comment",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to question page, scroll to first answer, click comment, fill comment text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const answer = document.querySelector('[data-testid=\\\"answer\\\"]') || document.querySelector('.AnswerItem') || document.querySelector('.List-item'); if (answer) { answer.scrollIntoView({ behavior: 'smooth', block: 'center' }); return 'answer-scrolled'; } return 'no-answer'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const commentBtns = document.querySelectorAll('button'); for (const btn of commentBtns) { if (btn.textContent?.match(/评论|条评论|comment/i)) { btn.click(); return 'comment-opened: ' + btn.textContent.trim(); } } const commentIcons = document.querySelectorAll('[data-testid=\\\"comment\\\"]') || []; for (const icon of commentIcons) { icon.click(); return 'comment-icon-clicked'; } return 'no-comment-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-comment-editor'; editor.focus(); if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { editor.value = '[AutoTest] 评论测试 ' + Date.now(); editor.dispatchEvent(new Event('input', { bubbles: true })); } else { editor.innerHTML = '<p>[AutoTest] 评论测试 ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); } return 'comment-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; return editor.value || editor.textContent || ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step comment chain: navigate question → scroll to answer → click comment → fill comment text → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-article-with-formatting",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhuanlan editor, fill title, fill body with multiple paragraphs and bold text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] 格式化文章测试 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是第一段:OpenCLI 格式化发文测试。</p><p><strong>[AutoTest-Bold] 这是加粗的第二段,用于验证富文本格式。</strong></p><p>这是第三段,包含普通文本内容,时间戳: ' + Date.now() + '。</p><p>这是第四段,测试多段落填充能力。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled-with-formatting'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; const hasBold = editor.querySelector('strong') || editor.querySelector('b'); const paragraphs = editor.querySelectorAll('p'); return JSON.stringify({ paragraphCount: paragraphs.length, hasBold: !!hasBold, preview: editor.textContent?.slice(0, 80) }); })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), bodyHasBold: body.includes('AutoTest-Bold'), bodyLength: body.length }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step formatted article: navigate editor → fill title → fill body with <strong> bold + 4 paragraphs → verify formatting + content"
|
||||
},
|
||||
{
|
||||
"name": "cross-zhihu-to-twitter",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read zhihu hot topic title, navigate to twitter compose, fill tweet with zhihu content, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const hotItem = document.querySelector('.HotItem-content a') || document.querySelector('.HotList-item a') || document.querySelector('[data-testid=\\\"hot-item\\\"] a') || document.querySelector('.HotItem a'); if (hotItem) return hotItem.textContent?.trim()?.slice(0, 60) || 'no-text'; const titles = document.querySelectorAll('h2'); for (const t of titles) { if (t.textContent?.trim().length > 5) return t.textContent.trim().slice(0, 60); } return 'no-hot-topic'; })()\"",
|
||||
"opencli operate state save zhihu_hot_title",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Zhihu热榜话题搬运: 知乎上正在热议的话题 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'tweet-filled-with-zhihu'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Zhihu热榜话题搬运"
|
||||
},
|
||||
"note": "10-step cross-platform: read zhihu hot → save state → navigate twitter compose → fill tweet with zhihu content → verify"
|
||||
},
|
||||
{
|
||||
"name": "cross-twitter-to-zhihu",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read twitter trending/explore topic, navigate to zhihu zhuanlan editor, fill title and body, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/explore/tabs/trending",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const trends = document.querySelectorAll('[data-testid=\\\"trend\\\"]'); if (trends.length > 0) { const first = trends[0]; return first.textContent?.trim()?.slice(0, 80) || 'no-text'; } const spans = document.querySelectorAll('span'); for (const s of spans) { if (s.textContent?.startsWith('#') || s.textContent?.includes('Trending')) { return s.textContent.trim().slice(0, 80); } } return 'no-trending-topic'; })()\"",
|
||||
"opencli operate state save twitter_trending",
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] Twitter热点搬运: 来自推特的热门话题 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这篇文章搬运自 Twitter 热门话题。</p><p>Twitter 上正在讨论的热门话题为大家带来了新的视角和思考。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), body: body.slice(0, 60) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Twitter热点搬运"
|
||||
},
|
||||
"note": "10-step cross-platform reverse: read twitter trending → save state → navigate zhihu editor → fill title + body → verify"
|
||||
}
|
||||
]
|
||||
@@ -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 "$@"
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Layer 4: Save as CLI — test the full save pipeline
|
||||
# Tests: operate init → write adapter → operate verify
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== Layer 4: Save as CLI ==="
|
||||
echo "Testing: init → write → verify pipeline"
|
||||
echo ""
|
||||
|
||||
npx tsx autoresearch/eval-save.ts "$@"
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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(/"/g, '\"').replace(/'/g, \"'\").slice(0, 80),\n answer: q.correct_answer,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Open Trivia DB API — stable, no-auth, returns trivia questions with correct answers"
|
||||
}
|
||||
]
|
||||
@@ -1,899 +0,0 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-tab",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-first-topic-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-extract-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "v2ex.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait selector \"a[href^='/t/']\"",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 2: Single Page Tasks (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-list",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-pagination-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"max\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=jobs",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-replies-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-count",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-member-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Livid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-search-results",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 3: Multi-Step (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-topic-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-navigate-node-from-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-pagination-page2",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "p=2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-then-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-find-more",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-to-topic-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-tab-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 4: Write Operations (5 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-type-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoResearch test reply"
|
||||
},
|
||||
"note": "Types into reply box but does NOT submit"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-favorite-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "favorite|收藏"
|
||||
},
|
||||
"note": "Finds favorite link but does NOT click it"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-thank-reply-find",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"found\":\\d+"
|
||||
},
|
||||
"note": "Finds thank buttons but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "\"textarea\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-create-topic-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/new",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"note": "Detects create topic form elements, does NOT submit"
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 5: Complex Chain (5 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-collect-hot-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-node-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-cross-page-data-collect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-full-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 6: State + Click Interaction (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate click 1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-tab-tech",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+|no-ref"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-count-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-scroll-state",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-type-search-box",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-value-after-type",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-screenshot-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-html-selector",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get html --selector h1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait text V2EX"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "found|appeared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-3-pages",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-navigate-extract-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-multi-node-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-topic-replies-pagination",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-member-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-search-navigate-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-tab-topic-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-node-page2-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-full-interaction",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-deep-5-step",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
|
||||
"opencli operate back",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA navigation, timing, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/go/python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-after-click",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on topic page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-and-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-concurrent-eval",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.*\"url\":.*\"links\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-unicode-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Agent-Style: state + click + type (no eval for interaction) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-first-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
},
|
||||
"note": "Finds the index of first topic link via data-opencli-ref"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-type-search",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate type 3 TypeScript",
|
||||
"opencli operate get value 3"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
},
|
||||
"note": "Types into search box using state index"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-navigate-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-has-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-after-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 800",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "page_scroll: [\\d.]+↑"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,848 +0,0 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "知乎"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-state",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[@?\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-click-tab-hot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "zhihu\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate wait text 推荐",
|
||||
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "pressed|Pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-screenshot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate screenshot /tmp/zhihu-test.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-list",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-metrics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-nav-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "推荐"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-with-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-types",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"question\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-avatar",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-input-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search found"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L3: Question Page Operations (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-first-answer",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"content\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-votes",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-buttons",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multiple-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-count-number",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L4: Multi-Step Navigation (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-page",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-load-more",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L5: Write Operations (6 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-upvote-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "赞同"
|
||||
},
|
||||
"note": "Finds upvote button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-follow-question-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "关注问题"
|
||||
},
|
||||
"note": "Finds follow button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-comment-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "评论"
|
||||
},
|
||||
"note": "Finds comment button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-bookmark-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "收藏|found"
|
||||
},
|
||||
"note": "Finds bookmark button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-write-answer-btn",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "写回答"
|
||||
},
|
||||
"note": "Finds write answer button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-share-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "分享"
|
||||
},
|
||||
"note": "Finds share button but does NOT click"
|
||||
},
|
||||
{
|
||||
"_comment": "=== L6: Long Chain Workflows (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-read-answer-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multi-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-scroll-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-compare-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-questions",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L7: Search Workflows (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-basic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-people",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-click-result",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-filter-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"total\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-full-browse-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-deep-author-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"profile\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-cross-question-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"q1_title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-read-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-3-page-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-scroll-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA lazy load, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/people/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-click-verify-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on question page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-lazy-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-extract-structured",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-answer-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"answerCount\":\\d+"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ export default defineConfig({
|
||||
{ text: 'Comparison', link: '/comparison' },
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
{ text: 'Add an Electron App CLI', link: '/guide/electron-app-cli' },
|
||||
{ text: 'Plugins', link: '/guide/plugins' },
|
||||
],
|
||||
},
|
||||
@@ -50,7 +49,6 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Twitter / X', link: '/adapters/browser/twitter' },
|
||||
{ text: 'Reddit', link: '/adapters/browser/reddit' },
|
||||
{ text: 'Tieba', link: '/adapters/browser/tieba' },
|
||||
{ text: 'Bilibili', link: '/adapters/browser/bilibili' },
|
||||
{ text: 'Zhihu', link: '/adapters/browser/zhihu' },
|
||||
{ text: 'Xiaohongshu', link: '/adapters/browser/xiaohongshu' },
|
||||
@@ -69,30 +67,12 @@ export default defineConfig({
|
||||
{ text: 'Jimeng', link: '/adapters/browser/jimeng' },
|
||||
{ text: 'Yollomi', link: '/adapters/browser/yollomi' },
|
||||
{ text: 'LINUX DO', link: '/adapters/browser/linux-do' },
|
||||
{ text: 'Band', link: '/adapters/browser/band' },
|
||||
{ text: 'Chaoxing', link: '/adapters/browser/chaoxing' },
|
||||
{ text: 'Grok', link: '/adapters/browser/grok' },
|
||||
{ text: 'Amazon', link: '/adapters/browser/amazon' },
|
||||
{ text: 'Gemini', link: '/adapters/browser/gemini' },
|
||||
{ text: 'Yuanbao', link: '/adapters/browser/yuanbao' },
|
||||
{ text: 'NotebookLM', link: '/adapters/browser/notebooklm' },
|
||||
{ text: 'WeRead', link: '/adapters/browser/weread' },
|
||||
{ text: 'Douban', link: '/adapters/browser/douban' },
|
||||
{ text: 'Sina Blog', link: '/adapters/browser/sinablog' },
|
||||
{ text: 'Substack', link: '/adapters/browser/substack' },
|
||||
{ text: 'Pixiv', link: '/adapters/browser/pixiv' },
|
||||
{ text: 'Douban', link: '/adapters/browser/douban' },
|
||||
{ text: 'Doubao', link: '/adapters/browser/doubao' },
|
||||
{ text: 'Facebook', link: '/adapters/browser/facebook' },
|
||||
{ text: 'Google', link: '/adapters/browser/google' },
|
||||
{ text: 'IMDb', link: '/adapters/browser/imdb' },
|
||||
{ text: 'Instagram', link: '/adapters/browser/instagram' },
|
||||
{ text: 'JD.com', link: '/adapters/browser/jd' },
|
||||
{ text: 'Medium', link: '/adapters/browser/medium' },
|
||||
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
|
||||
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
|
||||
{ text: 'Weixin', link: '/adapters/browser/weixin' },
|
||||
{ text: 'Xianyu', link: '/adapters/browser/xianyu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -107,15 +87,11 @@ export default defineConfig({
|
||||
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
|
||||
{ text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' },
|
||||
{ text: 'arXiv', link: '/adapters/browser/arxiv' },
|
||||
{ text: 'paperreview.ai', link: '/adapters/browser/paperreview' },
|
||||
{ text: 'Barchart', link: '/adapters/browser/barchart' },
|
||||
{ text: 'Hugging Face', link: '/adapters/browser/hf' },
|
||||
{ text: 'Sina Finance', link: '/adapters/browser/sinafinance' },
|
||||
{ text: 'Spotify', link: '/adapters/browser/spotify' },
|
||||
{ text: 'Stack Overflow', link: '/adapters/browser/stackoverflow' },
|
||||
{ text: 'Wikipedia', link: '/adapters/browser/wikipedia' },
|
||||
{ text: 'Lobsters', link: '/adapters/browser/lobsters' },
|
||||
{ text: 'Steam', link: '/adapters/browser/steam' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -129,7 +105,6 @@ export default defineConfig({
|
||||
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
|
||||
{ text: 'Notion', link: '/adapters/desktop/notion' },
|
||||
{ text: 'Discord', link: '/adapters/desktop/discord' },
|
||||
{ text: 'Doubao App', link: '/adapters/desktop/doubao-app' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -179,7 +154,6 @@ export default defineConfig({
|
||||
{ text: '快速开始', link: '/zh/guide/getting-started' },
|
||||
{ text: '安装', link: '/zh/guide/installation' },
|
||||
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
|
||||
{ text: '给新 Electron 应用生成 CLI', link: '/zh/guide/electron-app-cli' },
|
||||
{ text: '插件', link: '/zh/guide/plugins' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# ONES 项目管理平台(OpenCLI)
|
||||
|
||||
基于官方 [ONES Project API](https://developer.ones.cn/zh-CN/docs/api/readme/),经 **Chrome + Browser Bridge** 在页面里 `fetch`(`credentials: 'include'`)。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `ONES_BASE_URL` | 是 | 与 Chrome 中访问的 ONES 根 URL 一致 |
|
||||
| `ONES_USER_ID` / `ONES_AUTH_TOKEN` | 视部署 | 若接口强制要文档中的 Header,再设置(可先只依赖浏览器登录) |
|
||||
| `ONES_EMAIL` / `ONES_PHONE` / `ONES_PASSWORD` | 否 | 供 `ones login` 脚本化 |
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
export ONES_BASE_URL=https://your-host
|
||||
# 安装扩展,Chrome 已登录 ONES
|
||||
|
||||
opencli ones me
|
||||
opencli ones token-info # teams column includes name(uuid), useful for tasks
|
||||
opencli ones tasks <teamUUID> --limit 20 --project <optional>
|
||||
opencli ones my-tasks <teamUUID> --limit 100 # default assignee=self
|
||||
opencli ones my-tasks <teamUUID> --mode field004 # deployments using field004 as assignee
|
||||
opencli ones my-tasks <teamUUID> --mode both # assignee OR creator
|
||||
opencli ones task <taskUUID> --team <teamUUID> # single task (URL .../task/<uuid>)
|
||||
opencli ones worklog <taskUUID> 2 --team <teamUUID> # log hours for today
|
||||
opencli ones worklog <taskUUID> 1 --team <teamUUID> --date 2026-03-01 # backfill
|
||||
opencli ones login --email you@corp.com --password '***' # optional; stderr prints header export hints
|
||||
opencli ones logout
|
||||
```
|
||||
|
||||
更完整的说明见 [docs/adapters/browser/ones.md](../adapters/browser/ones.md)。
|
||||
@@ -1,48 +0,0 @@
|
||||
# 36kr (36氪)
|
||||
|
||||
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `36kr.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli 36kr hot` | 36氪热榜 — trending articles |
|
||||
| `opencli 36kr news` | Latest tech/startup news from 36kr |
|
||||
| `opencli 36kr search <query>` | Search 36kr articles |
|
||||
| `opencli 36kr article <id-or-url>` | Read full article content |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Trending articles
|
||||
opencli 36kr hot --limit 10
|
||||
|
||||
# Hot by type
|
||||
opencli 36kr hot --type renqi --limit 10
|
||||
opencli 36kr hot --type zonghe --limit 10
|
||||
|
||||
# Latest news
|
||||
opencli 36kr news --limit 20
|
||||
|
||||
# Search articles
|
||||
opencli 36kr search "AI" --limit 10
|
||||
opencli 36kr search "OpenAI" --limit 5
|
||||
|
||||
# Read full article (by ID or URL)
|
||||
opencli 36kr article 3000000123456
|
||||
opencli 36kr article https://36kr.com/p/3000000123456
|
||||
|
||||
# JSON output
|
||||
opencli 36kr hot -f json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `news` uses the public RSS feed and works without Browser Bridge.
|
||||
- `hot`, `search`, and `article` use Browser Bridge and are best run with Chrome open.
|
||||
- `hot --type` accepts `catalog`, `renqi`, `zonghe`, and `shoucang`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `news`: No browser required — uses public RSS feed
|
||||
- `hot`, `search`, `article`: Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -1,53 +0,0 @@
|
||||
# Amazon
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `amazon.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli amazon bestsellers [<best-sellers-url>]` | Read Amazon Best Sellers pages for ranked candidate discovery |
|
||||
| `opencli amazon search "<query>"` | Read Amazon search results for coarse filtering |
|
||||
| `opencli amazon product <asin-or-url>` | Read a product page with title, price, rating, breadcrumbs, and bullets |
|
||||
| `opencli amazon offer <asin-or-url>` | Read seller / fulfillment / buy-box facts from the product page |
|
||||
| `opencli amazon discussion <asin-or-url>` | Read review summary and sample customer reviews |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Root Best Sellers page
|
||||
opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs --limit 10 -f json
|
||||
|
||||
# Category-specific Best Sellers page
|
||||
opencli amazon bestsellers "<category-best-sellers-url>" --limit 50 -f json
|
||||
|
||||
# Search products
|
||||
opencli amazon search "desk shelf organizer" --limit 20 -f json
|
||||
|
||||
# Validate one product
|
||||
opencli amazon product B0FJS72893 -f json
|
||||
|
||||
# Validate seller / offer facts
|
||||
opencli amazon offer B0FJS72893 -f json
|
||||
|
||||
# Read review summary + samples
|
||||
opencli amazon discussion B0FJS72893 --limit 5 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running with an active `amazon.com` session in the shared profile
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- This adapter only returns fields visible on public Amazon pages.
|
||||
- `bestsellers` and `search` are for candidate discovery; `product`, `offer`, and `discussion` are the validation surfaces.
|
||||
- `offer` is the right surface for `sold_by`, `ships_from`, and Amazon-retail exclusion.
|
||||
- `discussion` may return review data even when Q&A is absent. Missing Q&A is a normal outcome, not an error.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If Amazon shows a robot-check page, clear it in Chrome and retry.
|
||||
- If CDP is attached to the wrong tab, retry with `OPENCLI_CDP_TARGET=amazon.com`.
|
||||
- Avoid running multiple Amazon browser commands in parallel against the same shared Chrome target.
|
||||
@@ -1,63 +0,0 @@
|
||||
# Band
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `www.band.us`
|
||||
|
||||
Read posts, comments, and notifications from [Band](https://www.band.us), a private community platform. Authentication uses your logged-in Chrome session (cookie-based).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli band bands` | List all Bands you belong to |
|
||||
| `opencli band posts <band_no>` | List posts from a Band |
|
||||
| `opencli band post <band_no> <post_no>` | Export full post content including nested comments |
|
||||
| `opencli band mentions` | Show notifications where you were @mentioned |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# List all your bands (get band_no from here)
|
||||
opencli band bands
|
||||
|
||||
# List recent posts in a band
|
||||
opencli band posts 12345678 --limit 10
|
||||
|
||||
# Export a post with comments
|
||||
opencli band post 12345678 987654321
|
||||
|
||||
# Export post body only (skip comments)
|
||||
opencli band post 12345678 987654321 --comments false
|
||||
|
||||
# Export post and download attached photos
|
||||
opencli band post 12345678 987654321 --output ./band-photos
|
||||
|
||||
# Show recent @mention notifications
|
||||
opencli band mentions --limit 20
|
||||
|
||||
# Show only unread mentions
|
||||
opencli band mentions --unread true
|
||||
|
||||
# Show all notification types
|
||||
opencli band mentions --filter all
|
||||
```
|
||||
|
||||
### `band mentions` filter options
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `mentioned` | Only notifications where you were @mentioned (default) |
|
||||
| `all` | All notifications |
|
||||
| `post` | Post-related notifications |
|
||||
| `comment` | Comment-related notifications |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** [band.us](https://www.band.us)
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `band_no` is the numeric ID in the Band URL: `band.us/band/{band_no}/post`
|
||||
- `band bands` lists all your bands with their `band_no` values
|
||||
- `band post` output rows: `type=post` (the post itself), `type=comment` (top-level comment), `type=reply` (nested reply)
|
||||
- Photo downloads use the full-resolution URL (thumbnail query params are stripped automatically)
|
||||
@@ -1,53 +0,0 @@
|
||||
# Bluesky
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `bsky.app`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli bluesky profile` | User profile info |
|
||||
| `opencli bluesky user` | Recent posts from a user |
|
||||
| `opencli bluesky trending` | Trending topics |
|
||||
| `opencli bluesky search` | Search users |
|
||||
| `opencli bluesky feeds` | Popular feed generators |
|
||||
| `opencli bluesky followers` | User's followers |
|
||||
| `opencli bluesky following` | Accounts a user follows |
|
||||
| `opencli bluesky thread` | Post thread with replies |
|
||||
| `opencli bluesky starter-packs` | User's starter packs |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# User profile
|
||||
opencli bluesky profile --handle bsky.app
|
||||
|
||||
# Recent posts
|
||||
opencli bluesky user --handle bsky.app --limit 10
|
||||
|
||||
# Trending topics
|
||||
opencli bluesky trending --limit 10
|
||||
|
||||
# Search users
|
||||
opencli bluesky search --query "AI" --limit 10
|
||||
|
||||
# Popular feeds
|
||||
opencli bluesky feeds --limit 10
|
||||
|
||||
# Followers / following
|
||||
opencli bluesky followers --handle bsky.app --limit 10
|
||||
opencli bluesky following --handle bsky.app
|
||||
|
||||
# Post thread with replies
|
||||
opencli bluesky thread --uri "at://did:.../app.bsky.feed.post/..."
|
||||
|
||||
# Starter packs
|
||||
opencli bluesky starter-packs --handle bsky.app
|
||||
|
||||
# JSON output
|
||||
opencli bluesky profile --handle bsky.app -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
None — all commands use the public Bluesky AT Protocol API, no browser or login required.
|
||||
@@ -9,8 +9,6 @@
|
||||
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
|
||||
| `opencli douban top250` | 豆瓣电影 Top 250 |
|
||||
| `opencli douban subject` | 条目详情 |
|
||||
| `opencli douban photos` | 获取电影海报/剧照图片列表 |
|
||||
| `opencli douban download` | 下载电影海报/剧照图片 |
|
||||
| `opencli douban marks` | 我的标记 |
|
||||
| `opencli douban reviews` | 我的短评 |
|
||||
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
|
||||
@@ -34,18 +32,6 @@ opencli douban top250 --limit 10
|
||||
# 条目详情
|
||||
opencli douban subject 1292052
|
||||
|
||||
# 获取海报直链(默认 type=Rb)
|
||||
opencli douban photos 30382501 --limit 20
|
||||
|
||||
# 下载海报到本地目录
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# 只下载指定 photo_id 的一张图
|
||||
opencli douban download 30382501 --photo-id 2913621075 --output ./douban
|
||||
|
||||
# 返回 JSON,便于上层界面直接渲染图片并右键取图
|
||||
opencli douban photos 30382501 -f json
|
||||
|
||||
# 电影热门
|
||||
opencli douban movie-hot --limit 10
|
||||
|
||||
|
||||
@@ -11,16 +11,12 @@ Browser adapter for [Doubao Chat](https://www.doubao.com/chat).
|
||||
| `opencli doubao send "..."` | Send a message to the current Doubao chat |
|
||||
| `opencli doubao read` | Read the visible Doubao conversation |
|
||||
| `opencli doubao ask "..."` | Send a prompt and wait for a reply |
|
||||
| `opencli doubao detail <id>` | 对话详情 |
|
||||
| `opencli doubao history` | 历史对话列表 |
|
||||
| `opencli doubao meeting-summary <id>` | 会议总结 |
|
||||
| `opencli doubao meeting-transcript <id>` | 会议记录 |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome is running
|
||||
- You are already logged into [doubao.com](https://www.doubao.com/)
|
||||
- Browser Bridge extension is installed and enabled for OpenCLI
|
||||
- Playwright MCP Bridge / browser bridge is configured for OpenCLI
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Douyin (抖音创作者中心)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `creator.douyin.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli douyin profile` | 获取账号信息 |
|
||||
| `opencli douyin videos` | 获取作品列表 |
|
||||
| `opencli douyin drafts` | 获取草稿列表 |
|
||||
| `opencli douyin draft` | 上传视频并保存为草稿 |
|
||||
| `opencli douyin publish` | 定时发布视频到抖音 |
|
||||
| `opencli douyin update` | 更新视频信息 |
|
||||
| `opencli douyin delete` | 删除作品 |
|
||||
| `opencli douyin stats` | 查询作品数据分析 |
|
||||
| `opencli douyin collections` | 获取合集列表 |
|
||||
| `opencli douyin activities` | 获取官方活动列表 |
|
||||
| `opencli douyin location` | 搜索发布可用的地理位置 |
|
||||
| `opencli douyin hashtag search` | 按关键词搜索话题 |
|
||||
| `opencli douyin hashtag suggest` | 基于封面 URI 推荐话题 |
|
||||
| `opencli douyin hashtag hot` | 获取热点词 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# 账号与作品
|
||||
opencli douyin profile
|
||||
opencli douyin videos --limit 10
|
||||
opencli douyin videos --status scheduled
|
||||
opencli douyin drafts
|
||||
|
||||
# 发布前辅助信息
|
||||
opencli douyin collections
|
||||
opencli douyin activities
|
||||
opencli douyin location "东京塔"
|
||||
opencli douyin hashtag search "春游"
|
||||
opencli douyin hashtag hot --limit 10
|
||||
|
||||
# 保存草稿
|
||||
opencli douyin draft ./video.mp4 \
|
||||
--title "春游 vlog" \
|
||||
--caption "#春游 先存草稿"
|
||||
|
||||
# 定时发布
|
||||
opencli douyin publish ./video.mp4 \
|
||||
--title "春游 vlog" \
|
||||
--caption "#春游 今天去看樱花" \
|
||||
--schedule "2026-04-08T12:00:00+09:00"
|
||||
|
||||
# 也支持 Unix 秒字符串
|
||||
opencli douyin publish ./video.mp4 \
|
||||
--title "春游 vlog" \
|
||||
--schedule 1775617200
|
||||
|
||||
# 更新与删除
|
||||
opencli douyin update 1234567890 --caption "更新后的文案"
|
||||
opencli douyin update 1234567890 --reschedule "2026-04-09T20:00:00+09:00"
|
||||
opencli douyin delete 1234567890
|
||||
|
||||
# JSON 输出
|
||||
opencli douyin profile -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** `creator.douyin.com`
|
||||
- The logged-in account must have access to Douyin Creator Center publishing features
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `publish` requires `--schedule` to be at least 2 hours later and no more than 14 days later
|
||||
- `draft` and `publish` upload the video through Douyin/ByteDance browser-authenticated APIs, so cookies in the active browser session must be valid
|
||||
- `hashtag suggest` expects a valid `cover`/`cover_uri` value produced during the publish pipeline; for normal manual use, `hashtag search` and `hashtag hot` are usually more convenient
|
||||
@@ -1,72 +0,0 @@
|
||||
# Gemini
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `gemini.google.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli gemini new` | Start a new Gemini web chat |
|
||||
| `opencli gemini ask <prompt>` | Send a prompt and return only the assistant reply |
|
||||
| `opencli gemini image <prompt>` | Generate images in Gemini and optionally save them locally |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Start a fresh chat
|
||||
opencli gemini new
|
||||
|
||||
# Ask Gemini and return minimal plain-text output
|
||||
opencli gemini ask "Reply with exactly: HELLO"
|
||||
|
||||
# Ask in a new chat and wait longer
|
||||
opencli gemini ask "Summarize this design in 3 bullets" --new true --timeout 90
|
||||
|
||||
# Generate an icon image with short flags
|
||||
opencli gemini image "Generate a tiny cyan moon icon" --rt 1:1 --st icon
|
||||
|
||||
# Only generate in Gemini and print the page link without downloading files
|
||||
opencli gemini image "A watercolor sunset over a lake" --sd true
|
||||
|
||||
# Save generated images to a custom directory
|
||||
opencli gemini image "A flat illustration of a robot" --op ~/tmp/gemini-images
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### `ask`
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `prompt` | Prompt to send (required positional argument) |
|
||||
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
|
||||
| `--new` | Start a new chat before sending (default: `false`) |
|
||||
|
||||
### `image`
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `prompt` | Image prompt to send (required positional argument) |
|
||||
| `--rt` | Aspect ratio shorthand: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3` |
|
||||
| `--st` | Optional style shorthand, e.g. `icon`, `anime`, `watercolor` |
|
||||
| `--op` | Output directory for downloaded images (default: `~/tmp/gemini-images`) |
|
||||
| `--sd` | Skip download and only print the Gemini page link |
|
||||
|
||||
## Behavior
|
||||
|
||||
- `ask` uses plain minimal output and returns only the assistant response text prefixed with `💬`.
|
||||
- `image` also uses plain output and prints `status / file / link` instead of a table.
|
||||
- `image` always starts from a fresh Gemini chat before sending the prompt.
|
||||
- When `--sd` is enabled, `image` keeps the generation in Gemini and only prints the conversation link.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome is running
|
||||
- You are already logged into `gemini.google.com`
|
||||
- [Browser Bridge extension](/guide/browser-bridge) is installed
|
||||
|
||||
## Caveats
|
||||
|
||||
- This adapter drives the Gemini consumer web UI, not a public API.
|
||||
- It depends on the current browser session and may fail if Gemini shows login, consent, challenge, quota, or other gating UI.
|
||||
- DOM or product changes on Gemini can break composer detection, new-chat handling, or image export behavior.
|
||||
@@ -1,47 +0,0 @@
|
||||
# IMDb
|
||||
|
||||
**Mode**: 🌐 Public (Browser) · **Domain**: `www.imdb.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli imdb search` | Search movies, TV shows, and people |
|
||||
| `opencli imdb title` | Get movie or TV show details |
|
||||
| `opencli imdb top` | IMDb Top 250 Movies |
|
||||
| `opencli imdb trending` | IMDb Most Popular Movies |
|
||||
| `opencli imdb person` | Get actor or director info |
|
||||
| `opencli imdb reviews` | Get user reviews for a title |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Search for a movie
|
||||
opencli imdb search "inception" --limit 10
|
||||
|
||||
# Get movie details
|
||||
opencli imdb title tt1375666
|
||||
|
||||
# Get TV series details (also accepts full URL)
|
||||
opencli imdb title "https://www.imdb.com/title/tt0903747/"
|
||||
|
||||
# Top 250 movies
|
||||
opencli imdb top --limit 20
|
||||
|
||||
# Currently trending movies
|
||||
opencli imdb trending --limit 10
|
||||
|
||||
# Actor/director info with filmography
|
||||
opencli imdb person nm0634240 --limit 5
|
||||
|
||||
# User reviews
|
||||
opencli imdb reviews tt1375666 --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli imdb top --limit 5 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome with Browser Bridge extension installed
|
||||
- No login required (all data is public)
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli jd item <sku>` | Fetch product details (price, shop, specs, AVIF images) |
|
||||
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# Get product details by SKU
|
||||
opencli jd item 100291143898
|
||||
|
||||
# Limit returned AVIF images
|
||||
# Limit detail images
|
||||
opencli jd item 100291143898 --images 5
|
||||
|
||||
# JSON output
|
||||
|
||||
@@ -6,198 +6,37 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli linux-do feed` | Browse topics (site-wide, by tag, or by category) |
|
||||
| `opencli linux-do categories` | List all categories |
|
||||
| `opencli linux-do tags` | List popular tags |
|
||||
| `opencli linux-do search <query>` | Search topics |
|
||||
| `opencli linux-do topic <id>` | View topic posts |
|
||||
| `opencli linux-do user-topics <username>` | Topics created by a user |
|
||||
| `opencli linux-do user-posts <username>` | Replies posted by a user |
|
||||
| `opencli linux-do hot` | 热门话题 |
|
||||
| `opencli linux-do latest` | 最新话题 |
|
||||
| `opencli linux-do categories` | 板块列表 |
|
||||
| `opencli linux-do category` | 板块话题 |
|
||||
| `opencli linux-do search` | 搜索话题 |
|
||||
| `opencli linux-do topic` | 话题详情 |
|
||||
|
||||
## feed
|
||||
|
||||
Browse topic listings. Defaults to latest topics when called with no arguments.
|
||||
|
||||
- Supports filtering by `--tag`, `--category`, or both
|
||||
- `--tag` accepts tag name, slug, or ID
|
||||
- `--category` accepts category name, slug, ID, or `Parent / Child` path for sub-categories
|
||||
- Use `--view` to switch between latest / hot / top
|
||||
|
||||
### Basic
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Latest topics (default)
|
||||
opencli linux-do feed
|
||||
# Hot topics this week
|
||||
opencli linux-do hot --limit 20
|
||||
|
||||
# Hot topics
|
||||
opencli linux-do feed --view hot
|
||||
# Hot topics by period
|
||||
opencli linux-do hot --period daily
|
||||
opencli linux-do hot --period monthly
|
||||
|
||||
# Top topics — default period is weekly
|
||||
opencli linux-do feed --view top
|
||||
opencli linux-do feed --view top --period daily
|
||||
opencli linux-do feed --view top --period monthly
|
||||
# Latest topics
|
||||
opencli linux-do latest --limit 10
|
||||
|
||||
# Sort by views descending
|
||||
opencli linux-do feed --order views
|
||||
# List all categories
|
||||
opencli linux-do categories
|
||||
|
||||
# Sort by created time ascending
|
||||
opencli linux-do feed --order created --ascending
|
||||
# Search topics
|
||||
opencli linux-do search "NixOS"
|
||||
|
||||
# Limit results
|
||||
opencli linux-do feed --limit 10
|
||||
# View topic details
|
||||
opencli linux-do topic 12345
|
||||
|
||||
# JSON output
|
||||
opencli linux-do feed -f json
|
||||
```
|
||||
|
||||
### Filter by tag
|
||||
|
||||
```bash
|
||||
# By tag name, slug, or ID — all equivalent
|
||||
opencli linux-do feed --tag "ChatGPT"
|
||||
opencli linux-do feed --tag chatgpt
|
||||
opencli linux-do feed --tag 3
|
||||
|
||||
# Tag + hot view
|
||||
opencli linux-do feed --tag "ChatGPT" --view hot
|
||||
|
||||
# Tag + top view with period
|
||||
opencli linux-do feed --tag "OpenAI" --view top --period monthly
|
||||
```
|
||||
|
||||
### Filter by category
|
||||
|
||||
Supports both top-level and sub-categories. Sub-categories auto-resolve their parent path.
|
||||
|
||||
```bash
|
||||
# Top-level category — name, slug, or ID
|
||||
opencli linux-do feed --category "开发调优"
|
||||
opencli linux-do feed --category develop
|
||||
opencli linux-do feed --category 4
|
||||
|
||||
# Sub-category
|
||||
opencli linux-do feed --category "开发调优 / Lv1"
|
||||
opencli linux-do feed --category "网盘资源"
|
||||
|
||||
# Category + hot / top view
|
||||
opencli linux-do feed --category "开发调优" --view hot
|
||||
opencli linux-do feed --category "开发调优" --view top --period weekly
|
||||
```
|
||||
|
||||
### Category + tag
|
||||
|
||||
Combine `--category` and `--tag` to narrow results within a category.
|
||||
|
||||
```bash
|
||||
opencli linux-do feed --category "开发调优" --tag "ChatGPT"
|
||||
opencli linux-do feed --category "网盘资源" --tag "OpenAI"
|
||||
opencli linux-do feed --category 94 --tag 4 --view top --period monthly
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `--view V` | `latest`, `hot`, `top` | `latest` |
|
||||
| `--tag VALUE` | Tag name, slug, or ID | — |
|
||||
| `--category VALUE` | Category name, slug, or ID | — |
|
||||
| `--limit N` | Number of results | `20` |
|
||||
| `--order O` | `default`, `created`, `activity`, `views`, `posts`, `category`, `likes`, `op_likes`, `posters` | `default` |
|
||||
| `--ascending` | Sort ascending instead of descending | off |
|
||||
| `--period P` | `all`, `daily`, `weekly`, `monthly`, `quarterly`, `yearly` (only with `--view top`) | `weekly` |
|
||||
|
||||
Output columns: `title`, `replies`, `created`, `likes`, `views`, `url`
|
||||
|
||||
## categories
|
||||
|
||||
List forum categories with optional sub-category expansion.
|
||||
|
||||
```bash
|
||||
opencli linux-do categories
|
||||
opencli linux-do categories --subcategories
|
||||
opencli linux-do categories --limit 50
|
||||
```
|
||||
|
||||
When `--subcategories` is enabled, sub-categories are rendered as `Parent / Child` so the `name` value can be copied directly into `opencli linux-do feed --category ...`.
|
||||
|
||||
Output columns: `name`, `slug`, `id`, `topics`, `description`
|
||||
|
||||
## tags
|
||||
|
||||
List tags sorted by usage count.
|
||||
|
||||
```bash
|
||||
opencli linux-do tags
|
||||
opencli linux-do tags --limit 50
|
||||
```
|
||||
|
||||
Output columns: `rank`, `name`, `count`, `url`
|
||||
|
||||
## search
|
||||
|
||||
Search topics by keyword.
|
||||
|
||||
```bash
|
||||
opencli linux-do search "NixOS"
|
||||
opencli linux-do search "Docker" --limit 10
|
||||
opencli linux-do search "Claude" -f json
|
||||
```
|
||||
|
||||
Output columns: `rank`, `title`, `views`, `likes`, `replies`, `url`
|
||||
|
||||
## topic
|
||||
|
||||
View posts within a topic (first page).
|
||||
|
||||
```bash
|
||||
opencli linux-do topic 1234
|
||||
opencli linux-do topic 1234 --limit 50
|
||||
opencli linux-do topic 1234 --main_only -f json | jq -r '.[0].content'
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--main_only` returns only the main post row and keeps the body untruncated
|
||||
|
||||
Output columns: `author`, `content`, `likes`, `created_at`
|
||||
|
||||
## user-topics
|
||||
|
||||
List topics created by a user.
|
||||
|
||||
```bash
|
||||
opencli linux-do user-topics neo
|
||||
opencli linux-do user-topics neo --limit 10
|
||||
```
|
||||
|
||||
Output columns: `rank`, `title`, `replies`, `created_at`, `likes`, `views`, `url`
|
||||
|
||||
## user-posts
|
||||
|
||||
List replies posted by a user.
|
||||
|
||||
```bash
|
||||
opencli linux-do user-posts neo
|
||||
opencli linux-do user-posts neo --limit 10
|
||||
```
|
||||
|
||||
Output columns: `index`, `topic_user`, `topic`, `reply`, `time`, `url`
|
||||
|
||||
## Compatibility
|
||||
|
||||
The legacy commands below are still available as compatibility wrappers while `feed` becomes the canonical entrypoint:
|
||||
|
||||
```bash
|
||||
opencli linux-do latest
|
||||
opencli linux-do hot --period weekly
|
||||
opencli linux-do category develop 4
|
||||
```
|
||||
|
||||
Preferred modern forms:
|
||||
|
||||
```bash
|
||||
opencli linux-do feed --view latest
|
||||
opencli linux-do feed --view top --period weekly
|
||||
opencli linux-do feed --category 4
|
||||
opencli linux-do hot -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# NotebookLM
|
||||
|
||||
**Mode**: 🔐 Browser Bridge · **Domain**: `notebooklm.google.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli notebooklm status` | Check whether NotebookLM is reachable in the current Chrome session |
|
||||
| `opencli notebooklm list` | List notebooks visible from the NotebookLM home page |
|
||||
| `opencli notebooklm open <notebook>` | Open one notebook in the NotebookLM automation workspace by id or URL |
|
||||
| `opencli notebooklm current` | Show metadata for the currently opened notebook in the automation workspace |
|
||||
| `opencli notebooklm get` | Get richer metadata for the current notebook |
|
||||
| `opencli notebooklm source-list` | List sources in the current notebook |
|
||||
| `opencli notebooklm source-get <source>` | Resolve one source in the current notebook by id or title |
|
||||
| `opencli notebooklm source-fulltext <source>` | Fetch extracted source fulltext through NotebookLM RPC |
|
||||
| `opencli notebooklm source-guide <source>` | Fetch guide summary and keywords for one source |
|
||||
| `opencli notebooklm history` | List conversation history threads for the current notebook |
|
||||
| `opencli notebooklm note-list` | List Studio notes visible in the current notebook |
|
||||
| `opencli notebooklm notes-get <note>` | Read the currently visible Studio note by title |
|
||||
| `opencli notebooklm summary` | Read the current notebook summary |
|
||||
|
||||
## Compatibility Aliases
|
||||
|
||||
| Alias | Canonical command |
|
||||
|-------|-------------------|
|
||||
| `opencli notebooklm select <notebook>` | `opencli notebooklm open <notebook>` |
|
||||
| `opencli notebooklm metadata` | `opencli notebooklm get` |
|
||||
| `opencli notebooklm notes-list` | `opencli notebooklm note-list` |
|
||||
|
||||
## Positioning
|
||||
|
||||
This adapter reuses the existing OpenCLI Browser Bridge runtime:
|
||||
|
||||
- no custom NotebookLM extension
|
||||
- no exported cookie replay
|
||||
- requests and page state stay in the real Chrome session
|
||||
|
||||
The current milestone focuses on a stable NotebookLM read surface in desktop Chrome with an already logged-in Google account.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
opencli notebooklm status
|
||||
opencli notebooklm list -f json
|
||||
opencli notebooklm open nb-demo -f json
|
||||
opencli notebooklm current -f json
|
||||
opencli notebooklm metadata -f json
|
||||
opencli notebooklm source-list -f json
|
||||
opencli notebooklm source-get "Quarterly report" -f json
|
||||
opencli notebooklm source-guide "Quarterly report" -f json
|
||||
opencli notebooklm source-fulltext "Quarterly report" -f json
|
||||
opencli notebooklm history -f json
|
||||
opencli notebooklm notes-list -f json
|
||||
opencli notebooklm notes-get "Draft note" -f json
|
||||
opencli notebooklm summary -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and logged into Google / NotebookLM
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
- NotebookLM accessible in the current browser session
|
||||
|
||||
## Notes
|
||||
|
||||
- Notebook-oriented commands run in OpenCLI's owned NotebookLM automation workspace/window. Use `opencli notebooklm open <notebook>` first to choose the current notebook for follow-up commands.
|
||||
- `list`, `get`, `source-list`, `history`, `source-fulltext`, and `source-guide` prefer NotebookLM RPC paths and fall back only when the richer path is unavailable.
|
||||
- `notes-get` currently reads note content only from the visible Studio note editor; if the note is listed but not open, open it in NotebookLM first and then retry.
|
||||
@@ -1,59 +0,0 @@
|
||||
# ONES
|
||||
|
||||
**Mode**: 🔐 Browser Bridge · **Domain**: `ones.cn` (self-hosted via `ONES_BASE_URL`)
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli ones login` | Login via Project API (`auth/login`) |
|
||||
| `opencli ones me` | Current user profile (`users/me`) |
|
||||
| `opencli ones token-info` | Token/user/team summary (`auth/token_info`) |
|
||||
| `opencli ones tasks` | Team task list with status/project labels and hours |
|
||||
| `opencli ones my-tasks` | My tasks (`assign`/`field004`/`owner`/`both`) |
|
||||
| `opencli ones task` | Task detail by UUID (`team/:team/task/:id/info`) |
|
||||
| `opencli ones worklog` | Log/backfill hours (GraphQL `addManhour` first, then REST fallbacks) |
|
||||
| `opencli ones logout` | Logout (`auth/logout`) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Required: your ONES base URL
|
||||
export ONES_BASE_URL=https://your-instance.example.com
|
||||
|
||||
# Optional if your deployment requires auth headers
|
||||
# export ONES_USER_ID=...
|
||||
# export ONES_AUTH_TOKEN=...
|
||||
|
||||
# Login/profile
|
||||
opencli ones login --email you@company.com --password 'your-password'
|
||||
opencli ones me
|
||||
opencli ones token-info
|
||||
|
||||
# Task lists
|
||||
opencli ones tasks <teamUUID> --limit 20
|
||||
opencli ones tasks <teamUUID> --project <projectUUID> --assign <userUUID>
|
||||
opencli ones my-tasks <teamUUID> --limit 100
|
||||
opencli ones my-tasks <teamUUID> --mode both
|
||||
|
||||
# Task detail
|
||||
opencli ones task <taskUUID> --team <teamUUID>
|
||||
|
||||
# Worklog: today / backfill
|
||||
opencli ones worklog <taskUUID> 2 --team <teamUUID>
|
||||
opencli ones worklog <taskUUID> 1.5 --team <teamUUID> --date 2026-03-23 --note "integration"
|
||||
|
||||
opencli ones logout
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and logged into your ONES instance
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
- `ONES_BASE_URL` set to the same origin opened in Chrome
|
||||
|
||||
## Notes
|
||||
|
||||
- This adapter targets legacy ONES Project API deployments.
|
||||
- `ONES_TEAM_UUID` can be set to omit `--team` in `tasks` / `my-tasks` / `task`.
|
||||
- Hours display and input use `ONES_MANHOUR_SCALE` (default `100000`).
|
||||
@@ -1,43 +0,0 @@
|
||||
# paperreview.ai
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `paperreview.ai`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli paperreview submit` | Submit a PDF to paperreview.ai for review |
|
||||
| `opencli paperreview review` | Fetch a review by token |
|
||||
| `opencli paperreview feedback` | Send feedback on a completed review |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Validate a local PDF without uploading it
|
||||
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --dry-run true
|
||||
|
||||
# Request an upload slot but stop before the actual upload
|
||||
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --prepare-only true
|
||||
|
||||
# Submit a paper for review
|
||||
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL -f json
|
||||
|
||||
# Check the review status or fetch the final review
|
||||
opencli paperreview review tok_123 -f json
|
||||
|
||||
# Submit feedback on the review quality
|
||||
opencli paperreview feedback tok_123 --helpfulness 4 --critical-error no --actionable-suggestions yes
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public paperreview.ai endpoints
|
||||
- The input file must be a local `.pdf`
|
||||
- paperreview.ai currently rejects files larger than `10MB`
|
||||
- `submit` requires `--email`; `--venue` is optional
|
||||
|
||||
## Notes
|
||||
|
||||
- `submit` returns both the review token and the review URL when submission succeeds
|
||||
- `review` returns `processing` until the paperreview.ai result is ready
|
||||
- `feedback` expects `yes` / `no` values for `--critical-error` and `--actionable-suggestions`
|
||||
@@ -1,92 +0,0 @@
|
||||
# Pixiv
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `www.pixiv.net`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli pixiv ranking` | Daily/weekly/monthly illustration rankings |
|
||||
| `opencli pixiv search <query>` | Search illustrations by keyword or tag |
|
||||
| `opencli pixiv user <uid>` | View artist profile info |
|
||||
| `opencli pixiv illusts <user-id>` | List illustrations by artist |
|
||||
| `opencli pixiv detail <id>` | View illustration details |
|
||||
| `opencli pixiv download <illust-id>` | Download original-quality images |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Ranking
|
||||
|
||||
```bash
|
||||
# Daily rankings (default)
|
||||
opencli pixiv ranking --limit 10
|
||||
|
||||
# Weekly / monthly rankings
|
||||
opencli pixiv ranking --mode weekly
|
||||
opencli pixiv ranking --mode monthly
|
||||
|
||||
# R18 rankings
|
||||
opencli pixiv ranking --mode daily_r18
|
||||
opencli pixiv ranking --mode weekly_r18
|
||||
|
||||
# Other modes: rookie, original, male, female
|
||||
opencli pixiv ranking --mode rookie
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
# Search by keyword or tag
|
||||
opencli pixiv search "初音ミク" --limit 20
|
||||
|
||||
# Filter by content rating
|
||||
opencli pixiv search "風景" --mode safe # Safe-for-work only
|
||||
opencli pixiv search "風景" --mode r18 # R18 only
|
||||
opencli pixiv search "風景" --mode all # All (default)
|
||||
|
||||
# Sort by popularity
|
||||
opencli pixiv search "VOCALOID" --order popular_d
|
||||
|
||||
# All sort options: date_d (newest), date (oldest), popular_d, popular_male_d, popular_female_d
|
||||
|
||||
# Pagination
|
||||
opencli pixiv search "オリジナル" --page 2 --limit 30
|
||||
```
|
||||
|
||||
### User & Illustrations
|
||||
|
||||
```bash
|
||||
# View artist profile
|
||||
opencli pixiv user 11
|
||||
|
||||
# List artist's illustrations (newest first)
|
||||
opencli pixiv illusts 11 --limit 10
|
||||
|
||||
# View illustration details (tags, stats, type)
|
||||
opencli pixiv detail 12345678
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
```bash
|
||||
# Download all images from an illustration
|
||||
opencli pixiv download 12345678
|
||||
|
||||
# Download to a custom directory
|
||||
opencli pixiv download 12345678 --output ./my-images
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
```bash
|
||||
# JSON output
|
||||
opencli pixiv ranking -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli pixiv search "test" -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** pixiv.net
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -1,49 +0,0 @@
|
||||
# Product Hunt
|
||||
|
||||
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `www.producthunt.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli producthunt posts` | Latest Product Hunt launches (optional category filter) |
|
||||
| `opencli producthunt today` | Today's Product Hunt launches (most recent day in feed) |
|
||||
| `opencli producthunt hot` | Today's top Product Hunt launches with vote counts |
|
||||
| `opencli producthunt browse <category>` | Best products in a Product Hunt category |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Today's top launches with vote counts
|
||||
opencli producthunt hot --limit 10
|
||||
|
||||
# Latest posts (RSS feed)
|
||||
opencli producthunt posts --limit 20
|
||||
|
||||
# Filter by category
|
||||
opencli producthunt posts --category developer-tools --limit 10
|
||||
|
||||
# Today's launches only
|
||||
opencli producthunt today --limit 10
|
||||
|
||||
# Browse best products in a category
|
||||
opencli producthunt browse vibe-coding --limit 10
|
||||
opencli producthunt browse ai-agents --limit 10
|
||||
opencli producthunt browse developer-tools --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli producthunt hot -f json
|
||||
```
|
||||
|
||||
## Category Slugs
|
||||
|
||||
Common categories for `browse` and `posts --category`:
|
||||
|
||||
`ai-agents`, `ai-coding-agents`, `ai-code-editors`, `ai-chatbots`, `ai-workflow-automation`,
|
||||
`vibe-coding`, `developer-tools`, `productivity`, `design-creative`, `marketing-sales`,
|
||||
`no-code-platforms`, `llms`, `finance`, `social-community`, `engineering-development`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `posts` and `today` — no browser required (public RSS feed)
|
||||
- `hot` and `browse` — Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -1,19 +1,15 @@
|
||||
# 新浪财经 (Sina Finance)
|
||||
|
||||
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn`
|
||||
**Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description | Mode |
|
||||
|---------|-------------|------|
|
||||
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public |
|
||||
| `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser |
|
||||
| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🌐 Public |
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### news - 7×24 实时快讯
|
||||
|
||||
```bash
|
||||
# Latest financial news
|
||||
opencli sinafinance news --limit 20
|
||||
@@ -27,59 +23,13 @@ opencli sinafinance news --type 6 # 国际
|
||||
opencli sinafinance news -f json
|
||||
```
|
||||
|
||||
### rolling-news - 滚动新闻
|
||||
|
||||
```bash
|
||||
# Rolling news feed
|
||||
opencli sinafinance rolling-news
|
||||
|
||||
# JSON output
|
||||
opencli sinafinance rolling-news -f json
|
||||
```
|
||||
|
||||
### stock - 股票行情
|
||||
|
||||
```bash
|
||||
# Search and view A-share stock
|
||||
opencli sinafinance stock 贵州茅台 --market cn
|
||||
|
||||
# Search and view HK stock
|
||||
opencli sinafinance stock 腾讯控股 --market hk
|
||||
|
||||
# Search and view US stock
|
||||
opencli sinafinance stock aapl --market us
|
||||
|
||||
# Auto-detect market (searches cn, hk, us in order)
|
||||
opencli sinafinance stock 招商证券
|
||||
|
||||
# JSON output
|
||||
opencli sinafinance stock 贵州茅台 -f json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### news
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--limit` | Max results, up to 50 (default: 20) |
|
||||
| `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 |
|
||||
|
||||
### stock
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `news` & `stock`: No browser required — uses public API
|
||||
- `rolling-news`: Chrome running and **logged into** `finance.sina.com.cn`
|
||||
- For `rolling-news`: [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `news` and `stock` use public APIs — no browser or login needed
|
||||
- `stock` supports Chinese names, Chinese codes, and ticker symbols; auto-detects market
|
||||
- Market priority for auto-detection: cn (A股) → hk (港股) → us (美股)
|
||||
- US stock `High`/`Low` columns show 52-week range; A股/港股 show today's range
|
||||
- No browser required — uses public API
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Spotify
|
||||
|
||||
**Mode**: 🔑 OAuth API · **Domains**: `accounts.spotify.com`, `api.spotify.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli spotify auth` | Authenticate with Spotify and store tokens locally |
|
||||
| `opencli spotify status` | Show current playback status |
|
||||
| `opencli spotify play [query]` | Resume playback or search-and-play a track |
|
||||
| `opencli spotify pause` | Pause playback |
|
||||
| `opencli spotify next` | Skip to the next track |
|
||||
| `opencli spotify prev` | Skip to the previous track |
|
||||
| `opencli spotify volume <0-100>` | Set playback volume |
|
||||
| `opencli spotify search <query>` | Search Spotify tracks |
|
||||
| `opencli spotify queue <query>` | Add a track to the playback queue |
|
||||
| `opencli spotify shuffle <on|off>` | Toggle shuffle |
|
||||
| `opencli spotify repeat <off|track|context>` | Set repeat mode |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# First-time setup
|
||||
opencli spotify auth
|
||||
|
||||
# What is playing right now?
|
||||
opencli spotify status
|
||||
|
||||
# Resume playback
|
||||
opencli spotify play
|
||||
|
||||
# Search and immediately play a track
|
||||
opencli spotify play "Numb Linkin Park"
|
||||
|
||||
# Search without playing
|
||||
opencli spotify search "Daft Punk" --limit 5 -f json
|
||||
|
||||
# Queue a track
|
||||
opencli spotify queue "Get Lucky"
|
||||
|
||||
# Playback controls
|
||||
opencli spotify pause
|
||||
opencli spotify next
|
||||
opencli spotify prev
|
||||
opencli spotify volume 35
|
||||
opencli spotify shuffle on
|
||||
opencli spotify repeat track
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Spotify app at <https://developer.spotify.com/dashboard>
|
||||
2. Add `http://127.0.0.1:8888/callback` to the app's Redirect URIs
|
||||
3. Fill in `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` in `~/.opencli/spotify.env`
|
||||
4. Run `opencli spotify auth`
|
||||
|
||||
## Notes
|
||||
|
||||
- Browser Bridge is not required.
|
||||
- Tokens are stored locally at `~/.opencli/spotify-tokens.json`.
|
||||
- Playback commands work best when you already have an active Spotify device/session.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Tieba
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `tieba.baidu.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli tieba hot` | Read Tieba trending topics |
|
||||
| `opencli tieba posts <forum>` | List threads in one forum |
|
||||
| `opencli tieba search <keyword>` | Search threads across Tieba |
|
||||
| `opencli tieba read <thread-id>` | Read one thread page |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Trending topics
|
||||
opencli tieba hot --limit 5
|
||||
|
||||
# List forum threads
|
||||
opencli tieba posts 李毅 --limit 10
|
||||
|
||||
# Search Tieba
|
||||
opencli tieba search 编程 --limit 10
|
||||
|
||||
# Read one thread
|
||||
opencli tieba read 10163164720 --limit 10
|
||||
|
||||
# Read page 2 of a thread
|
||||
opencli tieba read 10163164720 --page 2 --limit 10
|
||||
|
||||
# JSON output
|
||||
opencli tieba hot -f json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `tieba search` currently supports only `--page 1`
|
||||
- `tieba read --limit` counts reply rows; page 1 may also include the main post
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and able to open `tieba.baidu.com`
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
- For `posts`, `search`, and `read`, a valid Tieba login session in Chrome is recommended
|
||||
@@ -37,12 +37,6 @@
|
||||
# Quick start
|
||||
opencli twitter trending --limit 5
|
||||
|
||||
# Search top tweets (default)
|
||||
opencli twitter search "react 19"
|
||||
|
||||
# Search latest/live tweets
|
||||
opencli twitter search "react 19" --filter live
|
||||
|
||||
# JSON output
|
||||
opencli twitter trending -f json
|
||||
|
||||
|
||||
@@ -6,13 +6,8 @@
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli weibo hot` | 微博热搜 |
|
||||
| `opencli weibo hot` | |
|
||||
| `opencli weibo search` | Search Weibo posts by keyword |
|
||||
| `opencli weibo feed` | 首页时间线 |
|
||||
| `opencli weibo user` | 用户信息 |
|
||||
| `opencli weibo me` | 我的信息 |
|
||||
| `opencli weibo post` | 发微博 |
|
||||
| `opencli weibo comments` | 微博评论 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
|---------|-------------|
|
||||
| `opencli wikipedia search` | Search Wikipedia articles |
|
||||
| `opencli wikipedia summary` | Get Wikipedia article summary |
|
||||
| `opencli wikipedia random` | Random Wikipedia article |
|
||||
| `opencli wikipedia trending` | Trending Wikipedia articles |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Xianyu (闲鱼)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `goofish.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xianyu search <query>` | Search Xianyu items by keyword and return item cards with `item_id` |
|
||||
| `opencli xianyu item <item_id>` | Fetch item details including title, price, condition, brand, seller, and image URLs |
|
||||
| `opencli xianyu chat <item_id> <user_id>` | Open a Xianyu chat session for the item/user pair and optionally send a message with `--text` |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Search items
|
||||
opencli xianyu search "macbook" --limit 5
|
||||
|
||||
# Read a single item's details
|
||||
opencli xianyu item 1040754408976
|
||||
|
||||
# Open a chat session
|
||||
opencli xianyu chat 1038951278192 3650092411
|
||||
|
||||
# Send a message in chat
|
||||
opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?"
|
||||
|
||||
# JSON output
|
||||
opencli xianyu search "笔记本电脑" -f json
|
||||
opencli xianyu item 1040754408976 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** `goofish.com`
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `search` returns `item_id`, which can be passed directly into `opencli xianyu item`
|
||||
- `chat` requires both the item ID and the target user's `user_id` / `peerUserId`
|
||||
- Browser-authenticated commands depend on the active Chrome login session remaining valid
|
||||
@@ -7,18 +7,15 @@
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xiaohongshu search` | Search notes by keyword (returns title, author, likes, URL) |
|
||||
| `opencli xiaohongshu note` | Read full note content (title, author, description, likes, collects, comments, tags) |
|
||||
| `opencli xiaohongshu comments` | Read comments from a note (`--with-replies` for nested 楼中楼 replies) |
|
||||
| `opencli xiaohongshu feed` | Home feed recommendations (via Pinia store interception) |
|
||||
| `opencli xiaohongshu notifications` | User notifications (mentions, likes, connections) |
|
||||
| `opencli xiaohongshu user` | Get public notes from a user profile |
|
||||
| `opencli xiaohongshu download` | Download images and videos from a note |
|
||||
| `opencli xiaohongshu publish` | Publish image-text notes (creator center UI automation) |
|
||||
| `opencli xiaohongshu creator-notes` | Creator's note list with per-note metrics |
|
||||
| `opencli xiaohongshu creator-note-detail` | Detailed analytics for a single creator note |
|
||||
| `opencli xiaohongshu creator-notes-summary` | Combined note list + detail analytics summary |
|
||||
| `opencli xiaohongshu creator-profile` | Creator account info (followers, growth level) |
|
||||
| `opencli xiaohongshu creator-stats` | Creator data overview (views, likes, collects, trends) |
|
||||
| `opencli xiaohongshu notifications` | |
|
||||
| `opencli xiaohongshu feed` | |
|
||||
| `opencli xiaohongshu user` | |
|
||||
| `opencli xiaohongshu download` | |
|
||||
| `opencli xiaohongshu creator-notes` | |
|
||||
| `opencli xiaohongshu creator-note-detail` | |
|
||||
| `opencli xiaohongshu creator-notes-summary` | |
|
||||
| `opencli xiaohongshu creator-profile` | |
|
||||
| `opencli xiaohongshu creator-stats` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -26,19 +23,13 @@
|
||||
# Search for notes
|
||||
opencli xiaohongshu search 美食 --limit 10
|
||||
|
||||
# Read a note's full content (pass URL from search results to preserve xsec_token)
|
||||
opencli xiaohongshu note "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..."
|
||||
|
||||
# Read comments with nested replies (楼中楼)
|
||||
opencli xiaohongshu comments "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --with-replies --limit 20
|
||||
|
||||
# JSON output
|
||||
opencli xiaohongshu search 旅行 -f json
|
||||
|
||||
# Other commands
|
||||
opencli xiaohongshu feed
|
||||
opencli xiaohongshu notifications
|
||||
opencli xiaohongshu download <note-id or url>
|
||||
opencli xiaohongshu download <url>
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
# Xueqiu (雪球)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com` / `danjuanfunds.com`
|
||||
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xueqiu feed` | 获取雪球首页时间线 |
|
||||
| `opencli xueqiu earnings-date` | 获取股票预计财报发布日期 |
|
||||
| `opencli xueqiu hot-stock` | 获取雪球热门股票榜 |
|
||||
| `opencli xueqiu hot` | 获取雪球热门动态 |
|
||||
| `opencli xueqiu search` | 搜索雪球股票(代码或名称) |
|
||||
| `opencli xueqiu stock` | 获取雪球股票实时行情 |
|
||||
| `opencli xueqiu comments` | 获取单只股票的讨论动态(按时间排序) |
|
||||
| `opencli xueqiu watchlist` | 获取雪球自选股列表 |
|
||||
| `opencli xueqiu fund-holdings` | 获取蛋卷基金持仓明细(可用 `--account` 按子账户过滤) |
|
||||
| `opencli xueqiu fund-snapshot` | 获取蛋卷基金快照(总资产、子账户、持仓,推荐 `-f json`) |
|
||||
| `opencli xueqiu feed` | |
|
||||
| `opencli xueqiu earnings-date` | |
|
||||
| `opencli xueqiu hot-stock` | |
|
||||
| `opencli xueqiu hot` | |
|
||||
| `opencli xueqiu search` | |
|
||||
| `opencli xueqiu stock` | |
|
||||
| `opencli xueqiu watchlist` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -29,21 +26,9 @@ opencli xueqiu search 茅台
|
||||
# View one stock
|
||||
opencli xueqiu stock SH600519
|
||||
|
||||
# View recent discussions for one stock
|
||||
opencli xueqiu comments SH600519 --limit 5
|
||||
|
||||
# Upcoming earnings dates
|
||||
opencli xueqiu earnings-date SH600519 --next
|
||||
|
||||
# Danjuan all holdings
|
||||
opencli xueqiu fund-holdings
|
||||
|
||||
# Filter one Danjuan sub-account
|
||||
opencli xueqiu fund-holdings --account 默认账户
|
||||
|
||||
# Full Danjuan snapshot as JSON
|
||||
opencli xueqiu fund-snapshot -f json
|
||||
|
||||
# JSON output
|
||||
opencli xueqiu feed -f json
|
||||
|
||||
@@ -53,13 +38,5 @@ opencli xueqiu feed -v
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** `xueqiu.com`
|
||||
- For fund commands, Chrome must also be logged into `danjuanfunds.com` and able to open `https://danjuanfunds.com/my-money`
|
||||
- Chrome running and **logged into** xueqiu.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `fund-holdings` exposes both market value and share fields (`volume`, `usableRemainShare`)
|
||||
- `fund-snapshot -f json` is the easiest way to persist a full account snapshot for later analysis or diffing
|
||||
- `comments` returns stock-scoped discussion posts from the symbol page, not reply threads under one parent post
|
||||
- If the commands return empty data, first confirm the logged-in browser can directly see the Danjuan asset page
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Yuanbao
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `yuanbao.tencent.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli yuanbao new` | Start a new Yuanbao conversation |
|
||||
| `opencli yuanbao ask <prompt>` | Send a prompt to Yuanbao web chat and wait for the reply |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Start a fresh chat
|
||||
opencli yuanbao new
|
||||
|
||||
# Basic ask (internet search on by default, deep thinking off by default)
|
||||
opencli yuanbao ask "你好"
|
||||
|
||||
# Wait longer for a longer answer
|
||||
opencli yuanbao ask "帮我总结这篇文章" --timeout 90
|
||||
|
||||
# Disable internet search explicitly
|
||||
opencli yuanbao ask "你好" --search false
|
||||
|
||||
# Enable deep thinking explicitly
|
||||
opencli yuanbao ask "你好" --think true
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### `new`
|
||||
|
||||
- No options
|
||||
|
||||
### `ask`
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `prompt` | Prompt to send (required positional argument) |
|
||||
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
|
||||
| `--search` | Enable internet search before sending (default: `true`) |
|
||||
| `--think` | Enable deep thinking before sending (default: `false`) |
|
||||
|
||||
## Behavior
|
||||
|
||||
- The adapter targets the Yuanbao consumer web UI and sends the prompt through the visible Quill composer.
|
||||
- `new` clicks the left-side Yuanbao new-chat trigger and falls back to reloading the Yuanbao homepage if needed.
|
||||
- Before sending, it aligns the `联网搜索` and `深度思考` buttons to the requested `--search` / `--think` state.
|
||||
- It waits for transcript changes to stabilize before returning the assistant reply.
|
||||
- If Yuanbao opens a login gate instead of answering, the command returns a `[BLOCKED]` system message with a session hint.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome is running
|
||||
- You are already logged into `yuanbao.tencent.com`
|
||||
- [Browser Bridge extension](/guide/browser-bridge) is installed
|
||||
|
||||
## Caveats
|
||||
|
||||
- This adapter drives the Yuanbao web UI, not a public API.
|
||||
- It depends on the current browser session and may fail if Yuanbao shows login, consent, challenge, or other gating UI.
|
||||
- DOM or product changes on Yuanbao can break composer detection, submit behavior, or transcript extraction.
|
||||
@@ -1,49 +0,0 @@
|
||||
# 知识星球 (ZSXQ)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `wx.zsxq.com`
|
||||
|
||||
Read groups, topics, search results, dynamics, and single-topic details from [知识星球](https://wx.zsxq.com) using your logged-in Chrome session.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli zsxq groups` | List the groups your account has joined |
|
||||
| `opencli zsxq topics` | List topics in the active group |
|
||||
| `opencli zsxq topic <id>` | Fetch a single topic with comments |
|
||||
| `opencli zsxq search <keyword>` | Search topics inside a group |
|
||||
| `opencli zsxq dynamics` | List recent dynamics across groups |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# List your groups
|
||||
opencli zsxq groups
|
||||
|
||||
# List topics from the active group in Chrome
|
||||
opencli zsxq topics --limit 20
|
||||
|
||||
# Search inside the active group
|
||||
opencli zsxq search "opencli"
|
||||
|
||||
# Search inside a specific group explicitly
|
||||
opencli zsxq search "opencli" --group_id 123456789
|
||||
|
||||
# Export a single topic with comments
|
||||
opencli zsxq topic 987654321 --comment_limit 20
|
||||
|
||||
# Read recent dynamics across all joined groups
|
||||
opencli zsxq dynamics --limit 20
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** [wx.zsxq.com](https://wx.zsxq.com)
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `zsxq topics` and `zsxq search` use the current active group context from Chrome by default
|
||||
- If there is no active group context, pass `--group_id <id>` or open the target group in Chrome first
|
||||
- `zsxq groups` returns `group_id`, which you can reuse with `--group_id`
|
||||
- `zsxq topic` surfaces a missing topic as `NOT_FOUND` instead of a generic fetch error
|
||||
@@ -14,13 +14,8 @@ The current built-in commands use native AppleScript automation — no extra lau
|
||||
- `opencli chatgpt status`: Check if the ChatGPT app is currently running.
|
||||
- `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
|
||||
- `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
|
||||
- `opencli chatgpt send "message" --model thinking`: Switch model/mode first, then send the message.
|
||||
- `opencli chatgpt read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree.
|
||||
- `opencli chatgpt ask "message"`: Send a prompt and wait for the visible reply in one shot.
|
||||
- `opencli chatgpt ask "message" --model instant`: Run a one-shot prompt using a specific model/mode.
|
||||
- `opencli chatgpt model thinking`: Switch the active ChatGPT model/mode without sending a message.
|
||||
|
||||
Supported model choices: `auto`, `instant`, `thinking`, `5.2-instant`, `5.2-thinking`.
|
||||
|
||||
## Approach 2: CDP (Advanced, Electron Debug Mode)
|
||||
|
||||
|
||||
+53
-75
@@ -6,88 +6,66 @@ Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[twitter](./browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
|
||||
| **[reddit](./browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
||||
| **[tieba](./browser/tieba)** | `hot` `posts` `search` `read` | 🔐 Browser |
|
||||
| **[bilibili](./browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **[zhihu](./browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **[xiaohongshu](./browser/xiaohongshu)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
|
||||
| **[xueqiu](./browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser |
|
||||
| **[youtube](./browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
|
||||
| **[v2ex](./browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **[bloomberg](./browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
|
||||
| **[weibo](./browser/weibo)** | `hot` `search` `feed` `user` `me` `post` `comments` | 🔐 Browser |
|
||||
| **[linkedin](./browser/linkedin)** | `search` `timeline` | 🔐 Browser |
|
||||
| **[coupang](./browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **[boss](./browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
|
||||
| **[ctrip](./browser/ctrip)** | `search` | 🔐 Browser |
|
||||
| **[reuters](./browser/reuters)** | `search` | 🔐 Browser |
|
||||
| **[smzdm](./browser/smzdm)** | `search` | 🔐 Browser |
|
||||
| **[jike](./browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
|
||||
| **[jimeng](./browser/jimeng)** | `generate` `history` | 🔐 Browser |
|
||||
| **[yollomi](./browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
|
||||
| **[linux-do](./browser/linux-do)** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` | 🔐 Browser |
|
||||
| **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
|
||||
| **[grok](./browser/grok)** | `ask` | 🔐 Browser |
|
||||
| **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser |
|
||||
| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser |
|
||||
| **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 Browser |
|
||||
| **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser |
|
||||
| **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
|
||||
| **[douban](./browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
|
||||
| **[facebook](./browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
|
||||
| **[imdb](./browser/imdb)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 |
|
||||
| **[instagram](./browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
|
||||
| **[medium](./browser/medium)** | `feed` `search` `user` | 🔐 Browser |
|
||||
| **[sinablog](./browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
|
||||
| **[substack](./browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
|
||||
| **[pixiv](./browser/pixiv)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser |
|
||||
| **[tiktok](./browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
|
||||
| **[google](./browser/google)** | `news` `search` `suggest` `trends` | 🌐 / 🔐 |
|
||||
| **[jd](./browser/jd)** | `item` | 🔐 Browser |
|
||||
| **[amazon](./browser/amazon)** | `bestsellers` `search` `product` `offer` `discussion` | 🔐 Browser |
|
||||
| **[web](./browser/web)** | `read` | 🔐 Browser |
|
||||
| **[weixin](./browser/weixin)** | `download` | 🔐 Browser |
|
||||
| **[36kr](./browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 |
|
||||
| **[producthunt](./browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 |
|
||||
| **[ones](./browser/ones)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` |
|
||||
| **[band](./browser/band)** | `bands` `posts` `post` `mentions` | 🔐 Browser |
|
||||
| **[zsxq](./browser/zsxq)** | `groups` `dynamics` `topics` `topic` `search` | 🔐 Browser |
|
||||
| **[bluesky](./browser/bluesky)** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` | 🌐 Public |
|
||||
| **[douyin](./browser/douyin)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser |
|
||||
| **[xianyu](./browser/xianyu)** | `search` `item` `chat` | 🔐 Browser |
|
||||
| **[twitter](/adapters/browser/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` | 🔐 Browser |
|
||||
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
||||
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 🔐 Browser |
|
||||
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
|
||||
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
|
||||
| **[weibo](/adapters/browser/weibo)** | `hot` `search` | 🔐 Browser |
|
||||
| **[linkedin](/adapters/browser/linkedin)** | `search` `timeline` | 🔐 Browser |
|
||||
| **[coupang](/adapters/browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **[boss](/adapters/browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
|
||||
| **[ctrip](/adapters/browser/ctrip)** | `search` | 🔐 Browser |
|
||||
| **[reuters](/adapters/browser/reuters)** | `search` | 🔐 Browser |
|
||||
| **[smzdm](/adapters/browser/smzdm)** | `search` | 🔐 Browser |
|
||||
| **[jike](/adapters/browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
|
||||
| **[jimeng](/adapters/browser/jimeng)** | `generate` `history` | 🔐 Browser |
|
||||
| **[yollomi](/adapters/browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
|
||||
| **[linux-do](/adapters/browser/linux-do)** | `hot` `latest` `categories` `category` `search` `topic` | 🔐 Browser |
|
||||
| **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
|
||||
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
|
||||
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
|
||||
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
|
||||
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
|
||||
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
|
||||
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
|
||||
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
|
||||
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
|
||||
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
|
||||
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
|
||||
|
||||
## Public API Adapters
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[hackernews](./browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
|
||||
| **[bbc](./browser/bbc)** | `news` | 🌐 Public |
|
||||
| **[devto](./browser/devto)** | `top` `tag` `user` | 🌐 Public |
|
||||
| **[dictionary](./browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
|
||||
| **[apple-podcasts](./browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **[xiaoyuzhou](./browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](./browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
| **[arxiv](./browser/arxiv)** | `search` `paper` | 🌐 Public |
|
||||
| **[paperreview](./browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
|
||||
| **[barchart](./browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
|
||||
| **[hf](./browser/hf)** | `top` | 🌐 Public |
|
||||
| **[sinafinance](./browser/sinafinance)** | `news` | 🌐 Public |
|
||||
| **[spotify](./browser/spotify)** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | 🔑 OAuth API |
|
||||
| **[stackoverflow](./browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
|
||||
| **[wikipedia](./browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
|
||||
| **[lobsters](./browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
|
||||
| **[steam](./browser/steam)** | `top-sellers` | 🌐 Public |
|
||||
| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
|
||||
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
|
||||
| **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public |
|
||||
| **[dictionary](/adapters/browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
|
||||
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
| **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public |
|
||||
| **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
|
||||
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
|
||||
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
|
||||
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
|
||||
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
|
||||
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
|
||||
|
||||
## Desktop Adapters
|
||||
|
||||
| App | Description | Commands |
|
||||
|-----|-------------|----------|
|
||||
| **[Cursor](./desktop/cursor)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
|
||||
| **[Codex](./desktop/codex)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` |
|
||||
| **[Antigravity](./desktop/antigravity)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
|
||||
| **[ChatGPT](./desktop/chatgpt)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` `model` |
|
||||
| **[ChatWise](./desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
|
||||
| **[Notion](./desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
|
||||
| **[Discord](./desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
|
||||
| **[Doubao App](./desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
|
||||
| **[Cursor](/adapters/desktop/cursor)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
|
||||
| **[Codex](/adapters/desktop/codex)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` |
|
||||
| **[Antigravity](/adapters/desktop/antigravity)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
|
||||
| **[ChatGPT](/adapters/desktop/chatgpt)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` |
|
||||
| **[ChatWise](/adapters/desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
|
||||
| **[Notion](/adapters/desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
|
||||
| **[Discord](/adapters/desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
|
||||
| **[Doubao App](/adapters/desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
|
||||
|
||||
@@ -9,7 +9,6 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **douban** | Images | Downloads poster / still image lists from movie subjects |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
|
||||
@@ -40,9 +39,6 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Download Douban posters / stills
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Rate Limiter Plugin
|
||||
|
||||
An optional plugin that adds a random sleep between browser-based commands to reduce the risk of platform rate-limiting or bot detection.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
opencli plugin install github:jackwener/opencli-plugin-rate-limiter
|
||||
```
|
||||
|
||||
Or copy the example below into `~/.opencli/plugins/rate-limiter/` to use it locally without installing from GitHub.
|
||||
|
||||
## What it does
|
||||
|
||||
After every command targeting a browser platform (xiaohongshu, weibo, bilibili, douyin, tiktok, …), the plugin sleeps for a random duration — 5–30 seconds by default — before returning control to the caller.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OPENCLI_RATE_MIN` | `5` | Minimum sleep in seconds |
|
||||
| `OPENCLI_RATE_MAX` | `30` | Maximum sleep in seconds |
|
||||
| `OPENCLI_NO_RATE` | — | Set to `1` to disable entirely (local dev) |
|
||||
|
||||
```bash
|
||||
# Shorter delays for light scraping
|
||||
OPENCLI_RATE_MIN=3 OPENCLI_RATE_MAX=10 opencli xiaohongshu search "AI眼镜"
|
||||
|
||||
# Skip delays when iterating locally
|
||||
OPENCLI_NO_RATE=1 opencli bilibili comments BV1WtAGzYEBm
|
||||
```
|
||||
|
||||
## Local installation (without GitHub)
|
||||
|
||||
1. Create the plugin directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.opencli/plugins/rate-limiter
|
||||
```
|
||||
|
||||
2. Create `~/.opencli/plugins/rate-limiter/package.json`:
|
||||
|
||||
```json
|
||||
{ "type": "module" }
|
||||
```
|
||||
|
||||
3. Create `~/.opencli/plugins/rate-limiter/index.js`:
|
||||
|
||||
```js
|
||||
import { onAfterExecute } from '@jackwener/opencli/hooks'
|
||||
|
||||
const BROWSER_DOMAINS = [
|
||||
'xiaohongshu', 'weibo', 'bilibili', 'douyin', 'tiktok',
|
||||
'instagram', 'twitter', 'youtube', 'zhihu', 'douban',
|
||||
'jike', 'weixin', 'xiaoyuzhou',
|
||||
]
|
||||
|
||||
onAfterExecute(async (ctx) => {
|
||||
if (process.env.OPENCLI_NO_RATE === '1') return
|
||||
|
||||
const site = ctx.command?.split('/')?.[0] ?? ''
|
||||
if (!BROWSER_DOMAINS.includes(site)) return
|
||||
|
||||
const min = Number(process.env.OPENCLI_RATE_MIN ?? 5)
|
||||
const max = Number(process.env.OPENCLI_RATE_MAX ?? 30)
|
||||
const ms = Math.floor(Math.random() * (max - min + 1) + min) * 1000
|
||||
|
||||
process.stderr.write(`[rate-limiter] ${site}: sleeping ${(ms / 1000).toFixed(0)}s\n`)
|
||||
await new Promise(r => setTimeout(r, ms))
|
||||
})
|
||||
```
|
||||
|
||||
4. Verify it loaded:
|
||||
|
||||
```bash
|
||||
OPENCLI_NO_RATE=1 opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
|
||||
# → (no output — plugin loaded but rate limit skipped)
|
||||
|
||||
opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
|
||||
# → [rate-limiter] xiaohongshu: sleeping 12s
|
||||
```
|
||||
|
||||
## Writing your own plugin
|
||||
|
||||
Plugins are plain JS/TS files in `~/.opencli/plugins/<name>/`. A plugin file must export a hook registration call that matches the pattern `onStartup(`, `onBeforeExecute(`, or `onAfterExecute(` — opencli's discovery engine uses this pattern to identify hook files vs. command files.
|
||||
|
||||
```js
|
||||
// ~/.opencli/plugins/my-plugin/index.js
|
||||
import { onAfterExecute } from '@jackwener/opencli/hooks'
|
||||
|
||||
onAfterExecute(async (ctx) => {
|
||||
// ctx.command — e.g. "bilibili/comments"
|
||||
// ctx.args — coerced command arguments
|
||||
// ctx.error — set if the command threw
|
||||
console.error(`[my-plugin] finished: ${ctx.command}`)
|
||||
})
|
||||
```
|
||||
|
||||
See [hooks.ts](../../src/hooks.ts) for the full `HookContext` type.
|
||||
+1
-1
@@ -87,7 +87,7 @@ OpenCLI occupies a specific niche in the browser automation ecosystem. This guid
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times for free.
|
||||
- **Deterministic output** — Same command always returns the same schema. Pipeable, scriptable, CI-friendly.
|
||||
- **Speed** — Adapter commands return in seconds, not minutes.
|
||||
- **Broad platform coverage** — 73+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Broad platform coverage** — 50+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more.
|
||||
- **Easy to extend** — Drop a `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
|
||||
|
||||
|
||||
@@ -28,12 +28,6 @@ npm link
|
||||
|
||||
This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows.
|
||||
|
||||
Before you start:
|
||||
|
||||
- Prefer positional args for the command's primary subject (`search <query>`, `topic <id>`, `download <url>`). Reserve named flags for optional modifiers such as `--limit`, `--sort`, `--lang`, and `--output`.
|
||||
- Normalize expected adapter failures to `CliError` subclasses instead of raw `Error` whenever possible. Prefer `AuthRequiredError`, `EmptyResultError`, `CommandExecutionError`, `TimeoutError`, and `ArgumentError` so the top-level CLI can render better messages and hints.
|
||||
- If you add a new adapter or make a command newly discoverable, update the matching doc page and the user-facing indexes that expose it.
|
||||
|
||||
### YAML Adapter (Recommended for data-fetching commands)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.yaml`:
|
||||
@@ -77,7 +71,6 @@ Create a file like `src/clis/<site>/<command>.ts`:
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
@@ -86,7 +79,7 @@ cli({
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Search query' },
|
||||
{ name: 'query', required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['title', 'url', 'date'],
|
||||
@@ -94,8 +87,6 @@ cli({
|
||||
func: async (page, kwargs) => {
|
||||
const { query, limit = 10 } = kwargs;
|
||||
// ... browser automation logic
|
||||
if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
|
||||
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
@@ -119,7 +110,6 @@ opencli <site> <command> -v # Verbose mode for debugging
|
||||
- **ES Modules** — use `.js` extensions in imports (TypeScript output).
|
||||
- **Naming**: `kebab-case` for files, `camelCase` for variables/functions, `PascalCase` for types/classes.
|
||||
- **No default exports** — use named exports.
|
||||
- **Errors** — throw `CliError` subclasses for expected adapter failures; avoid raw `Error` for normal adapter control flow.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -146,10 +136,3 @@ chore: bump vitest to v4
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
5. Push and open a PR
|
||||
|
||||
If your PR adds a new adapter or changes user-facing commands, also verify:
|
||||
|
||||
- Adapter docs exist under `docs/adapters/`
|
||||
- `docs/adapters/index.md` is updated for new adapters
|
||||
- VitePress sidebar includes the new doc page
|
||||
- `README.md` / `README.zh-CN.md` stay aligned when command discoverability changes
|
||||
|
||||
@@ -6,7 +6,6 @@ Use TypeScript adapters when you need browser-side logic, multi-step flows, DOM
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
@@ -35,9 +34,6 @@ cli({
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
|
||||
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
|
||||
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
@@ -73,20 +69,6 @@ Contains parsed CLI arguments as key-value pairs. Always destructure with defaul
|
||||
const { query, limit = 10, format = 'json' } = kwargs;
|
||||
```
|
||||
|
||||
For most search/read/detail commands, the main subject should be positional (`opencli mysite search "rust"`, `opencli mysite article 123`) instead of a named flag such as `--query` or `--id`. Keep named flags for optional modifiers.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Prefer throwing `CliError` subclasses from `src/errors.ts` for expected adapter failures:
|
||||
|
||||
- `AuthRequiredError` for missing login / cookies
|
||||
- `EmptyResultError` for empty but valid responses
|
||||
- `CommandExecutionError` for unexpected API or browser failures
|
||||
- `TimeoutError` for site timeouts
|
||||
- `ArgumentError` for invalid user input
|
||||
|
||||
Avoid raw `Error` for normal adapter control flow. This keeps top-level CLI output consistent and preserves hints for users.
|
||||
|
||||
## AI-Assisted Development
|
||||
|
||||
Use the AI workflow tools to accelerate adapter creation:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
YAML adapters are the recommended way to add new commands when the site offers a straightforward API. They use a declarative pipeline approach — no TypeScript required.
|
||||
|
||||
Use YAML only when the command stays mostly declarative. If you find yourself embedding long JavaScript expressions, many fallbacks, or multi-step browser logic, move the command to a TypeScript adapter instead of growing an opaque template blob.
|
||||
|
||||
## Basic Structure
|
||||
|
||||
::: v-pre
|
||||
@@ -35,14 +33,6 @@ columns: [rank, title, score, url]
|
||||
```
|
||||
:::
|
||||
|
||||
For most commands, keep the primary subject positional. Good examples:
|
||||
|
||||
- `opencli mysite search "rust"`
|
||||
- `opencli mysite topic 123`
|
||||
- `opencli mysite download "https://example.com/post/1"`
|
||||
|
||||
Prefer named flags only for optional modifiers such as `--limit`, `--sort`, `--lang`, or `--output`.
|
||||
|
||||
## Pipeline Steps
|
||||
|
||||
### `fetch`
|
||||
@@ -116,9 +106,3 @@ Use `${{ ... }}` for dynamic values:
|
||||
## Real Example
|
||||
|
||||
See [`src/clis/hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/src/clis/hackernews/top.yaml).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Add fallbacks for optional fields in `map` expressions when upstream payloads may be sparse.
|
||||
- Keep template expressions short and readable. If the expression starts looking like a mini program, switch to TypeScript.
|
||||
- If you add a new adapter, also add the matching doc page plus index/sidebar entries so `doc-coverage` stays green.
|
||||
|
||||
@@ -35,15 +35,3 @@ opencli doctor # Check extension + daemon connectivity
|
||||
```
|
||||
|
||||
The daemon manages the WebSocket connection between your CLI commands and the Chrome extension. The extension executes JavaScript in the context of web pages, with access to the logged-in session.
|
||||
|
||||
## Daemon Lifecycle
|
||||
|
||||
The daemon auto-starts on first browser command and stays alive for **4 hours** by default. It exits only when both conditions are met: no CLI requests for the timeout period AND no Chrome extension connected.
|
||||
|
||||
```bash
|
||||
opencli daemon status # Check daemon state (PID, uptime, extension, memory)
|
||||
opencli daemon stop # Graceful shutdown
|
||||
opencli daemon restart # Stop + restart
|
||||
```
|
||||
|
||||
Override the timeout via the `OPENCLI_DAEMON_TIMEOUT` environment variable (milliseconds). Set to `0` to keep the daemon alive indefinitely.
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
---
|
||||
description: How to turn a new Electron desktop app into an OpenCLI adapter
|
||||
---
|
||||
|
||||
# Add a New Electron App CLI
|
||||
|
||||
This guide is the **fast entry point** for turning a new Electron desktop application into an OpenCLI adapter.
|
||||
|
||||
If you want the full background and deeper SOP, read:
|
||||
- [CLI-ifying Electron Applications](/advanced/electron)
|
||||
- [Chrome DevTools Protocol](/advanced/cdp)
|
||||
- [TypeScript Adapter Guide](/developer/ts-adapter)
|
||||
|
||||
## When to use this guide
|
||||
|
||||
Use this workflow when the target app:
|
||||
- is built with **Electron**, or at least exposes a working **Chrome DevTools Protocol (CDP)** endpoint
|
||||
- can be launched with `--remote-debugging-port=<port>`
|
||||
- should be automated through its real UI instead of a public HTTP API
|
||||
|
||||
If the app is **not** Electron and does **not** expose CDP, use the native desktop automation pattern instead. See [CLI-ifying Electron Applications](/advanced/electron#non-electron-pattern-applescript).
|
||||
|
||||
## The shortest path
|
||||
|
||||
### 1. Confirm the app is Electron
|
||||
|
||||
Typical macOS check:
|
||||
|
||||
```bash
|
||||
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
|
||||
```
|
||||
|
||||
If Electron is present, the next step is usually to launch the app with a debugging port.
|
||||
|
||||
### 2. Launch it with CDP enabled
|
||||
|
||||
```bash
|
||||
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
Then point OpenCLI at that CDP endpoint:
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
### 3. Start with the 5-command pattern
|
||||
|
||||
For a new Electron adapter, implement these commands first in `src/clis/<app>/`:
|
||||
|
||||
- `status.ts` — verify the app is reachable through CDP
|
||||
- `dump.ts` — inspect DOM and snapshot structure before guessing selectors
|
||||
- `read.ts` — extract the visible context you actually need
|
||||
- `send.ts` — inject text and submit through the real editor
|
||||
- `new.ts` — create a new session, tab, thread, or document
|
||||
|
||||
This is the standard baseline because it gives you:
|
||||
- a connection check
|
||||
- a reverse-engineering tool
|
||||
- one read path
|
||||
- one write path
|
||||
- one session reset path
|
||||
|
||||
The full rationale and examples are in [CLI-ifying Electron Applications](/advanced/electron).
|
||||
|
||||
## Recommended implementation workflow
|
||||
|
||||
### Step 1: Build `status`
|
||||
|
||||
Goal: prove CDP connectivity before touching app-specific logic.
|
||||
|
||||
Typical checks:
|
||||
- current URL
|
||||
- document title
|
||||
- app shell presence
|
||||
|
||||
If `status` is unstable, stop there and fix connectivity first.
|
||||
|
||||
### Step 2: Build `dump`
|
||||
|
||||
Do **not** guess selectors from the rendered UI.
|
||||
|
||||
Dump:
|
||||
- `document.body.innerHTML`
|
||||
- accessibility snapshot
|
||||
- any stable attributes such as `data-testid`, `role`, `aria-*`, framework-specific markers
|
||||
|
||||
Use the dump to identify real containers, buttons, composers, and conversation regions.
|
||||
|
||||
### Step 3: Build `read`
|
||||
|
||||
Target only the app region that matters.
|
||||
|
||||
Good targets:
|
||||
- message list
|
||||
- editor history
|
||||
- visible thread content
|
||||
- selected document panel
|
||||
|
||||
Avoid dumping the entire page text into the final command output.
|
||||
|
||||
### Step 4: Build `send`
|
||||
|
||||
Most Electron apps use React-style controlled editors, so direct `.value = ...` assignments are often ignored.
|
||||
|
||||
Prefer editor-aware input patterns such as:
|
||||
- focus the editable region
|
||||
- use `document.execCommand('insertText', false, text)` when applicable
|
||||
- use real key presses like `Enter`, `Meta+Enter`, or app-specific shortcuts
|
||||
|
||||
### Step 5: Build `new`
|
||||
|
||||
Many desktop apps rely on keyboard shortcuts for “new chat”, “new tab”, or “new note”.
|
||||
|
||||
Typical pattern:
|
||||
|
||||
```ts
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
```
|
||||
|
||||
## Where to put files
|
||||
|
||||
For a TypeScript desktop adapter, the usual layout is:
|
||||
|
||||
```text
|
||||
src/clis/<app>/status.ts
|
||||
src/clis/<app>/dump.ts
|
||||
src/clis/<app>/read.ts
|
||||
src/clis/<app>/send.ts
|
||||
src/clis/<app>/new.ts
|
||||
src/clis/<app>/utils.ts
|
||||
```
|
||||
|
||||
If the app grows beyond the baseline, add higher-level commands such as:
|
||||
- `ask`
|
||||
- `history`
|
||||
- `model`
|
||||
- `screenshot`
|
||||
- `export`
|
||||
|
||||
## What to document when you add a new app
|
||||
|
||||
When the adapter is ready, also add:
|
||||
|
||||
- an adapter doc under `docs/adapters/desktop/`
|
||||
- command list and examples
|
||||
- launch instructions with `--remote-debugging-port`
|
||||
- any required environment variables
|
||||
- platform-specific caveats
|
||||
|
||||
Examples to study:
|
||||
- `docs/adapters/desktop/codex.md`
|
||||
- `docs/adapters/desktop/chatwise.md`
|
||||
- `docs/adapters/desktop/notion.md`
|
||||
- `docs/adapters/desktop/discord.md`
|
||||
|
||||
## Common failure modes
|
||||
|
||||
### CDP endpoint exists, but commands are flaky
|
||||
|
||||
Usually one of these:
|
||||
- the wrong window/tab is selected
|
||||
- the app has not finished rendering
|
||||
- selectors were guessed instead of discovered from `dump`
|
||||
- the editor is controlled and ignores direct value assignment
|
||||
|
||||
### The app is Chromium-based but not truly controllable
|
||||
|
||||
Some desktop apps embed Chromium but do not expose a usable CDP surface.
|
||||
In that case, switch to the non-Electron desktop automation approach instead of forcing the Electron pattern.
|
||||
|
||||
### You already have a browser workflow and wonder whether to reuse it
|
||||
|
||||
If the app exposes a normal web URL and the browser flow is enough, a browser adapter is usually simpler.
|
||||
Use an Electron adapter only when the desktop app is the real integration surface.
|
||||
|
||||
## Recommended reading order
|
||||
|
||||
If you are starting from zero:
|
||||
|
||||
1. This page
|
||||
2. [CLI-ifying Electron Applications](/advanced/electron)
|
||||
3. [Chrome DevTools Protocol](/advanced/cdp)
|
||||
4. [TypeScript Adapter Guide](/developer/ts-adapter)
|
||||
5. One concrete desktop adapter doc under `docs/adapters/desktop/`
|
||||
|
||||
## Practical rule
|
||||
|
||||
Do not start with a large feature surface.
|
||||
|
||||
Start with:
|
||||
- `status`
|
||||
- `dump`
|
||||
- `read`
|
||||
- `send`
|
||||
- `new`
|
||||
|
||||
Once those are stable, extend outward.
|
||||
@@ -48,27 +48,6 @@ opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # Verbose: show pipeline debug
|
||||
```
|
||||
|
||||
### Tab Completion
|
||||
|
||||
OpenCLI supports intelligent tab completion to speed up command input:
|
||||
|
||||
```bash
|
||||
# Add shell completion to your startup config
|
||||
echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
|
||||
echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
|
||||
echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
|
||||
|
||||
# Restart your shell, then press Tab to complete:
|
||||
opencli [Tab] # Complete site names (bilibili, zhihu, twitter...)
|
||||
opencli bilibili [Tab] # Complete commands (hot, search, me, download...)
|
||||
```
|
||||
|
||||
The completion includes:
|
||||
- All available sites and adapters
|
||||
- Built-in commands (list, explore, validate...)
|
||||
- Command aliases
|
||||
- Real-time updates as you add new adapters
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Installation details](/guide/installation)
|
||||
@@ -76,4 +55,3 @@ The completion includes:
|
||||
- [Plugins — extend with community adapters](/guide/plugins)
|
||||
- [All available adapters](/adapters/)
|
||||
- [For developers / AI agents](/developer/contributing)
|
||||
- [Add a new Electron app CLI](/guide/electron-app-cli)
|
||||
|
||||
@@ -11,12 +11,6 @@ opencli plugin install github:ByteYue/opencli-plugin-github-trending
|
||||
# List installed plugins
|
||||
opencli plugin list
|
||||
|
||||
# Update one plugin
|
||||
opencli plugin update github-trending
|
||||
|
||||
# Update all installed plugins
|
||||
opencli plugin update --all
|
||||
|
||||
# Use the plugin (it's just a regular command)
|
||||
opencli github-trending repos --limit 10
|
||||
|
||||
@@ -31,113 +25,12 @@ Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at st
|
||||
### Supported Source Formats
|
||||
|
||||
```bash
|
||||
# GitHub shorthand
|
||||
opencli plugin install github:user/repo
|
||||
opencli plugin install github:user/repo/subplugin # install specific sub-plugin from monorepo
|
||||
opencli plugin install https://github.com/user/repo
|
||||
|
||||
# Any git-cloneable URL
|
||||
opencli plugin install https://gitlab.example.com/team/repo.git
|
||||
opencli plugin install ssh://git@gitlab.example.com/team/repo.git
|
||||
opencli plugin install git@gitlab.example.com:team/repo.git
|
||||
|
||||
# Local plugin (for development)
|
||||
opencli plugin install file:///path/to/plugin
|
||||
opencli plugin install /path/to/plugin
|
||||
```
|
||||
|
||||
The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
|
||||
|
||||
## Plugin Manifest (`opencli-plugin.json`)
|
||||
|
||||
Plugins can include an `opencli-plugin.json` manifest file at the repo root to declare metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"opencli": ">=1.0.0",
|
||||
"description": "My awesome plugin"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Plugin name (overrides repo-derived name) |
|
||||
| `version` | Semantic version |
|
||||
| `opencli` | Required opencli version range (e.g. `>=1.0.0`, `^1.2.0`) |
|
||||
| `description` | Human-readable description |
|
||||
| `plugins` | Monorepo sub-plugin declarations (see below) |
|
||||
|
||||
The manifest is optional — plugins without one continue to work exactly as before.
|
||||
|
||||
## Monorepo Plugins
|
||||
|
||||
A single repository can contain multiple plugins by declaring a `plugins` field in `opencli-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"opencli": ">=1.0.0",
|
||||
"description": "My plugin collection",
|
||||
"plugins": {
|
||||
"polymarket": {
|
||||
"path": "packages/polymarket",
|
||||
"description": "Prediction market analysis",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"defi": {
|
||||
"path": "packages/defi",
|
||||
"description": "DeFi protocol data",
|
||||
"version": "0.8.0",
|
||||
"opencli": ">=1.2.0"
|
||||
},
|
||||
"experimental": {
|
||||
"path": "packages/experimental",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Installing
|
||||
|
||||
```bash
|
||||
# Install ALL enabled sub-plugins from a monorepo
|
||||
opencli plugin install github:user/opencli-plugins
|
||||
|
||||
# Install a SPECIFIC sub-plugin
|
||||
opencli plugin install github:user/opencli-plugins/polymarket
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
- The monorepo is cloned once to `~/.opencli/monorepos/<repo>/`
|
||||
- Each sub-plugin gets a symlink in `~/.opencli/plugins/<name>/` pointing to its subdirectory
|
||||
- Command discovery works transparently — symlinks are scanned just like regular directories
|
||||
- Disabled sub-plugins (with `"disabled": true`) are skipped during install
|
||||
- Sub-plugins can specify their own `opencli` compatibility range
|
||||
|
||||
### Updating
|
||||
|
||||
Updating any sub-plugin from a monorepo pulls the entire repo and refreshes all sub-plugins:
|
||||
|
||||
```bash
|
||||
opencli plugin update polymarket # updates the monorepo, refreshes all
|
||||
```
|
||||
|
||||
### Uninstalling
|
||||
|
||||
```bash
|
||||
opencli plugin uninstall polymarket # removes just this sub-plugin's symlink
|
||||
```
|
||||
|
||||
When the last sub-plugin from a monorepo is uninstalled, the monorepo clone is automatically cleaned up.
|
||||
|
||||
## Version Tracking
|
||||
|
||||
OpenCLI records installed plugin versions in `~/.opencli/plugins.lock.json`. Each entry stores the plugin source, current git commit hash, install time, and last update time. `opencli plugin list` shows the short commit hash when version metadata is available.
|
||||
|
||||
## Creating a Plugin
|
||||
|
||||
### Option 1: YAML Plugin (Simplest)
|
||||
@@ -243,7 +136,6 @@ On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version
|
||||
| [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 (zhihu, weibo, bilibili, v2ex, stackoverflow, reddit, linux-do) |
|
||||
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles, categories, and article feed |
|
||||
| [opencli-plugin-rubysec](https://github.com/nullptrKey/opencli-plugin-rubysec) | TS | RubySec advisory archive and advisory article reader |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -20,22 +20,17 @@
|
||||
### Daemon issues
|
||||
|
||||
```bash
|
||||
# Check daemon status (PID, uptime, extension connection, memory)
|
||||
opencli daemon status
|
||||
# Check daemon status
|
||||
curl localhost:19825/status
|
||||
|
||||
# View extension logs
|
||||
curl localhost:19825/logs
|
||||
|
||||
# Stop or restart the daemon
|
||||
opencli daemon stop
|
||||
opencli daemon restart
|
||||
|
||||
# Full diagnostics
|
||||
# Kill and restart daemon
|
||||
pkill -f opencli-daemon
|
||||
opencli doctor
|
||||
```
|
||||
|
||||
> The daemon auto-exits after 4 hours of inactivity (no CLI requests and no extension connection). Override with `OPENCLI_DAEMON_TIMEOUT` (milliseconds, `0` = never timeout).
|
||||
|
||||
### Desktop adapter connection issues
|
||||
|
||||
For Electron/CDP-based adapters (Cursor, Codex, etc.):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,857 +0,0 @@
|
||||
# Daemon Lifecycle Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the daemon's aggressive 5-minute idle timeout with a long-lived model (4h default) that requires both CLI inactivity AND Extension disconnection before exiting, plus add `daemon status/stop/restart` CLI commands.
|
||||
|
||||
**Architecture:** The daemon keeps its existing HTTP + WebSocket bridge architecture. We change the idle timeout logic to track two independent activity signals (CLI requests and Extension connection), add `/status` and `/shutdown` HTTP endpoints, reduce the Extension reconnect backoff cap, and register new CLI commands via Commander.js.
|
||||
|
||||
**Tech Stack:** Node.js, TypeScript, Commander.js, ws, Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/constants.ts` | Modify | Add `DEFAULT_DAEMON_IDLE_TIMEOUT` constant |
|
||||
| `src/daemon.ts` | Modify | Dual-condition idle timer, `/status` endpoint, `/shutdown` endpoint |
|
||||
| `src/daemon.test.ts` | Create | Unit tests for idle timer logic, `/status`, `/shutdown` |
|
||||
| `extension/src/protocol.ts` | Modify | Change `WS_RECONNECT_MAX_DELAY` from 60000 to 5000 |
|
||||
| `src/cli.ts` | Modify | Register `daemon` subcommand group |
|
||||
| `src/commands/daemon.ts` | Create | `status`, `stop`, `restart` subcommand implementations |
|
||||
| `src/commands/daemon.test.ts` | Create | Unit tests for daemon commands |
|
||||
| `src/browser/mcp.ts` | Modify | Better connection-waiting UX messages, 200ms poll interval |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `DEFAULT_DAEMON_IDLE_TIMEOUT` constant
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/constants.ts`
|
||||
|
||||
- [ ] **Step 1: Add the constant**
|
||||
|
||||
In `src/constants.ts`, add after the `DEFAULT_DAEMON_PORT` line:
|
||||
|
||||
```typescript
|
||||
/** Default idle timeout before daemon auto-exits (ms). Override via OPENCLI_DAEMON_TIMEOUT env var. */
|
||||
export const DEFAULT_DAEMON_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/constants.ts
|
||||
git commit -m "feat(daemon): add DEFAULT_DAEMON_IDLE_TIMEOUT constant (4 hours)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement dual-condition idle timer in daemon
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/daemon.ts:27,29-57,116-123,196-198,245-262,265-269`
|
||||
- Test: `src/daemon.test.ts` (create)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the new idle timer logic**
|
||||
|
||||
Create `src/daemon.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// We test the idle timer logic by extracting it into testable functions.
|
||||
// The daemon module has side effects (starts server), so we test the logic unit directly.
|
||||
|
||||
describe('IdleManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not start timer when extension is connected', async () => {
|
||||
const { IdleManager } = await import('./daemon.js');
|
||||
const exit = vi.fn();
|
||||
const mgr = new IdleManager(300_000, exit); // 5 min for fast test
|
||||
|
||||
mgr.setExtensionConnected(true);
|
||||
mgr.onCliRequest();
|
||||
|
||||
vi.advanceTimersByTime(300_000 + 1000);
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts timer when extension disconnects and CLI is idle', async () => {
|
||||
const { IdleManager } = await import('./daemon.js');
|
||||
const exit = vi.fn();
|
||||
const mgr = new IdleManager(300_000, exit);
|
||||
|
||||
mgr.onCliRequest(); // CLI was active
|
||||
mgr.setExtensionConnected(true);
|
||||
mgr.setExtensionConnected(false); // Extension disconnects
|
||||
|
||||
// Should not exit immediately — CLI was just active
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past timeout
|
||||
vi.advanceTimersByTime(300_000 + 1000);
|
||||
expect(exit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exits immediately on extension disconnect if CLI has been idle past timeout', async () => {
|
||||
const { IdleManager } = await import('./daemon.js');
|
||||
const exit = vi.fn();
|
||||
const mgr = new IdleManager(300_000, exit);
|
||||
|
||||
mgr.onCliRequest(); // Last CLI activity
|
||||
vi.advanceTimersByTime(400_000); // 400s elapsed — past 300s timeout
|
||||
|
||||
mgr.setExtensionConnected(true);
|
||||
mgr.setExtensionConnected(false);
|
||||
|
||||
expect(exit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets timer on new CLI request', async () => {
|
||||
const { IdleManager } = await import('./daemon.js');
|
||||
const exit = vi.fn();
|
||||
const mgr = new IdleManager(300_000, exit);
|
||||
|
||||
mgr.onCliRequest();
|
||||
vi.advanceTimersByTime(200_000); // 200s elapsed
|
||||
mgr.onCliRequest(); // Reset
|
||||
|
||||
vi.advanceTimersByTime(200_000); // 200s more — only 200s since last request
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100_001); // Now 300s+ since last request
|
||||
expect(exit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not exit when timeout is 0 (disabled)', async () => {
|
||||
const { IdleManager } = await import('./daemon.js');
|
||||
const exit = vi.fn();
|
||||
const mgr = new IdleManager(0, exit);
|
||||
|
||||
mgr.onCliRequest();
|
||||
vi.advanceTimersByTime(24 * 60 * 60 * 1000); // 24 hours
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears timer when extension connects', async () => {
|
||||
const { IdleManager } = await import('./daemon.js');
|
||||
const exit = vi.fn();
|
||||
const mgr = new IdleManager(300_000, exit);
|
||||
|
||||
mgr.onCliRequest();
|
||||
vi.advanceTimersByTime(200_000); // Timer running
|
||||
|
||||
mgr.setExtensionConnected(true); // Should clear timer
|
||||
vi.advanceTimersByTime(200_000); // Would have fired
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
npx vitest run src/daemon.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL — `IdleManager` is not exported from `./daemon.js`
|
||||
|
||||
- [ ] **Step 3: Extract IdleManager class and refactor daemon.ts**
|
||||
|
||||
In `src/daemon.ts`, replace the idle timeout section (lines 27, 29-57) with:
|
||||
|
||||
Replace the `IDLE_TIMEOUT` constant (line 27):
|
||||
```typescript
|
||||
import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js';
|
||||
|
||||
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT);
|
||||
```
|
||||
|
||||
Replace the idle timer state and `resetIdleTimer` function (lines 37, 49-57) with the `IdleManager` class:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Manages daemon idle timeout with dual-condition logic:
|
||||
* exits only when BOTH CLI is idle AND Extension is disconnected.
|
||||
*/
|
||||
export class IdleManager {
|
||||
private _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _lastCliRequestTime = Date.now();
|
||||
private _extensionConnected = false;
|
||||
private _timeoutMs: number;
|
||||
private _onExit: () => void;
|
||||
|
||||
constructor(timeoutMs: number, onExit: () => void) {
|
||||
this._timeoutMs = timeoutMs;
|
||||
this._onExit = onExit;
|
||||
}
|
||||
|
||||
/** Call when an HTTP request arrives from CLI */
|
||||
onCliRequest(): void {
|
||||
this._lastCliRequestTime = Date.now();
|
||||
this._resetTimer();
|
||||
}
|
||||
|
||||
/** Call when Extension WebSocket connects or disconnects */
|
||||
setExtensionConnected(connected: boolean): void {
|
||||
this._extensionConnected = connected;
|
||||
if (connected) {
|
||||
// Extension is alive — clear any pending exit timer
|
||||
this._clearTimer();
|
||||
} else {
|
||||
// Extension gone — check if CLI has also been idle long enough
|
||||
this._resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private _clearTimer(): void {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _resetTimer(): void {
|
||||
this._clearTimer();
|
||||
|
||||
// Timeout disabled
|
||||
if (this._timeoutMs <= 0) return;
|
||||
|
||||
// Extension connected — don't start timer
|
||||
if (this._extensionConnected) return;
|
||||
|
||||
const elapsed = Date.now() - this._lastCliRequestTime;
|
||||
if (elapsed >= this._timeoutMs) {
|
||||
// CLI has been idle past the timeout already
|
||||
this._onExit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timer for remaining duration
|
||||
this._timer = setTimeout(() => {
|
||||
this._onExit();
|
||||
}, this._timeoutMs - elapsed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then create the global `idleManager` instance after the class definition:
|
||||
|
||||
```typescript
|
||||
const idleManager = new IdleManager(IDLE_TIMEOUT, () => {
|
||||
console.error('[daemon] Idle timeout (no CLI requests + no Extension), shutting down');
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire IdleManager into existing daemon code**
|
||||
|
||||
In the `handleRequest` function, replace `resetIdleTimer()` (line 142) with:
|
||||
```typescript
|
||||
idleManager.onCliRequest();
|
||||
```
|
||||
|
||||
In the `wss.on('connection')` handler (around line 196-198), add after `extensionWs = ws;`:
|
||||
```typescript
|
||||
idleManager.setExtensionConnected(true);
|
||||
```
|
||||
|
||||
In the `ws.on('close')` handler (around line 245-249), add after `extensionWs = null;`:
|
||||
```typescript
|
||||
idleManager.setExtensionConnected(false);
|
||||
```
|
||||
|
||||
In the `ws.on('error')` handler (around line 259-261), add after `extensionWs = null;`:
|
||||
```typescript
|
||||
idleManager.setExtensionConnected(false);
|
||||
```
|
||||
|
||||
In the `httpServer.listen` callback (line 268-269), replace `resetIdleTimer()` with:
|
||||
```typescript
|
||||
idleManager.onCliRequest(); // Start initial idle countdown
|
||||
```
|
||||
|
||||
Remove the old `resetIdleTimer` function and `idleTimer` variable entirely.
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
npx vitest run src/daemon.test.ts
|
||||
```
|
||||
|
||||
Expected: All 6 tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/daemon.ts src/daemon.test.ts
|
||||
git commit -m "feat(daemon): replace fixed 5min timeout with dual-condition idle manager (4h default)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add `/status` and `/shutdown` endpoints to daemon
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/daemon.ts:116-123`
|
||||
|
||||
- [ ] **Step 1: Add tests for /status and /shutdown endpoints**
|
||||
|
||||
Append to `src/daemon.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('/status endpoint', () => {
|
||||
it('returns daemon status with correct fields', async () => {
|
||||
// This is an integration test — tested via the daemon command tests.
|
||||
// Here we just verify the shape of the status response type.
|
||||
expect(true).toBe(true); // Placeholder — real coverage in Task 6
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: The `/status` and `/shutdown` endpoints run inside the daemon process, which makes them hard to unit test in isolation. They are integration-tested via the `opencli daemon status/stop` commands in Task 6.
|
||||
|
||||
- [ ] **Step 2: Enhance the existing `/status` endpoint**
|
||||
|
||||
In `src/daemon.ts`, replace the existing `/status` handler (lines 116-123) with:
|
||||
|
||||
```typescript
|
||||
if (req.method === 'GET' && pathname === '/status') {
|
||||
const uptime = process.uptime();
|
||||
const mem = process.memoryUsage();
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
pid: process.pid,
|
||||
uptime,
|
||||
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
|
||||
pending: pending.size,
|
||||
lastCliRequestTime: idleManager.lastCliRequestTime,
|
||||
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
|
||||
port: PORT,
|
||||
});
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Also add a public getter to `IdleManager`:
|
||||
|
||||
```typescript
|
||||
get lastCliRequestTime(): number {
|
||||
return this._lastCliRequestTime;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `/shutdown` endpoint**
|
||||
|
||||
In `src/daemon.ts`, add before the `POST /command` handler:
|
||||
|
||||
```typescript
|
||||
if (req.method === 'POST' && pathname === '/shutdown') {
|
||||
jsonResponse(res, 200, { ok: true, message: 'Shutting down' });
|
||||
// Graceful shutdown after response is sent
|
||||
setTimeout(() => shutdown(), 100);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all tests**
|
||||
|
||||
```bash
|
||||
npx vitest run src/daemon.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/daemon.ts src/daemon.test.ts
|
||||
git commit -m "feat(daemon): enhance /status endpoint, add /shutdown endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Reduce Extension WebSocket reconnect backoff cap
|
||||
|
||||
**Files:**
|
||||
- Modify: `extension/src/protocol.ts:57`
|
||||
|
||||
- [ ] **Step 1: Change the constant**
|
||||
|
||||
In `extension/src/protocol.ts`, change line 57:
|
||||
|
||||
```typescript
|
||||
/** Max reconnect delay (ms) — kept short since daemon is long-lived */
|
||||
export const WS_RECONNECT_MAX_DELAY = 5000;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/protocol.ts
|
||||
git commit -m "feat(extension): reduce WS reconnect backoff cap from 60s to 5s"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement `daemon status/stop/restart` CLI commands
|
||||
|
||||
**Files:**
|
||||
- Create: `src/commands/daemon.ts`
|
||||
- Modify: `src/cli.ts`
|
||||
|
||||
- [ ] **Step 1: Create daemon command module**
|
||||
|
||||
Create `src/commands/daemon.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* CLI commands for daemon lifecycle management:
|
||||
* opencli daemon status — show daemon state
|
||||
* opencli daemon stop — graceful shutdown
|
||||
* opencli daemon restart — stop + respawn
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
||||
|
||||
interface DaemonStatus {
|
||||
ok: boolean;
|
||||
pid: number;
|
||||
uptime: number;
|
||||
extensionConnected: boolean;
|
||||
pending: number;
|
||||
lastCliRequestTime: number;
|
||||
memoryMB: number;
|
||||
port: number;
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<DaemonStatus | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`${DAEMON_URL}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as DaemonStatus;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestShutdown(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(`${DAEMON_URL}/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${Math.floor(seconds)}s`;
|
||||
}
|
||||
|
||||
function formatTimeSince(timestampMs: number): string {
|
||||
const seconds = (Date.now() - timestampMs) / 1000;
|
||||
if (seconds < 60) return `${Math.floor(seconds)}s ago`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m ago`;
|
||||
}
|
||||
|
||||
export async function daemonStatus(): Promise<void> {
|
||||
const status = await fetchStatus();
|
||||
if (!status) {
|
||||
console.log(`Daemon: ${chalk.dim('not running')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Daemon: ${chalk.green('running')} (PID ${status.pid})`);
|
||||
console.log(`Uptime: ${formatUptime(status.uptime)}`);
|
||||
console.log(`Extension: ${status.extensionConnected ? chalk.green('connected') : chalk.yellow('disconnected')}`);
|
||||
console.log(`Last CLI request: ${formatTimeSince(status.lastCliRequestTime)}`);
|
||||
console.log(`Memory: ${status.memoryMB} MB`);
|
||||
console.log(`Port: ${status.port}`);
|
||||
}
|
||||
|
||||
export async function daemonStop(): Promise<void> {
|
||||
const status = await fetchStatus();
|
||||
if (!status) {
|
||||
console.log(chalk.dim('Daemon is not running.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await requestShutdown();
|
||||
if (ok) {
|
||||
console.log(chalk.green('Daemon stopped.'));
|
||||
} else {
|
||||
console.error(chalk.red('Failed to stop daemon.'));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonRestart(): Promise<void> {
|
||||
const status = await fetchStatus();
|
||||
if (status) {
|
||||
const ok = await requestShutdown();
|
||||
if (!ok) {
|
||||
console.error(chalk.red('Failed to stop daemon.'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Wait for daemon to exit
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// Import BrowserBridge to spawn a new daemon
|
||||
const { BrowserBridge } = await import('../browser/mcp.js');
|
||||
const bridge = new BrowserBridge();
|
||||
try {
|
||||
console.log('Starting daemon...');
|
||||
await bridge.connect({ timeout: 10 });
|
||||
console.log(chalk.green('Daemon restarted.'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to restart daemon: ${err instanceof Error ? err.message : err}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register daemon commands in cli.ts**
|
||||
|
||||
In `src/cli.ts`, add the import at the top:
|
||||
|
||||
```typescript
|
||||
import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
|
||||
```
|
||||
|
||||
Add the daemon subcommand group before the `// ── External CLIs` section (around line 380):
|
||||
|
||||
```typescript
|
||||
// ── Built-in: daemon ──────────────────────────────────────────────────────
|
||||
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
|
||||
daemonCmd
|
||||
.command('status')
|
||||
.description('Show daemon status')
|
||||
.action(async () => { await daemonStatus(); });
|
||||
daemonCmd
|
||||
.command('stop')
|
||||
.description('Stop the daemon')
|
||||
.action(async () => { await daemonStop(); });
|
||||
daemonCmd
|
||||
.command('restart')
|
||||
.description('Restart the daemon')
|
||||
.action(async () => { await daemonRestart(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run linter/type check**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/commands/daemon.ts src/cli.ts
|
||||
git commit -m "feat(daemon): add opencli daemon status/stop/restart commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Write tests for daemon commands
|
||||
|
||||
**Files:**
|
||||
- Create: `src/commands/daemon.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
Create `src/commands/daemon.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock fetch globally for all tests
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// Mock chalk to avoid ANSI in assertions
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
green: (s: string) => s,
|
||||
yellow: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
dim: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('daemonStatus', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('shows "not running" when daemon is unreachable', async () => {
|
||||
mockFetch.mockRejectedValue(new TypeError('fetch failed'));
|
||||
|
||||
const { daemonStatus } = await import('./daemon.js');
|
||||
await daemonStatus();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
||||
});
|
||||
|
||||
it('shows daemon info when running', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
pid: 12345,
|
||||
uptime: 7200,
|
||||
extensionConnected: true,
|
||||
pending: 0,
|
||||
lastCliRequestTime: Date.now() - 60_000,
|
||||
memoryMB: 12.3,
|
||||
port: 19825,
|
||||
}),
|
||||
});
|
||||
|
||||
const { daemonStatus } = await import('./daemon.js');
|
||||
await daemonStatus();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('12345'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemonStop', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrSpy.mockRestore();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('reports when daemon is not running', async () => {
|
||||
mockFetch.mockRejectedValue(new TypeError('fetch failed'));
|
||||
|
||||
const { daemonStop } = await import('./daemon.js');
|
||||
await daemonStop();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
||||
});
|
||||
|
||||
it('sends shutdown and reports success', async () => {
|
||||
// First call: fetchStatus
|
||||
// Second call: requestShutdown
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, pid: 123, uptime: 100, extensionConnected: false, pending: 0, lastCliRequestTime: Date.now(), memoryMB: 10, port: 19825 }),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const { daemonStop } = await import('./daemon.js');
|
||||
await daemonStop();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('stopped'));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
npx vitest run src/commands/daemon.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/commands/daemon.test.ts
|
||||
git commit -m "test(daemon): add tests for daemon status/stop commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Improve CLI connection-waiting UX
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/mcp.ts:58-118`
|
||||
|
||||
- [ ] **Step 1: Improve error messages and poll interval**
|
||||
|
||||
In `src/browser/mcp.ts`, replace the `_ensureDaemon` method (lines 58-118) with:
|
||||
|
||||
```typescript
|
||||
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
|
||||
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
||||
const timeoutMs = effectiveSeconds * 1000;
|
||||
|
||||
// Fast path: extension already connected
|
||||
if (await isExtensionConnected()) return;
|
||||
|
||||
// Daemon running but no extension — wait for extension with progress
|
||||
if (await isDaemonRunning()) {
|
||||
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
||||
process.stderr.write('⏳ Waiting for Chrome extension to connect...\n');
|
||||
process.stderr.write(' Make sure Chrome is open and the OpenCLI extension is enabled.\n');
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
throw new Error(
|
||||
'Daemon is running but the Browser Extension is not connected.\n' +
|
||||
'Please install and enable the opencli Browser Bridge extension in Chrome.',
|
||||
);
|
||||
}
|
||||
|
||||
// No daemon — spawn one
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const parentDir = path.resolve(__dirname, '..');
|
||||
const daemonTs = path.join(parentDir, 'daemon.ts');
|
||||
const daemonJs = path.join(parentDir, 'daemon.js');
|
||||
const isTs = fs.existsSync(daemonTs);
|
||||
const daemonPath = isTs ? daemonTs : daemonJs;
|
||||
|
||||
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
||||
process.stderr.write('⏳ Starting daemon...\n');
|
||||
}
|
||||
|
||||
const spawnArgs = isTs
|
||||
? [process.execPath, '--import', 'tsx/esm', daemonPath]
|
||||
: [process.execPath, daemonPath];
|
||||
|
||||
this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env },
|
||||
});
|
||||
this._daemonProc.unref();
|
||||
|
||||
// Wait for daemon + extension with faster polling
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
|
||||
if (await isDaemonRunning()) {
|
||||
throw new Error(
|
||||
'Daemon is running but the Browser Extension is not connected.\n' +
|
||||
'Please install and enable the opencli Browser Bridge extension in Chrome.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Failed to start opencli daemon. Try running manually:\n' +
|
||||
` node ${daemonPath}\n` +
|
||||
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run existing browser tests to check for regressions**
|
||||
|
||||
```bash
|
||||
npx vitest run src/browser.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/mcp.ts
|
||||
git commit -m "feat(daemon): improve CLI connection-waiting UX with progress messages and 200ms polling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Run full test suite and verify
|
||||
|
||||
- [ ] **Step 1: Run type check**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: All tests pass, no regressions
|
||||
|
||||
- [ ] **Step 3: Manual smoke test**
|
||||
|
||||
```bash
|
||||
# Check daemon status (should be "not running" if daemon isn't started)
|
||||
npx tsx src/main.ts daemon status
|
||||
|
||||
# Start daemon by running any browser command, then check status
|
||||
npx tsx src/main.ts daemon status
|
||||
|
||||
# Stop daemon
|
||||
npx tsx src/main.ts daemon stop
|
||||
|
||||
# Verify stopped
|
||||
npx tsx src/main.ts daemon status
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Final commit if any fixes needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: address issues found during smoke testing"
|
||||
```
|
||||
@@ -1,170 +0,0 @@
|
||||
# Performance: Smart Wait & INTERCEPT Fix
|
||||
|
||||
**Date**: 2026-03-28
|
||||
**Status**: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Three distinct performance/correctness issues:
|
||||
|
||||
1. **INTERCEPT strategy semantic bug**: After `installInterceptor()` + `goto()`, adapters call `wait(N)` — which now uses `waitForDomStableJs` and returns early when the DOM settles. But DOM-settle != network capture. The API response may arrive *after* DOM is stable, causing `getInterceptedRequests()` to return an empty array.
|
||||
|
||||
2. **Blind `wait(N)` in adapters**: ~30 high-traffic adapters (Twitter family, Medium, Substack, etc.) call `wait(5)` waiting for React/Vue to hydrate. These should wait for a specific DOM element to appear, not a fixed cap.
|
||||
|
||||
3. **Daemon cold-start polling**: Fixed 300ms poll loop means ~600ms before first successful `isExtensionConnected()` check, even though the daemon is typically ready in 500–800ms.
|
||||
|
||||
## Design
|
||||
|
||||
### Layer 1 — `waitForCapture()` (correctness fix + perf)
|
||||
|
||||
Add `waitForCapture(timeout?: number): Promise<void>` to `IPage`.
|
||||
|
||||
Polls `window.__opencli_xhr.length > 0` every 100ms inside the browser tab. Resolves as soon as ≥1 capture arrives; rejects after `timeout` seconds.
|
||||
|
||||
```typescript
|
||||
// dom-helpers.ts
|
||||
export function waitForCaptureJs(maxMs: number): string {
|
||||
return `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + ${maxMs};
|
||||
const check = () => {
|
||||
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
|
||||
if (Date.now() > deadline) return reject(new Error('No capture within ${maxMs / 1000}s'));
|
||||
setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
})
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
`page.ts` and `cdp.ts` implement `waitForCapture()` by calling `waitForCaptureJs`.
|
||||
|
||||
**All INTERCEPT adapters** replace `wait(N)` → `waitForCapture(N+2)` (slightly longer timeout as safety margin).
|
||||
|
||||
`stepIntercept` in `pipeline/steps/intercept.ts` replaces its internal `wait(timeout)` with `waitForCapture(timeout)`.
|
||||
|
||||
**Expected gain**: 36kr hot/search: 6s → ~1–2s. Twitter search/followers: 5–8s → ~1–3s.
|
||||
|
||||
### Layer 2 — `wait({ selector })` (semantic precision)
|
||||
|
||||
Extend `WaitOptions` with `selector?: string`.
|
||||
|
||||
Add `waitForSelectorJs(selector, timeoutMs)` to `dom-helpers.ts` — polls `document.querySelector(selector)` every 100ms, resolves on first match, rejects on timeout.
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
export interface WaitOptions {
|
||||
text?: string;
|
||||
selector?: string; // NEW
|
||||
time?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// dom-helpers.ts
|
||||
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
|
||||
return `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + ${timeoutMs};
|
||||
const check = () => {
|
||||
if (document.querySelector(${JSON.stringify(selector)})) return resolve('found');
|
||||
if (Date.now() > deadline) return reject(new Error('Selector not found: ' + ${JSON.stringify(selector)}));
|
||||
setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
})
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
`page.ts` and `cdp.ts` handle `selector` branch in `wait()`.
|
||||
|
||||
**High-impact adapter changes**:
|
||||
|
||||
| Adapter | Old | New |
|
||||
|---------|-----|-----|
|
||||
| `twitter/*` (15 adapters) | `wait(5)` | `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
|
||||
| `twitter/reply.ts` | `wait(5)` | `wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 })` |
|
||||
| `medium/utils.ts` | `wait(5)` + inline 3s setTimeout | `wait({ selector: 'article', timeout: 8 })` + remove inline sleep |
|
||||
| `substack/utils.ts` | `wait(5)` × 2 | `wait({ selector: 'article', timeout: 8 })` |
|
||||
| `bloomberg/news.ts` | `wait(5)` | `wait({ selector: 'article', timeout: 6 })` |
|
||||
| `sinablog/utils.ts` | `wait(5)` | `wait({ selector: 'article, .article', timeout: 6 })` |
|
||||
| `producthunt` (already covered by layer 1) | — | — |
|
||||
|
||||
**Expected gain**: Twitter commands: 5s → ~0.5–2s. Medium: 8s → ~1–3s.
|
||||
|
||||
### Layer 3 — Daemon exponential backoff (cold-start)
|
||||
|
||||
Replace fixed 300ms poll in `_ensureDaemon()` (`browser/mcp.ts`) with exponential backoff:
|
||||
|
||||
```typescript
|
||||
// before
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
|
||||
// after
|
||||
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
|
||||
let i = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i++, backoffs.length - 1)]));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
```
|
||||
|
||||
**Expected gain**: First cold-start check succeeds at ~150ms instead of ~600ms.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New / Modified (framework)
|
||||
- `src/types.ts` — `WaitOptions.selector`, `IPage.waitForCapture()`
|
||||
- `src/browser/dom-helpers.ts` — `waitForCaptureJs()`, `waitForSelectorJs()`
|
||||
- `src/browser/page.ts` — `waitForCapture()`, `wait()` selector branch
|
||||
- `src/browser/cdp.ts` — `waitForCapture()`, `wait()` selector branch
|
||||
- `src/browser/mcp.ts` — exponential backoff in `_ensureDaemon()`
|
||||
- `src/pipeline/steps/intercept.ts` — use `waitForCapture()`
|
||||
|
||||
### Modified (adapters — Layer 1, INTERCEPT)
|
||||
- `src/clis/36kr/hot.ts`
|
||||
- `src/clis/36kr/search.ts`
|
||||
- `src/clis/twitter/search.ts`
|
||||
- `src/clis/twitter/followers.ts`
|
||||
- `src/clis/twitter/following.ts`
|
||||
- `src/clis/producthunt/hot.ts`
|
||||
- `src/clis/producthunt/browse.ts`
|
||||
|
||||
### Modified (adapters — Layer 2, selector)
|
||||
- `src/clis/twitter/reply.ts`
|
||||
- `src/clis/twitter/follow.ts`
|
||||
- `src/clis/twitter/unfollow.ts`
|
||||
- `src/clis/twitter/like.ts`
|
||||
- `src/clis/twitter/bookmark.ts`
|
||||
- `src/clis/twitter/unbookmark.ts`
|
||||
- `src/clis/twitter/block.ts`
|
||||
- `src/clis/twitter/unblock.ts`
|
||||
- `src/clis/twitter/hide-reply.ts`
|
||||
- `src/clis/twitter/notifications.ts`
|
||||
- `src/clis/twitter/profile.ts`
|
||||
- `src/clis/twitter/thread.ts`
|
||||
- `src/clis/twitter/timeline.ts`
|
||||
- `src/clis/twitter/delete.ts`
|
||||
- `src/clis/twitter/reply-dm.ts`
|
||||
- `src/clis/medium/utils.ts`
|
||||
- `src/clis/substack/utils.ts`
|
||||
- `src/clis/bloomberg/news.ts`
|
||||
- `src/clis/sinablog/utils.ts`
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Layer 1 (`waitForCapture`) — correctness fix, highest ROI
|
||||
2. Layer 3 (backoff) — 3-line change, zero risk
|
||||
3. Layer 2 (`wait({ selector })`) — largest adapter surface, can be done per-site
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `waitForCaptureJs`, `waitForSelectorJs` exported and tested in `dom-helpers.test.ts` (if exists) or new test file
|
||||
- Adapter tests: existing tests must continue to pass (mock `page.wait` / `page.waitForCapture`)
|
||||
- Run: `npx vitest run --project unit --project adapter`
|
||||
@@ -1,208 +0,0 @@
|
||||
# Daemon Lifecycle Redesign
|
||||
|
||||
## Problem
|
||||
|
||||
OpenCLI's daemon auto-exits after 5 minutes of idle time. During typical development
|
||||
cycles (write code → test → modify → test again), coding intervals frequently exceed
|
||||
5 minutes. Each restart incurs 2-4 seconds of overhead (process spawn + Extension
|
||||
WebSocket reconnection), creating a noticeable and frustrating delay.
|
||||
|
||||
The current design treats the daemon as a disposable process, but the actual cost
|
||||
profile doesn't justify this:
|
||||
|
||||
| Cost of staying alive | Cost of restarting |
|
||||
|-----------------------|--------------------|
|
||||
| ~12 MB memory, 0% CPU | 2-4 seconds delay per restart |
|
||||
|
||||
The restart cost far outweighs the idle cost.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the aggressive 5-minute fixed timeout with a long-lived daemon model. The
|
||||
daemon stays running for hours, exits only when truly abandoned, and reconnects to
|
||||
the Chrome Extension faster when needed.
|
||||
|
||||
Four changes:
|
||||
|
||||
1. Extend idle timeout from 5 minutes to 4 hours (configurable)
|
||||
2. Require dual idle condition: both no CLI requests AND no Extension connection
|
||||
3. Reduce Extension WebSocket reconnect backoff cap from 60s to 5s
|
||||
4. Add `opencli daemon status/stop/restart` commands
|
||||
|
||||
## Design
|
||||
|
||||
### Timeout Strategy
|
||||
|
||||
**Current behavior:** A single idle timer resets on each HTTP request. After 5
|
||||
minutes without a request, the daemon calls `process.exit(0)`.
|
||||
|
||||
**New behavior:** The daemon tracks two activity signals independently:
|
||||
|
||||
- **CLI activity:** timestamp of the last HTTP request from any CLI invocation
|
||||
- **Extension activity:** whether a WebSocket connection from the Chrome Extension
|
||||
is currently open
|
||||
|
||||
The exit countdown starts only when BOTH conditions are met simultaneously:
|
||||
|
||||
- No CLI request for `IDLE_TIMEOUT` duration
|
||||
- No Extension WebSocket connection
|
||||
|
||||
If either signal is active, the daemon stays alive. This means:
|
||||
|
||||
- A connected Extension keeps the daemon alive indefinitely (user has Chrome open,
|
||||
likely still working)
|
||||
- Recent CLI activity keeps the daemon alive even if Extension temporarily
|
||||
disconnects (Chrome restarting, Extension updating)
|
||||
|
||||
**Timeout value:** 4 hours by default, configurable via `OPENCLI_DAEMON_TIMEOUT`
|
||||
environment variable. Value in milliseconds. Set to `0` to disable timeout entirely.
|
||||
|
||||
```typescript
|
||||
const DEFAULT_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours
|
||||
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_IDLE_TIMEOUT);
|
||||
```
|
||||
|
||||
**Timer implementation:**
|
||||
|
||||
```
|
||||
resetIdleTimer():
|
||||
clear existing timer
|
||||
if Extension is connected:
|
||||
do not start timer (Extension connection keeps daemon alive)
|
||||
return
|
||||
start timer with IDLE_TIMEOUT duration
|
||||
on timeout: process.exit(0)
|
||||
|
||||
On CLI HTTP request:
|
||||
update lastRequestTime
|
||||
resetIdleTimer()
|
||||
|
||||
On Extension WebSocket connect:
|
||||
clear timer (Extension keeps daemon alive)
|
||||
|
||||
On Extension WebSocket disconnect:
|
||||
elapsed = now - lastRequestTime
|
||||
if elapsed >= IDLE_TIMEOUT:
|
||||
process.exit(0) // CLI has been idle long enough already
|
||||
else:
|
||||
start timer with (IDLE_TIMEOUT - elapsed) // count remaining time
|
||||
```
|
||||
|
||||
### Extension Fast Reconnect
|
||||
|
||||
**Current behavior:** When the Extension loses its WebSocket connection to the
|
||||
daemon, it reconnects with exponential backoff: 2s → 4s → 8s → 16s → 32s → 60s
|
||||
(capped). In the worst case, the Extension waits up to 60 seconds before attempting
|
||||
reconnection.
|
||||
|
||||
**New behavior:** Cap the backoff at 5 seconds instead of 60 seconds.
|
||||
|
||||
```typescript
|
||||
// extension/src/background.ts
|
||||
const WS_RECONNECT_MAX_DELAY = 5000; // was 60000
|
||||
```
|
||||
|
||||
Rationale: with a 4-hour daemon timeout, the daemon is almost always running. Long
|
||||
backoff intervals are unnecessary and only increase reconnection latency. A 5-second
|
||||
cap means the Extension reconnects within 5 seconds of the daemon becoming available.
|
||||
|
||||
### Daemon Management Commands
|
||||
|
||||
Add three new CLI commands for daemon lifecycle management:
|
||||
|
||||
**`opencli daemon status`**
|
||||
|
||||
Queries the daemon's `/status` endpoint (new) and displays:
|
||||
|
||||
```
|
||||
Daemon: running (PID 12345)
|
||||
Uptime: 2h 15m
|
||||
Extension: connected
|
||||
Last CLI request: 8 min ago
|
||||
Memory: 12.3 MB
|
||||
Port: 19825
|
||||
```
|
||||
|
||||
If daemon is not running:
|
||||
|
||||
```
|
||||
Daemon: not running
|
||||
```
|
||||
|
||||
**`opencli daemon stop`**
|
||||
|
||||
Sends a `POST /shutdown` request to the daemon, which triggers a graceful shutdown:
|
||||
reject pending requests with a shutdown message, close WebSocket connections, close
|
||||
HTTP server, then exit.
|
||||
|
||||
**`opencli daemon restart`**
|
||||
|
||||
Equivalent to `stop` followed by spawning a new daemon. Useful when the daemon gets
|
||||
into a bad state.
|
||||
|
||||
**Daemon-side endpoints:**
|
||||
|
||||
- `GET /status` — returns JSON with PID, uptime, extension connection state, last
|
||||
request time, memory usage
|
||||
- `POST /shutdown` — initiates graceful shutdown
|
||||
|
||||
Both endpoints require the same `X-OpenCLI` header as existing endpoints for CSRF
|
||||
protection.
|
||||
|
||||
### CLI Connection Experience
|
||||
|
||||
**Current behavior:** When daemon is running but Extension is not connected, the CLI
|
||||
silently polls every 300ms and eventually times out with a generic error.
|
||||
|
||||
**New behavior:** Show a progress indicator and actionable message:
|
||||
|
||||
```
|
||||
⏳ Waiting for Chrome extension to connect...
|
||||
Make sure Chrome is open and the OpenCLI extension is enabled.
|
||||
```
|
||||
|
||||
Poll interval reduced from 300ms to 200ms for slightly faster detection.
|
||||
|
||||
If the daemon is not running at all (connection refused), the CLI spawns it as before
|
||||
and shows:
|
||||
|
||||
```
|
||||
⏳ Starting daemon...
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change | Estimated LOC |
|
||||
|------|--------|---------------|
|
||||
| `src/daemon.ts` | Dual-condition idle timeout, `/status` endpoint, `/shutdown` endpoint | ~40 |
|
||||
| `extension/src/background.ts` | `WS_RECONNECT_MAX_DELAY` 60000 → 5000 | 1 |
|
||||
| `src/browser/daemon-client.ts` | Better connection-waiting UX, 200ms poll interval | ~20 |
|
||||
| `src/commands/daemon.ts` (new) | `status`, `stop`, `restart` subcommands | ~80 |
|
||||
| `src/constants.ts` | `DEFAULT_IDLE_TIMEOUT` constant | 2 |
|
||||
|
||||
**Total: ~143 lines of new/changed code.**
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- No breaking changes to CLI commands or Extension protocol
|
||||
- Existing `OPENCLI_DAEMON_PORT` environment variable continues to work
|
||||
- The only observable behavior change: daemon stays alive longer
|
||||
- New `daemon` subcommands are additive
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test: idle timer starts only when both CLI and Extension are idle
|
||||
- Unit test: idle timer is cleared when Extension connects
|
||||
- Unit test: `/status` returns correct state
|
||||
- Unit test: `/shutdown` triggers graceful exit
|
||||
- Integration test: daemon survives 10+ minutes without CLI requests while Extension
|
||||
is connected
|
||||
- Integration test: daemon exits after configured timeout when fully idle
|
||||
- Integration test: `opencli daemon status/stop/restart` work correctly
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- OS-level daemon management (launchd/systemd) — can be added later if needed
|
||||
- Daemon auto-update mechanism
|
||||
- Multi-daemon coordination
|
||||
- Persistent daemon state across restarts
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user