Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34624adb18 | |||
| 12fe89d12c | |||
| 4546a3d3e3 | |||
| c8c773197e | |||
| f594e500a8 | |||
| f2a3ee6ee4 | |||
| f377ec000c | |||
| 988908f348 | |||
| 2b623b35b6 | |||
| 6cdcb9dd51 | |||
| 6e950a4201 | |||
| 835c146fb7 | |||
| fc818b3c2c | |||
| 0ce46b15bb | |||
| 37f1b46a77 | |||
| f54401488a | |||
| e22a312f50 | |||
| 2d005d14a8 | |||
| 1708626731 | |||
| 5fe081b28c | |||
| 0c75ab3f7e | |||
| 017cbc5692 | |||
| cd5da59187 | |||
| d7d5211fde | |||
| de817730ca | |||
| 9cdbcd3066 | |||
| b113885dde | |||
| ef449058aa | |||
| 706e01dbca | |||
| d50cfe345f | |||
| ba67a3e086 | |||
| ed1a61a445 | |||
| fe82b3882f | |||
| c7d8678c11 | |||
| 6aad5522f1 | |||
| 4d036a5364 | |||
| a23de8fe7d | |||
| b8f1abc3a1 | |||
| aa3edfefc0 | |||
| b657c946a2 | |||
| bb137ce901 | |||
| 7d7203891f | |||
| 081efe37f7 | |||
| 777b882040 | |||
| eead9e0aa5 | |||
| e8c27d8da5 | |||
| a21cc5e9f0 | |||
| abd46bccba | |||
| a8fbb637cb |
@@ -22,3 +22,4 @@ docs/.vitepress/cache
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
autoresearch/results/
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
**Built for AI Agents** — Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.
|
||||
**Built for AI Agents** — Load the [`opencli-operate` skill](./skills/opencli-operate/SKILL.md) to give any AI agent (Claude Code, Cursor) direct browser control. Operate any website, then crystallize those interactions into reusable CLI commands. Configure `opencli list` in your `AGENT.md` or `.cursorrules` so the AI auto-discovers all available tools.
|
||||
|
||||
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
|
||||
|
||||
@@ -21,33 +21,17 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **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's logged-in state; your credentials never leave the browser.
|
||||
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `operate` controls the browser directly.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Why opencli?
|
||||
|
||||
There are many great browser automation tools. Here's when opencli is the right choice:
|
||||
|
||||
| Your need | Best tool | Why |
|
||||
|-----------|-----------|-----|
|
||||
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
|
||||
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
|
||||
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
|
||||
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
|
||||
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
|
||||
|
||||
**What makes opencli different:**
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
|
||||
- **Broad coverage** — 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).
|
||||
- **Broad coverage** — 73+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,6 +68,12 @@ opencli hackernews top --limit 5 # Public API, no browser needed
|
||||
opencli bilibili hot --limit 5 # Browser command (requires Extension)
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -100,6 +90,7 @@ 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
|
||||
```
|
||||
@@ -124,7 +115,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
@@ -138,10 +129,13 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
| **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` |
|
||||
|
||||
66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
## CLI Hub
|
||||
|
||||
@@ -250,6 +244,7 @@ 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.
|
||||
|
||||
|
||||
+17
-24
@@ -10,7 +10,7 @@
|
||||
|
||||
OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh`、`docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
**专为 AI Agent 打造**:只需在全局 `.cursorrules` 或 `AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`),AI 便能瞬间学会自动调用相应的本地工具!
|
||||
**专为 AI Agent 打造**:加载 [`opencli-operate` skill](./skills/opencli-operate/SKILL.md),赋予 AI Agent(Claude Code、Cursor 等)直接操控浏览器的能力——操作任意网站,并将这些交互沉淀为可复用的 CLI 命令。在 `AGENT.md` 或 `.cursorrules` 中配置 `opencli list`,AI 即可自动发现并调用所有可用工具。
|
||||
|
||||
**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
|
||||
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
|
||||
@@ -22,37 +22,20 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
## 亮点
|
||||
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **浏览器自动化** — `operate` 赋予 AI Agent 直接操控浏览器的能力:点击、输入、提取、截图,任意交互皆可脚本化
|
||||
- **网页转 CLI** — 将任意网站变成确定性命令行工具:73+ 预置适配器,或用 `opencli record` 沉淀自己的操作
|
||||
- **多站点覆盖** — 73+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
## 为什么选 opencli?
|
||||
|
||||
浏览器自动化工具很多,opencli 适合什么场景?
|
||||
|
||||
| 你的需求 | 最佳工具 | 原因 |
|
||||
|----------|----------|------|
|
||||
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
|
||||
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
|
||||
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
|
||||
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
|
||||
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
|
||||
|
||||
**opencli 的核心差异:**
|
||||
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略,`operate` 直接控制浏览器
|
||||
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
|
||||
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
|
||||
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
|
||||
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com)
|
||||
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
@@ -116,6 +99,12 @@ opencli list # 可以在任何地方使用了!
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
### 浏览器自动化 — 让 AI Agent 直接控制浏览器
|
||||
|
||||
将 [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) 指向你的 AI Agent(Claude Code、Cursor),即可开箱即用,内含完整命令参考与使用示例。
|
||||
|
||||
可用命令:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`screenshot`、`scroll`、`back`、`eval`、`network`、`init`、`verify`、`close`。
|
||||
|
||||
### 安装 AI Skills
|
||||
|
||||
OpenCLI 提供 [skills](./skills/) 供 AI Agent(Claude Code 等)使用:
|
||||
@@ -126,6 +115,7 @@ 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 # 快速命令参考
|
||||
```
|
||||
@@ -191,6 +181,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **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` | 公开 / 浏览器 |
|
||||
@@ -204,9 +195,11 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
|
||||
| **xianyu** | `search` `item` `chat` | 浏览器 |
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
|
||||
66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
56/59
|
||||
@@ -0,0 +1 @@
|
||||
31/31
|
||||
@@ -0,0 +1,690 @@
|
||||
[
|
||||
{
|
||||
"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\""
|
||||
],
|
||||
"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('article h1, article 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')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.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')].slice(0,3).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' '),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')].slice(0,5).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' ')})))\""
|
||||
],
|
||||
"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",
|
||||
"opencli operate eval \"document.querySelector('textarea[name=q], input[name=q]').value='opencli github';document.querySelector('form').submit();'submitted'\"",
|
||||
"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",
|
||||
"opencli operate eval \"document.querySelector('input[name=search]').value='Rust programming language';document.querySelector('form#searchform, form[role=search]').submit();'submitted'\"",
|
||||
"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",
|
||||
"opencli operate state",
|
||||
"opencli operate type 1 \"react\"",
|
||||
"opencli operate keys Enter",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
],
|
||||
"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('#toc a, .toc a, [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History, #History ~ p')?.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]');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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:debug — Hypothesis-driven debugging for specific failing tasks.
|
||||
*
|
||||
* Scientific method: Gather → Hypothesize → Test → Classify → Log → Repeat
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/debug.ts --task extract-npm-description
|
||||
* npx tsx autoresearch/commands/debug.ts --task bench-imdb-matrix --iterations 5
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
const TASKS_FILE = join(__dirname, '..', 'browse-tasks.json');
|
||||
const DEBUG_LOG = join(ROOT, 'debug-results.tsv');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: { type: string; value?: string; minLength?: number; pattern?: string };
|
||||
}
|
||||
|
||||
function exec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT, timeout: 30_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function initLog(): void {
|
||||
if (!existsSync(DEBUG_LOG)) {
|
||||
writeFileSync(DEBUG_LOG, '# AutoResearch Debug Log\niteration\ttask\thypothesis\tresult\tverdict\tdescription\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(iteration: number, task: string, hypothesis: string, result: string, verdict: string, description: string): void {
|
||||
appendFileSync(DEBUG_LOG, `${iteration}\t${task}\t${hypothesis}\t${result}\t${verdict}\t${description}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const taskName = args.task;
|
||||
const maxIterations = args.iterations ?? 10;
|
||||
|
||||
if (!taskName) {
|
||||
console.error('Usage: npx tsx autoresearch/commands/debug.ts --task <task-name> [--iterations N]');
|
||||
console.error('\nAvailable tasks:');
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
// Show only failing tasks
|
||||
for (const task of tasks) {
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
let lastOutput = '';
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
const passed = lastOutput.trim().length > 0; // simplified check
|
||||
if (!passed) console.error(` ✗ ${task.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const task = tasks.find(t => t.name === taskName);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${taskName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
|
||||
console.log(` Steps: ${task.steps.length}`);
|
||||
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
|
||||
console.log(` Max iterations: ${maxIterations}\n`);
|
||||
|
||||
initLog();
|
||||
|
||||
// Phase 1: Gather — run the task and capture output
|
||||
console.log('Phase 1: Gathering symptoms...');
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
|
||||
lastOutput = exec(step);
|
||||
if (i < task.steps.length - 1) {
|
||||
console.log(` → ${lastOutput.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
|
||||
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
|
||||
|
||||
// Phase 2: Hypothesize + investigate via Claude Code
|
||||
for (let iter = 1; iter <= maxIterations; iter++) {
|
||||
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
|
||||
|
||||
const prompt = `You are debugging a failing browser automation task.
|
||||
|
||||
## Task: ${taskName}
|
||||
Steps:
|
||||
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
## Judge criteria
|
||||
${JSON.stringify(task.judge)}
|
||||
|
||||
## Last output
|
||||
${lastOutput.slice(0, 500)}
|
||||
|
||||
## Instructions
|
||||
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
|
||||
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
|
||||
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
|
||||
4. If CONFIRMED: describe the root cause and suggest a fix
|
||||
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
|
||||
|
||||
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
|
||||
// Extract hypothesis and result
|
||||
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
|
||||
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
|
||||
|
||||
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
|
||||
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
|
||||
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
|
||||
|
||||
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
|
||||
console.log(` Verdict: ${verdict} — ${description.slice(0, 100)}`);
|
||||
|
||||
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
|
||||
|
||||
if (verdict === 'CONFIRMED') {
|
||||
console.log(`\n✅ Root cause found at iteration ${iter}!`);
|
||||
console.log(` ${description}`);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(` Error: ${err.message?.slice(0, 100)}`);
|
||||
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
|
||||
}
|
||||
|
||||
// Re-run task for fresh output
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
}
|
||||
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:fix — Iterative error elimination.
|
||||
*
|
||||
* Auto-detects broken state (build → test → browse tests) and iteratively
|
||||
* fixes errors one at a time. Stops when error count reaches 0.
|
||||
*
|
||||
* Priority: build errors → test failures → browse task failures
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/fix.ts
|
||||
* npx tsx autoresearch/commands/fix.ts --iterations 10
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function exec(cmd: string): { ok: boolean; output: string } {
|
||||
try {
|
||||
const output = execSync(cmd, {
|
||||
cwd: ROOT, timeout: 120_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return { ok: true, output };
|
||||
} catch (err: any) {
|
||||
return { ok: false, output: (err.stdout ?? '') + '\n' + (err.stderr ?? '') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect current broken state and return verify command + error count */
|
||||
function detectBrokenState(): { verify: string; errors: number; description: string } | null {
|
||||
// 1. Build
|
||||
const build = exec('npm run build 2>&1');
|
||||
if (!build.ok) {
|
||||
const errorCount = (build.output.match(/error TS/g) || []).length || 1;
|
||||
return {
|
||||
verify: 'npm run build 2>&1 | grep -c "error TS" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} TypeScript build error(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Tests
|
||||
const test = exec('npm test 2>&1');
|
||||
if (!test.ok) {
|
||||
const failMatch = test.output.match(/(\d+)\s+fail/i);
|
||||
const errorCount = failMatch ? parseInt(failMatch[1], 10) : 1;
|
||||
return {
|
||||
verify: 'npm test 2>&1 | grep -oP "\\d+(?= fail)" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} test failure(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Browse tests
|
||||
const browse = exec('npx tsx autoresearch/eval-browse.ts 2>&1');
|
||||
const scoreMatch = browse.output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const passed = parseInt(scoreMatch[1], 10);
|
||||
const total = parseInt(scoreMatch[2], 10);
|
||||
const failures = total - passed;
|
||||
if (failures > 0) {
|
||||
return {
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
errors: failures,
|
||||
description: `${failures} browse task failure(s) (${passed}/${total})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null; // all clean
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const maxIterations = args.iterations ?? 20;
|
||||
|
||||
console.log('\n🔧 AutoResearch Fix — Detecting broken state...\n');
|
||||
|
||||
const broken = detectBrokenState();
|
||||
if (!broken) {
|
||||
console.log(' ✓ All clean — nothing to fix!\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Found: ${broken.description}`);
|
||||
console.log(` Verify: ${broken.verify}\n`);
|
||||
|
||||
const config = {
|
||||
goal: `Fix all errors: ${broken.description}`,
|
||||
scope: ['src/**/*.ts', 'extension/src/**/*.ts'],
|
||||
metric: 'error_count',
|
||||
direction: 'lower' as const,
|
||||
verify: broken.verify,
|
||||
guard: 'npm run build',
|
||||
iterations: maxIterations,
|
||||
minDelta: 1,
|
||||
};
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: async (ctx: ModifyContext) => {
|
||||
const prompt = `Fix ONE error. Current error count: ${ctx.currentMetric}. Goal: 0 errors.
|
||||
|
||||
Read the error output, understand the root cause, and make ONE focused fix.
|
||||
Do NOT fix multiple unrelated errors at once.
|
||||
Do NOT modify test files.
|
||||
|
||||
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await engine.run();
|
||||
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
|
||||
if (finalMetric === 0) {
|
||||
console.log('\n✅ All errors fixed!\n');
|
||||
} else {
|
||||
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:plan — Interactive configuration wizard.
|
||||
*
|
||||
* Walks through goal, scope, metric, verify, guard settings
|
||||
* and outputs a ready-to-paste run command.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/plan.ts
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔬 AutoResearch — Configuration Wizard\n');
|
||||
|
||||
// Offer presets first
|
||||
const presetNames = Object.keys(PRESETS);
|
||||
console.log('Available presets:');
|
||||
presetNames.forEach((name, i) => {
|
||||
console.log(` [${i + 1}] ${name} — ${PRESETS[name].goal}`);
|
||||
});
|
||||
console.log(` [0] Custom config\n`);
|
||||
|
||||
const choice = await ask('Choose preset or 0 for custom: ');
|
||||
const idx = parseInt(choice, 10);
|
||||
|
||||
if (idx > 0 && idx <= presetNames.length) {
|
||||
const name = presetNames[idx - 1];
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
const iterFlag = iterations ? ` --iterations ${iterations}` : '';
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` npx tsx autoresearch/commands/run.ts --preset ${name}${iterFlag}\n`);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom config
|
||||
const goal = await ask('Goal (what to improve): ');
|
||||
const scope = await ask('Scope (file globs, comma-separated): ');
|
||||
const metric = await ask('Metric name (e.g. pass_count, coverage): ');
|
||||
const direction = await ask('Direction (higher/lower): ') as 'higher' | 'lower';
|
||||
const verify = await ask('Verify command (must output a number): ');
|
||||
|
||||
// Dry-run verify
|
||||
console.log('\n Dry-running verify command...');
|
||||
try {
|
||||
const output = execSync(verify, { cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
const { extractMetric } = await import('../config.js');
|
||||
const value = extractMetric(output);
|
||||
if (value != null) {
|
||||
console.log(` ✓ Verify works — current ${metric}: ${value}`);
|
||||
} else {
|
||||
console.log(` ⚠ Verify ran but no number extracted from output:\n ${output.slice(0, 200)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(` ✗ Verify failed: ${err.message?.slice(0, 100)}`);
|
||||
}
|
||||
|
||||
const guard = await ask('Guard command (optional, press Enter to skip): ');
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
|
||||
const parts = ['npx tsx autoresearch/commands/run.ts'];
|
||||
parts.push(`--goal "${goal}"`);
|
||||
parts.push(`--scope "${scope}"`);
|
||||
parts.push(`--metric "${metric}"`);
|
||||
parts.push(`--direction ${direction}`);
|
||||
parts.push(`--verify "${verify}"`);
|
||||
if (guard) parts.push(`--guard "${guard}"`);
|
||||
if (iterations) parts.push(`--iterations ${iterations}`);
|
||||
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` ${parts.join(' \\\n ')}\n`);
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch — Main autonomous iteration loop.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
|
||||
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
|
||||
*
|
||||
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
|
||||
* Engine handles commit, verify, guard, keep/discard, and logging.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, type AutoResearchConfig } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
|
||||
const recent = ctx.recentLog.slice(-10).map(r =>
|
||||
` ${r.status.padEnd(12)} ${r.description}`
|
||||
).join('\n');
|
||||
|
||||
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
|
||||
|
||||
## Goal
|
||||
${config.goal}
|
||||
|
||||
## Current State
|
||||
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
|
||||
- Iteration: ${ctx.iteration}
|
||||
- Consecutive discards: ${ctx.consecutiveDiscards}
|
||||
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
|
||||
|
||||
## Recent History
|
||||
${recent || ' (no history yet)'}
|
||||
|
||||
## Git Log (recent experiments)
|
||||
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
|
||||
|
||||
## Scope (files you can modify)
|
||||
${ctx.scopeFiles.join('\n')}
|
||||
|
||||
## Rules
|
||||
1. Make ONE atomic change (one logical intent, even if multiple files)
|
||||
2. Read the failing test output or code BEFORE modifying
|
||||
3. DO NOT modify test files or the verify command
|
||||
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
|
||||
5. If previous approach was discarded, try something DIFFERENT
|
||||
6. Focus on the specific failures — read error messages carefully`;
|
||||
}
|
||||
|
||||
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
|
||||
const prompt = buildModifyPrompt(ctx, config);
|
||||
|
||||
console.log(' Claude Code making a change...');
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{
|
||||
cwd: ROOT,
|
||||
timeout: 180_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}
|
||||
).trim();
|
||||
|
||||
// Extract description from Claude's response (last non-empty line or summary)
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
|
||||
return desc.slice(0, 120);
|
||||
} catch (err: any) {
|
||||
console.error(' Claude Code failed:', err.message?.slice(0, 100));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Resolve config from preset or CLI args
|
||||
let config: AutoResearchConfig;
|
||||
if (args.preset) {
|
||||
config = PRESETS[args.preset];
|
||||
if (!config) {
|
||||
console.error(`Unknown preset: ${args.preset}`);
|
||||
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Allow CLI overrides
|
||||
if (args.iterations != null) config = { ...config, iterations: args.iterations };
|
||||
if (args.guard != null) config = { ...config, guard: args.guard };
|
||||
} else if (args.goal && args.verify) {
|
||||
config = {
|
||||
goal: args.goal,
|
||||
scope: args.scope ?? ['src/**/*.ts'],
|
||||
metric: args.metric ?? 'score',
|
||||
direction: args.direction ?? 'higher',
|
||||
verify: args.verify,
|
||||
guard: args.guard,
|
||||
iterations: args.iterations,
|
||||
minDelta: args.minDelta,
|
||||
};
|
||||
} else {
|
||||
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
|
||||
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
|
||||
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 AutoResearch: ${config.goal}`);
|
||||
console.log(` Metric: ${config.metric} (${config.direction})`);
|
||||
console.log(` Verify: ${config.verify}`);
|
||||
console.log(` Guard: ${config.guard ?? '(none)'}`);
|
||||
console.log(` Iterations: ${config.iterations ?? '∞'}`);
|
||||
console.log('');
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: (ctx) => modify(ctx, config),
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
await engine.run();
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AutoResearch Configuration — type definitions and CLI parsing.
|
||||
*
|
||||
* Based on Karpathy's autoresearch: constraint + mechanical metric + unbounded loop.
|
||||
*/
|
||||
|
||||
export interface AutoResearchConfig {
|
||||
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
|
||||
goal: string;
|
||||
/** Glob patterns for files the agent can modify */
|
||||
scope: string[];
|
||||
/** What the metric measures, e.g. "pass_count" */
|
||||
metric: string;
|
||||
/** Whether improvement means the number goes up or down */
|
||||
direction: 'higher' | 'lower';
|
||||
/** Shell command that outputs a number (the metric value) */
|
||||
verify: string;
|
||||
/** Optional guard command — must pass for a keep decision */
|
||||
guard?: string;
|
||||
/** Max iterations (undefined = unbounded) */
|
||||
iterations?: number;
|
||||
/** Minimum delta to count as real improvement (noise filter) */
|
||||
minDelta?: number;
|
||||
}
|
||||
|
||||
export type IterationStatus =
|
||||
| 'baseline'
|
||||
| 'keep'
|
||||
| 'keep (reworked)'
|
||||
| 'discard'
|
||||
| 'crash'
|
||||
| 'no-op'
|
||||
| 'hook-blocked';
|
||||
|
||||
export interface IterationResult {
|
||||
iteration: number;
|
||||
commit: string;
|
||||
metric: number;
|
||||
delta: number;
|
||||
guard: 'pass' | 'fail' | '-';
|
||||
status: IterationStatus;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
|
||||
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
|
||||
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (arg) {
|
||||
case '--preset': config.preset = next; i++; break;
|
||||
case '--goal': config.goal = next; i++; break;
|
||||
case '--scope': config.scope = next?.split(','); i++; break;
|
||||
case '--metric': config.metric = next; i++; break;
|
||||
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
|
||||
case '--verify': config.verify = next; i++; break;
|
||||
case '--guard': config.guard = next; i++; break;
|
||||
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
|
||||
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
|
||||
case '--task': config.task = next; i++; break;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Extract a number from command output using common patterns */
|
||||
export function extractMetric(output: string): number | null {
|
||||
// Try: last line that looks like a number
|
||||
const lines = output.trim().split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
|
||||
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
|
||||
if (scoreMatch) return parseFloat(scoreMatch[1]);
|
||||
const numMatch = line.match(/^[\d.]+$/);
|
||||
if (numMatch) return parseFloat(numMatch[0]);
|
||||
}
|
||||
// Fallback: first number in output
|
||||
const fallback = output.match(/(\d+(?:\.\d+)?)/);
|
||||
return fallback ? parseFloat(fallback[1]) : null;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* AutoResearch Engine — Karpathy's 8-phase autonomous iteration loop.
|
||||
*
|
||||
* Phase 0: Precondition checks (git clean, no locks)
|
||||
* Phase 1: Review (read scope files + log + git history)
|
||||
* Phase 2: Ideate (select next change based on history)
|
||||
* Phase 3: Modify (one atomic change — delegated to caller)
|
||||
* Phase 4: Commit (git add + commit with experiment prefix)
|
||||
* Phase 5: Verify (run verify command, extract metric)
|
||||
* Phase 5.5: Guard (optional regression check)
|
||||
* Phase 6: Decide (keep/discard/crash + rollback)
|
||||
* Phase 7: Log (append TSV)
|
||||
* Phase 8: Repeat
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
export interface EngineCallbacks {
|
||||
/** Called at Phase 2-3: review context, ideate, and make ONE change.
|
||||
* Return a one-sentence description of what was changed, or null to skip. */
|
||||
modify(context: ModifyContext): Promise<string | null>;
|
||||
|
||||
/** Called when engine needs to report status */
|
||||
onStatus?(msg: string): void;
|
||||
}
|
||||
|
||||
export interface ModifyContext {
|
||||
iteration: number;
|
||||
bestMetric: number;
|
||||
currentMetric: number;
|
||||
recentLog: IterationResult[];
|
||||
gitLog: string;
|
||||
scopeFiles: string[];
|
||||
consecutiveDiscards: number;
|
||||
stuckHint: string | null;
|
||||
}
|
||||
|
||||
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
|
||||
|
||||
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: opts?.cwd ?? ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function execStrict(cmd: string, opts?: { timeout?: number }): string {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private config: AutoResearchConfig;
|
||||
private logger: Logger;
|
||||
private callbacks: EngineCallbacks;
|
||||
private bestMetric: number = 0;
|
||||
private currentMetric: number = 0;
|
||||
private iteration: number = 0;
|
||||
|
||||
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
|
||||
this.config = config;
|
||||
this.logger = new Logger(logPath);
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
private log(msg: string): void {
|
||||
this.callbacks.onStatus?.(msg);
|
||||
}
|
||||
|
||||
/** Phase 0: Precondition checks */
|
||||
private checkPreconditions(): void {
|
||||
// Git repo exists
|
||||
try { execStrict('git rev-parse --git-dir'); }
|
||||
catch { throw new Error('Not a git repository'); }
|
||||
|
||||
// Clean working tree
|
||||
const status = exec('git status --porcelain');
|
||||
if (status) throw new Error(`Working tree not clean:\n${status}`);
|
||||
|
||||
// No stale locks
|
||||
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
|
||||
throw new Error('Stale .git/index.lock found — remove it first');
|
||||
}
|
||||
|
||||
// Not detached HEAD
|
||||
try { execStrict('git symbolic-ref HEAD'); }
|
||||
catch { throw new Error('Detached HEAD — checkout a branch first'); }
|
||||
}
|
||||
|
||||
/** Phase 5: Run verify command and extract metric */
|
||||
private runVerify(): number | null {
|
||||
this.log(' verify...');
|
||||
const output = exec(this.config.verify, { timeout: 300_000 });
|
||||
return extractMetric(output);
|
||||
}
|
||||
|
||||
/** Phase 5.5: Run guard command */
|
||||
private runGuard(): boolean {
|
||||
if (!this.config.guard) return true;
|
||||
this.log(' guard...');
|
||||
try {
|
||||
execStrict(this.config.guard, { timeout: 300_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 4: Commit changes */
|
||||
private commit(description: string): string | null {
|
||||
// Stage all changes in scope (but not untracked outside scope)
|
||||
exec('git add -A');
|
||||
const diff = exec('git diff --cached --quiet; echo $?');
|
||||
if (diff === '0') return null; // no changes
|
||||
|
||||
try {
|
||||
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
|
||||
return exec('git rev-parse --short HEAD');
|
||||
} catch {
|
||||
// Hook failure
|
||||
exec('git reset HEAD');
|
||||
return 'hook-blocked';
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 6: Rollback */
|
||||
private safeRevert(): void {
|
||||
try {
|
||||
execStrict('git revert HEAD --no-edit');
|
||||
} catch {
|
||||
exec('git revert --abort');
|
||||
exec('git reset --hard HEAD~1');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get stuck hint when >5 consecutive discards */
|
||||
private getStuckHint(discards: number): string | null {
|
||||
if (discards < 5) return null;
|
||||
const hints = [
|
||||
'Re-read ALL scope files from scratch. Try a completely different approach.',
|
||||
'Review entire results log — what worked before? Try combining successful changes.',
|
||||
'Try the OPPOSITE of what has been failing.',
|
||||
'Try a radical architectural change instead of incremental tweaks.',
|
||||
'Simplify — remove complexity rather than adding it.',
|
||||
];
|
||||
return hints[Math.min(discards - 5, hints.length - 1)];
|
||||
}
|
||||
|
||||
/** Run the main loop */
|
||||
async run(): Promise<IterationResult[]> {
|
||||
const results: IterationResult[] = [];
|
||||
|
||||
// Phase 0: Preconditions
|
||||
this.log('Phase 0: Precondition checks...');
|
||||
this.checkPreconditions();
|
||||
|
||||
// Initialize logger
|
||||
this.logger.init(this.config);
|
||||
|
||||
// Baseline measurement
|
||||
this.log('Measuring baseline...');
|
||||
const baseline = this.runVerify();
|
||||
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
|
||||
this.bestMetric = baseline;
|
||||
this.currentMetric = baseline;
|
||||
|
||||
const baselineCommit = exec('git rev-parse --short HEAD');
|
||||
const baselineResult: IterationResult = {
|
||||
iteration: 0,
|
||||
commit: baselineCommit,
|
||||
metric: baseline,
|
||||
delta: 0,
|
||||
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
|
||||
status: 'baseline',
|
||||
description: `initial state — ${this.config.metric} ${baseline}`,
|
||||
};
|
||||
this.logger.append(baselineResult);
|
||||
results.push(baselineResult);
|
||||
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
|
||||
|
||||
// Main loop
|
||||
const maxIter = this.config.iterations ?? Infinity;
|
||||
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
|
||||
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
|
||||
|
||||
// Phase 1: Review
|
||||
const gitLog = exec('git log --oneline -20');
|
||||
const recentLog = this.logger.readLast(20);
|
||||
const scopeFiles = this.config.scope;
|
||||
const consecutiveDiscards = this.logger.consecutiveDiscards();
|
||||
|
||||
// Phase 2-3: Ideate + Modify (delegated to callback)
|
||||
const context: ModifyContext = {
|
||||
iteration: this.iteration,
|
||||
bestMetric: this.bestMetric,
|
||||
currentMetric: this.currentMetric,
|
||||
recentLog,
|
||||
gitLog,
|
||||
scopeFiles,
|
||||
consecutiveDiscards,
|
||||
stuckHint: this.getStuckHint(consecutiveDiscards),
|
||||
};
|
||||
|
||||
let description: string | null;
|
||||
try {
|
||||
description = await this.callbacks.modify(context);
|
||||
} catch (err: any) {
|
||||
this.log(` modify error: ${err.message}`);
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `modify crashed: ${err.message?.slice(0, 80)}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: 'no changes made',
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 4: Commit
|
||||
this.log(` commit: ${description}`);
|
||||
const commitHash = this.commit(description);
|
||||
if (!commitHash) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: `no diff after: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
if (commitHash === 'hook-blocked') {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'hook-blocked',
|
||||
description: `hook rejected: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 5: Verify
|
||||
const metric = this.runVerify();
|
||||
if (metric == null) {
|
||||
this.log(' verify crashed — reverting');
|
||||
this.safeRevert();
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `verify crashed: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
const improved = this.config.direction === 'higher'
|
||||
? metric > this.bestMetric
|
||||
: metric < this.bestMetric;
|
||||
const delta = +(metric - this.bestMetric).toFixed(4);
|
||||
const absDelta = Math.abs(delta);
|
||||
const minDelta = this.config.minDelta ?? 0;
|
||||
|
||||
// Phase 5.5: Guard
|
||||
let guardResult: 'pass' | 'fail' | '-' = '-';
|
||||
if (this.config.guard && improved && absDelta >= minDelta) {
|
||||
guardResult = this.runGuard() ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
// Phase 6: Decide
|
||||
let status: IterationStatus;
|
||||
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
|
||||
status = 'keep';
|
||||
this.bestMetric = metric;
|
||||
this.currentMetric = metric;
|
||||
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
} else if (improved && guardResult === 'fail') {
|
||||
this.log(' guard failed — reverting');
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
this.log(` ✗ DISCARD (guard) — ${description}`);
|
||||
} else {
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
|
||||
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
}
|
||||
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: status === 'keep' ? commitHash : '-',
|
||||
metric,
|
||||
delta,
|
||||
guard: guardResult,
|
||||
status,
|
||||
description,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
|
||||
const discards = results.filter(r => r.status === 'discard');
|
||||
this.log(`\n${'━'.repeat(50)}`);
|
||||
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
|
||||
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Combined Test Suite Runner — runs browse + V2EX + Zhihu tasks.
|
||||
* Reports combined score for AutoResearch iteration.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-all.ts # Run all
|
||||
* npx tsx autoresearch/eval-all.ts --suite v2ex # Run one suite
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface SuiteResult {
|
||||
name: string;
|
||||
passed: number;
|
||||
total: number;
|
||||
failures: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function runSuite(name: string, script: string): SuiteResult {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const output = execSync(`npx tsx ${script}`, {
|
||||
cwd: ROOT,
|
||||
timeout: 600_000,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Parse SCORE=X/Y from output
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
|
||||
// Parse failures
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
} catch (err: any) {
|
||||
const output = err.stdout ?? '';
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleSuite = args.includes('--suite') ? args[args.indexOf('--suite') + 1] : null;
|
||||
|
||||
const suites = [
|
||||
{ name: 'browse', script: 'autoresearch/eval-browse.ts' },
|
||||
{ name: 'v2ex', script: 'autoresearch/eval-v2ex.ts' },
|
||||
{ name: 'zhihu', script: 'autoresearch/eval-zhihu.ts' },
|
||||
].filter(s => !singleSuite || s.name === singleSuite);
|
||||
|
||||
console.log(`\n🔬 Combined AutoResearch — ${suites.length} suites\n`);
|
||||
|
||||
const results: SuiteResult[] = [];
|
||||
for (const suite of suites) {
|
||||
console.log(` Running ${suite.name}...`);
|
||||
const result = runSuite(suite.name, suite.script);
|
||||
results.push(result);
|
||||
const icon = result.passed === result.total ? '✓' : '✗';
|
||||
console.log(` ${icon} ${result.name}: ${result.passed}/${result.total} (${Math.round(result.duration / 1000)}s)`);
|
||||
if (result.failures.length > 0) {
|
||||
for (const f of result.failures.slice(0, 5)) {
|
||||
console.log(` ✗ ${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.reduce((s, r) => s + r.passed, 0);
|
||||
const totalTasks = results.reduce((s, r) => s + r.total, 0);
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
const allFailures = results.flatMap(r => r.failures.map(f => `${r.name}:${f}`));
|
||||
|
||||
console.log(`\n${'━'.repeat(50)}`);
|
||||
console.log(` Combined: ${totalPassed}/${totalTasks}`);
|
||||
for (const r of results) {
|
||||
console.log(` ${r.name}: ${r.passed}/${r.total}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
if (allFailures.length > 0) {
|
||||
console.log(`\n All failures:`);
|
||||
for (const f of allFailures) console.log(` ✗ ${f}`);
|
||||
}
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('all-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `all-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${totalTasks}`,
|
||||
suites: Object.fromEntries(results.map(r => [r.name, `${r.passed}/${r.total}`])),
|
||||
failures: allFailures,
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(`\n Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${totalTasks}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/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();
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/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();
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* V2EX Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task v2ex-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'v2ex-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name prefix pattern
|
||||
function getLayer(name: string): string {
|
||||
if (['v2ex-open-', 'v2ex-state-', 'v2ex-get-title', 'v2ex-click-tab', 'v2ex-scroll-down',
|
||||
'v2ex-get-first-', 'v2ex-eval-extract', 'v2ex-get-url', 'v2ex-back-nav', 'v2ex-wait-'].some(p => name.startsWith(p)))
|
||||
return 'L1-atomic';
|
||||
if (['v2ex-hot-topics', 'v2ex-node-list', 'v2ex-topic-meta', 'v2ex-node-topics',
|
||||
'v2ex-node-pagination', 'v2ex-tab-content', 'v2ex-topic-replies-extract',
|
||||
'v2ex-topic-reply-count', 'v2ex-member-info', 'v2ex-search-results'].includes(name))
|
||||
return 'L2-single-page';
|
||||
if (['v2ex-click-topic-read', 'v2ex-click-author', 'v2ex-navigate-node', 'v2ex-pagination-page2',
|
||||
'v2ex-topic-and-back', 'v2ex-tab-then-topic', 'v2ex-scroll-find-more',
|
||||
'v2ex-node-to-topic', 'v2ex-multi-tab-compare', 'v2ex-topic-reply-to-author'].some(p => name.startsWith(p)))
|
||||
return 'L3-multi-step';
|
||||
if (['v2ex-reply-', 'v2ex-favorite-', 'v2ex-thank-', 'v2ex-create-'].some(p => name.startsWith(p)))
|
||||
return 'L4-write';
|
||||
if (['v2ex-collect-', 'v2ex-multi-node-', 'v2ex-topic-deep-', 'v2ex-cross-page-', 'v2ex-full-'].some(p => name.startsWith(p)))
|
||||
return 'L5-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 V2EX Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('v2ex-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `v2ex-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Zhihu Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task zhihu-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'zhihu-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name
|
||||
function getLayer(name: string): string {
|
||||
const l1 = ['zhihu-open-home', 'zhihu-get-title', 'zhihu-state', 'zhihu-get-url', 'zhihu-scroll-down',
|
||||
'zhihu-click-tab-hot', 'zhihu-back-navigation', 'zhihu-wait-page-load', 'zhihu-keys-escape', 'zhihu-screenshot'];
|
||||
const l2 = ['zhihu-feed-titles', 'zhihu-hot-list', 'zhihu-hot-metrics', 'zhihu-nav-tabs',
|
||||
'zhihu-feed-with-authors', 'zhihu-feed-types', 'zhihu-user-avatar', 'zhihu-search-input-exists'];
|
||||
const l3 = ['zhihu-question-title', 'zhihu-question-meta', 'zhihu-first-answer', 'zhihu-answer-votes',
|
||||
'zhihu-question-buttons', 'zhihu-multiple-answers', 'zhihu-question-description', 'zhihu-answer-count-number'];
|
||||
const l4 = ['zhihu-hot-to-question', 'zhihu-feed-to-question', 'zhihu-question-to-author',
|
||||
'zhihu-search-navigate', 'zhihu-topic-page', 'zhihu-user-profile', 'zhihu-question-and-back', 'zhihu-scroll-load-more'];
|
||||
const l5 = ['zhihu-upvote-button-find', 'zhihu-follow-question-find', 'zhihu-comment-button-find',
|
||||
'zhihu-bookmark-find', 'zhihu-write-answer-btn', 'zhihu-share-find'];
|
||||
const l6 = ['zhihu-hot-read-answer-author', 'zhihu-hot-to-author-profile', 'zhihu-multi-hot-topics',
|
||||
'zhihu-search-then-read', 'zhihu-question-scroll-answers', 'zhihu-compare-tabs', 'zhihu-user-answers', 'zhihu-topic-questions'];
|
||||
const l7 = ['zhihu-search-basic', 'zhihu-search-people', 'zhihu-search-topic',
|
||||
'zhihu-search-click-result', 'zhihu-search-filter-answers', 'zhihu-search-and-back'];
|
||||
const l8 = ['zhihu-full-browse-workflow', 'zhihu-deep-author-chain', 'zhihu-cross-question-compare',
|
||||
'zhihu-search-read-chain', 'zhihu-3-page-chain', 'zhihu-hot-scroll-deep-read'];
|
||||
|
||||
if (l1.includes(name)) return 'L1-atomic';
|
||||
if (l2.includes(name)) return 'L2-feed';
|
||||
if (l3.includes(name)) return 'L3-question';
|
||||
if (l4.includes(name)) return 'L4-navigation';
|
||||
if (l5.includes(name)) return 'L5-write';
|
||||
if (l6.includes(name)) return 'L6-chain';
|
||||
if (l7.includes(name)) return 'L7-search';
|
||||
if (l8.includes(name)) return 'L8-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 Zhihu Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('zhihu-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `zhihu-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AutoResearch TSV Logger — append-only results log with metadata header.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import type { AutoResearchConfig, IterationResult } from './config.js';
|
||||
|
||||
const COLUMNS = ['iteration', 'commit', 'metric', 'delta', 'guard', 'status', 'description'];
|
||||
|
||||
export class Logger {
|
||||
constructor(private path: string) {}
|
||||
|
||||
/** Create the TSV file with metadata header */
|
||||
init(config: AutoResearchConfig): void {
|
||||
const header = [
|
||||
`# metric_direction: ${config.direction === 'higher' ? 'higher_is_better' : 'lower_is_better'}`,
|
||||
`# goal: ${config.goal}`,
|
||||
`# scope: ${config.scope.join(', ')}`,
|
||||
`# verify: ${config.verify}`,
|
||||
config.guard ? `# guard: ${config.guard}` : null,
|
||||
COLUMNS.join('\t'),
|
||||
].filter(Boolean).join('\n');
|
||||
writeFileSync(this.path, header + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Append one iteration result */
|
||||
append(result: IterationResult): void {
|
||||
const row = [
|
||||
result.iteration,
|
||||
result.commit,
|
||||
result.metric,
|
||||
result.delta >= 0 ? `+${result.delta}` : result.delta,
|
||||
result.guard,
|
||||
result.status,
|
||||
result.description,
|
||||
].join('\t');
|
||||
appendFileSync(this.path, row + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Read last N entries for pattern recognition */
|
||||
readLast(n: number): IterationResult[] {
|
||||
if (!existsSync(this.path)) return [];
|
||||
const lines = readFileSync(this.path, 'utf-8').split('\n')
|
||||
.filter(l => l && !l.startsWith('#') && !l.startsWith('iteration'));
|
||||
return lines.slice(-n).map(line => {
|
||||
const [iteration, commit, metric, delta, guard, status, ...desc] = line.split('\t');
|
||||
return {
|
||||
iteration: parseInt(iteration, 10),
|
||||
commit,
|
||||
metric: parseFloat(metric),
|
||||
delta: parseFloat(delta),
|
||||
guard: guard as 'pass' | 'fail' | '-',
|
||||
status: status as IterationResult['status'],
|
||||
description: desc.join('\t'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Count consecutive discards from the end */
|
||||
consecutiveDiscards(): number {
|
||||
const entries = this.readLast(20);
|
||||
let count = 0;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].status === 'discard') count++;
|
||||
else break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Preset: Combined Reliability (browse + V2EX + Zhihu)
|
||||
*
|
||||
* Optimizes across ALL test suites simultaneously.
|
||||
* Current baseline: 57/59 + 60/60 + 60/60 = 177/179
|
||||
* Target: 179/179 (100%)
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const combinedReliability: AutoResearchConfig = {
|
||||
goal: 'Fix all remaining test failures across browse + V2EX + Zhihu (177/179 → 179/179)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
'autoresearch/browse-tasks.json',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-all.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 10,
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
export const PRESETS: Record<string, AutoResearchConfig> = {
|
||||
'operate-reliability': operateReliability,
|
||||
'skill-quality': skillQuality,
|
||||
'v2ex-reliability': v2exReliability,
|
||||
'zhihu-reliability': zhihuReliability,
|
||||
'combined': combinedReliability,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: Operate Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
|
||||
* Metric: number of passing browse-tasks (out of 59).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const operateReliability: AutoResearchConfig = {
|
||||
goal: 'Increase operate command pass rate to 59/59 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Preset: Skill E2E Quality
|
||||
*
|
||||
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Metric: number of passing skill-tasks (out of 35).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const skillQuality: AutoResearchConfig = {
|
||||
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
|
||||
scope: [
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 20,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: V2EX Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the V2EX-specific test suite.
|
||||
* 40 tasks across 5 difficulty layers (atomic → complex chain).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const v2exReliability: AutoResearchConfig = {
|
||||
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-v2ex.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Preset: Zhihu Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Zhihu test suite.
|
||||
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
|
||||
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const zhihuReliability: AutoResearchConfig = {
|
||||
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-zhihu.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/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 "$@"
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/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 "$@"
|
||||
@@ -0,0 +1,899 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-tab",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-first-topic-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-extract-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "v2ex.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait selector \"a[href^='/t/']\"",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 2: Single Page Tasks (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-list",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-pagination-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"max\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=jobs",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-replies-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-count",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-member-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Livid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-search-results",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 3: Multi-Step (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-topic-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-navigate-node-from-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-pagination-page2",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "p=2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-then-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-find-more",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-to-topic-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-tab-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 4: Write Operations (5 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-type-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoResearch test reply"
|
||||
},
|
||||
"note": "Types into reply box but does NOT submit"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-favorite-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "favorite|收藏"
|
||||
},
|
||||
"note": "Finds favorite link but does NOT click it"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-thank-reply-find",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"found\":\\d+"
|
||||
},
|
||||
"note": "Finds thank buttons but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "\"textarea\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-create-topic-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/new",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"note": "Detects create topic form elements, does NOT submit"
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 5: Complex Chain (5 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-collect-hot-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-node-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-cross-page-data-collect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-full-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 6: State + Click Interaction (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate click 1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-tab-tech",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+|no-ref"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-count-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-scroll-state",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-type-search-box",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-value-after-type",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-screenshot-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-html-selector",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get html --selector h1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait text V2EX"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "found|appeared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-3-pages",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-navigate-extract-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-multi-node-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-topic-replies-pagination",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-member-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-search-navigate-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-tab-topic-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-node-page2-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-full-interaction",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-deep-5-step",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
|
||||
"opencli operate back",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA navigation, timing, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/go/python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-after-click",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on topic page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-and-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-concurrent-eval",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.*\"url\":.*\"links\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-unicode-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Agent-Style: state + click + type (no eval for interaction) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-first-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
},
|
||||
"note": "Finds the index of first topic link via data-opencli-ref"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-type-search",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate type 3 TypeScript",
|
||||
"opencli operate get value 3"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
},
|
||||
"note": "Types into search box using state index"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-navigate-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-has-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-after-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 800",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "page_scroll: [\\d.]+↑"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,848 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "知乎"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-state",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[@?\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-click-tab-hot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "zhihu\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate wait text 推荐",
|
||||
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "pressed|Pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-screenshot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate screenshot /tmp/zhihu-test.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-list",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-metrics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-nav-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "推荐"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-with-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-types",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"question\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-avatar",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-input-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search found"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L3: Question Page Operations (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-first-answer",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"content\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-votes",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-buttons",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multiple-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-count-number",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L4: Multi-Step Navigation (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-page",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-load-more",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L5: Write Operations (6 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-upvote-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "赞同"
|
||||
},
|
||||
"note": "Finds upvote button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-follow-question-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "关注问题"
|
||||
},
|
||||
"note": "Finds follow button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-comment-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "评论"
|
||||
},
|
||||
"note": "Finds comment button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-bookmark-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "收藏|found"
|
||||
},
|
||||
"note": "Finds bookmark button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-write-answer-btn",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "写回答"
|
||||
},
|
||||
"note": "Finds write answer button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-share-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "分享"
|
||||
},
|
||||
"note": "Finds share button but does NOT click"
|
||||
},
|
||||
{
|
||||
"_comment": "=== L6: Long Chain Workflows (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-read-answer-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multi-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-scroll-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-compare-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-questions",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L7: Search Workflows (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-basic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-people",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-click-result",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-filter-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"total\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-full-browse-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-deep-author-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"profile\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-cross-question-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"q1_title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-read-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-3-page-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-scroll-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA lazy load, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/people/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-click-verify-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on question page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-lazy-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-extract-structured",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-answer-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"answerCount\":\\d+"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,615 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.24.6",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@algolia/abtesting": ["@algolia/abtesting@1.15.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA=="],
|
||||
|
||||
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
|
||||
|
||||
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
|
||||
|
||||
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
|
||||
|
||||
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
|
||||
|
||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ=="],
|
||||
|
||||
"@algolia/client-analytics": ["@algolia/client-analytics@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q=="],
|
||||
|
||||
"@algolia/client-common": ["@algolia/client-common@5.49.2", "", {}, "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw=="],
|
||||
|
||||
"@algolia/client-insights": ["@algolia/client-insights@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA=="],
|
||||
|
||||
"@algolia/client-personalization": ["@algolia/client-personalization@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA=="],
|
||||
|
||||
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA=="],
|
||||
|
||||
"@algolia/client-search": ["@algolia/client-search@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg=="],
|
||||
|
||||
"@algolia/ingestion": ["@algolia/ingestion@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw=="],
|
||||
|
||||
"@algolia/monitoring": ["@algolia/monitoring@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg=="],
|
||||
|
||||
"@algolia/recommend": ["@algolia/recommend@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA=="],
|
||||
|
||||
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA=="],
|
||||
|
||||
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ=="],
|
||||
|
||||
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
|
||||
|
||||
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
|
||||
|
||||
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
||||
|
||||
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.74", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
||||
|
||||
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
||||
|
||||
"algoliasearch": ["algoliasearch@5.49.2", "", { "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", "@algolia/client-analytics": "5.49.2", "@algolia/client-common": "5.49.2", "@algolia/client-insights": "5.49.2", "@algolia/client-personalization": "5.49.2", "@algolia/client-query-suggestions": "5.49.2", "@algolia/client-search": "5.49.2", "@algolia/ingestion": "1.49.2", "@algolia/monitoring": "1.49.2", "@algolia/recommend": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": "bin/esbuild" }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
|
||||
|
||||
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3"], "bin": "bin/vitepress.js" }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="],
|
||||
|
||||
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" } }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export default defineConfig({
|
||||
{ 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' },
|
||||
@@ -90,6 +92,7 @@ export default defineConfig({
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -44,4 +44,5 @@ opencli 36kr hot -f json
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
- `news`: No browser required — uses public RSS feed
|
||||
- `hot`, `search`, `article`: Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
@@ -11,12 +11,16 @@ 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/)
|
||||
- Playwright MCP Bridge / browser bridge is configured for OpenCLI
|
||||
- Browser Bridge extension is installed and enabled for OpenCLI
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
@@ -6,8 +6,13 @@
|
||||
|
||||
| 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,6 +8,8 @@
|
||||
|---------|-------------|
|
||||
| `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
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
+12
-5
@@ -6,17 +6,17 @@ 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` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
|
||||
| **[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` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 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` | 🔐 Browser |
|
||||
| **[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 |
|
||||
@@ -26,9 +26,11 @@ Run `opencli list` for the live registry.
|
||||
| **[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)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 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 |
|
||||
@@ -49,6 +51,11 @@ Run `opencli list` for the live registry.
|
||||
| **[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 |
|
||||
|
||||
## Public API Adapters
|
||||
|
||||
@@ -79,7 +86,7 @@ Run `opencli list` for the live registry.
|
||||
| **[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` |
|
||||
| **[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` |
|
||||
|
||||
+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** — 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ cli({
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', required: true, help: 'Search query' },
|
||||
{ name: 'query', positional: true, required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['title', 'url', 'date'],
|
||||
|
||||
@@ -48,6 +48,27 @@ 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)
|
||||
|
||||
@@ -243,6 +243,7 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Browse Skill Testing Design
|
||||
|
||||
Two-layer testing framework for `opencli browse` commands and the
|
||||
Claude Code skill integration.
|
||||
|
||||
## Goal
|
||||
|
||||
Verify that `opencli browse` works reliably on real websites and that
|
||||
Claude Code can use the skill to complete browser tasks end-to-end.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
autoresearch/
|
||||
├── browse-tasks.json ← 59 task definitions with browse command sequences
|
||||
├── eval-browse.ts ← Layer 1: deterministic browse command testing
|
||||
├── eval-skill.ts ← Layer 2: Claude Code skill E2E testing
|
||||
├── run-browse.sh ← Launch Layer 1
|
||||
├── run-skill.sh ← Launch Layer 2
|
||||
├── baseline-browse.txt ← Layer 1 best score
|
||||
├── baseline-skill.txt ← Layer 2 best score
|
||||
└── results/ ← Per-run results (gitignored)
|
||||
```
|
||||
|
||||
## Layer 1: Deterministic Browse Command Testing
|
||||
|
||||
Tests `opencli browse` commands directly on real websites. No LLM
|
||||
involved — pure command reliability testing.
|
||||
|
||||
### How It Works
|
||||
|
||||
Each task defines a sequence of browse commands and a judge for the
|
||||
last command's output:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hn-top-stories",
|
||||
"steps": [
|
||||
"opencli browse open https://news.ycombinator.com",
|
||||
"opencli browse eval \"JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
|
||||
],
|
||||
"judge": { "type": "arrayMinLength", "minLength": 5 }
|
||||
}
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
./autoresearch/run-browse.sh
|
||||
```
|
||||
|
||||
- Runs all 59 tasks serially
|
||||
- Each task: execute steps → judge last step output → pass/fail
|
||||
- `opencli browse close` between tasks for clean state
|
||||
- Expected: ~2 minutes, $0 cost
|
||||
|
||||
### Task Categories
|
||||
|
||||
| Category | Count | Example |
|
||||
|----------|-------|---------|
|
||||
| extract | 9 | Open page, eval JS to extract data |
|
||||
| list | 10 | Open page, eval JS to extract array |
|
||||
| search | 6 | Open, type query, keys Enter, eval results |
|
||||
| nav | 7 | Open, click link, eval new page title |
|
||||
| scroll | 5 | Open, scroll, eval footer/hidden content |
|
||||
| form | 6 | Open, type into fields, eval field values |
|
||||
| complex | 6 | Multi-step: open → click → navigate → extract |
|
||||
| bench | 10 | Test set (various) |
|
||||
|
||||
## Layer 2: Claude Code Skill E2E Testing
|
||||
|
||||
Spawns Claude Code with the opencli-operate skill to complete tasks
|
||||
autonomously using browse commands.
|
||||
|
||||
### How It Works
|
||||
|
||||
```bash
|
||||
claude -p \
|
||||
--system-prompt "$(cat skills/opencli-operate/SKILL.md)" \
|
||||
--dangerously-skip-permissions \
|
||||
--allowedTools "Bash(opencli:*)" \
|
||||
--output-format json \
|
||||
"用 opencli browse 完成任务:Extract the top 5 stories from Hacker News with title and score. Start URL: https://news.ycombinator.com"
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
./autoresearch/run-skill.sh
|
||||
```
|
||||
|
||||
- Runs all 59 tasks serially
|
||||
- Each task: spawn Claude Code → it uses browse commands autonomously → judge output
|
||||
- Expected: ~20 minutes, ~$5-10
|
||||
|
||||
### Judge
|
||||
|
||||
Both layers use the same judge types:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `contains` | Output contains a substring |
|
||||
| `arrayMinLength` | Output is an array with ≥ N items |
|
||||
| `arrayFieldsPresent` | Array items have required fields |
|
||||
| `nonEmpty` | Output is non-empty |
|
||||
| `matchesPattern` | Output matches a regex |
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
🔬 Layer 1: Browse Commands — 59 tasks
|
||||
|
||||
[1/59] extract-title-example... ✓ (0.5s)
|
||||
[2/59] hn-top-stories... ✓ (1.2s)
|
||||
...
|
||||
|
||||
Score: 55/59 (93%)
|
||||
Time: 2min
|
||||
Cost: $0
|
||||
|
||||
🔬 Layer 2: Skill E2E — 59 tasks
|
||||
|
||||
[1/59] extract-title-example... ✓ (8s, $0.01)
|
||||
[2/59] hn-top-stories... ✓ (15s, $0.08)
|
||||
...
|
||||
|
||||
Score: 52/59 (88%)
|
||||
Time: 20min
|
||||
Cost: $6.50
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- All 59 tasks run on real websites (no mocks)
|
||||
- Layer 1: zero LLM cost, ~2 min
|
||||
- Layer 2: ~$5-10 LLM cost, ~20 min
|
||||
- Results saved to `autoresearch/results/` (gitignored)
|
||||
- Baselines tracked in `baseline-browse.txt` and `baseline-skill.txt`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Layer 1 ≥ 90% (browse commands work on real sites)
|
||||
- Layer 2 ≥ 85% (Claude Code can use skill effectively)
|
||||
- Both layers cover all 8 task categories
|
||||
@@ -0,0 +1,41 @@
|
||||
# V2EX AutoResearch Test Suite Design
|
||||
|
||||
## Goal
|
||||
|
||||
Build a comprehensive test suite using V2EX (https://v2ex.com/) as the single target website to iteratively improve OpenCLI Operate's reliability and Claude Code skill effectiveness. Run 10 rounds of AutoResearch iteration (5 code-level + 5 SKILL.md-level).
|
||||
|
||||
## Test Suite Structure — 5 Layers, 40 Tasks
|
||||
|
||||
### Layer 1: Atomic (10 tasks)
|
||||
Single operate commands testing command-level reliability.
|
||||
|
||||
### Layer 2: Single Page (10 tasks)
|
||||
Meaningful extraction/interaction within one page.
|
||||
|
||||
### Layer 3: Multi-Step (10 tasks)
|
||||
Cross-page navigation + extraction combos.
|
||||
|
||||
### Layer 4: Write Operations (5 tasks)
|
||||
Login-required write operations (reply, favorite, thank).
|
||||
|
||||
### Layer 5: Complex Chain (5 tasks)
|
||||
Long chains: cross-post reference, multi-node comparison, full workflows.
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
- **v2ex-tasks.json** — Layer 1 deterministic tasks (browse commands + judge criteria)
|
||||
- **eval-v2ex.ts** — Runner for V2EX tasks (reuses eval-browse.ts pattern)
|
||||
- **v2ex-skill-tasks** — Layer 2 LLM E2E tasks embedded in eval runner
|
||||
- **presets/v2ex-reliability.ts** — AutoResearch preset for code optimization
|
||||
- **presets/v2ex-skill.ts** — AutoResearch preset for SKILL.md optimization
|
||||
|
||||
## AutoResearch Iteration Plan
|
||||
|
||||
- Rounds 1-5: Layer 1 preset → optimize src/browser/*.ts code
|
||||
- Rounds 6-10: Layer 2 preset → optimize skills/opencli-operate/SKILL.md
|
||||
- Alternating: fix code issues first, then improve LLM guidance
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Layer 1 baseline → target 100% pass rate after 5 code iterations
|
||||
- Layer 2 baseline → target 100% pass rate after 5 SKILL.md iterations
|
||||
@@ -32,6 +32,27 @@ opencli bilibili hot -f md # Markdown
|
||||
opencli bilibili hot -f csv # CSV
|
||||
```
|
||||
|
||||
## 终端自动补全
|
||||
|
||||
OpenCLI 支持智能的 Tab 自动补全,加快命令输入:
|
||||
|
||||
```bash
|
||||
# 把自动补全加入 shell 启动配置
|
||||
echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
|
||||
echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
|
||||
echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
|
||||
|
||||
# 重启 shell 后,按 Tab 键补全:
|
||||
opencli [Tab] # 补全站点名称(bilibili、zhihu、twitter...)
|
||||
opencli bilibili [Tab] # 补全命令(hot、search、me、download...)
|
||||
```
|
||||
|
||||
补全功能包含:
|
||||
- 所有可用的站点和适配器
|
||||
- 内置命令(list、explore、validate...)
|
||||
- 命令别名
|
||||
- 新增适配器时的实时更新
|
||||
|
||||
## 下一步
|
||||
|
||||
- [安装详情](/zh/guide/installation)
|
||||
|
||||
@@ -167,6 +167,7 @@ cli({
|
||||
- `opencli-plugin-github-trending`:GitHub Trending 仓库
|
||||
- `opencli-plugin-hot-digest`:多平台热点聚合(zhihu、weibo、bilibili、v2ex、stackoverflow、reddit、linux-do)
|
||||
- `opencli-plugin-juejin`:稀土掘金热榜、分类和文章流
|
||||
- `opencli-plugin-rubysec`:RubySec 漏洞归档与单篇漏洞文章读取
|
||||
|
||||
## 排查问题
|
||||
|
||||
|
||||
Vendored
+1055
-609
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "1.5.4",
|
||||
"version": "1.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opencli-extension",
|
||||
"version": "1.5.4",
|
||||
"version": "1.5.5",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -73,6 +73,12 @@ function createChromeMock() {
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
return tab;
|
||||
}),
|
||||
move: vi.fn(async (tabId: number, moveProps: { windowId: number; index: number }) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
tab.windowId = moveProps.windowId;
|
||||
return tab;
|
||||
}),
|
||||
onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>,
|
||||
},
|
||||
windows: {
|
||||
@@ -219,6 +225,39 @@ describe('background tab isolation', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('moves drifted tab back to automation window instead of creating a new one', async () => {
|
||||
const { chrome, tabs } = createChromeMock();
|
||||
// Tab 1 belongs to automation window 1 but drifted to window 2
|
||||
tabs[0].windowId = 2;
|
||||
tabs[0].url = 'https://twitter.com/home';
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
|
||||
|
||||
// Should have moved tab 1 back to window 1 and reused it
|
||||
expect(chrome.tabs.move).toHaveBeenCalledWith(1, { windowId: 1, index: -1 });
|
||||
expect(tabId).toBe(1);
|
||||
});
|
||||
|
||||
it('falls through to re-resolve when drifted tab move fails', async () => {
|
||||
const { chrome, tabs } = createChromeMock();
|
||||
tabs[0].windowId = 2;
|
||||
tabs[0].url = 'https://twitter.com/home';
|
||||
// Make move fail
|
||||
chrome.tabs.move = vi.fn(async () => { throw new Error('Cannot move tab'); });
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
// Should still resolve (by finding/creating a tab in the correct window)
|
||||
const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
|
||||
expect(typeof tabId).toBe('number');
|
||||
});
|
||||
|
||||
it('idle timeout closes the automation window for site:notebooklm', async () => {
|
||||
const { chrome, tabs } = createChromeMock();
|
||||
tabs[0].url = 'https://notebooklm.google.com/';
|
||||
|
||||
+274
-38
@@ -117,6 +117,8 @@ type AutomationSession = {
|
||||
windowId: number;
|
||||
idleTimer: ReturnType<typeof setTimeout> | null;
|
||||
idleDeadlineAt: number;
|
||||
owned: boolean;
|
||||
preferredTabId: number | null;
|
||||
};
|
||||
|
||||
const automationSessions = new Map<string, AutomationSession>();
|
||||
@@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void {
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
if (!current.owned) {
|
||||
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
|
||||
automationSessions.delete(workspace);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
@@ -144,8 +151,11 @@ function resetWindowIdleTimer(workspace: string): void {
|
||||
}, WINDOW_IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
/** Get or create the dedicated automation window. */
|
||||
async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
/** Get or create the dedicated automation window.
|
||||
* @param initialUrl — if provided (http/https), used as the initial page instead of about:blank.
|
||||
* This avoids an extra blank-page→target-domain navigation on first command.
|
||||
*/
|
||||
async function getAutomationWindow(workspace: string, initialUrl?: string): Promise<number> {
|
||||
// Check if our window is still alive
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing) {
|
||||
@@ -158,12 +168,13 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
|
||||
// Using about:blank would be hijacked by extensions like "New Tab Override".
|
||||
// Use the target URL directly if it's a safe navigation URL, otherwise fall back to about:blank.
|
||||
const startUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE;
|
||||
|
||||
// Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
|
||||
// state value for windows.create(). The window defaults to 'normal' state anyway.
|
||||
const win = await chrome.windows.create({
|
||||
url: BLANK_PAGE,
|
||||
url: startUrl,
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
@@ -173,12 +184,33 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
windowId: win.id!,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
||||
owned: true,
|
||||
preferredTabId: null,
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
// Brief delay to let Chrome load the initial data: URI tab
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
// Wait for the initial tab to finish loading instead of a fixed 200ms sleep.
|
||||
const tabs = await chrome.tabs.query({ windowId: win.id! });
|
||||
if (tabs[0]?.id) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 500); // fallback cap
|
||||
const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (tabId === tabs[0].id && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
// Check if already complete before listening
|
||||
if (tabs[0].status === 'complete') {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else {
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
return session.windowId;
|
||||
}
|
||||
|
||||
@@ -250,10 +282,20 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
||||
return await handleScreenshot(cmd, workspace);
|
||||
case 'close-window':
|
||||
return await handleCloseWindow(cmd, workspace);
|
||||
case 'cdp':
|
||||
return await handleCdp(cmd, workspace);
|
||||
case 'sessions':
|
||||
return await handleSessions(cmd);
|
||||
case 'set-file-input':
|
||||
return await handleSetFileInput(cmd, workspace);
|
||||
case 'insert-text':
|
||||
return await handleInsertText(cmd, workspace);
|
||||
case 'bind-current':
|
||||
return await handleBindCurrent(cmd, workspace);
|
||||
case 'network-capture-start':
|
||||
return await handleNetworkCaptureStart(cmd, workspace);
|
||||
case 'network-capture-read':
|
||||
return await handleNetworkCaptureRead(cmd, workspace);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
@@ -269,12 +311,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
||||
// ─── Action handlers ─────────────────────────────────────────────────
|
||||
|
||||
/** Internal blank page used when no user URL is provided. */
|
||||
const BLANK_PAGE = 'data:text/html,<html></html>';
|
||||
const BLANK_PAGE = 'about:blank';
|
||||
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
|
||||
}
|
||||
|
||||
/** Check if a URL is safe for user-facing navigation (http/https only). */
|
||||
@@ -301,7 +343,31 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
|
||||
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
||||
}
|
||||
|
||||
function setWorkspaceSession(workspace: string, session: Pick<AutomationSession, 'windowId'>): void {
|
||||
function matchesDomain(url: string | undefined, domain: string): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
|
||||
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
|
||||
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
|
||||
if (cmd.matchPathPrefix) {
|
||||
try {
|
||||
const parsed = new URL(tab.url!);
|
||||
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
||||
automationSessions.set(workspace, {
|
||||
@@ -311,50 +377,69 @@ function setWorkspaceSession(workspace: string, session: Pick<AutomationSession,
|
||||
});
|
||||
}
|
||||
|
||||
type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null };
|
||||
|
||||
/**
|
||||
* Resolve target tab in the automation window.
|
||||
* If explicit tabId is given, use that directly.
|
||||
* Otherwise, find or create a tab in the dedicated automation window.
|
||||
* Resolve target tab in the automation window, returning both the tabId and
|
||||
* the Tab object (when available) so callers can skip a redundant chrome.tabs.get().
|
||||
*/
|
||||
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
|
||||
async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<ResolvedTab> {
|
||||
// Even when an explicit tabId is provided, validate it is still debuggable.
|
||||
// This prevents issues when extensions hijack the tab URL to chrome-extension://
|
||||
// or when the tab has been closed by the user.
|
||||
if (tabId !== undefined) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const session = automationSessions.get(workspace);
|
||||
const matchesSession = session ? tab.windowId === session.windowId : false;
|
||||
if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
|
||||
if (session && !matchesSession) {
|
||||
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
|
||||
const matchesSession = session
|
||||
? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
|
||||
: false;
|
||||
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
|
||||
if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
|
||||
// Tab drifted to another window but content is still valid.
|
||||
// Try to move it back instead of abandoning it.
|
||||
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
|
||||
try {
|
||||
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
|
||||
const moved = await chrome.tabs.get(tabId);
|
||||
if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
|
||||
return { tabId, tab: moved };
|
||||
}
|
||||
} catch (moveErr) {
|
||||
console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
|
||||
}
|
||||
} else if (!isDebuggableUrl(tab.url)) {
|
||||
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
||||
}
|
||||
} catch {
|
||||
// Tab was closed — fall through to auto-resolve
|
||||
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = automationSessions.get(workspace);
|
||||
if (existingSession?.preferredTabId !== null) {
|
||||
try {
|
||||
const preferredTab = await chrome.tabs.get(existingSession.preferredTabId);
|
||||
if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab };
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
// Get (or create) the automation window
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const windowId = await getAutomationWindow(workspace, initialUrl);
|
||||
|
||||
// Prefer an existing debuggable tab
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
|
||||
if (debuggableTab?.id) return debuggableTab.id;
|
||||
if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab };
|
||||
|
||||
// No debuggable tab — another extension may have hijacked the tab URL.
|
||||
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
|
||||
const reuseTab = tabs.find(t => t.id);
|
||||
if (reuseTab?.id) {
|
||||
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
try {
|
||||
const updated = await chrome.tabs.get(reuseTab.id);
|
||||
if (isDebuggableUrl(updated.url)) return reuseTab.id;
|
||||
if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated };
|
||||
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
||||
} catch {
|
||||
// Tab was closed during navigation
|
||||
@@ -364,12 +449,26 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
||||
// Fallback: create a new tab
|
||||
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
|
||||
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
||||
return newTab.id;
|
||||
return { tabId: newTab.id, tab: newTab };
|
||||
}
|
||||
|
||||
/** Convenience wrapper returning just the tabId (used by most handlers) */
|
||||
async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<number> {
|
||||
const resolved = await resolveTab(tabId, workspace, initialUrl);
|
||||
return resolved.tabId;
|
||||
}
|
||||
|
||||
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
if (session.preferredTabId !== null) {
|
||||
try {
|
||||
return [await chrome.tabs.get(session.preferredTabId)];
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
@@ -387,7 +486,8 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.evaluateAsync(tabId, cmd.code);
|
||||
const aggressive = workspace.startsWith('operate:');
|
||||
const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
@@ -399,9 +499,11 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
|
||||
if (!isSafeNavigationUrl(cmd.url)) {
|
||||
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
// Pass target URL so that first-time window creation can start on the right domain
|
||||
const resolved = await resolveTab(cmd.tabId, workspace, cmd.url);
|
||||
const tabId = resolved.tabId;
|
||||
|
||||
const beforeTab = await chrome.tabs.get(tabId);
|
||||
const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
|
||||
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
|
||||
const targetUrl = cmd.url;
|
||||
|
||||
@@ -472,7 +574,22 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
let tab = await chrome.tabs.get(tabId);
|
||||
|
||||
// Post-navigation drift detection: if the tab moved to another window
|
||||
// during navigation (e.g. a tab-management extension regrouped it),
|
||||
// try to move it back to maintain session isolation.
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session && tab.windowId !== session.windowId) {
|
||||
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`);
|
||||
try {
|
||||
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch (moveErr) {
|
||||
console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
@@ -578,13 +695,59 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
|
||||
}
|
||||
}
|
||||
|
||||
/** CDP methods permitted via the 'cdp' passthrough action. */
|
||||
const CDP_ALLOWLIST = new Set([
|
||||
// Agent DOM context
|
||||
'Accessibility.getFullAXTree',
|
||||
'DOM.getDocument',
|
||||
'DOM.getBoxModel',
|
||||
'DOM.getContentQuads',
|
||||
'DOM.querySelectorAll',
|
||||
'DOM.scrollIntoViewIfNeeded',
|
||||
'DOMSnapshot.captureSnapshot',
|
||||
// Native input events
|
||||
'Input.dispatchMouseEvent',
|
||||
'Input.dispatchKeyEvent',
|
||||
'Input.insertText',
|
||||
// Page metrics & screenshots
|
||||
'Page.getLayoutMetrics',
|
||||
'Page.captureScreenshot',
|
||||
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
|
||||
'Runtime.enable',
|
||||
// Emulation (used by screenshot full-page)
|
||||
'Emulation.setDeviceMetricsOverride',
|
||||
'Emulation.clearDeviceMetricsOverride',
|
||||
]);
|
||||
|
||||
async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' };
|
||||
if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
|
||||
return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const aggressive = workspace.startsWith('operate:');
|
||||
await executor.ensureAttached(tabId, aggressive);
|
||||
const data = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
cmd.cdpMethod,
|
||||
cmd.cdpParams ?? {},
|
||||
);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
// Window may already be closed
|
||||
if (session.owned) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
// Window may already be closed
|
||||
}
|
||||
}
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
@@ -605,6 +768,39 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise<Resu
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInsertText(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (typeof cmd.text !== 'string') {
|
||||
return { id: cmd.id, ok: false, error: 'Missing text payload' };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
await executor.insertText(tabId, cmd.text);
|
||||
return { id: cmd.id, ok: true, data: { inserted: true } };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
await executor.startNetworkCapture(tabId, cmd.pattern);
|
||||
return { id: cmd.id, ok: true, data: { started: true } };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.readNetworkCapture(tabId);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessions(cmd: Command): Promise<Result> {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
@@ -616,11 +812,49 @@ async function handleSessions(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
|
||||
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
||||
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
||||
?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
||||
?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
|
||||
if (!boundTab?.id) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: false,
|
||||
error: cmd.matchDomain || cmd.matchPathPrefix
|
||||
? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
|
||||
: 'No active debuggable tab found',
|
||||
};
|
||||
}
|
||||
|
||||
setWorkspaceSession(workspace, {
|
||||
windowId: boundTab.windowId,
|
||||
owned: false,
|
||||
preferredTabId: boundTab.id,
|
||||
});
|
||||
resetWindowIdleTimer(workspace);
|
||||
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
data: {
|
||||
tabId: boundTab.id,
|
||||
windowId: boundTab.windowId,
|
||||
url: boundTab.url,
|
||||
title: boundTab.title,
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
handleNavigate,
|
||||
isTargetUrl,
|
||||
handleTabs,
|
||||
handleSessions,
|
||||
handleBindCurrent,
|
||||
resolveTabId,
|
||||
resetWindowIdleTimer,
|
||||
getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
|
||||
@@ -634,9 +868,11 @@ export const __test__ = {
|
||||
}
|
||||
setWorkspaceSession(workspace, {
|
||||
windowId,
|
||||
owned: true,
|
||||
preferredTabId: null,
|
||||
});
|
||||
},
|
||||
setSession: (workspace: string, session: { windowId: number }) => {
|
||||
setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
|
||||
setWorkspaceSession(workspace, session);
|
||||
},
|
||||
};
|
||||
|
||||
+267
-124
@@ -8,79 +8,34 @@
|
||||
|
||||
const attached = new Set<number>();
|
||||
|
||||
/** Internal blank page used when no user URL is provided. */
|
||||
const BLANK_PAGE = 'data:text/html,<html></html>';
|
||||
const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://';
|
||||
const ATTACH_RECOVERY_DELAY_MS = 120;
|
||||
type NetworkCaptureEntry = {
|
||||
kind: 'cdp';
|
||||
url: string;
|
||||
method: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBodyKind?: string;
|
||||
requestBodyPreview?: string;
|
||||
responseStatus?: number;
|
||||
responseContentType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
responsePreview?: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
||||
type NetworkCaptureState = {
|
||||
patterns: string[];
|
||||
entries: NetworkCaptureEntry[];
|
||||
requestToIndex: Map<string, number>;
|
||||
};
|
||||
|
||||
const networkCaptures = new Map<number, NetworkCaptureState>();
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
|
||||
}
|
||||
|
||||
type CleanupResult = { removed: number };
|
||||
|
||||
async function removeForeignExtensionEmbeds(tabId: number): Promise<CleanupResult> {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!tab.url || (!tab.url.startsWith('http://') && !tab.url.startsWith('https://'))) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
if (!chrome.scripting?.executeScript) return { removed: 0 };
|
||||
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
|
||||
func: (ownExtensionPrefix: string) => {
|
||||
const extensionPrefix = 'chrome-extension://';
|
||||
const selectors = ['iframe', 'frame', 'embed', 'object'];
|
||||
const visitedRoots = new Set<Document | ShadowRoot>();
|
||||
const roots: Array<Document | ShadowRoot> = [document];
|
||||
let removed = 0;
|
||||
|
||||
while (roots.length > 0) {
|
||||
const root = roots.pop();
|
||||
if (!root || visitedRoots.has(root)) continue;
|
||||
visitedRoots.add(root);
|
||||
|
||||
for (const selector of selectors) {
|
||||
const nodes = root.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
const src = node.getAttribute('src') || node.getAttribute('data') || '';
|
||||
if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
|
||||
node.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const element = current as Element & { shadowRoot?: ShadowRoot | null };
|
||||
if (element.shadowRoot) roots.push(element.shadowRoot);
|
||||
current = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
return { removed };
|
||||
},
|
||||
});
|
||||
return result?.result ?? { removed: 0 };
|
||||
} catch {
|
||||
return { removed: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function tryAttach(tabId: number): Promise<void> {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
}
|
||||
|
||||
async function ensureAttached(tabId: number): Promise<void> {
|
||||
export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
|
||||
// Verify the tab URL is debuggable before attempting attach
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
@@ -109,34 +64,56 @@ async function ensureAttached(tabId: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await tryAttach(tabId);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const hint = msg.includes('chrome-extension://')
|
||||
// Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
|
||||
// can temporarily interfere with chrome.debugger. A short delay usually resolves it.
|
||||
// Normal commands: 2 retries, 500ms delay (fast fail for non-operate use)
|
||||
// Operate commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference)
|
||||
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
|
||||
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
|
||||
let lastError = '';
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
|
||||
try {
|
||||
// Force detach first to clear any stale state from other extensions
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
lastError = '';
|
||||
break; // Success
|
||||
} catch (e: unknown) {
|
||||
lastError = e instanceof Error ? e.message : String(e);
|
||||
if (attempt < MAX_ATTACH_RETRIES) {
|
||||
console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
// Re-verify tab URL before retrying (it may have changed)
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!isDebuggableUrl(tab.url)) {
|
||||
lastError = `Tab URL changed to ${tab.url} during retry`;
|
||||
break; // Don't retry if URL became un-debuggable
|
||||
}
|
||||
} catch {
|
||||
lastError = `Tab ${tabId} no longer exists`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
// Log detailed diagnostics for debugging extension conflicts
|
||||
let finalUrl = 'unknown';
|
||||
let finalWindowId = 'unknown';
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
finalUrl = tab.url ?? 'undefined';
|
||||
finalWindowId = String(tab.windowId);
|
||||
} catch { /* tab gone */ }
|
||||
console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`);
|
||||
|
||||
const hint = lastError.includes('chrome-extension://')
|
||||
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
||||
: '';
|
||||
if (msg.includes('chrome-extension://')) {
|
||||
const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
|
||||
if (recoveryCleanup.removed > 0) {
|
||||
console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
|
||||
}
|
||||
await delay(ATTACH_RECOVERY_DELAY_MS);
|
||||
try {
|
||||
await tryAttach(tabId);
|
||||
} catch {
|
||||
throw new Error(`attach failed: ${msg}${hint}`);
|
||||
}
|
||||
} else if (msg.includes('Another debugger is already attached')) {
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
try {
|
||||
await tryAttach(tabId);
|
||||
} catch {
|
||||
throw new Error(`attach failed: ${msg}${hint}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`attach failed: ${msg}${hint}`);
|
||||
}
|
||||
throw new Error(`attach failed: ${lastError}${hint}`);
|
||||
}
|
||||
attached.add(tabId);
|
||||
|
||||
@@ -145,40 +122,50 @@ async function ensureAttached(tabId: number): Promise<void> {
|
||||
} catch {
|
||||
// Some pages may not need explicit enable
|
||||
}
|
||||
|
||||
// Disable breakpoints so that `debugger;` statements in page code don't
|
||||
// pause execution. Anti-bot scripts use `debugger;` traps to detect CDP —
|
||||
// they measure the time gap caused by the pause. Deactivating breakpoints
|
||||
// makes the engine skip `debugger;` entirely, neutralising the timing
|
||||
// side-channel without patching page JS.
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Debugger.enable');
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Debugger.setBreakpointsActive', { active: false });
|
||||
} catch {
|
||||
// Non-fatal: best-effort hardening
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|
||||
await ensureAttached(tabId);
|
||||
export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {
|
||||
// Retry the entire evaluate (attach + command).
|
||||
// Normal: 2 retries. Operate: 3 retries (tolerates extension interference).
|
||||
const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
|
||||
for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) {
|
||||
try {
|
||||
await ensureAttached(tabId, aggressiveRetry);
|
||||
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
}) as {
|
||||
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
||||
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
||||
};
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
}) as {
|
||||
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
||||
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
||||
};
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
const errMsg = result.exceptionDetails.exception?.description
|
||||
|| result.exceptionDetails.text
|
||||
|| 'Eval error';
|
||||
throw new Error(errMsg);
|
||||
if (result.exceptionDetails) {
|
||||
const errMsg = result.exceptionDetails.exception?.description
|
||||
|| result.exceptionDetails.text
|
||||
|| 'Eval error';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Only retry on attach/debugger errors, not on JS eval errors
|
||||
const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed');
|
||||
const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached')
|
||||
|| msg.includes('chrome-extension://');
|
||||
if (isAttachError && attempt < MAX_EVAL_RETRIES) {
|
||||
attached.delete(tabId); // Force re-attach on next attempt
|
||||
// SPA navigations recover quickly; debugger detach needs longer
|
||||
const retryMs = isNavigateError ? 200 : 500;
|
||||
await new Promise(resolve => setTimeout(resolve, retryMs));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
throw new Error('evaluate: max retries exhausted');
|
||||
}
|
||||
|
||||
export const evaluateAsync = evaluate;
|
||||
@@ -275,18 +262,100 @@ export async function setFileInputFiles(
|
||||
});
|
||||
}
|
||||
|
||||
export async function insertText(
|
||||
tabId: number,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await ensureAttached(tabId);
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
|
||||
}
|
||||
|
||||
function normalizeCapturePatterns(pattern?: string): string[] {
|
||||
return String(pattern || '')
|
||||
.split('|')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean {
|
||||
if (!url) return false;
|
||||
if (!patterns.length) return true;
|
||||
return patterns.some((pattern) => url.includes(pattern));
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: unknown): Record<string, string> {
|
||||
if (!headers || typeof headers !== 'object') return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
out[String(key)] = String(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
}): NetworkCaptureEntry | null {
|
||||
const state = networkCaptures.get(tabId);
|
||||
if (!state) return null;
|
||||
const existingIndex = state.requestToIndex.get(requestId);
|
||||
if (existingIndex !== undefined) {
|
||||
return state.entries[existingIndex] || null;
|
||||
}
|
||||
const url = fallback?.url || '';
|
||||
if (!shouldCaptureUrl(url, state.patterns)) return null;
|
||||
const entry: NetworkCaptureEntry = {
|
||||
kind: 'cdp',
|
||||
url,
|
||||
method: fallback?.method || 'GET',
|
||||
requestHeaders: fallback?.requestHeaders || {},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
state.entries.push(entry);
|
||||
state.requestToIndex.set(requestId, state.entries.length - 1);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function startNetworkCapture(
|
||||
tabId: number,
|
||||
pattern?: string,
|
||||
): Promise<void> {
|
||||
await ensureAttached(tabId);
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
|
||||
networkCaptures.set(tabId, {
|
||||
patterns: normalizeCapturePatterns(pattern),
|
||||
entries: [],
|
||||
requestToIndex: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureEntry[]> {
|
||||
const state = networkCaptures.get(tabId);
|
||||
if (!state) return [];
|
||||
const entries = state.entries.slice();
|
||||
state.entries = [];
|
||||
state.requestToIndex.clear();
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function detach(tabId: number): Promise<void> {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
networkCaptures.delete(tabId);
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function registerListeners(): void {
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
attached.delete(tabId);
|
||||
networkCaptures.delete(tabId);
|
||||
});
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
if (source.tabId) {
|
||||
attached.delete(source.tabId);
|
||||
networkCaptures.delete(source.tabId);
|
||||
}
|
||||
});
|
||||
// Invalidate attached cache when tab URL changes to non-debuggable
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
||||
@@ -294,4 +363,78 @@ export function registerListeners(): void {
|
||||
await detach(tabId);
|
||||
}
|
||||
});
|
||||
chrome.debugger.onEvent.addListener(async (source, method, params) => {
|
||||
const tabId = source.tabId;
|
||||
if (!tabId) return;
|
||||
const state = networkCaptures.get(tabId);
|
||||
if (!state) return;
|
||||
|
||||
if (method === 'Network.requestWillBeSent') {
|
||||
const requestId = String(params?.requestId || '');
|
||||
const request = params?.request as {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, unknown>;
|
||||
postData?: string;
|
||||
hasPostData?: boolean;
|
||||
} | undefined;
|
||||
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
|
||||
url: request?.url,
|
||||
method: request?.method,
|
||||
requestHeaders: normalizeHeaders(request?.headers),
|
||||
});
|
||||
if (!entry) return;
|
||||
entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty';
|
||||
entry.requestBodyPreview = String(request?.postData || '').slice(0, 4000);
|
||||
try {
|
||||
const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string };
|
||||
if (postData?.postData) {
|
||||
entry.requestBodyKind = 'string';
|
||||
entry.requestBodyPreview = postData.postData.slice(0, 4000);
|
||||
}
|
||||
} catch {
|
||||
// Optional; some requests do not expose postData.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'Network.responseReceived') {
|
||||
const requestId = String(params?.requestId || '');
|
||||
const response = params?.response as {
|
||||
url?: string;
|
||||
mimeType?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
} | undefined;
|
||||
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
|
||||
url: response?.url,
|
||||
});
|
||||
if (!entry) return;
|
||||
entry.responseStatus = response?.status;
|
||||
entry.responseContentType = response?.mimeType || '';
|
||||
entry.responseHeaders = normalizeHeaders(response?.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'Network.loadingFinished') {
|
||||
const requestId = String(params?.requestId || '');
|
||||
const stateEntryIndex = state.requestToIndex.get(requestId);
|
||||
if (stateEntryIndex === undefined) return;
|
||||
const entry = state.entries[stateEntryIndex];
|
||||
if (!entry) return;
|
||||
try {
|
||||
const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as {
|
||||
body?: string;
|
||||
base64Encoded?: boolean;
|
||||
};
|
||||
if (typeof body?.body === 'string') {
|
||||
entry.responsePreview = body.base64Encoded
|
||||
? `base64:${body.body.slice(0, 4000)}`
|
||||
: body.body.slice(0, 4000);
|
||||
}
|
||||
} catch {
|
||||
// Optional; bodies are unavailable for some requests (e.g. uploads).
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,20 @@
|
||||
* Everything else is just JS code sent via 'exec'.
|
||||
*/
|
||||
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input';
|
||||
export type Action =
|
||||
| 'exec'
|
||||
| 'navigate'
|
||||
| 'tabs'
|
||||
| 'cookies'
|
||||
| 'screenshot'
|
||||
| 'close-window'
|
||||
| 'sessions'
|
||||
| 'set-file-input'
|
||||
| 'insert-text'
|
||||
| 'bind-current'
|
||||
| 'network-capture-start'
|
||||
| 'network-capture-read'
|
||||
| 'cdp';
|
||||
|
||||
export interface Command {
|
||||
/** Unique request ID */
|
||||
@@ -26,6 +39,10 @@ export interface Command {
|
||||
index?: number;
|
||||
/** Cookie domain filter */
|
||||
domain?: string;
|
||||
/** Optional hostname/domain to require for current-tab binding */
|
||||
matchDomain?: string;
|
||||
/** Optional pathname prefix to require for current-tab binding */
|
||||
matchPathPrefix?: string;
|
||||
/** Screenshot format: png (default) or jpeg */
|
||||
format?: 'png' | 'jpeg';
|
||||
/** JPEG quality (0-100), only for jpeg format */
|
||||
@@ -36,6 +53,14 @@ export interface Command {
|
||||
files?: string[];
|
||||
/** CSS selector for file input element (set-file-input action) */
|
||||
selector?: string;
|
||||
/** Raw text payload for insert-text action */
|
||||
text?: string;
|
||||
/** URL substring filter pattern for network capture actions */
|
||||
pattern?: string;
|
||||
/** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
|
||||
cdpMethod?: string;
|
||||
/** CDP method params for 'cdp' action */
|
||||
cdpParams?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
|
||||
Generated
+138
-133
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -196,6 +196,7 @@
|
||||
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.49.2",
|
||||
"@algolia/requester-browser-xhr": "5.49.2",
|
||||
@@ -411,7 +412,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.0",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -424,7 +424,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -436,7 +435,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -914,22 +912,20 @@
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
||||
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
@@ -943,9 +939,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -960,9 +956,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -977,9 +973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -994,9 +990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1011,9 +1007,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1028,9 +1024,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1045,9 +1041,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1062,9 +1058,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1079,9 +1075,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1096,9 +1092,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1113,9 +1109,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1130,9 +1126,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1147,9 +1143,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -1164,9 +1160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1181,9 +1177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1198,9 +1194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1785,31 +1781,31 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
|
||||
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
|
||||
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
|
||||
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1830,26 +1826,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
|
||||
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
|
||||
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
|
||||
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
|
||||
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1857,14 +1853,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
|
||||
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
|
||||
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1873,9 +1869,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
|
||||
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1883,15 +1879,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
|
||||
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
|
||||
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -2168,6 +2164,7 @@
|
||||
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.15.2",
|
||||
"@algolia/client-abtesting": "5.49.2",
|
||||
@@ -2496,6 +2493,7 @@
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
@@ -2624,6 +2622,7 @@
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -3090,11 +3089,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3198,14 +3198,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"version": "1.0.0-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
|
||||
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
"@rolldown/pluginutils": "1.0.0-rc.11"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -3214,21 +3214,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -3483,6 +3483,7 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -3512,6 +3513,7 @@
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3640,16 +3642,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
|
||||
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"rolldown": "1.0.0-rc.11",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4209,6 +4212,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -4264,19 +4268,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
|
||||
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.2",
|
||||
"@vitest/mocker": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/runner": "4.1.2",
|
||||
"@vitest/snapshot": "4.1.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@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",
|
||||
@@ -4287,7 +4291,7 @@
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
@@ -4304,10 +4308,10 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.2",
|
||||
"@vitest/browser-preview": "4.1.2",
|
||||
"@vitest/browser-webdriverio": "4.1.2",
|
||||
"@vitest/ui": "4.1.2",
|
||||
"@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"
|
||||
@@ -4351,6 +4355,7 @@
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
|
||||
+3
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"description": "Make any website your CLI. AI-powered.",
|
||||
"description": "Make any website or Electron App your CLI. AI-powered.",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -30,6 +30,7 @@
|
||||
"postinstall": "node scripts/postinstall.js || true",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepare": "[ -d src ] && npm run build || true",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run --project unit",
|
||||
"test:bun": "bun vitest run --project unit",
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
name: opencli-explorer
|
||||
description: Use when creating a new OpenCLI adapter from scratch, adding support for a new website or platform, or exploring a site's API endpoints via browser DevTools. Covers API discovery workflow, authentication strategy selection, YAML/TS adapter writing, and testing.
|
||||
tags: [opencli, adapter, browser, api-discovery, cli, web-scraping, automation]
|
||||
---
|
||||
|
||||
# CLI-EXPLORER — 适配器探索式开发完全指南
|
||||
|
||||
> 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
name: opencli-oneshot
|
||||
description: Use when quickly generating a single OpenCLI command from a specific URL and goal description. 4-step process — open page, capture API, write YAML adapter, test. For full site exploration, use opencli-explorer instead.
|
||||
tags: [opencli, adapter, quick-start, yaml, cli, one-shot, automation]
|
||||
---
|
||||
|
||||
# CLI-ONESHOT — 单点快速 CLI 生成
|
||||
|
||||
> 给一个 URL + 一句话描述,4 步生成一个 CLI 命令。
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
name: opencli-operate
|
||||
description: Make websites accessible for AI agents. Navigate, click, type, extract, wait — using Chrome with existing login sessions. No LLM API key needed.
|
||||
allowed-tools: Bash(opencli:*), Read, Edit, Write
|
||||
---
|
||||
|
||||
# OpenCLI Operate — Browser Automation for AI Agents
|
||||
|
||||
Control Chrome step-by-step via CLI. Reuses existing login sessions — no passwords needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
opencli doctor # Verify extension + daemon connectivity
|
||||
```
|
||||
|
||||
Requires: Chrome running + OpenCLI Browser Bridge extension installed.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **ALWAYS use `state` to inspect the page, NEVER use `screenshot`** — `state` returns structured DOM with `[N]` element indices, is instant and costs zero tokens. `screenshot` requires vision processing and is slow. Only use `screenshot` when the user explicitly asks to save a visual.
|
||||
2. **ALWAYS use `click`/`type`/`select` for interaction, NEVER use `eval` to click or type** — `eval "el.click()"` bypasses scrollIntoView and CDP click pipeline, causing failures on off-screen elements. Use `state` to find the `[N]` index, then `click <N>`.
|
||||
3. **Verify inputs with `get value`, not screenshots** — after `type`, run `get value <index>` to confirm.
|
||||
4. **Run `state` after every page change** — after `open`, `click` (on links), `scroll`, always run `state` to see the new elements and their indices. Never guess indices.
|
||||
5. **Chain commands aggressively with `&&`** — combine `open + state`, multiple `type` calls, and `type + get value` into single `&&` chains. Each tool call has overhead; chaining cuts it.
|
||||
6. **`eval` is read-only** — use `eval` ONLY for data extraction (`JSON.stringify(...)`), never for clicking, typing, or navigating. Always wrap in IIFE to avoid variable conflicts: `eval "(function(){ const x = ...; return JSON.stringify(x); })()"`.
|
||||
7. **Minimize total tool calls** — plan your sequence before acting. A good task completion uses 3-5 tool calls, not 15-20. Combine `open + state` as one call. Combine `type + type + click` as one call. Only run `state` separately when you need to discover new indices.
|
||||
8. **Prefer `network` to discover APIs** — most sites have JSON APIs. API-based adapters are more reliable than DOM scraping.
|
||||
|
||||
## Command Cost Guide
|
||||
|
||||
| Cost | Commands | When to use |
|
||||
|------|----------|-------------|
|
||||
| **Free & instant** | `state`, `get *`, `eval`, `network`, `scroll`, `keys` | Default — use these |
|
||||
| **Free but changes page** | `open`, `click`, `type`, `select`, `back` | Interaction — run `state` after |
|
||||
| **Expensive (vision tokens)** | `screenshot` | ONLY when user needs a saved image |
|
||||
|
||||
## Action Chaining Rules
|
||||
|
||||
Commands can be chained with `&&`. The browser persists via daemon, so chaining is safe.
|
||||
|
||||
**Always chain when possible** — fewer tool calls = faster completion:
|
||||
```bash
|
||||
# GOOD: open + inspect in one call (saves 1 round trip)
|
||||
opencli operate open https://example.com && opencli operate state
|
||||
|
||||
# GOOD: fill form in one call (saves 2 round trips)
|
||||
opencli operate type 3 "hello" && opencli operate type 4 "world" && opencli operate click 7
|
||||
|
||||
# GOOD: type + verify in one call
|
||||
opencli operate type 5 "test@example.com" && opencli operate get value 5
|
||||
|
||||
# GOOD: click + wait + state in one call (for page-changing clicks)
|
||||
opencli operate click 12 && opencli operate wait time 1 && opencli operate state
|
||||
|
||||
# BAD: separate calls for each action (wasteful)
|
||||
opencli operate type 3 "hello" # Don't do this
|
||||
opencli operate type 4 "world" # when you can chain
|
||||
opencli operate click 7 # all three together
|
||||
```
|
||||
|
||||
**Page-changing — always put last** in a chain (subsequent commands see stale indices):
|
||||
- `open <url>`, `back`, `click <link/button that navigates>`
|
||||
|
||||
**Rule**: Chain when you already know the indices. Run `state` separately when you need to discover indices first.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Navigate**: `opencli operate open <url>`
|
||||
2. **Inspect**: `opencli operate state` → elements with `[N]` indices
|
||||
3. **Interact**: use indices — `click`, `type`, `select`, `keys`
|
||||
4. **Wait** (if needed): `opencli operate wait selector ".loaded"` or `wait text "Success"`
|
||||
5. **Verify**: `opencli operate state` or `opencli operate get value <N>`
|
||||
6. **Repeat**: browser stays open between commands
|
||||
7. **Save**: write a TS adapter to `~/.opencli/clis/<site>/<command>.ts`
|
||||
|
||||
## Commands
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
opencli operate open <url> # Open URL (page-changing)
|
||||
opencli operate back # Go back (page-changing)
|
||||
opencli operate scroll down # Scroll (up/down, --amount N)
|
||||
opencli operate scroll up --amount 1000
|
||||
```
|
||||
|
||||
### Inspect (free & instant)
|
||||
|
||||
```bash
|
||||
opencli operate state # Structured DOM with [N] indices — PRIMARY tool
|
||||
opencli operate screenshot [path.png] # Save visual to file — ONLY for user deliverables
|
||||
```
|
||||
|
||||
### Get (free & instant)
|
||||
|
||||
```bash
|
||||
opencli operate get title # Page title
|
||||
opencli operate get url # Current URL
|
||||
opencli operate get text <index> # Element text content
|
||||
opencli operate get value <index> # Input/textarea value (use to verify after type)
|
||||
opencli operate get html # Full page HTML
|
||||
opencli operate get html --selector "h1" # Scoped HTML
|
||||
opencli operate get attributes <index> # Element attributes
|
||||
```
|
||||
|
||||
### Interact
|
||||
|
||||
```bash
|
||||
opencli operate click <index> # Click element [N]
|
||||
opencli operate type <index> "text" # Type into element [N]
|
||||
opencli operate select <index> "option" # Select dropdown
|
||||
opencli operate keys "Enter" # Press key (Enter, Escape, Tab, Control+a)
|
||||
```
|
||||
|
||||
### Wait
|
||||
|
||||
```bash
|
||||
opencli operate wait selector ".loaded" # Wait for element
|
||||
opencli operate wait selector ".spinner" --timeout 5000 # With timeout
|
||||
opencli operate wait text "Success" # Wait for text
|
||||
opencli operate wait time 3 # Wait N seconds
|
||||
```
|
||||
|
||||
### Extract (free & instant, read-only)
|
||||
|
||||
Use `eval` ONLY for reading data. Never use it to click, type, or navigate.
|
||||
|
||||
```bash
|
||||
opencli operate eval "document.title"
|
||||
opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e => e.textContent))"
|
||||
|
||||
# IMPORTANT: wrap complex logic in IIFE to avoid "already declared" errors
|
||||
opencli operate eval "(function(){ const items = [...document.querySelectorAll('.item')]; return JSON.stringify(items.map(e => e.textContent)); })()"
|
||||
```
|
||||
|
||||
### Network (API Discovery)
|
||||
|
||||
```bash
|
||||
opencli operate network # Show captured API requests (auto-captured since open)
|
||||
opencli operate network --detail 3 # Show full response body of request #3
|
||||
opencli operate network --all # Include static resources
|
||||
```
|
||||
|
||||
### Sedimentation (Save as CLI)
|
||||
|
||||
```bash
|
||||
opencli operate init hn/top # Generate adapter scaffold
|
||||
opencli operate verify hn/top # Test the adapter
|
||||
```
|
||||
|
||||
### Session
|
||||
|
||||
```bash
|
||||
opencli operate close # Close automation window
|
||||
```
|
||||
|
||||
## Example: Extract HN Stories
|
||||
|
||||
```bash
|
||||
opencli operate open https://news.ycombinator.com
|
||||
opencli operate state # See [1] a "Story 1", [2] a "Story 2"...
|
||||
opencli operate eval "JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a => ({title: a.textContent, url: a.href})))"
|
||||
opencli operate close
|
||||
```
|
||||
|
||||
## Example: Fill a Form
|
||||
|
||||
```bash
|
||||
opencli operate open https://httpbin.org/forms/post
|
||||
opencli operate state # See [3] input "Customer Name", [4] input "Telephone"
|
||||
opencli operate type 3 "OpenCLI" && opencli operate type 4 "555-0100"
|
||||
opencli operate get value 3 # Verify: "OpenCLI"
|
||||
opencli operate close
|
||||
```
|
||||
|
||||
## Saving as Reusable CLI — Complete Workflow
|
||||
|
||||
### Step-by-step sedimentation flow:
|
||||
|
||||
```bash
|
||||
# 1. Explore the website
|
||||
opencli operate open https://news.ycombinator.com
|
||||
opencli operate state # Understand DOM structure
|
||||
|
||||
# 2. Discover APIs (crucial for high-quality adapters)
|
||||
opencli operate eval "fetch('/api/...').then(r=>r.json())" # Trigger API calls
|
||||
opencli operate network # See captured API requests
|
||||
opencli operate network --detail 0 # Inspect response body
|
||||
|
||||
# 3. Generate scaffold
|
||||
opencli operate init hn/top # Creates ~/.opencli/clis/hn/top.ts
|
||||
|
||||
# 4. Edit the adapter (fill in func logic)
|
||||
# - If API found: use fetch() directly (Strategy.PUBLIC or COOKIE)
|
||||
# - If no API: use page.evaluate() for DOM extraction (Strategy.UI)
|
||||
|
||||
# 5. Verify
|
||||
opencli operate verify hn/top # Runs the adapter and shows output
|
||||
|
||||
# 6. If verify fails, edit and retry
|
||||
# 7. Close when done
|
||||
opencli operate close
|
||||
```
|
||||
|
||||
### Example adapter:
|
||||
|
||||
```typescript
|
||||
// ~/.opencli/clis/hn/top.ts
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'hn',
|
||||
name: 'top',
|
||||
description: 'Top Hacker News stories',
|
||||
domain: 'news.ycombinator.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [{ name: 'limit', type: 'int', default: 5 }],
|
||||
columns: ['rank', 'title', 'score', 'url'],
|
||||
func: async (_page, kwargs) => {
|
||||
const limit = Math.min(Math.max(1, kwargs.limit ?? 5), 50);
|
||||
const resp = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');
|
||||
const ids = await resp.json();
|
||||
return Promise.all(
|
||||
ids.slice(0, limit).map(async (id: number, i: number) => {
|
||||
const item = await (await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)).json();
|
||||
return { rank: i + 1, title: item.title, score: item.score, url: item.url ?? '' };
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Save to `~/.opencli/clis/<site>/<command>.ts` → immediately available as `opencli <site> <command>`.
|
||||
|
||||
### Strategy Guide
|
||||
|
||||
| Strategy | When | browser: |
|
||||
|----------|------|----------|
|
||||
| `Strategy.PUBLIC` | Public API, no auth | `false` |
|
||||
| `Strategy.COOKIE` | Needs login cookies | `true` |
|
||||
| `Strategy.UI` | Direct DOM interaction | `true` |
|
||||
|
||||
**Always prefer API over UI** — if you discovered an API during browsing, use `fetch()` directly.
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Always `state` first** — never guess element indices, always inspect first
|
||||
2. **Sessions persist** — browser stays open between commands, no need to re-open
|
||||
3. **Use `eval` for data extraction** — `eval "JSON.stringify(...)"` is faster than multiple `get` calls
|
||||
4. **Use `network` to find APIs** — JSON APIs are more reliable than DOM scraping
|
||||
5. **Alias**: `opencli op` is shorthand for `opencli operate`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Fix |
|
||||
|-------|-----|
|
||||
| "Browser not connected" | Run `opencli doctor` |
|
||||
| "attach failed: chrome-extension://" | Disable 1Password temporarily |
|
||||
| Element not found | `opencli operate scroll down && opencli operate state` |
|
||||
| Stale indices after page change | Run `opencli operate state` again to get fresh indices |
|
||||
+121
-29
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: opencli-usage
|
||||
description: "OpenCLI usage guide — installation, prerequisites, and command reference organized by platform"
|
||||
version: 1.5.6
|
||||
description: "Use when running OpenCLI commands to interact with websites (Bilibili, Twitter, Reddit, Xiaohongshu, etc.), desktop apps (Cursor, Notion), or public APIs (HackerNews, arXiv). Covers installation, command reference, and output formats for 73+ adapters."
|
||||
version: 1.6.1
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, chrome-extension, cdp, AI, agent]
|
||||
tags: [opencli, cli, browser, web, chrome-extension, cdp, bilibili, twitter, reddit, xiaohongshu, github, youtube, AI, agent, automation]
|
||||
---
|
||||
|
||||
# OpenCLI Usage Guide
|
||||
@@ -36,36 +36,128 @@ Browser commands require:
|
||||
|
||||
Public API commands (`hackernews`, `v2ex`) need no browser.
|
||||
|
||||
## Command Categories
|
||||
## Quick Lookup by Capability
|
||||
|
||||
OpenCLI commands are organized by platform type:
|
||||
| Capability | Platforms (partial list) | File |
|
||||
|-----------|--------------------------|------|
|
||||
| **search** | Bilibili, Twitter, Reddit, Xiaohongshu, Zhihu, YouTube, Google, arXiv, LinkedIn, Pixiv, etc. | browser.md / public-api.md |
|
||||
| **hot/trending** | Bilibili, Twitter, Weibo, HackerNews, Reddit, V2EX, Xueqiu, Lobsters, Douban | browser.md / public-api.md |
|
||||
| **feed/timeline** | Twitter, Reddit, Xiaohongshu, Xueqiu, Jike, Facebook, Instagram, Medium | browser.md |
|
||||
| **user/profile** | Twitter, Reddit, Instagram, TikTok, Facebook, Bilibili, Pixiv | browser.md |
|
||||
| **post/create** | Twitter, Jike, Douyin, Weibo | browser.md |
|
||||
| **AI chat** | Grok, Doubao, ChatGPT, Gemini, Cursor, Codex, NotebookLM | browser.md / desktop.md |
|
||||
| **finance/stock** | Xueqiu, Yahoo Finance, Barchart, Sina Finance, Bloomberg | browser.md / public-api.md |
|
||||
| **web scraping** | `opencli web read --url <url>` — any URL to Markdown | browser.md |
|
||||
|
||||
### 📱 Browser-based Commands
|
||||
Commands that require Chrome browser with login state. See [browser.md](./browser.md) for full reference.
|
||||
## Command Quick Reference
|
||||
|
||||
**Supported platforms:**
|
||||
- Bilibili (哔哩哔哩), Zhihu (知乎), Xiaohongshu (小红书), Xueqiu (雪球)
|
||||
- Twitter/X, Reddit, Facebook, Instagram, TikTok, LinkedIn
|
||||
- YouTube, Medium, Substack, Sinablog (新浪博客)
|
||||
- V2EX (browser features), Weibo (微博), Jike (即刻), Linux.do (browser features)
|
||||
- BOSS直聘, Coupang (쿠팡), JD (京东), SMZDM (什么值得买), Ctrip (携程)
|
||||
- Yahoo Finance, Sina Finance, Reuters, Barchart, Bloomberg (full article)
|
||||
- Douban (豆瓣), Chaoxing (超星学习通), WeRead (微信读书), Pixiv
|
||||
- Yollomi, Jimeng (即梦 AI), Web, Weixin (微信公众号)
|
||||
- Grok, Doubao Web (豆包), Kimi, DeepSeek, Qwen (通义千问)
|
||||
Usage: `opencli <site> <command> [args] [--limit N] [-f json|yaml|md|csv|table]`
|
||||
|
||||
### 🖥️ Desktop Adapter Commands
|
||||
Commands that interact with desktop applications via CDP or external CLI tools. See [desktop.md](./desktop.md) for full reference.
|
||||
### Browser-based (login required)
|
||||
|
||||
**Supported platforms:**
|
||||
- GitHub (via gh CLI), Cursor, Codex, ChatGPT, ChatWise
|
||||
- Notion, Discord App, Doubao App (豆包桌面版), Antigravity
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` `following` |
|
||||
| **zhihu** | `hot` `search` `question` |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` |
|
||||
| **xueqiu** | `hot-stock` `stock` `watchlist` `feed` `hot` `search` `comments` `earnings-date` `fund-holdings` `fund-snapshot` |
|
||||
| **twitter** | `trending` `bookmarks` `search` `profile` `timeline` `thread` `article` `follow` `unfollow` `bookmark` `unbookmark` `post` `like` `likes` `reply` `delete` `block` `unblock` `followers` `following` `notifications` `hide-reply` `download` `accept` `reply-dm` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` |
|
||||
| **youtube** | `search` `video` `transcript` |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` |
|
||||
| **linkedin** | `search` `timeline` |
|
||||
| **medium** | `feed` `search` `user` |
|
||||
| **substack** | `feed` `search` `publication` |
|
||||
| **sinablog** | `hot` `search` `article` `user` |
|
||||
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` |
|
||||
| **douyin** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` |
|
||||
| **bluesky** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` |
|
||||
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` |
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` |
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` |
|
||||
| **band** | `bands` `posts` `post` `mentions` |
|
||||
| **zsxq** | `groups` `dynamics` `topics` `topic` `search` |
|
||||
| **tieba** | `hot` `search` `posts` `read` |
|
||||
| **yahoo-finance** | `quote` |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` |
|
||||
| **sinafinance** | `news` |
|
||||
| **reuters** | `search` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
|
||||
| **coupang** | `search` `add-to-cart` |
|
||||
| **jd** | `item` |
|
||||
| **smzdm** | `search` |
|
||||
| **ctrip** | `search` |
|
||||
| **36kr** | `hot` `news` `search` `article` |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` |
|
||||
| **chaoxing** | `assignments` `exams` |
|
||||
| **jimeng** | `generate` `history` |
|
||||
| **yollomi** | `models` `generate` `video` `upload` `remove-bg` `edit` `background` `face-swap` `object-remover` `restore` `try-on` `upscale` |
|
||||
| **ones** | `login` `logout` `me` `tasks` `task` `my-tasks` `worklog` `token-info` |
|
||||
| **web** | `read` — any URL to Markdown |
|
||||
| **weixin** | `download` — 公众号 article to Markdown |
|
||||
| **v2ex** (browser) | `daily` `me` `notifications` |
|
||||
| **linux-do** (browser) | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` |
|
||||
| **bloomberg** (browser) | `news` — full article reader |
|
||||
| **grok** | `ask` |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` `detail` `history` `meeting-summary` `meeting-transcript` |
|
||||
| **gemini** | `ask` `new` `image` |
|
||||
| **notebooklm** | `status` `list` `open` `get` `current` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
|
||||
### 🌐 Public API Commands
|
||||
Commands that work without browser or authentication. See [public-api.md](./public-api.md) for full reference.
|
||||
### Desktop (CDP/Electron)
|
||||
|
||||
**Supported platforms:**
|
||||
- Hacker News, V2EX (public features), BBC News, Sina Finance
|
||||
- Lobsters, Google, DEV.to, Steam, Apple Podcasts
|
||||
- arXiv, Bloomberg (RSS), Dictionary, HuggingFace
|
||||
- StackOverflow, Xiaoyuzhou (小宇宙), Wikipedia, Product Hunt
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **gh** | `repo` `pr` `issue` — passthrough to gh CLI |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
|
||||
|
||||
### Public API (no browser)
|
||||
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
|
||||
| **v2ex** (public) | `hot` `latest` `topic` `node` `nodes` `member` `user` `replies` |
|
||||
| **bbc** | `news` |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` |
|
||||
| **google** | `news` `search` `suggest` `trends` |
|
||||
| **devto** | `top` `tag` `user` |
|
||||
| **steam** | `top-sellers` |
|
||||
| **apple-podcasts** | `top` `search` `episodes` |
|
||||
| **arxiv** | `search` `paper` |
|
||||
| **bloomberg** (RSS) | `main` `markets` `tech` `politics` `economics` `opinions` `industries` `businessweek` `feeds` |
|
||||
| **dictionary** | `search` `synonyms` `examples` |
|
||||
| **hf** | `top` |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` |
|
||||
| **producthunt** | `today` `hot` `browse` `posts` |
|
||||
| **imdb** | `top` `trending` `search` `title` `person` `reviews` |
|
||||
| **spotify** | `auth` `status` `play` |
|
||||
| **paperreview** | `submit` `review` `feedback` |
|
||||
|
||||
### Management
|
||||
|
||||
```bash
|
||||
opencli list [-f json|yaml] # List all commands
|
||||
opencli validate [site] # Validate adapter definitions
|
||||
opencli doctor # Diagnose browser bridge
|
||||
opencli explore <url> # AI-powered API discovery
|
||||
opencli record <url> # Record API calls manually
|
||||
```
|
||||
|
||||
All commands support: `--format` / `-f` with `table` `json` `yaml` `md` `csv`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **opencli-operate** — Browser automation for AI agents (navigate, click, type, extract via Chrome)
|
||||
- **opencli-explorer** — Full guide for creating new adapters (API discovery, auth strategy, YAML/TS writing)
|
||||
- **opencli-oneshot** — Quick 4-step template for adding a single command from a URL
|
||||
|
||||
+114
-16
@@ -29,10 +29,14 @@ opencli zhihu question 34816524 # 问题详情和回答 (id positiona
|
||||
## Xiaohongshu (小红书)
|
||||
|
||||
```bash
|
||||
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 search "美食" # 搜索笔记 (query positional)
|
||||
opencli xiaohongshu note <note-id-or-url> # 读取笔记正文和互动数据
|
||||
opencli xiaohongshu comments <note-id> # 笔记评论
|
||||
opencli xiaohongshu notifications # 通知(mentions/likes/connections)
|
||||
opencli xiaohongshu feed --limit 10 # 推荐 Feed
|
||||
opencli xiaohongshu user xxx # 用户主页 (id positional)
|
||||
opencli xiaohongshu download <note-id> # 下载笔记图片/视频
|
||||
opencli xiaohongshu publish # 发布笔记
|
||||
opencli xiaohongshu creator-notes --limit 10 # 创作者笔记列表
|
||||
opencli xiaohongshu creator-note-detail --note-id xxx # 笔记详情
|
||||
opencli xiaohongshu creator-notes-summary # 笔记数据概览
|
||||
@@ -116,6 +120,12 @@ opencli v2ex notifications --limit 10 # 通知
|
||||
|
||||
```bash
|
||||
opencli weibo hot --limit 10 # 微博热搜
|
||||
opencli weibo search "关键词" # 搜索微博
|
||||
opencli weibo feed --limit 20 # 首页时间线
|
||||
opencli weibo user <uid> # 用户信息
|
||||
opencli weibo me # 我的信息
|
||||
opencli weibo post "内容" # 发微博
|
||||
opencli weibo comments <mid> # 微博评论
|
||||
```
|
||||
|
||||
## BOSS直聘
|
||||
@@ -353,6 +363,10 @@ opencli doubao new # 新建对话
|
||||
opencli doubao send "你好" # 发送消息 (text positional)
|
||||
opencli doubao read # 读取对话记录
|
||||
opencli doubao ask "问题" # 一键提问并等回复 (text positional)
|
||||
opencli doubao detail <id> # 对话详情
|
||||
opencli doubao history # 历史对话列表
|
||||
opencli doubao meeting-summary <id> # 会议总结
|
||||
opencli doubao meeting-transcript <id> # 会议记录
|
||||
```
|
||||
|
||||
## Grok
|
||||
@@ -404,26 +418,110 @@ opencli linkedin timeline --limit 20 # 首页动态流
|
||||
opencli bloomberg news "https://..." # 阅读 Bloomberg 文章全文 (link positional, browser)
|
||||
```
|
||||
|
||||
## Kimi
|
||||
## Gemini
|
||||
|
||||
```bash
|
||||
opencli kimi status # 检查 Kimi 页面状态
|
||||
opencli kimi new # 新建对话
|
||||
opencli kimi ask "问题" # 提问 (prompt positional)
|
||||
opencli gemini ask "问题" # 提问 (prompt positional)
|
||||
opencli gemini new # 新建对话
|
||||
opencli gemini image "描述" # 生成图片
|
||||
```
|
||||
|
||||
## DeepSeek
|
||||
## NotebookLM
|
||||
|
||||
```bash
|
||||
opencli deepseek status # 检查 DeepSeek 页面状态
|
||||
opencli deepseek new # 新建对话
|
||||
opencli deepseek ask "问题" # 提问 (prompt positional)
|
||||
opencli notebooklm status # 检查页面状态
|
||||
opencli notebooklm list # 列出所有笔记本
|
||||
opencli notebooklm open <notebook> # 打开笔记本
|
||||
opencli notebooklm current # 当前笔记本信息
|
||||
opencli notebooklm get # 获取笔记本详情
|
||||
opencli notebooklm history # 对话历史
|
||||
opencli notebooklm summary # 笔记本摘要
|
||||
opencli notebooklm source-list # 列出来源
|
||||
opencli notebooklm source-get <source> # 获取来源详情
|
||||
opencli notebooklm source-fulltext <src> # 来源全文
|
||||
opencli notebooklm source-guide <src> # 来源指南
|
||||
opencli notebooklm note-list # 笔记列表
|
||||
opencli notebooklm notes-get <note> # 获取笔记内容
|
||||
```
|
||||
|
||||
## Qwen (通义千问)
|
||||
## Bluesky
|
||||
|
||||
```bash
|
||||
opencli qwen status # 检查 Qwen 页面状态
|
||||
opencli qwen new # 新建对话
|
||||
opencli qwen ask "问题" # 提问 (prompt positional)
|
||||
opencli bluesky search "关键词" # 搜索帖子 (query positional)
|
||||
opencli bluesky profile <handle> # 用户资料
|
||||
opencli bluesky user <handle> # 用户详情
|
||||
opencli bluesky feeds <handle> # 用户 feeds
|
||||
opencli bluesky followers <handle> # 粉丝列表
|
||||
opencli bluesky following <handle> # 关注列表
|
||||
opencli bluesky thread <uri> # 帖子线程
|
||||
opencli bluesky trending # 热门话题
|
||||
opencli bluesky starter-packs # Starter packs
|
||||
```
|
||||
|
||||
## Douyin (抖音)
|
||||
|
||||
```bash
|
||||
opencli douyin profile # 创作者资料
|
||||
opencli douyin videos --limit 10 # 浏览视频
|
||||
opencli douyin user-videos # 我的作品列表
|
||||
opencli douyin activities # 动态
|
||||
opencli douyin collections # 收藏夹
|
||||
opencli douyin hashtag <tag> # 话题页
|
||||
opencli douyin location <poi> # 地点页
|
||||
opencli douyin stats # 数据统计
|
||||
opencli douyin publish # 发布视频
|
||||
opencli douyin draft # 编辑草稿
|
||||
opencli douyin drafts # 草稿列表
|
||||
opencli douyin delete <id> # 删除作品
|
||||
opencli douyin update <id> # 更新作品信息
|
||||
```
|
||||
|
||||
## Band
|
||||
|
||||
```bash
|
||||
opencli band bands # 列出已加入的 bands
|
||||
opencli band posts <band-id> # Band 帖子列表
|
||||
opencli band post <post-key> # 帖子详情
|
||||
opencli band mentions # 提到我的消息
|
||||
```
|
||||
|
||||
## ZSXQ (知识星球)
|
||||
|
||||
```bash
|
||||
opencli zsxq groups # 我加入的星球
|
||||
opencli zsxq dynamics <group-id> # 星球动态
|
||||
opencli zsxq topics <group-id> # 主题列表
|
||||
opencli zsxq topic <topic-id> # 主题详情
|
||||
opencli zsxq search "关键词" # 搜索
|
||||
```
|
||||
|
||||
## Tieba (百度贴吧)
|
||||
|
||||
```bash
|
||||
opencli tieba hot # 热门贴吧
|
||||
opencli tieba search "关键词" # 搜索
|
||||
opencli tieba posts <forum> # 帖子列表
|
||||
opencli tieba read <thread-id> # 阅读帖子
|
||||
```
|
||||
|
||||
## 36kr
|
||||
|
||||
```bash
|
||||
opencli 36kr hot # 热门文章
|
||||
opencli 36kr news # 最新资讯
|
||||
opencli 36kr search "关键词" # 搜索文章
|
||||
opencli 36kr article <id> # 文章全文
|
||||
```
|
||||
|
||||
## ONES
|
||||
|
||||
```bash
|
||||
opencli ones login # 登录
|
||||
opencli ones me # 我的信息
|
||||
opencli ones tasks --team <id> # 项目任务列表
|
||||
opencli ones my-tasks # 我的任务
|
||||
opencli ones task <id> # 任务详情
|
||||
opencli ones worklog --task <id> # 工时日志
|
||||
opencli ones token-info # Token 信息
|
||||
opencli ones logout # 登出
|
||||
```
|
||||
|
||||
@@ -109,6 +109,8 @@ opencli xiaoyuzhou episode 12345 # 单集详情 (id positional)
|
||||
```bash
|
||||
opencli wikipedia search "AI" # 搜索 (query positional)
|
||||
opencli wikipedia summary "Python" # 摘要 (title positional)
|
||||
opencli wikipedia random # 随机条目
|
||||
opencli wikipedia trending # 热门条目
|
||||
```
|
||||
|
||||
## Bloomberg (RSS)
|
||||
@@ -143,7 +145,34 @@ opencli hf top --limit 10 # 热门模型
|
||||
|
||||
```bash
|
||||
opencli producthunt today --limit 10 # 今日产品
|
||||
opencli producthunt week --limit 10 # 本周产品
|
||||
opencli producthunt month --limit 10 # 本月产品
|
||||
opencli producthunt search "AI" # 搜索产品 (query positional)
|
||||
opencli producthunt hot --limit 10 # 热门产品
|
||||
opencli producthunt browse --limit 10 # 浏览产品
|
||||
opencli producthunt posts --limit 10 # 最新产品
|
||||
```
|
||||
|
||||
## IMDB
|
||||
|
||||
```bash
|
||||
opencli imdb top --limit 10 # Top 250
|
||||
opencli imdb trending --limit 10 # 热门影视
|
||||
opencli imdb search "关键词" # 搜索 (query positional)
|
||||
opencli imdb title <id> # 影视详情
|
||||
opencli imdb person <id> # 演员详情
|
||||
opencli imdb reviews <id> # 评论
|
||||
```
|
||||
|
||||
## Spotify
|
||||
|
||||
```bash
|
||||
opencli spotify auth # OAuth 授权
|
||||
opencli spotify status # 播放状态
|
||||
opencli spotify play "歌曲" # 播放 (query positional)
|
||||
```
|
||||
|
||||
## Paper Review
|
||||
|
||||
```bash
|
||||
opencli paperreview submit # 提交论文
|
||||
opencli paperreview review # 审阅
|
||||
opencli paperreview feedback # 反馈
|
||||
```
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: smart-search
|
||||
description: 基于 opencli 命令的智能搜索路由器。当用户想要搜索、查询、查找或研究信息时,尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时,务必使用此 skill
|
||||
---
|
||||
|
||||
# 智能搜索路由器
|
||||
|
||||
根据话题和场景,将查询路由到最佳的 opencli 搜索源。此 skill 的核心目标不是记忆命令,而是先定位数据源,再让 Agent 通过 `opencli` 自己读取实时帮助,避免文档漂移。
|
||||
|
||||
## 强制预检
|
||||
|
||||
每次使用前,必须先做下面两步:
|
||||
|
||||
- 运行 `opencli list -f yaml`
|
||||
- 用 live registry 确认候选站点是否存在,并检查 `strategy`、`browser`、`domain`
|
||||
|
||||
选定站点后,必须再做下面两步:
|
||||
|
||||
- 运行 `opencli <site> -h` 查看该站点有哪些子命令
|
||||
- 若已锁定某个子命令,再运行 `opencli <site> <command> -h` 查看参数、输出列、策略
|
||||
|
||||
不要在 skill 文档里硬编码参数或假设命令签名;以 `opencli ... -h` 的实时输出为准。
|
||||
|
||||
## 主路由规则
|
||||
|
||||
只使用这一条规则,不再维护多套优先级:
|
||||
|
||||
1. 当用户明确指定网站、平台或数据源时,直接使用对应网站。
|
||||
2. 当用户没有指定网站时,优先只选择一个 AI 源:`grok`、`doubao`、`gemini` 三选一。
|
||||
3. 当 AI 返回内容不足、缺少原始数据、需要权威佐证或需要垂直结果时,再补充 1-2 个专用源。
|
||||
|
||||
## 单题预算与频率限制
|
||||
|
||||
把“单个用户问题”理解为同一意图链路下的一次问题求解;同一轮追问、澄清、补充条件,若核心问题未变,仍算同一题。
|
||||
|
||||
先建立一份站点调用台账。每次真正执行搜索命令后,立刻更新:
|
||||
|
||||
- `site`
|
||||
- `query`
|
||||
- `count`
|
||||
- `status`
|
||||
|
||||
计数规则:
|
||||
|
||||
- `opencli list -f yaml`、`opencli <site> -h`、`opencli <site> <command> -h` 属于预检与帮助,不计入搜索次数
|
||||
- 一次真正的 `opencli <site> ...` 搜索/查询执行,计为该站点 1 次调用
|
||||
- 同站点因为报错、超时、验证码、反爬、登录态异常而失败,也算 1 次调用,不要无限重试
|
||||
|
||||
频率上限:
|
||||
|
||||
- AI 站点硬限制:同一题内,每个 AI 站点最多调用 1 次
|
||||
- 默认策略仍然是只选 1 个 AI 站点,不要把多个 AI 站点串成常规流程
|
||||
- 只有当用户明确要求比较多个 AI 站点时,才可以额外调用其他 AI 站点;但每个被点名的 AI 站点仍然最多 1 次
|
||||
- 非 AI 站点默认最多调用 2 次
|
||||
- 非 AI 站点第 2 次调用必须有明确理由,例如第一次结果过宽,需要加时间、地区、类别、排序或关键词限定
|
||||
- 非 AI 站点不要进行第 3 次调用;若信息仍不足,停止扩搜并明确说明缺口
|
||||
|
||||
触发限频后的处理:
|
||||
|
||||
- 记录:「已跳过:<site> 达到频率上限」
|
||||
- 优先改用其他同类站点
|
||||
- 若没有合适替代源,则直接基于已收集信息回答,并说明覆盖范围与缺口
|
||||
|
||||
## 查询结束汇报
|
||||
|
||||
每次查询结束后,回答末尾必须追加一段简短的“搜索摘要”,至少包含下面三项:
|
||||
|
||||
- 使用了什么网站搜索
|
||||
- 每个网站搜了什么词
|
||||
- 每个网站搜了几次
|
||||
|
||||
如果有被限频跳过的站点,也要明确写出。
|
||||
|
||||
建议使用下面的固定格式:
|
||||
|
||||
```md
|
||||
搜索摘要
|
||||
- 网站:<site1> | 查询词:<term1> | 次数:<n>
|
||||
- 网站:<site2> | 查询词:<term2>;<term3> | 次数:<n>
|
||||
- 已跳过:<site3>,原因:达到频率上限
|
||||
```
|
||||
|
||||
## AI 源选择
|
||||
|
||||
- `grok`
|
||||
适合实时讨论、英文互联网舆论、Twitter/X 语境、热点追踪。
|
||||
- `doubao`
|
||||
适合中文语境、字节抖音生态、生活方式内容、中文热点与泛中文问答。
|
||||
- `gemini`
|
||||
适合全球网页、英文资料、通用信息检索、背景综述。
|
||||
|
||||
如果用户没有指定网站,默认先判断语言和语境,再从这三个里只选一个。
|
||||
|
||||
一旦某个 AI 站点已经执行过一次真实查询,就不要在同一题里改写关键词后再次调用该 AI 站点。若答案不足,优先补专用源,不要反复追打同一个 AI 站点。
|
||||
|
||||
## AI 查询词建议
|
||||
|
||||
当使用 AI 源时,不要只丢一个过短关键词。优先构造成“主题 + 目标 + 限定条件”的查询。
|
||||
|
||||
- 主题
|
||||
用户真正要查的对象、事件、产品、人物、公司、技术名词。
|
||||
- 目标
|
||||
想要什么结果,例如总结、对比、原因、趋势、推荐、原始线索。
|
||||
- 限定条件
|
||||
语言、地区、时间范围、平台范围、受众、价格带、岗位地点、是否要引用原始来源。
|
||||
|
||||
优先使用下面这种表达方式:
|
||||
|
||||
- `<主题> + <你要回答的问题>`
|
||||
- `<主题> + <时间范围/地区/语言>`
|
||||
- `<主题> + <平台或来源范围>`
|
||||
- `<主题> + <输出要求>`
|
||||
|
||||
避免只输入:
|
||||
|
||||
- 单个名词
|
||||
- 没有时间范围的热点问题
|
||||
- 没有地区限制的购物、求职、旅游问题
|
||||
- 没有平台限制的社交媒体问题
|
||||
|
||||
## 专用源补充时机
|
||||
|
||||
当出现以下任一情况时,再补充专用源:
|
||||
|
||||
- AI 给出的是摘要,但你需要原始帖子、原始视频、原始商品或原始职位结果
|
||||
- AI 覆盖面不足,漏掉垂直站点信息
|
||||
- 需要更高权威性或更强领域相关性
|
||||
- 用户明确要求“从某个平台找”
|
||||
|
||||
单次查询通常控制在 1 个 AI 源 + 1 到 2 个专用源,避免结果过载。
|
||||
|
||||
## 处理不可用的源
|
||||
|
||||
当站点不可用时:
|
||||
|
||||
- 不要因为单个源失败而中止整个搜索
|
||||
- 记录:「已跳过:<site> 不可用」
|
||||
- 回退到同类其他站点,或回退到一个 AI 源
|
||||
- 始终以 `opencli list -f yaml` 与 `opencli <site> -h` 的实际结果为准
|
||||
|
||||
不要假设任何站点“绝对可用”。即使是公开站点,也以当前环境中的 live help 和执行结果为准。
|
||||
|
||||
## 参考文件
|
||||
|
||||
根据需要读取对应文件:
|
||||
|
||||
- **`references/sources-ai.md`** — AI 默认源
|
||||
- **`references/sources-tech.md`** — 技术 / 学术
|
||||
- **`references/sources-social.md`** — 社交媒体
|
||||
- **`references/sources-media.md`** — 媒体 / 娱乐
|
||||
- **`references/sources-info.md`** — 资讯 / 知识
|
||||
- **`references/sources-shopping.md`** — 购物
|
||||
- **`references/sources-travel.md`** — 旅游
|
||||
- **`references/sources-other.md`** — 其他垂直源
|
||||
|
||||
只读与当前查询相关的文件,无需全部加载。
|
||||
@@ -0,0 +1,74 @@
|
||||
# AI 默认源
|
||||
|
||||
当用户没有明确指定网站时,先在 `grok`、`doubao`、`gemini` 中选择一个,不要一开始并行跑多个 AI 源。
|
||||
|
||||
## 使用规则
|
||||
|
||||
1. 先运行 `opencli list -f yaml`
|
||||
2. 确认 `grok`、`doubao`、`gemini` 哪些在当前 registry 中可用
|
||||
3. 运行 `opencli <site> -h`
|
||||
4. 锁定具体子命令后,再运行 `opencli <site> <command> -h`
|
||||
|
||||
## 路由建议
|
||||
|
||||
### grok
|
||||
|
||||
- 适用:实时热点、Twitter/X 语境、英文互联网讨论、舆论与趋势
|
||||
- 补充源常见去向:`twitter`、`reddit`、`reuters`、`google`
|
||||
- 搜索词建议:
|
||||
- 加上时间范围,例如“today / this week”
|
||||
- 加上平台范围,例如“on X”, “from social posts”
|
||||
- 加上目标,例如“latest reactions”, “main viewpoints”, “key claims”
|
||||
- 示例:
|
||||
- `OpenAI latest reactions on X this week`
|
||||
- `TSLA earnings main viewpoints on social media April 2026`
|
||||
- `Nintendo Switch 2 rumors latest discussion on X`
|
||||
|
||||
### doubao
|
||||
|
||||
- 适用:中文语境、国内热点、生活方式、字节生态、中文泛问答
|
||||
- 补充源常见去向:`xiaohongshu`、`weibo`、`zhihu`、`bilibili`、`36kr`
|
||||
- 搜索词建议:
|
||||
- 加上中文场景限定,例如“中文讨论”“国内用户”“小红书/微博语境”
|
||||
- 加上需求目标,例如“帮我总结”“给我对比”“提取推荐理由”
|
||||
- 加上人群或用途,例如“适合新手”“预算 500 元内”“上海求职”
|
||||
- 示例:
|
||||
- `2026年MacBook Air值得买吗 中文讨论里主要观点是什么`
|
||||
- `上海 AI 产品经理招聘趋势 近一个月中文信息总结`
|
||||
- `敏感肌防晒推荐 中文用户常提到的优缺点`
|
||||
|
||||
### gemini
|
||||
|
||||
- 适用:全球网页、英文资料、背景综述、通用检索
|
||||
- 补充源常见去向:`google`、`wikipedia`、`arxiv`、`stackoverflow`
|
||||
- 搜索词建议:
|
||||
- 加上主题类型,例如“overview”, “comparison”, “background”, “best sources”
|
||||
- 加上范围限定,例如地区、时间、语言、行业
|
||||
- 加上结果形式,例如“with sources”, “compare pros and cons”, “official guidance”
|
||||
- 示例:
|
||||
- `MCP overview and official guidance with sources`
|
||||
- `best budget travel destinations in Japan April 2026 compare pros and cons`
|
||||
- `TypeScript decorators current status official sources`
|
||||
|
||||
## 补充原则
|
||||
|
||||
- 先用一个 AI 源拿到初步答案
|
||||
- 若答案缺少原始数据、垂直结果或权威来源,再补 1-2 个专用源
|
||||
- 不要把 AI 默认源当成命令签名的事实来源;命令细节始终以 `opencli ... -h` 为准
|
||||
|
||||
## 通用写法模板
|
||||
|
||||
可直接按下面模板构造 AI 查询:
|
||||
|
||||
- 热点/新闻:
|
||||
`<事件> + 最新进展 + <时间范围> + <地区/平台>`
|
||||
- 对比/推荐:
|
||||
`<对象A> vs <对象B> + <关注维度> + <人群/预算/用途>`
|
||||
- 中文社区:
|
||||
`<主题> + 中文讨论里主要观点 + <时间范围>`
|
||||
- 全球资料:
|
||||
`<主题> + overview/background + with sources`
|
||||
- 求职:
|
||||
`<岗位> + <城市/国家> + market trends/hiring + <时间范围>`
|
||||
- 购物:
|
||||
`<商品> + reviews/price/value + <地区/预算>`
|
||||
@@ -0,0 +1,43 @@
|
||||
# 资讯 / 知识
|
||||
|
||||
用于新闻、百科、通用知识、中文科技资讯。
|
||||
|
||||
## 站点
|
||||
|
||||
### google
|
||||
|
||||
- 适用:通用网页搜索、跨站点兜底
|
||||
- 使用前先运行:`opencli google -h`
|
||||
|
||||
### wikipedia
|
||||
|
||||
- 适用:名词解释、背景知识、历史事实
|
||||
- 使用前先运行:`opencli wikipedia -h`
|
||||
|
||||
### reuters
|
||||
|
||||
- 适用:国际新闻、事实性报道
|
||||
- 使用前先运行:`opencli reuters -h`
|
||||
|
||||
### weibo
|
||||
|
||||
- 适用:微博热点、话题、中文舆论
|
||||
- 使用前先运行:`opencli weibo -h`
|
||||
|
||||
### 36kr
|
||||
|
||||
- 适用:中文科技、创业、融资资讯
|
||||
- 使用前先运行:`opencli 36kr -h`
|
||||
|
||||
### substack
|
||||
|
||||
- 适用:newsletter、作者订阅内容、长文
|
||||
- 使用前先运行:`opencli substack -h`
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 背景知识与定义,优先 `wikipedia`
|
||||
- 通用网页与跨站信息,优先 `google`
|
||||
- 国际新闻优先 `reuters`
|
||||
- 中文科技资讯优先 `36kr`
|
||||
- 当 AI 回答过于泛化时,用这些源补充事实和链接
|
||||
@@ -0,0 +1,50 @@
|
||||
# 媒体 / 娱乐
|
||||
|
||||
用于视频、电影、剧集、音乐、播客、内容平台搜索。
|
||||
|
||||
## 站点
|
||||
|
||||
### bilibili
|
||||
|
||||
- 适用:中文视频、教程、评测、UP 主内容
|
||||
- 使用前先运行:`opencli bilibili -h`
|
||||
|
||||
### youtube
|
||||
|
||||
- 适用:英文视频、教程、评测、访谈
|
||||
- 使用前先运行:`opencli youtube -h`
|
||||
|
||||
### tiktok
|
||||
|
||||
- 适用:短视频、热点、娱乐内容
|
||||
- 使用前先运行:`opencli tiktok -h`
|
||||
|
||||
### imdb
|
||||
|
||||
- 适用:电影、剧集、演员、评分
|
||||
- 使用前先运行:`opencli imdb -h`
|
||||
|
||||
### douban
|
||||
|
||||
- 适用:中文电影、书籍、音乐口碑与评分
|
||||
- 使用前先运行:`opencli douban -h`
|
||||
|
||||
### spotify
|
||||
|
||||
- 适用:歌曲、歌手、专辑
|
||||
- 使用前先运行:`opencli spotify -h`
|
||||
|
||||
### apple-podcasts
|
||||
|
||||
- 适用:播客节目、播客搜索
|
||||
- 使用前先运行:`opencli apple-podcasts -h`
|
||||
|
||||
### medium
|
||||
|
||||
- 适用:长文、技术博客、设计与产品文章
|
||||
- 使用前先运行:`opencli medium -h`
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 用户只说“找视频/影评/播客/文章”,先按内容形态选站点
|
||||
- 用户没有指定站点时,可先用 `doubao`、`grok` 或 `gemini` 做一轮粗检索,再补媒体站点拿原始结果
|
||||
@@ -0,0 +1,42 @@
|
||||
# 其他垂直源
|
||||
|
||||
用于求职、金融、书籍、词典等不适合归入前述分类的场景。
|
||||
|
||||
## 站点
|
||||
|
||||
### linkedin
|
||||
|
||||
- 适用:全球职位、英文岗位、跨国公司招聘
|
||||
- 使用前先运行:`opencli linkedin -h`
|
||||
|
||||
### boss
|
||||
|
||||
- 适用:国内职位搜索、招聘与岗位信息
|
||||
- 使用前先运行:`opencli boss -h`
|
||||
|
||||
### xueqiu
|
||||
|
||||
- 适用:股票、金融讨论、行情相关线索
|
||||
- 使用前先运行:`opencli xueqiu -h`
|
||||
|
||||
### weread
|
||||
|
||||
- 适用:中文书籍搜索与书单线索
|
||||
- 使用前先运行:`opencli weread -h`
|
||||
|
||||
### dictionary
|
||||
|
||||
- 适用:英文词义、基础词典查询
|
||||
- 使用前先运行:`opencli dictionary -h`
|
||||
|
||||
### sinablog
|
||||
|
||||
- 适用:较旧的中文博客内容
|
||||
- 使用前先运行:`opencli sinablog -h`
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 求职:全球优先 `linkedin`,国内优先 `boss`
|
||||
- 金融:优先 `xueqiu`
|
||||
- 书籍:中文优先 `weread`
|
||||
- 英文单词定义:优先 `dictionary`
|
||||
@@ -0,0 +1,31 @@
|
||||
# 购物
|
||||
|
||||
用于商品搜索、价格、好价、口碑与区域电商结果。
|
||||
|
||||
## 站点
|
||||
|
||||
### amazon
|
||||
|
||||
- 适用:全球商品搜索、价格参考、英文电商
|
||||
- 使用前先运行:`opencli amazon -h`
|
||||
|
||||
### smzdm
|
||||
|
||||
- 适用:国内好价、优惠、导购、商品讨论
|
||||
- 使用前先运行:`opencli smzdm -h`
|
||||
|
||||
### coupang
|
||||
|
||||
- 适用:韩国电商商品搜索
|
||||
- 使用前先运行:`opencli coupang -h`
|
||||
|
||||
### douban
|
||||
|
||||
- 适用:图书、影视、音乐类口碑补充,不是标准电商,但可用于消费决策补充
|
||||
- 使用前先运行:`opencli douban -h`
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 用户指定平台时直接使用该平台
|
||||
- 未指定平台时,全球商品优先 `amazon`,国内好价优先 `smzdm`
|
||||
- 若先用 AI 做产品调研,后续可补电商站点拿实际商品结果
|
||||
@@ -0,0 +1,51 @@
|
||||
# 社交媒体
|
||||
|
||||
用于需要原始帖子、原始用户结果、中文或英文社区讨论时。
|
||||
|
||||
## 站点
|
||||
|
||||
### twitter
|
||||
|
||||
- 适用:Twitter/X 原始帖子、作者、实时讨论
|
||||
- 使用前先运行:`opencli twitter -h`
|
||||
|
||||
### weibo
|
||||
|
||||
- 适用:微博热点、话题、中文舆论
|
||||
- 使用前先运行:`opencli weibo -h`
|
||||
|
||||
### xiaohongshu
|
||||
|
||||
- 适用:生活方式、穿搭、美妆、旅行、真实体验
|
||||
- 使用前先运行:`opencli xiaohongshu -h`
|
||||
|
||||
### zhihu
|
||||
|
||||
- 适用:中文深度问答、专业解释、行业经验
|
||||
- 使用前先运行:`opencli zhihu -h`
|
||||
|
||||
### tieba
|
||||
|
||||
- 适用:兴趣圈子、历史帖子、粉丝社区
|
||||
- 使用前先运行:`opencli tieba -h`
|
||||
|
||||
### instagram
|
||||
|
||||
- 适用:账号搜索、图片社交线索
|
||||
- 使用前先运行:`opencli instagram -h`
|
||||
|
||||
### facebook
|
||||
|
||||
- 适用:主页、人物、帖子线索
|
||||
- 使用前先运行:`opencli facebook -h`
|
||||
|
||||
### bluesky
|
||||
|
||||
- 适用:Bluesky 用户与账号搜索
|
||||
- 使用前先运行:`opencli bluesky -h`
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 用户明确指定某个平台时,直接用该平台
|
||||
- 用户只说“社交媒体上怎么看”时,可先选 `grok` 或 `doubao`
|
||||
- AI 只给摘要而没有原始帖子时,再切到对应社交站点
|
||||
@@ -0,0 +1,42 @@
|
||||
# 技术 / 学术
|
||||
|
||||
用于技术问题、研究论文、开发者讨论、开源社区信息。
|
||||
|
||||
## 站点
|
||||
|
||||
### arxiv
|
||||
|
||||
- 适用:论文、研究、模型、算法、学术背景
|
||||
- 使用前先运行:`opencli arxiv -h`
|
||||
|
||||
### stackoverflow
|
||||
|
||||
- 适用:具体报错、API 用法、代码模式、实现细节
|
||||
- 使用前先运行:`opencli stackoverflow -h`
|
||||
|
||||
### hackernews
|
||||
|
||||
- 适用:技术社区讨论、开发者观点、创业和产品话题
|
||||
- 使用前先运行:`opencli hackernews -h`
|
||||
|
||||
### reddit
|
||||
|
||||
- 适用:英文社区问答、经验贴、推荐、对比讨论
|
||||
- 使用前先运行:`opencli reddit -h`
|
||||
|
||||
### linux-do
|
||||
|
||||
- 适用:人工智能、开源工具、中文技术社区
|
||||
- 使用前先运行:`opencli linux-do -h`
|
||||
|
||||
### v2ex
|
||||
|
||||
- 适用:中文技术社区
|
||||
- 使用前先运行:`opencli linux-do
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 用户明确说“论文”“研究”时,优先 `arxiv`
|
||||
- 用户明确说“报错”“API 怎么用”时,优先 `stackoverflow`
|
||||
- 用户要“社区讨论”“开发者观点”时,优先 `hackernews` 或 `reddit`
|
||||
- 若用户没有明确指定站点,可先选 `gemini` 或 `grok`,内容不足时再补这些专用源
|
||||
@@ -0,0 +1,20 @@
|
||||
# 旅游
|
||||
|
||||
用于目的地、景区、酒店联想和旅行线索。
|
||||
|
||||
## 站点
|
||||
|
||||
### ctrip
|
||||
|
||||
- 适用:目的地、景区、酒店、旅行联想搜索
|
||||
- 使用前先运行:`opencli ctrip -h`
|
||||
|
||||
### xiaohongshu
|
||||
|
||||
- 适用:生活方式、穿搭、美妆、旅行、真实体验
|
||||
- 使用前先运行:`opencli xiaohongshu -h`
|
||||
|
||||
## 路由提示
|
||||
|
||||
- 用户明确说“携程”时直接使用 `ctrip`
|
||||
- 用户只说“旅行/目的地/景区”时,可先选 `doubao` 做中文语境粗检索,再补 `ctrip`
|
||||
+1
-1
@@ -136,7 +136,7 @@ describe('BrowserBridge state', () => {
|
||||
|
||||
it('fails fast when daemon is running but extension is disconnected', async () => {
|
||||
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
|
||||
vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
|
||||
vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false } as any);
|
||||
|
||||
const bridge = new BrowserBridge();
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import { formatSnapshot } from '../snapshotFormatter.js';
|
||||
|
||||
export abstract class BasePage implements IPage {
|
||||
protected _lastUrl: string | null = null;
|
||||
/** Cached previous snapshot hashes for incremental diff marking */
|
||||
private _prevSnapshotHashes: string | null = null;
|
||||
|
||||
// ── Transport-specific methods (must be implemented by subclasses) ──
|
||||
|
||||
@@ -35,14 +37,34 @@ export abstract class BasePage implements IPage {
|
||||
abstract getCookies(opts?: { domain?: string; url?: string }): Promise<BrowserCookie[]>;
|
||||
abstract screenshot(options?: ScreenshotOptions): Promise<string>;
|
||||
abstract tabs(): Promise<unknown[]>;
|
||||
abstract closeTab(index?: number): Promise<void>;
|
||||
abstract newTab(): Promise<void>;
|
||||
abstract selectTab(index: number): Promise<void>;
|
||||
|
||||
// ── Shared DOM helper implementations ──
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.evaluate(clickJs(ref));
|
||||
const result = await this.evaluate(clickJs(ref)) as
|
||||
| string
|
||||
| { status: string; x?: number; y?: number; w?: number; h?: number; error?: string }
|
||||
| null;
|
||||
|
||||
// Backwards compat: old format returned 'clicked' string
|
||||
if (typeof result === 'string' || result == null) return;
|
||||
|
||||
// JS click succeeded
|
||||
if (result.status === 'clicked') return;
|
||||
|
||||
// JS click failed — try CDP native click if coordinates available
|
||||
if (result.x != null && result.y != null) {
|
||||
const success = await this.tryNativeClick(result.x, result.y);
|
||||
if (success) return;
|
||||
}
|
||||
|
||||
throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
|
||||
}
|
||||
|
||||
/** Override in subclasses with CDP native click support */
|
||||
protected async tryNativeClick(_x: number, _y: number): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
@@ -117,10 +139,19 @@ export abstract class BasePage implements IPage {
|
||||
maxTextLength: opts.maxTextLength ?? 120,
|
||||
includeScrollInfo: true,
|
||||
bboxDedup: true,
|
||||
previousHashes: this._prevSnapshotHashes,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.evaluate(snapshotJs);
|
||||
const result = await this.evaluate(snapshotJs);
|
||||
// Read back the hashes stored by the snapshot for next diff
|
||||
try {
|
||||
const hashes = await this.evaluate('window.__opencli_prev_hashes') as string | null;
|
||||
this._prevSnapshotHashes = typeof hashes === 'string' ? hashes : null;
|
||||
} catch {
|
||||
// Non-fatal: diff is best-effort
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return this._basicSnapshot(opts);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
|
||||
import type { IPage } from '../types.js';
|
||||
import type { IBrowserFactory } from '../runtime.js';
|
||||
import { Page } from './page.js';
|
||||
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
||||
import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
|
||||
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
||||
@@ -60,11 +60,14 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
||||
const timeoutMs = effectiveSeconds * 1000;
|
||||
|
||||
// Single status check instead of two separate fetchDaemonStatus() calls
|
||||
const status = await fetchDaemonStatus();
|
||||
|
||||
// Fast path: extension already connected
|
||||
if (await isExtensionConnected()) return;
|
||||
if (status?.extensionConnected) return;
|
||||
|
||||
// Daemon running but no extension — wait for extension with progress
|
||||
if (await isDaemonRunning()) {
|
||||
if (status !== null) {
|
||||
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');
|
||||
@@ -110,7 +113,7 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
|
||||
if (await isDaemonRunning()) {
|
||||
if ((await fetchDaemonStatus()) !== null) {
|
||||
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.',
|
||||
|
||||
+1
-8
@@ -223,14 +223,6 @@ class CDPPage extends BasePage {
|
||||
return [];
|
||||
}
|
||||
|
||||
async closeTab(_index?: number): Promise<void> {
|
||||
// Not supported in direct CDP mode
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await this.bridge.send('Target.createTarget', { url: 'about:blank' });
|
||||
}
|
||||
|
||||
async selectTab(_index: number): Promise<void> {
|
||||
// Not supported in direct CDP mode
|
||||
}
|
||||
@@ -274,6 +266,7 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
|
||||
|
||||
if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY;
|
||||
if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY;
|
||||
if (type === 'background_page' || type === 'service_worker') return Number.NEGATIVE_INFINITY;
|
||||
|
||||
let score = 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
fetchDaemonStatus,
|
||||
isDaemonRunning,
|
||||
isExtensionConnected,
|
||||
requestDaemonShutdown,
|
||||
} from './daemon-client.js';
|
||||
|
||||
describe('daemon-client', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => {
|
||||
const status = {
|
||||
ok: true,
|
||||
pid: 123,
|
||||
uptime: 10,
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.2.3',
|
||||
pending: 0,
|
||||
lastCliRequestTime: Date.now(),
|
||||
memoryMB: 32,
|
||||
port: 19825,
|
||||
};
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(status),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchDaemonStatus()).resolves.toEqual(status);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/status$/),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fetchDaemonStatus returns null on network failure', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(fetchDaemonStatus()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('requestDaemonShutdown POSTs to the shared shutdown endpoint', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
await expect(requestDaemonShutdown()).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/shutdown$/),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('isDaemonRunning reflects shared status availability', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
pid: 123,
|
||||
uptime: 10,
|
||||
extensionConnected: false,
|
||||
pending: 0,
|
||||
lastCliRequestTime: Date.now(),
|
||||
memoryMB: 16,
|
||||
port: 19825,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(isDaemonRunning()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('isExtensionConnected reflects shared status payload', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
pid: 123,
|
||||
uptime: 10,
|
||||
extensionConnected: false,
|
||||
pending: 0,
|
||||
lastCliRequestTime: Date.now(),
|
||||
memoryMB: 16,
|
||||
port: 19825,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(isExtensionConnected()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { isTransientBrowserError } from './errors.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}`;
|
||||
const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
@@ -20,7 +21,7 @@ function generateId(): string {
|
||||
|
||||
export interface DaemonCommand {
|
||||
id: string;
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input';
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
|
||||
tabId?: number;
|
||||
code?: string;
|
||||
workspace?: string;
|
||||
@@ -28,13 +29,22 @@ export interface DaemonCommand {
|
||||
op?: string;
|
||||
index?: number;
|
||||
domain?: string;
|
||||
matchDomain?: string;
|
||||
matchPathPrefix?: string;
|
||||
format?: 'png' | 'jpeg';
|
||||
quality?: number;
|
||||
fullPage?: boolean;
|
||||
|
||||
/** Local file paths for set-file-input action */
|
||||
files?: string[];
|
||||
/** CSS selector for file input element (set-file-input action) */
|
||||
selector?: string;
|
||||
/** Raw text payload for insert-text action */
|
||||
text?: string;
|
||||
/** URL substring filter pattern for network capture */
|
||||
pattern?: string;
|
||||
cdpMethod?: string;
|
||||
cdpParams?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DaemonResult {
|
||||
@@ -44,42 +54,65 @@ export interface DaemonResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running.
|
||||
*/
|
||||
export async function isDaemonRunning(): Promise<boolean> {
|
||||
export interface DaemonStatus {
|
||||
ok: boolean;
|
||||
pid: number;
|
||||
uptime: number;
|
||||
extensionConnected: boolean;
|
||||
extensionVersion?: string;
|
||||
pending: number;
|
||||
lastCliRequestTime: number;
|
||||
memoryMB: number;
|
||||
port: number;
|
||||
}
|
||||
|
||||
async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> {
|
||||
const { timeout = 2000, headers, ...rest } = init ?? {};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`${DAEMON_URL}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
return await fetch(`${DAEMON_URL}${pathname}`, {
|
||||
...rest,
|
||||
headers: { ...OPENCLI_HEADERS, ...headers },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDaemonStatus(opts?: { timeout?: number }): Promise<DaemonStatus | null> {
|
||||
try {
|
||||
const res = await requestDaemon('/status', { timeout: opts?.timeout ?? 2000 });
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as DaemonStatus;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> {
|
||||
try {
|
||||
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running.
|
||||
*/
|
||||
export async function isDaemonRunning(): Promise<boolean> {
|
||||
return (await fetchDaemonStatus()) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running AND the extension is connected.
|
||||
*/
|
||||
export async function isExtensionConnected(): Promise<boolean> {
|
||||
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 false;
|
||||
const data = await res.json() as { extensionConnected?: boolean };
|
||||
return !!data.extensionConnected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const status = await fetchDaemonStatus();
|
||||
return !!status?.extensionConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,16 +131,12 @@ export async function sendCommand(
|
||||
const id = generateId();
|
||||
const command: DaemonCommand = { id, action, ...params };
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const res = await fetch(`${DAEMON_URL}/command`, {
|
||||
const res = await requestDaemon('/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: controller.signal,
|
||||
timeout: 30000,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
const result = (await res.json()) as DaemonResult;
|
||||
|
||||
@@ -140,3 +169,7 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
|
||||
const result = await sendCommand('sessions');
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> {
|
||||
return sendCommand('bind-current', { workspace, ...opts });
|
||||
}
|
||||
|
||||
+8
-17
@@ -2,8 +2,7 @@
|
||||
* Daemon discovery — checks if the daemon is running.
|
||||
*/
|
||||
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import { isDaemonRunning } from './daemon-client.js';
|
||||
import { fetchDaemonStatus, isDaemonRunning } from './daemon-client.js';
|
||||
|
||||
export { isDaemonRunning };
|
||||
|
||||
@@ -15,21 +14,13 @@ export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
|
||||
extensionConnected: boolean;
|
||||
extensionVersion?: string;
|
||||
}> {
|
||||
try {
|
||||
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
|
||||
return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} catch {
|
||||
const status = await fetchDaemonStatus({ timeout: opts?.timeout ?? 2000 });
|
||||
if (!status) {
|
||||
return { running: false, extensionConnected: false };
|
||||
}
|
||||
return {
|
||||
running: true,
|
||||
extensionConnected: status.extensionConnected,
|
||||
extensionVersion: status.extensionVersion,
|
||||
};
|
||||
}
|
||||
|
||||
+50
-38
@@ -5,64 +5,76 @@
|
||||
* to eliminate code duplication for click, type, press, wait, scroll, etc.
|
||||
*/
|
||||
|
||||
/** Generate JS to click an element by ref */
|
||||
/** Shared element lookup JS fragment (4-strategy resolution) */
|
||||
function resolveElementJs(safeRef: string, selectorSet: string): string {
|
||||
return `
|
||||
const ref = ${safeRef};
|
||||
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
|
||||
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
|
||||
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
|
||||
try { el = document.querySelector(ref); } catch {}
|
||||
}
|
||||
if (!el) {
|
||||
const idx = parseInt(ref, 10);
|
||||
if (!isNaN(idx)) {
|
||||
el = document.querySelectorAll('${selectorSet}')[idx];
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
/** Generate JS to click an element by ref.
|
||||
* Returns { status, x, y, w, h } for CDP fallback when JS click fails. */
|
||||
export function clickJs(ref: string): string {
|
||||
const safeRef = JSON.stringify(ref);
|
||||
return `
|
||||
(() => {
|
||||
const ref = ${safeRef};
|
||||
// 1. data-opencli-ref (set by snapshot engine)
|
||||
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
|
||||
// 2. data-ref (legacy)
|
||||
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
|
||||
// 3. CSS selector
|
||||
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
|
||||
try { el = document.querySelector(ref); } catch {}
|
||||
}
|
||||
// 4. Numeric index into interactive elements
|
||||
if (!el) {
|
||||
const idx = parseInt(ref, 10);
|
||||
if (!isNaN(idx)) {
|
||||
el = document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')[idx];
|
||||
}
|
||||
}
|
||||
${resolveElementJs(safeRef, 'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')}
|
||||
if (!el) throw new Error('Element not found: ' + ref);
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
el.click();
|
||||
return 'clicked';
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = Math.round(rect.left + rect.width / 2);
|
||||
const y = Math.round(rect.top + rect.height / 2);
|
||||
try {
|
||||
el.click();
|
||||
return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) };
|
||||
} catch (e) {
|
||||
return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message };
|
||||
}
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
/** Generate JS to type text into an element by ref */
|
||||
/** Generate JS to type text into an element by ref.
|
||||
* Uses native setter for React compat + execCommand for contenteditable. */
|
||||
export function typeTextJs(ref: string, text: string): string {
|
||||
const safeRef = JSON.stringify(ref);
|
||||
const safeText = JSON.stringify(text);
|
||||
return `
|
||||
(() => {
|
||||
const ref = ${safeRef};
|
||||
// 1. data-opencli-ref (set by snapshot engine)
|
||||
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
|
||||
// 2. data-ref (legacy)
|
||||
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
|
||||
// 3. CSS selector
|
||||
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
|
||||
try { el = document.querySelector(ref); } catch {}
|
||||
}
|
||||
// 4. Numeric index into typeable elements
|
||||
if (!el) {
|
||||
const idx = parseInt(ref, 10);
|
||||
if (!isNaN(idx)) {
|
||||
el = document.querySelectorAll('input, textarea, [contenteditable="true"]')[idx];
|
||||
}
|
||||
}
|
||||
${resolveElementJs(safeRef, 'input, textarea, [contenteditable="true"]')}
|
||||
if (!el) throw new Error('Element not found: ' + ref);
|
||||
el.focus();
|
||||
if (el.isContentEditable) {
|
||||
el.textContent = ${safeText};
|
||||
// Select all content + delete, then insert (supports undo, works with rich text editors)
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.execCommand('delete', false);
|
||||
document.execCommand('insertText', false, ${safeText});
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
el.value = ${safeText};
|
||||
// Use native setter for React/framework compatibility (match element type)
|
||||
const proto = el instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(el, ${safeText});
|
||||
} else {
|
||||
el.value = ${safeText};
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
@@ -377,6 +377,8 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
||||
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true;
|
||||
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true;
|
||||
// Framework event listener detection (React/Vue/Angular onClick)
|
||||
if (hasFrameworkListener(el)) return true;
|
||||
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {}
|
||||
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true;
|
||||
// Search element heuristic detection
|
||||
@@ -384,6 +386,25 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasFrameworkListener(el) {
|
||||
try {
|
||||
// React: __reactProps$xxx / __reactEvents$xxx with onClick/onMouseDown
|
||||
for (const key of Object.keys(el)) {
|
||||
if (key.startsWith('__reactProps$') || key.startsWith('__reactEvents$')) {
|
||||
const props = el[key];
|
||||
if (props && (props.onClick || props.onMouseDown || props.onPointerDown)) return true;
|
||||
}
|
||||
}
|
||||
// Vue 3: _vei (Vue Event Invoker) with onClick
|
||||
if (el._vei && (el._vei.onClick || el._vei.click || el._vei.onMousedown)) return true;
|
||||
// Vue 2: __vue__ instance with $listeners
|
||||
if (el.__vue__?.$listeners?.click) return true;
|
||||
// Angular: ng-reflect-click binding
|
||||
if (el.hasAttribute('ng-reflect-click')) return true;
|
||||
} catch { /* ignore errors from cross-origin or frozen objects */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSearchElement(el) {
|
||||
// Check class names for search indicators
|
||||
const className = el.className?.toLowerCase() || '';
|
||||
|
||||
+163
-32
@@ -58,25 +58,17 @@ export class Page extends BasePage {
|
||||
this._tabId = result.tabId;
|
||||
}
|
||||
this._lastUrl = url;
|
||||
// Inject stealth anti-detection patches (guard flag prevents double-injection).
|
||||
try {
|
||||
await sendCommand('exec', {
|
||||
code: generateStealthJs(),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
// Smart settle: use DOM stability detection instead of fixed sleep.
|
||||
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
||||
// Inject stealth + settle in a single round-trip instead of two sequential exec calls.
|
||||
// The stealth guard flag prevents double-injection; settle uses DOM stability detection.
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const maxMs = options?.settleMs ?? 1000;
|
||||
const settleOpts = {
|
||||
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
||||
const combinedCode = `${generateStealthJs()};\n${waitForDomStableJs(maxMs, Math.min(500, maxMs))}`;
|
||||
const combinedOpts = {
|
||||
code: combinedCode,
|
||||
...this._cmdOpts(),
|
||||
};
|
||||
try {
|
||||
await sendCommand('exec', settleOpts);
|
||||
await sendCommand('exec', combinedOpts);
|
||||
} catch (err) {
|
||||
if (!isRetryableSettleError(err)) throw err;
|
||||
// SPA client-side redirects can invalidate the CDP target after
|
||||
@@ -84,14 +76,21 @@ export class Page extends BasePage {
|
||||
// to load, then retry the settle probe once.
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await sendCommand('exec', settleOpts);
|
||||
await sendCommand('exec', combinedOpts);
|
||||
} catch (retryErr) {
|
||||
if (!isRetryableSettleError(retryErr)) throw retryErr;
|
||||
// Retry also failed — give up silently. Settle is best-effort
|
||||
// after successful navigation; the next real command will surface
|
||||
// any persistent target error immediately.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Even with waitUntil='none', still inject stealth (best-effort)
|
||||
try {
|
||||
await sendCommand('exec', {
|
||||
code: generateStealthJs(),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: stealth is best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +120,9 @@ export class Page extends BasePage {
|
||||
await sendCommand('close-window', { ...this._wsOpt() });
|
||||
} catch {
|
||||
// Window may already be closed or daemon may be down
|
||||
} finally {
|
||||
this._tabId = undefined;
|
||||
this._lastUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,18 +131,6 @@ export class Page extends BasePage {
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
|
||||
// Invalidate cached tabId — the closed tab might have been our active one.
|
||||
// We can't know for sure (close-by-index doesn't return tabId), so reset.
|
||||
this._tabId = undefined;
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
|
||||
if (result?.tabId) this._tabId = result.tabId;
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
|
||||
if (result?.selected) this._tabId = result.selected;
|
||||
@@ -164,6 +154,19 @@ export class Page extends BasePage {
|
||||
return base64;
|
||||
}
|
||||
|
||||
async startNetworkCapture(pattern: string = ''): Promise<void> {
|
||||
await sendCommand('network-capture-start', {
|
||||
pattern,
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
}
|
||||
|
||||
async readNetworkCapture(): Promise<unknown[]> {
|
||||
const result = await sendCommand('network-capture-read', {
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
/**
|
||||
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
||||
* Chrome reads the files directly from the local filesystem, avoiding the
|
||||
@@ -179,6 +182,134 @@ export class Page extends BasePage {
|
||||
throw new Error('setFileInput returned no count — command may not be supported by the extension');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (End of file)
|
||||
async insertText(text: string): Promise<void> {
|
||||
const result = await sendCommand('insert-text', {
|
||||
text,
|
||||
...this._cmdOpts(),
|
||||
}) as { inserted?: boolean };
|
||||
if (!result?.inserted) {
|
||||
throw new Error('insertText returned no inserted flag — command may not be supported by the extension');
|
||||
}
|
||||
}
|
||||
|
||||
async cdp(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
return sendCommand('cdp', {
|
||||
cdpMethod: method,
|
||||
cdpParams: params,
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
}
|
||||
|
||||
/** CDP native click fallback — called when JS el.click() fails */
|
||||
protected override async tryNativeClick(x: number, y: number): Promise<boolean> {
|
||||
try {
|
||||
await this.nativeClick(x, y);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Precise click using DOM.getContentQuads/getBoxModel for inline elements */
|
||||
async clickWithQuads(ref: string): Promise<void> {
|
||||
const safeRef = JSON.stringify(ref);
|
||||
const cssSelector = `[data-opencli-ref="${ref.replace(/"/g, '\\"')}"]`;
|
||||
|
||||
// Scroll element into view first
|
||||
await this.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
return !!el;
|
||||
})()
|
||||
`);
|
||||
|
||||
try {
|
||||
// Find DOM node via CDP
|
||||
const doc = await this.cdp('DOM.getDocument', {}) as { root: { nodeId: number } };
|
||||
const result = await this.cdp('DOM.querySelectorAll', {
|
||||
nodeId: doc.root.nodeId,
|
||||
selector: cssSelector,
|
||||
}) as { nodeIds: number[] };
|
||||
|
||||
if (!result.nodeIds?.length) throw new Error('DOM node not found');
|
||||
|
||||
const nodeId = result.nodeIds[0];
|
||||
|
||||
// Try getContentQuads first (precise for inline elements)
|
||||
try {
|
||||
const quads = await this.cdp('DOM.getContentQuads', { nodeId }) as { quads: number[][] };
|
||||
if (quads.quads?.length) {
|
||||
const q = quads.quads[0];
|
||||
const cx = (q[0] + q[2] + q[4] + q[6]) / 4;
|
||||
const cy = (q[1] + q[3] + q[5] + q[7]) / 4;
|
||||
await this.nativeClick(Math.round(cx), Math.round(cy));
|
||||
return;
|
||||
}
|
||||
} catch { /* fallthrough */ }
|
||||
|
||||
// Try getBoxModel
|
||||
try {
|
||||
const box = await this.cdp('DOM.getBoxModel', { nodeId }) as { model: { content: number[] } };
|
||||
if (box.model?.content) {
|
||||
const c = box.model.content;
|
||||
const cx = (c[0] + c[2] + c[4] + c[6]) / 4;
|
||||
const cy = (c[1] + c[3] + c[5] + c[7]) / 4;
|
||||
await this.nativeClick(Math.round(cx), Math.round(cy));
|
||||
return;
|
||||
}
|
||||
} catch { /* fallthrough */ }
|
||||
} catch { /* fallthrough */ }
|
||||
|
||||
// Final fallback: regular click
|
||||
await this.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]');
|
||||
if (!el) throw new Error('Element not found: ' + ${safeRef});
|
||||
el.click();
|
||||
return 'clicked';
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
async nativeClick(x: number, y: number): Promise<void> {
|
||||
await this.cdp('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x, y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
});
|
||||
await this.cdp('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x, y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async nativeType(text: string): Promise<void> {
|
||||
// Use Input.insertText for reliable Unicode/CJK text insertion
|
||||
await this.cdp('Input.insertText', { text });
|
||||
}
|
||||
|
||||
async nativeKeyPress(key: string, modifiers: string[] = []): Promise<void> {
|
||||
let modifierFlags = 0;
|
||||
for (const mod of modifiers) {
|
||||
if (mod === 'Alt') modifierFlags |= 1;
|
||||
if (mod === 'Ctrl') modifierFlags |= 2;
|
||||
if (mod === 'Meta') modifierFlags |= 4;
|
||||
if (mod === 'Shift') modifierFlags |= 8;
|
||||
}
|
||||
await this.cdp('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key,
|
||||
modifiers: modifierFlags,
|
||||
});
|
||||
await this.cdp('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key,
|
||||
modifiers: modifierFlags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ManifestEntry {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
valueRequired?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
@@ -62,6 +63,7 @@ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
|
||||
type: arg.type ?? 'str',
|
||||
default: arg.default,
|
||||
required: !!arg.required,
|
||||
valueRequired: !!arg.valueRequired || undefined,
|
||||
positional: arg.positional || undefined,
|
||||
help: arg.help ?? '',
|
||||
choices: arg.choices,
|
||||
|
||||
+408
@@ -18,6 +18,13 @@ import { registerAllCommands } from './commanderAdapter.js';
|
||||
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
||||
import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
|
||||
|
||||
/** Create a browser page for operate commands. Uses 'operate' workspace for session persistence. */
|
||||
async function getOperatePage(): Promise<import('./types.js').IPage> {
|
||||
const { BrowserBridge } = await import('./browser/index.js');
|
||||
const bridge = new BrowserBridge();
|
||||
return bridge.connect({ timeout: 30, workspace: 'operate:default' });
|
||||
}
|
||||
|
||||
export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const program = new Command();
|
||||
// enablePositionalOptions: prevents parent from consuming flags meant for subcommands;
|
||||
@@ -229,6 +236,407 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log(renderCascadeResult(result));
|
||||
});
|
||||
|
||||
// ── Built-in: operate (browser control for Claude Code skill) ───────────────
|
||||
//
|
||||
// Make websites accessible for AI agents.
|
||||
// All commands wrapped in operateAction() for consistent error handling.
|
||||
|
||||
const operate = program
|
||||
.command('operate')
|
||||
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
||||
|
||||
/** Wrap operate actions with error handling and optional --json output */
|
||||
function operateAction(fn: (page: Awaited<ReturnType<typeof getOperatePage>>, ...args: any[]) => Promise<unknown>) {
|
||||
return async (...args: any[]) => {
|
||||
try {
|
||||
const page = await getOperatePage();
|
||||
await fn(page, ...args);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
|
||||
console.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
|
||||
} else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
|
||||
console.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
|
||||
} else {
|
||||
console.error(`Error: ${msg}`);
|
||||
}
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
|
||||
/** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */
|
||||
const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length<M){var b=null;if(t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),method:(arguments[1]&&arguments[1].method)||'GET',status:r.status,size:t.length,ct:ct,body:b})}}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if((ct.includes('json')||ct.includes('text'))&&window.__opencli_net.length<M){var t=x.responseText,b=null;if(t&&t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:x._ou,method:x._om||'GET',status:x.status,size:t?t.length:0,ct:ct,body:b})}}catch(e){}});return S.apply(this,arguments)}})()`;
|
||||
|
||||
operate.command('open').argument('<url>').description('Open URL in automation window')
|
||||
.action(operateAction(async (page, url) => {
|
||||
await page.goto(url);
|
||||
await page.wait(2);
|
||||
// Auto-inject network interceptor for API discovery
|
||||
try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
|
||||
console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`);
|
||||
}));
|
||||
|
||||
operate.command('back').description('Go back in browser history')
|
||||
.action(operateAction(async (page) => {
|
||||
await page.evaluate('history.back()');
|
||||
await page.wait(2);
|
||||
console.log('Navigated back');
|
||||
}));
|
||||
|
||||
operate.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500')
|
||||
.description('Scroll page')
|
||||
.action(operateAction(async (page, direction, opts) => {
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
console.error(`Invalid direction "${direction}". Use "up" or "down".`);
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
await page.scroll(direction, parseInt(opts.amount, 10));
|
||||
console.log(`Scrolled ${direction}`);
|
||||
}));
|
||||
|
||||
// ── Inspect ──
|
||||
|
||||
operate.command('state').description('Page state: URL, title, interactive elements with [N] indices')
|
||||
.action(operateAction(async (page) => {
|
||||
const snapshot = await page.snapshot({ viewportExpand: 800 });
|
||||
const url = await page.getCurrentUrl?.() ?? '';
|
||||
console.log(`URL: ${url}\n`);
|
||||
console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
|
||||
}));
|
||||
|
||||
operate.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')
|
||||
.description('Take screenshot')
|
||||
.action(operateAction(async (page, path) => {
|
||||
if (path) {
|
||||
await page.screenshot({ path });
|
||||
console.log(`Screenshot saved to: ${path}`);
|
||||
} else {
|
||||
console.log(await page.screenshot({ format: 'png' }));
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Get commands (structured data extraction) ──
|
||||
|
||||
const get = operate.command('get').description('Get page properties');
|
||||
|
||||
get.command('title').description('Page title')
|
||||
.action(operateAction(async (page) => {
|
||||
console.log(await page.evaluate('document.title'));
|
||||
}));
|
||||
|
||||
get.command('url').description('Current page URL')
|
||||
.action(operateAction(async (page) => {
|
||||
console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
|
||||
}));
|
||||
|
||||
get.command('text').argument('<index>', 'Element index').description('Element text content')
|
||||
.action(operateAction(async (page, index) => {
|
||||
const text = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.textContent?.trim()`);
|
||||
console.log(text ?? '(empty)');
|
||||
}));
|
||||
|
||||
get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
|
||||
.action(operateAction(async (page, index) => {
|
||||
const val = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.value`);
|
||||
console.log(val ?? '(empty)');
|
||||
}));
|
||||
|
||||
get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
|
||||
.action(operateAction(async (page, opts) => {
|
||||
const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
|
||||
const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`);
|
||||
console.log(html ?? '(empty)');
|
||||
}));
|
||||
|
||||
get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
|
||||
.action(operateAction(async (page, index) => {
|
||||
const attrs = await page.evaluate(`JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="${index}"]')?.attributes].map(a=>[a.name,a.value])))`);
|
||||
console.log(attrs ?? '{}');
|
||||
}));
|
||||
|
||||
// ── Interact ──
|
||||
|
||||
operate.command('click').argument('<index>', 'Element index from state').description('Click element by index')
|
||||
.action(operateAction(async (page, index) => {
|
||||
await page.click(index);
|
||||
console.log(`Clicked element [${index}]`);
|
||||
}));
|
||||
|
||||
operate.command('type').argument('<index>', 'Element index').argument('<text>', 'Text to type')
|
||||
.description('Click element, then type text')
|
||||
.action(operateAction(async (page, index, text) => {
|
||||
await page.click(index);
|
||||
await page.wait(0.3);
|
||||
await page.typeText(index, text);
|
||||
// Detect autocomplete/combobox fields and wait for dropdown suggestions
|
||||
const isAutocomplete = await page.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector('[data-opencli-ref="${index}"]');
|
||||
if (!el) return false;
|
||||
const role = el.getAttribute('role');
|
||||
const ac = el.getAttribute('aria-autocomplete');
|
||||
const list = el.getAttribute('list');
|
||||
return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
|
||||
})()
|
||||
`);
|
||||
if (isAutocomplete) {
|
||||
await page.wait(0.4);
|
||||
console.log(`Typed "${text}" into autocomplete [${index}] — use state to see suggestions`);
|
||||
} else {
|
||||
console.log(`Typed "${text}" into element [${index}]`);
|
||||
}
|
||||
}));
|
||||
|
||||
operate.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
|
||||
.description('Select dropdown option')
|
||||
.action(operateAction(async (page, index, option) => {
|
||||
const result = await page.evaluate(`
|
||||
(function() {
|
||||
var sel = document.querySelector('[data-opencli-ref="${index}"]');
|
||||
if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
|
||||
var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
|
||||
if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
|
||||
var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||
if (setter) setter.call(sel, match.value); else sel.value = match.value;
|
||||
sel.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
sel.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return { selected: match.text };
|
||||
})()
|
||||
`) as { error?: string; selected?: string; available?: string[] } | null;
|
||||
if (result?.error) {
|
||||
console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
} else {
|
||||
console.log(`Selected "${result?.selected}" in element [${index}]`);
|
||||
}
|
||||
}));
|
||||
|
||||
operate.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)')
|
||||
.description('Press keyboard key')
|
||||
.action(operateAction(async (page, key) => {
|
||||
await page.pressKey(key);
|
||||
console.log(`Pressed: ${key}`);
|
||||
}));
|
||||
|
||||
// ── Wait commands ──
|
||||
|
||||
operate.command('wait')
|
||||
.argument('<type>', 'selector, text, or time')
|
||||
.argument('[value]', 'CSS selector, text string, or seconds')
|
||||
.option('--timeout <ms>', 'Timeout in milliseconds', '10000')
|
||||
.description('Wait for selector, text, or time (e.g. wait selector ".loaded", wait text "Success", wait time 3)')
|
||||
.action(operateAction(async (page, type, value, opts) => {
|
||||
const timeout = parseInt(opts.timeout, 10);
|
||||
if (type === 'time') {
|
||||
const seconds = parseFloat(value ?? '2');
|
||||
await page.wait(seconds);
|
||||
console.log(`Waited ${seconds}s`);
|
||||
} else if (type === 'selector') {
|
||||
if (!value) { console.error('Missing CSS selector'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
|
||||
await page.wait({ selector: value, timeout: timeout / 1000 });
|
||||
console.log(`Element "${value}" appeared`);
|
||||
} else if (type === 'text') {
|
||||
if (!value) { console.error('Missing text'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
|
||||
await page.wait({ text: value, timeout: timeout / 1000 });
|
||||
console.log(`Text "${value}" appeared`);
|
||||
} else {
|
||||
console.error(`Unknown wait type "${type}". Use: selector, text, or time`);
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Extract ──
|
||||
|
||||
operate.command('eval').argument('<js>', 'JavaScript code').description('Execute JS in page context, return result')
|
||||
.action(operateAction(async (page, js) => {
|
||||
const result = await page.evaluate(js);
|
||||
if (typeof result === 'string') console.log(result);
|
||||
else console.log(JSON.stringify(result, null, 2));
|
||||
}));
|
||||
|
||||
// ── Network (API discovery) ──
|
||||
|
||||
operate.command('network')
|
||||
.option('--detail <index>', 'Show full response body of request at index')
|
||||
.option('--all', 'Show all requests including static resources')
|
||||
.description('Show captured network requests (auto-captured since last open)')
|
||||
.action(operateAction(async (page, opts) => {
|
||||
const requests = await page.evaluate(`(function(){
|
||||
var reqs = window.__opencli_net || [];
|
||||
return JSON.stringify(reqs);
|
||||
})()`) as string;
|
||||
|
||||
let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = [];
|
||||
try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "operate open <url>" first.'); return; }
|
||||
|
||||
if (items.length === 0) { console.log('No requests captured.'); return; }
|
||||
|
||||
// Filter out static resources unless --all
|
||||
if (!opts.all) {
|
||||
items = items.filter(r =>
|
||||
(r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
|
||||
!/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
|
||||
!/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url)
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.detail !== undefined) {
|
||||
const idx = parseInt(opts.detail, 10);
|
||||
const req = items[idx];
|
||||
if (!req) { console.error(`Request #${idx} not found. ${items.length} requests available.`); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
|
||||
console.log('---');
|
||||
console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
|
||||
} else {
|
||||
console.log(`Captured ${items.length} API requests:\n`);
|
||||
items.forEach((r, i) => {
|
||||
const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
|
||||
console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
|
||||
if (bodyPreview) console.log(` ${bodyPreview}...`);
|
||||
});
|
||||
console.log(`\nUse --detail <index> to see full response body.`);
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Init (adapter scaffolding) ──
|
||||
|
||||
operate.command('init')
|
||||
.argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
|
||||
.description('Generate adapter scaffold in ~/.opencli/clis/')
|
||||
.action(async (name: string) => {
|
||||
try {
|
||||
const parts = name.split('/');
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
console.error('Name must be site/command format (e.g. hn/top)');
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
const [site, command] = parts;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
|
||||
console.error('Name parts must be alphanumeric/dash/underscore only');
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
const os = await import('node:os');
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.join(os.homedir(), '.opencli', 'clis', site);
|
||||
const filePath = path.join(dir, `${command}.ts`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(`Adapter already exists: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to detect domain from last operate session
|
||||
let domain = site;
|
||||
try {
|
||||
const page = await getOperatePage();
|
||||
const url = await page.getCurrentUrl?.();
|
||||
if (url) { try { domain = new URL(url).hostname; } catch {} }
|
||||
} catch { /* no active session */ }
|
||||
|
||||
const template = `import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: '${site}',
|
||||
name: '${command}',
|
||||
description: '', // TODO: describe what this command does
|
||||
domain: '${domain}',
|
||||
strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction)
|
||||
browser: false, // TODO: set true if needs browser
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
|
||||
],
|
||||
columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
|
||||
func: async (page, kwargs) => {
|
||||
// TODO: implement data fetching
|
||||
// Prefer API calls (fetch) over browser automation
|
||||
// page is available if browser: true
|
||||
return [];
|
||||
},
|
||||
});
|
||||
`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, template, 'utf-8');
|
||||
console.log(`Created: ${filePath}`);
|
||||
console.log(`Edit the file to implement your adapter, then run: opencli operate verify ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Verify (test adapter) ──
|
||||
|
||||
operate.command('verify')
|
||||
.argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
|
||||
.description('Execute an adapter and show results')
|
||||
.action(async (name: string) => {
|
||||
try {
|
||||
const parts = name.split('/');
|
||||
if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
|
||||
const [site, command] = parts;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
|
||||
console.error('Name parts must be alphanumeric/dash/underscore only');
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
const { execSync } = await import('node:child_process');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.ts`);
|
||||
|
||||
const fs = await import('node:fs');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Adapter not found: ${filePath}`);
|
||||
console.error(`Run "opencli operate init ${name}" to create it.`);
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔍 Verifying ${name}...\n`);
|
||||
console.log(` Loading: ${filePath}`);
|
||||
|
||||
try {
|
||||
const output = execSync(`node dist/main.js ${site} ${command} --limit 3`, {
|
||||
cwd: path.join(path.dirname(import.meta.url.replace('file://', '')), '..'),
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
|
||||
console.log(output);
|
||||
console.log(`\n ✓ Adapter works!`);
|
||||
} catch (err: any) {
|
||||
console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
|
||||
if (err.stdout) console.log(err.stdout);
|
||||
if (err.stderr) console.error(err.stderr.slice(0, 500));
|
||||
console.log(`\n ✗ Adapter failed. Fix the code and try again.`);
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Session ──
|
||||
|
||||
operate.command('close').description('Close the automation window')
|
||||
.action(operateAction(async (page) => {
|
||||
await page.closeWindow?.();
|
||||
console.log('Automation window closed');
|
||||
}));
|
||||
|
||||
// ── Built-in: doctor / completion ──────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
+32
-12
@@ -149,33 +149,53 @@ function getTurnsScript(): string {
|
||||
return '';
|
||||
};
|
||||
|
||||
const extractText = (root) => {
|
||||
const selectors = [
|
||||
'[data-testid="message_text_content"]',
|
||||
'[data-testid="message_content"]',
|
||||
'[data-testid*="message_text"]',
|
||||
'[data-testid*="message_content"]',
|
||||
'[class*="message-text"]',
|
||||
'[class*="message-content"]',
|
||||
];
|
||||
const messageTextSelectors = [
|
||||
'[data-testid="message_text_content"]',
|
||||
'[data-testid="message_content"]',
|
||||
'[data-testid*="message_text"]',
|
||||
'[data-testid*="message_content"]',
|
||||
'[class*="message-text"]',
|
||||
'[class*="message-content"]',
|
||||
];
|
||||
const messageImageSelector = messageTextSelectors.map((s) => s + ' img').join(', ');
|
||||
|
||||
const extractTextChunks = (root) => {
|
||||
const chunks = [];
|
||||
const seen = new Set();
|
||||
for (const selector of selectors) {
|
||||
for (const selector of messageTextSelectors) {
|
||||
const nodes = Array.from(root.querySelectorAll(selector))
|
||||
.filter((el) => isVisible(el))
|
||||
.map((el) => clean(el.innerText || el.textContent || ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const nodeText of nodes) {
|
||||
if (seen.has(nodeText)) continue;
|
||||
seen.add(nodeText);
|
||||
chunks.push(nodeText);
|
||||
}
|
||||
|
||||
if (chunks.length > 0) break;
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
if (chunks.length > 0) return clean(chunks.join('\\n'));
|
||||
return clean(root.innerText || root.textContent || '');
|
||||
const extractImageLines = (root) => Array.from(root.querySelectorAll(messageImageSelector))
|
||||
.filter((el) => el instanceof HTMLImageElement && isVisible(el))
|
||||
.map((el) => {
|
||||
const width = el.naturalWidth || el.width || 0;
|
||||
const height = el.naturalHeight || el.height || 0;
|
||||
if (width > 0 && height > 0 && width <= 48 && height <= 48) return '';
|
||||
const url = clean(el.currentSrc || el.src || '');
|
||||
return /^https?:\\/\\//i.test(url) ? 'Image: ' + url : '';
|
||||
})
|
||||
.filter((line, index, items) => Boolean(line) && items.indexOf(line) === index);
|
||||
|
||||
const extractText = (root) => {
|
||||
const chunks = extractTextChunks(root);
|
||||
const text = chunks.length > 0 ? clean(chunks.join('\\n')) : clean(root.innerText || root.textContent || '');
|
||||
const imageLines = extractImageLines(root);
|
||||
if (imageLines.length === 0) return text;
|
||||
return text ? text + '\\n' + imageLines.join('\\n') : imageLines.join('\\n');
|
||||
};
|
||||
|
||||
const messageList = document.querySelector('[data-testid="message-list"]');
|
||||
|
||||
@@ -8,7 +8,6 @@ function makePage(result: unknown): IPage {
|
||||
getCookies: vi.fn(), snapshot: vi.fn(), click: vi.fn(),
|
||||
typeText: vi.fn(), pressKey: vi.fn(), scrollTo: vi.fn(),
|
||||
getFormState: vi.fn(), wait: vi.fn(), tabs: vi.fn(),
|
||||
closeTab: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(),
|
||||
networkRequests: vi.fn(), consoleMessages: vi.fn(),
|
||||
scroll: vi.fn(), autoScroll: vi.fn(),
|
||||
installInterceptor: vi.fn(), getInterceptedRequests: vi.fn(),
|
||||
|
||||
@@ -19,8 +19,6 @@ function makePage(): IPage {
|
||||
getFormState: vi.fn(),
|
||||
wait: vi.fn(),
|
||||
tabs: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
newTab: vi.fn(),
|
||||
selectTab: vi.fn(),
|
||||
networkRequests: vi.fn(),
|
||||
consoleMessages: vi.fn(),
|
||||
|
||||
@@ -86,8 +86,6 @@ function createPageMock(
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -32,8 +32,6 @@ function createMockPage(): IPage {
|
||||
getFormState: vi.fn().mockResolvedValue({}),
|
||||
wait: vi.fn(),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn(),
|
||||
newTab: vi.fn(),
|
||||
selectTab: vi.fn(),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue(''),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { GEMINI_DOMAIN, getGeminiTranscriptLines, sendGeminiMessage, startNewGeminiChat, waitForGeminiResponse } from './utils.js';
|
||||
|
||||
function normalizeBooleanFlag(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
const NO_RESPONSE_PREFIX = '[NO RESPONSE]';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt to Gemini and return only the assistant response',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
defaultFormat: 'plain',
|
||||
timeoutSeconds: 180,
|
||||
args: [
|
||||
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', required: false, help: 'Max seconds to wait (default: 60)', default: '60' },
|
||||
{ name: 'new', required: false, help: 'Start a new chat first (true/false, default: false)', default: 'false' },
|
||||
],
|
||||
columns: ['response'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const prompt = kwargs.prompt as string;
|
||||
const timeout = parseInt(kwargs.timeout as string, 10) || 60;
|
||||
const startFresh = normalizeBooleanFlag(kwargs.new);
|
||||
|
||||
if (startFresh) await startNewGeminiChat(page);
|
||||
|
||||
const beforeLines = await getGeminiTranscriptLines(page);
|
||||
await sendGeminiMessage(page, prompt);
|
||||
const response = await waitForGeminiResponse(page, beforeLines, prompt, timeout);
|
||||
|
||||
if (!response) {
|
||||
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
|
||||
}
|
||||
|
||||
return [{ response: `💬 ${response}` }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { saveBase64ToFile } from '../../utils.js';
|
||||
import { GEMINI_DOMAIN, exportGeminiImages, getGeminiVisibleImageUrls, sendGeminiMessage, startNewGeminiChat, waitForGeminiImages } from './utils.js';
|
||||
|
||||
function extFromMime(mime: string): string {
|
||||
if (mime.includes('png')) return '.png';
|
||||
if (mime.includes('webp')) return '.webp';
|
||||
if (mime.includes('gif')) return '.gif';
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function displayPath(filePath: string): string {
|
||||
const home = os.homedir();
|
||||
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
|
||||
}
|
||||
|
||||
function buildImagePrompt(prompt: string, options: {
|
||||
ratio?: string;
|
||||
style?: string;
|
||||
}): string {
|
||||
const extras: string[] = [];
|
||||
if (options.ratio) extras.push(`aspect ratio ${options.ratio}`);
|
||||
if (options.style) extras.push(`style ${options.style}`);
|
||||
if (extras.length === 0) return prompt;
|
||||
return `${prompt}
|
||||
|
||||
Image requirements: ${extras.join(', ')}.`;
|
||||
}
|
||||
|
||||
function normalizeRatio(value: string): string {
|
||||
const normalized = value.trim();
|
||||
const allowed = new Set(['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3']);
|
||||
return allowed.has(normalized) ? normalized : '1:1';
|
||||
}
|
||||
async function currentGeminiLink(page: IPage): Promise<string> {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
return typeof url === 'string' && url ? url : 'https://gemini.google.com/app';
|
||||
}
|
||||
|
||||
export const imageCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'image',
|
||||
description: 'Generate images with Gemini web and save them locally',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
defaultFormat: 'plain',
|
||||
timeoutSeconds: 240,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to Gemini' },
|
||||
{ name: 'rt', default: '1:1', help: 'Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)' },
|
||||
{ name: 'st', default: '', help: 'Style shorthand, e.g. anime, icon, watercolor' },
|
||||
{ name: 'op', default: path.join(os.homedir(), 'tmp', 'gemini-images'), help: 'Output directory shorthand' },
|
||||
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show Gemini page link' },
|
||||
],
|
||||
columns: ['status', 'file', 'link'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const prompt = kwargs.prompt as string;
|
||||
const ratio = normalizeRatio(String(kwargs.rt ?? '1:1'));
|
||||
const style = String(kwargs.st ?? '').trim();
|
||||
const outputDir = (kwargs.op as string) || path.join(os.homedir(), 'tmp', 'gemini-images');
|
||||
const timeout = 120;
|
||||
const startFresh = true;
|
||||
const skipDownloadRaw = kwargs.sd;
|
||||
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
|
||||
|
||||
const effectivePrompt = buildImagePrompt(prompt, {
|
||||
ratio,
|
||||
style: style || undefined,
|
||||
});
|
||||
|
||||
if (startFresh) await startNewGeminiChat(page);
|
||||
|
||||
const beforeUrls = await getGeminiVisibleImageUrls(page);
|
||||
await sendGeminiMessage(page, effectivePrompt);
|
||||
const urls = await waitForGeminiImages(page, beforeUrls, timeout);
|
||||
const link = await currentGeminiLink(page);
|
||||
|
||||
if (!urls.length) {
|
||||
return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
|
||||
}
|
||||
|
||||
if (skipDownload) {
|
||||
return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }];
|
||||
}
|
||||
|
||||
const assets = await exportGeminiImages(page, urls);
|
||||
if (!assets.length) {
|
||||
return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
|
||||
}
|
||||
|
||||
const stamp = Date.now();
|
||||
const results = [];
|
||||
for (let index = 0; index < assets.length; index += 1) {
|
||||
const asset = assets[index];
|
||||
const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
|
||||
const suffix = assets.length > 1 ? `_${index + 1}` : '';
|
||||
const filePath = path.join(outputDir, `gemini_${stamp}${suffix}${extFromMime(asset.mimeType)}`);
|
||||
await saveBase64ToFile(base64, filePath);
|
||||
results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { GEMINI_DOMAIN, startNewGeminiChat } from './utils.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'gemini',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in Gemini web chat',
|
||||
domain: GEMINI_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Action'],
|
||||
func: async (page: IPage) => {
|
||||
const action = await startNewGeminiChat(page);
|
||||
return [{
|
||||
Status: 'Success',
|
||||
Action: action === 'navigate' ? 'Reloaded /app as fallback' : 'Clicked New chat',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectGeminiTranscriptAdditions, sanitizeGeminiResponseText } from './utils.js';
|
||||
|
||||
describe('sanitizeGeminiResponseText', () => {
|
||||
it('strips a prompt echo only when it appears as a prefixed block', () => {
|
||||
const prompt = 'Reply with the word opencli';
|
||||
const value = `Reply with the word opencli\n\nopencli`;
|
||||
expect(sanitizeGeminiResponseText(value, prompt)).toBe('opencli');
|
||||
});
|
||||
|
||||
it('does not strip prompt text that appears later in a legitimate answer', () => {
|
||||
const prompt = 'opencli';
|
||||
const value = 'You asked about opencli, and opencli is the right keyword here.';
|
||||
expect(sanitizeGeminiResponseText(value, prompt)).toBe(value);
|
||||
});
|
||||
|
||||
it('removes known Gemini footer noise', () => {
|
||||
const value = 'Answer body\nGemini can make mistakes.\nGoogle Terms';
|
||||
expect(sanitizeGeminiResponseText(value, '')).toBe('Answer body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectGeminiTranscriptAdditions', () => {
|
||||
it('joins multiple new transcript lines instead of keeping only the last line', () => {
|
||||
const before = ['Older answer'];
|
||||
const current = ['Older answer', 'First new line', 'Second new line'];
|
||||
expect(collectGeminiTranscriptAdditions(before, current, '')).toBe('First new line\nSecond new line');
|
||||
});
|
||||
|
||||
it('filters prompt echoes out of transcript additions', () => {
|
||||
const prompt = 'Tell me a haiku';
|
||||
const before = ['Previous'];
|
||||
const current = ['Previous', 'Tell me a haiku', 'Tell me a haiku\n\nSoft spring rain arrives'];
|
||||
expect(collectGeminiTranscriptAdditions(before, current, prompt)).toBe('Soft spring rain arrives');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const GEMINI_DOMAIN = 'gemini.google.com';
|
||||
export const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||
|
||||
export interface GeminiPageState {
|
||||
url: string;
|
||||
title: string;
|
||||
isSignedIn: boolean | null;
|
||||
composerLabel: string;
|
||||
canSend: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiTurn {
|
||||
Role: 'User' | 'Assistant' | 'System';
|
||||
Text: string;
|
||||
}
|
||||
|
||||
const GEMINI_RESPONSE_NOISE_PATTERNS = [
|
||||
/Gemini can make mistakes\.?/gi,
|
||||
/Google Terms/gi,
|
||||
/Google Privacy Policy/gi,
|
||||
/Opens in a new window/gi,
|
||||
];
|
||||
|
||||
export function sanitizeGeminiResponseText(value: string, promptText: string): string {
|
||||
let sanitized = value;
|
||||
for (const pattern of GEMINI_RESPONSE_NOISE_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
}
|
||||
sanitized = sanitized.trim();
|
||||
|
||||
const prompt = promptText.trim();
|
||||
if (!prompt) return sanitized;
|
||||
if (sanitized === prompt) return '';
|
||||
|
||||
for (const separator of ['\n\n', '\n', '\r\n\r\n', '\r\n']) {
|
||||
const prefix = `${prompt}${separator}`;
|
||||
if (sanitized.startsWith(prefix)) {
|
||||
return sanitized.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function collectGeminiTranscriptAdditions(
|
||||
beforeLines: string[],
|
||||
currentLines: string[],
|
||||
promptText: string,
|
||||
): string {
|
||||
const beforeSet = new Set(beforeLines);
|
||||
const additions = currentLines
|
||||
.filter((line) => !beforeSet.has(line))
|
||||
.map((line) => sanitizeGeminiResponseText(line, promptText))
|
||||
.filter((line) => line && line !== promptText);
|
||||
|
||||
return additions.join('\n').trim();
|
||||
}
|
||||
|
||||
function getStateScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const signInNode = Array.from(document.querySelectorAll('a, button')).find((node) => {
|
||||
const text = (node.textContent || '').trim().toLowerCase();
|
||||
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
|
||||
const href = node.getAttribute('href') || '';
|
||||
return text === 'sign in'
|
||||
|| aria === 'sign in'
|
||||
|| href.includes('accounts.google.com/ServiceLogin');
|
||||
});
|
||||
|
||||
const composer = document.querySelector('[aria-label="Enter a prompt for Gemini"], [aria-label*="prompt for Gemini"], .ql-editor[aria-label*="Gemini"], [contenteditable="true"][aria-label*="Gemini"]');
|
||||
const sendButton = document.querySelector('button[aria-label="Send message"]');
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title || '',
|
||||
isSignedIn: signInNode ? false : (composer ? true : null),
|
||||
composerLabel: composer?.getAttribute('aria-label') || '',
|
||||
canSend: !!(sendButton && !sendButton.disabled),
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function getTranscriptLinesScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '')
|
||||
.replace(/\\u00a0/g, ' ')
|
||||
.replace(/\\n{3,}/g, '\\n\\n')
|
||||
.trim();
|
||||
|
||||
const main = document.querySelector('main') || document.body;
|
||||
const root = main.cloneNode(true);
|
||||
|
||||
const removableSelectors = [
|
||||
'button',
|
||||
'nav',
|
||||
'header',
|
||||
'footer',
|
||||
'[aria-label="Enter a prompt for Gemini"]',
|
||||
'[aria-label*="prompt for Gemini"]',
|
||||
'.input-area-container',
|
||||
'.input-wrapper',
|
||||
'.textbox-container',
|
||||
'.ql-toolbar',
|
||||
'.send-button',
|
||||
'.main-menu-button',
|
||||
'.sign-in-button',
|
||||
];
|
||||
|
||||
for (const selector of removableSelectors) {
|
||||
root.querySelectorAll(selector).forEach((node) => node.remove());
|
||||
}
|
||||
root.querySelectorAll('script, style, noscript').forEach((node) => node.remove());
|
||||
|
||||
const stopLines = new Set([
|
||||
'Gemini',
|
||||
'Google Terms',
|
||||
'Google Privacy Policy',
|
||||
'Meet Gemini, your personal AI assistant',
|
||||
'Conversation with Gemini',
|
||||
'Ask Gemini 3',
|
||||
'Write',
|
||||
'Plan',
|
||||
'Research',
|
||||
'Learn',
|
||||
'Fast',
|
||||
'send',
|
||||
'Microphone',
|
||||
'Main menu',
|
||||
'New chat',
|
||||
'Sign in',
|
||||
'Google Terms Opens in a new window',
|
||||
'Google Privacy Policy Opens in a new window',
|
||||
]);
|
||||
|
||||
const noisyPatterns = [
|
||||
/^Google Terms$/,
|
||||
/^Google Privacy Policy$/,
|
||||
/^Gemini is AI and can make mistakes\.?$/,
|
||||
/^and the$/,
|
||||
/^apply\.$/,
|
||||
/^Opens in a new window$/,
|
||||
/^Open mode picker$/,
|
||||
/^Open upload file menu$/,
|
||||
/^Tools$/,
|
||||
];
|
||||
|
||||
return clean(root.innerText || root.textContent || '')
|
||||
.split('\\n')
|
||||
.map((line) => clean(line))
|
||||
.filter((line) => line
|
||||
&& line.length <= 4000
|
||||
&& !stopLines.has(line)
|
||||
&& !noisyPatterns.some((pattern) => pattern.test(line)));
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function getTurnsScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '')
|
||||
.replace(/\\u00a0/g, ' ')
|
||||
.replace(/\\n{3,}/g, '\\n\\n')
|
||||
.trim();
|
||||
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
|
||||
const selectors = [
|
||||
'[data-testid*="message"]',
|
||||
'[data-test-id*="message"]',
|
||||
'[class*="message"]',
|
||||
'[class*="conversation-turn"]',
|
||||
'[class*="query-text"]',
|
||||
'[class*="response-text"]',
|
||||
];
|
||||
|
||||
const roots = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
|
||||
const unique = roots.filter((el, index, all) => all.indexOf(el) === index).filter(isVisible);
|
||||
|
||||
const turns = unique.map((el) => {
|
||||
const text = clean(el.innerText || el.textContent || '');
|
||||
if (!text) return null;
|
||||
|
||||
const roleAttr = [
|
||||
el.getAttribute('data-message-author-role'),
|
||||
el.getAttribute('data-role'),
|
||||
el.getAttribute('aria-label'),
|
||||
el.getAttribute('class'),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
let role = '';
|
||||
if (roleAttr.includes('user') || roleAttr.includes('query')) role = 'User';
|
||||
else if (roleAttr.includes('assistant') || roleAttr.includes('model') || roleAttr.includes('response') || roleAttr.includes('gemini')) role = 'Assistant';
|
||||
|
||||
return role ? { Role: role, Text: text } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
const deduped = [];
|
||||
const seen = new Set();
|
||||
for (const turn of turns) {
|
||||
const key = turn.Role + '::' + turn.Text;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(turn);
|
||||
}
|
||||
return deduped;
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function fillAndSubmitComposerScript(text: string): string {
|
||||
return `
|
||||
((inputText) => {
|
||||
const cleanInsert = (el) => {
|
||||
if (!(el instanceof HTMLElement)) throw new Error('Composer is not editable');
|
||||
el.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
el.textContent = '';
|
||||
document.execCommand('insertText', false, inputText);
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' }));
|
||||
};
|
||||
|
||||
const composer = document.querySelector('[aria-label="Enter a prompt for Gemini"], [aria-label*="prompt for Gemini"], .ql-editor[aria-label*="Gemini"], [contenteditable="true"][aria-label*="Gemini"]');
|
||||
if (!(composer instanceof HTMLElement)) {
|
||||
throw new Error('Could not find Gemini composer');
|
||||
}
|
||||
|
||||
cleanInsert(composer);
|
||||
|
||||
const sendButton = document.querySelector('button[aria-label="Send message"]');
|
||||
if (sendButton instanceof HTMLButtonElement && !sendButton.disabled) {
|
||||
sendButton.click();
|
||||
return 'button';
|
||||
}
|
||||
|
||||
composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
return 'enter';
|
||||
})(${JSON.stringify(text)})
|
||||
`;
|
||||
}
|
||||
|
||||
function clickNewChatScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll('button, a')).filter((node) => {
|
||||
const text = (node.textContent || '').trim().toLowerCase();
|
||||
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
|
||||
return isVisible(node) && (text === 'new chat' || aria === 'new chat');
|
||||
});
|
||||
|
||||
const target = candidates.find((node) => !node.hasAttribute('disabled')) || candidates[0];
|
||||
if (target instanceof HTMLElement) {
|
||||
target.click();
|
||||
return 'clicked';
|
||||
}
|
||||
return 'navigate';
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function currentUrlScript(): string {
|
||||
return 'window.location.href';
|
||||
}
|
||||
|
||||
export async function isOnGemini(page: IPage): Promise<boolean> {
|
||||
const url = await page.evaluate(currentUrlScript()).catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return hostname === GEMINI_DOMAIN || hostname.endsWith(`.${GEMINI_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureGeminiPage(page: IPage): Promise<void> {
|
||||
if (!(await isOnGemini(page))) {
|
||||
await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGeminiPageState(page: IPage): Promise<GeminiPageState> {
|
||||
await ensureGeminiPage(page);
|
||||
return await page.evaluate(getStateScript()) as GeminiPageState;
|
||||
}
|
||||
|
||||
export async function startNewGeminiChat(page: IPage): Promise<'clicked' | 'navigate'> {
|
||||
await ensureGeminiPage(page);
|
||||
const action = await page.evaluate(clickNewChatScript()) as 'clicked' | 'navigate';
|
||||
if (action === 'navigate') {
|
||||
await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
}
|
||||
await page.wait(1);
|
||||
return action;
|
||||
}
|
||||
|
||||
export async function getGeminiVisibleTurns(page: IPage): Promise<GeminiTurn[]> {
|
||||
await ensureGeminiPage(page);
|
||||
const turns = await page.evaluate(getTurnsScript()) as GeminiTurn[];
|
||||
if (Array.isArray(turns) && turns.length > 0) return turns;
|
||||
|
||||
const lines = await getGeminiTranscriptLines(page);
|
||||
return lines.map((line) => ({ Role: 'System', Text: line }));
|
||||
}
|
||||
|
||||
export async function getGeminiTranscriptLines(page: IPage): Promise<string[]> {
|
||||
await ensureGeminiPage(page);
|
||||
return await page.evaluate(getTranscriptLinesScript()) as string[];
|
||||
}
|
||||
|
||||
export async function sendGeminiMessage(page: IPage, text: string): Promise<'button' | 'enter'> {
|
||||
await ensureGeminiPage(page);
|
||||
const submittedBy = await page.evaluate(fillAndSubmitComposerScript(text)) as 'button' | 'enter';
|
||||
await page.wait(1);
|
||||
return submittedBy;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function getGeminiVisibleImageUrls(page: IPage): Promise<string[]> {
|
||||
await ensureGeminiPage(page);
|
||||
return await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 32 && rect.height > 32;
|
||||
};
|
||||
|
||||
const imgs = Array.from(document.querySelectorAll('main img')).filter((img) => img instanceof HTMLImageElement && isVisible(img));
|
||||
const urls = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const img of imgs) {
|
||||
const src = img.currentSrc || img.src || '';
|
||||
const alt = (img.getAttribute('alt') || '').toLowerCase();
|
||||
const width = img.naturalWidth || img.width || 0;
|
||||
const height = img.naturalHeight || img.height || 0;
|
||||
if (!src) continue;
|
||||
if (alt.includes('avatar') || alt.includes('logo') || alt.includes('icon')) continue;
|
||||
if (width < 128 && height < 128) continue;
|
||||
if (seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
urls.push(src);
|
||||
}
|
||||
return urls;
|
||||
})()
|
||||
`) as string[];
|
||||
}
|
||||
|
||||
export async function waitForGeminiImages(
|
||||
page: IPage,
|
||||
beforeUrls: string[],
|
||||
timeoutSeconds: number,
|
||||
): Promise<string[]> {
|
||||
const beforeSet = new Set(beforeUrls);
|
||||
const pollIntervalSeconds = 3;
|
||||
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
|
||||
let lastUrls: string[] = [];
|
||||
let stableCount = 0;
|
||||
|
||||
for (let index = 0; index < maxPolls; index += 1) {
|
||||
await page.wait(index === 0 ? 2 : pollIntervalSeconds);
|
||||
const urls = (await getGeminiVisibleImageUrls(page)).filter((url) => !beforeSet.has(url));
|
||||
if (urls.length === 0) continue;
|
||||
|
||||
const key = urls.join('\n');
|
||||
const prevKey = lastUrls.join('\n');
|
||||
if (key == prevKey) stableCount += 1;
|
||||
else {
|
||||
lastUrls = urls;
|
||||
stableCount = 1;
|
||||
}
|
||||
|
||||
if (stableCount >= 2 || index === maxPolls - 1) return lastUrls;
|
||||
}
|
||||
|
||||
return lastUrls;
|
||||
}
|
||||
|
||||
export interface GeminiImageAsset {
|
||||
url: string;
|
||||
dataUrl: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export async function exportGeminiImages(page: IPage, urls: string[]): Promise<GeminiImageAsset[]> {
|
||||
await ensureGeminiPage(page);
|
||||
const urlsJson = JSON.stringify(urls);
|
||||
return await page.evaluate(`
|
||||
(async (targetUrls) => {
|
||||
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('Failed to read blob'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const inferMime = (value, fallbackUrl) => {
|
||||
if (value) return value;
|
||||
const lower = String(fallbackUrl || '').toLowerCase();
|
||||
if (lower.includes('.png')) return 'image/png';
|
||||
if (lower.includes('.webp')) return 'image/webp';
|
||||
if (lower.includes('.gif')) return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
};
|
||||
|
||||
const images = Array.from(document.querySelectorAll('main img'));
|
||||
const results = [];
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const img = images.find((node) => (node.currentSrc || node.src || '') === targetUrl);
|
||||
let dataUrl = '';
|
||||
let mimeType = 'image/jpeg';
|
||||
const width = img?.naturalWidth || img?.width || 0;
|
||||
const height = img?.naturalHeight || img?.height || 0;
|
||||
|
||||
try {
|
||||
if (String(targetUrl).startsWith('data:')) {
|
||||
dataUrl = String(targetUrl);
|
||||
mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png';
|
||||
} else {
|
||||
const res = await fetch(String(targetUrl), { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
mimeType = inferMime(blob.type, targetUrl);
|
||||
dataUrl = await blobToDataUrl(blob);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!dataUrl && img instanceof HTMLImageElement) {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
dataUrl = canvas.toDataURL('image/png');
|
||||
mimeType = 'image/png';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (dataUrl) {
|
||||
results.push({ url: String(targetUrl), dataUrl, mimeType, width, height });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
})(${urlsJson})
|
||||
`) as GeminiImageAsset[];
|
||||
}
|
||||
export async function waitForGeminiResponse(
|
||||
page: IPage,
|
||||
beforeLines: string[],
|
||||
promptText: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<string> {
|
||||
const getCandidate = async (): Promise<string> => {
|
||||
const turns = await getGeminiVisibleTurns(page);
|
||||
const assistantCandidate = [...turns].reverse().find((turn) => turn.Role === 'Assistant');
|
||||
const visibleCandidate = assistantCandidate
|
||||
? sanitizeGeminiResponseText(assistantCandidate.Text, promptText)
|
||||
: '';
|
||||
if (visibleCandidate && visibleCandidate !== promptText) return visibleCandidate;
|
||||
|
||||
const lines = await getGeminiTranscriptLines(page);
|
||||
return collectGeminiTranscriptAdditions(beforeLines, lines, promptText);
|
||||
};
|
||||
|
||||
const pollIntervalSeconds = 2;
|
||||
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
|
||||
let lastCandidate = '';
|
||||
let stableCount = 0;
|
||||
|
||||
for (let index = 0; index < maxPolls; index += 1) {
|
||||
await page.wait(index === 0 ? 1.5 : pollIntervalSeconds);
|
||||
const candidate = await getCandidate();
|
||||
if (!candidate) continue;
|
||||
|
||||
if (candidate === lastCandidate) stableCount += 1;
|
||||
else {
|
||||
lastCandidate = candidate;
|
||||
stableCount = 1;
|
||||
}
|
||||
|
||||
if (stableCount >= 2 || index === maxPolls - 1) return candidate;
|
||||
}
|
||||
|
||||
return lastCandidate;
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InstagramProtocolCaptureEntry } from './protocol-capture.js';
|
||||
import {
|
||||
buildConfigureBody,
|
||||
buildConfigureSidecarPayload,
|
||||
buildConfigureToStoryPhotoPayload,
|
||||
buildConfigureToStoryVideoPayload,
|
||||
deriveInstagramJazoest,
|
||||
derivePrivateApiContextFromCapture,
|
||||
extractInstagramRuntimeInfo,
|
||||
getInstagramFeedNormalizedDimensions,
|
||||
getInstagramStoryNormalizedDimensions,
|
||||
isInstagramFeedAspectRatioAllowed,
|
||||
isInstagramStoryAspectRatioAllowed,
|
||||
publishStoryViaPrivateApi,
|
||||
publishMediaViaPrivateApi,
|
||||
publishImagesViaPrivateApi,
|
||||
readImageAsset,
|
||||
resolveInstagramPrivatePublishConfig,
|
||||
} from './private-publish.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempFile(name: string, bytes: Buffer): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-private-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('instagram private publish helpers', () => {
|
||||
it('derives the private API context from captured instagram request headers', () => {
|
||||
const entries: InstagramProtocolCaptureEntry[] = [
|
||||
{
|
||||
kind: 'cdp' as never,
|
||||
url: 'https://www.instagram.com/api/v1/feed/timeline/',
|
||||
method: 'GET',
|
||||
requestHeaders: {
|
||||
'X-ASBD-ID': '359341',
|
||||
'X-CSRFToken': 'csrf-token',
|
||||
'X-IG-App-ID': '936619743392459',
|
||||
'X-IG-WWW-Claim': 'hmac.claim',
|
||||
'X-Instagram-AJAX': '1036517563',
|
||||
'X-Web-Session-ID': 'abc:def:ghi',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
expect(derivePrivateApiContextFromCapture(entries)).toEqual({
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives jazoest from the csrf token', () => {
|
||||
expect(deriveInstagramJazoest('SJ_btbvfkpAVFKCN_tJstW')).toBe('22047');
|
||||
});
|
||||
|
||||
it('extracts app id, rollout hash, and csrf token from instagram html', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/json">
|
||||
{"csrf_token":"csrf-from-html","rollout_hash":"1036523242","X-IG-App-ID":"936619743392459"}
|
||||
</script>
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
expect(extractInstagramRuntimeInfo(html)).toEqual({
|
||||
appId: '936619743392459',
|
||||
csrfToken: 'csrf-from-html',
|
||||
instagramAjax: '1036523242',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves private publish config from capture, runtime html, and cookies', async () => {
|
||||
const entries: InstagramProtocolCaptureEntry[] = [
|
||||
{
|
||||
kind: 'cdp' as never,
|
||||
url: 'https://www.instagram.com/api/v1/feed/timeline/',
|
||||
method: 'GET',
|
||||
requestHeaders: {
|
||||
'X-ASBD-ID': '359341',
|
||||
'X-IG-WWW-Claim': 'hmac.claim',
|
||||
'X-Web-Session-ID': 'abc:def:ghi',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const page = {
|
||||
goto: async () => undefined,
|
||||
wait: async () => undefined,
|
||||
getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }],
|
||||
startNetworkCapture: async () => undefined,
|
||||
readNetworkCapture: async () => entries,
|
||||
evaluate: async () => ({
|
||||
appId: '936619743392459',
|
||||
csrfToken: 'csrf-from-html',
|
||||
instagramAjax: '1036523242',
|
||||
}),
|
||||
} as any;
|
||||
|
||||
await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-from-html',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036523242',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: deriveInstagramJazoest('csrf-from-html'),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries transient private publish config resolution failures and then succeeds', async () => {
|
||||
const entries: InstagramProtocolCaptureEntry[] = [
|
||||
{
|
||||
kind: 'cdp' as never,
|
||||
url: 'https://www.instagram.com/api/v1/feed/timeline/',
|
||||
method: 'GET',
|
||||
requestHeaders: {
|
||||
'X-ASBD-ID': '359341',
|
||||
'X-IG-WWW-Claim': 'hmac.claim',
|
||||
'X-Web-Session-ID': 'abc:def:ghi',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
let evaluateAttempts = 0;
|
||||
const page = {
|
||||
goto: async () => undefined,
|
||||
wait: async () => undefined,
|
||||
getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }],
|
||||
startNetworkCapture: async () => undefined,
|
||||
readNetworkCapture: async () => entries,
|
||||
evaluate: async () => {
|
||||
evaluateAttempts += 1;
|
||||
if (evaluateAttempts === 1) {
|
||||
throw new TypeError('fetch failed');
|
||||
}
|
||||
return {
|
||||
appId: '936619743392459',
|
||||
csrfToken: 'csrf-from-html',
|
||||
instagramAjax: '1036523242',
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
|
||||
await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-from-html',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036523242',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: deriveInstagramJazoest('csrf-from-html'),
|
||||
});
|
||||
expect(evaluateAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('builds the single-image configure form body', () => {
|
||||
expect(buildConfigureBody({
|
||||
uploadId: '1775134280303',
|
||||
caption: 'hello private route',
|
||||
jazoest: '22047',
|
||||
})).toBe(
|
||||
'archive_only=false&caption=hello+private+route&clips_share_preview_to_feed=1'
|
||||
+ '&disable_comments=0&disable_oa_reuse=false&igtv_share_preview_to_feed=1'
|
||||
+ '&is_meta_only_post=0&is_unified_video=1&like_and_view_counts_disabled=0'
|
||||
+ '&media_share_flow=creation_flow&share_to_facebook=&share_to_fb_destination_type=USER'
|
||||
+ '&source_type=library&upload_id=1775134280303&video_subtitles_enabled=0&jazoest=22047'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the carousel configure_sidecar JSON payload', () => {
|
||||
expect(buildConfigureSidecarPayload({
|
||||
uploadIds: ['1', '3', '2'],
|
||||
caption: 'hello carousel',
|
||||
clientSidecarId: '1775134574348',
|
||||
jazoest: '22047',
|
||||
})).toEqual({
|
||||
archive_only: false,
|
||||
caption: 'hello carousel',
|
||||
children_metadata: [
|
||||
{ upload_id: '1' },
|
||||
{ upload_id: '3' },
|
||||
{ upload_id: '2' },
|
||||
],
|
||||
client_sidecar_id: '1775134574348',
|
||||
disable_comments: '0',
|
||||
is_meta_only_post: false,
|
||||
is_open_to_public_submission: false,
|
||||
like_and_view_counts_disabled: 0,
|
||||
media_share_flow: 'creation_flow',
|
||||
share_to_facebook: '',
|
||||
share_to_fb_destination_type: 'USER',
|
||||
source_type: 'library',
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads png and jpeg image assets with mime type and dimensions', () => {
|
||||
const png = createTempFile('sample.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const jpeg = createTempFile('sample.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
|
||||
expect(readImageAsset(png)).toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
width: 3,
|
||||
height: 5,
|
||||
});
|
||||
expect(readImageAsset(jpeg)).toMatchObject({
|
||||
mimeType: 'image/jpeg',
|
||||
width: 6,
|
||||
height: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('computes feed-safe aspect-ratio normalization targets', () => {
|
||||
expect(isInstagramFeedAspectRatioAllowed(1080, 1350)).toBe(true);
|
||||
expect(isInstagramFeedAspectRatioAllowed(1179, 2556)).toBe(false);
|
||||
expect(getInstagramFeedNormalizedDimensions(1179, 2556)).toEqual({
|
||||
width: 2045,
|
||||
height: 2556,
|
||||
});
|
||||
expect(getInstagramFeedNormalizedDimensions(2120, 1140)).toBeNull();
|
||||
});
|
||||
|
||||
it('computes story-safe aspect-ratio normalization targets', () => {
|
||||
expect(isInstagramStoryAspectRatioAllowed(1080, 1920)).toBe(true);
|
||||
expect(isInstagramStoryAspectRatioAllowed(1080, 1080)).toBe(false);
|
||||
expect(getInstagramStoryNormalizedDimensions(1080, 1080)).toEqual({
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the single-photo configure_to_story payload', () => {
|
||||
expect(buildConfigureToStoryPhotoPayload({
|
||||
uploadId: '1775134280303',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
now: () => 1_775_134_280_303,
|
||||
jazoest: '22047',
|
||||
})).toMatchObject({
|
||||
source_type: '4',
|
||||
upload_id: '1775134280303',
|
||||
configure_mode: 1,
|
||||
edits: {
|
||||
crop_original_size: [1080, 1920],
|
||||
crop_center: [0, 0],
|
||||
crop_zoom: 1.3333334,
|
||||
},
|
||||
extra: {
|
||||
source_width: 1080,
|
||||
source_height: 1920,
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the single-video configure_to_story payload', () => {
|
||||
expect(buildConfigureToStoryVideoPayload({
|
||||
uploadId: '1775134280303',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 12500,
|
||||
now: () => 1_775_134_280_303,
|
||||
jazoest: '22047',
|
||||
})).toMatchObject({
|
||||
source_type: '4',
|
||||
upload_id: '1775134280303',
|
||||
configure_mode: 1,
|
||||
poster_frame_index: 0,
|
||||
length: 12.5,
|
||||
extra: {
|
||||
source_width: 1080,
|
||||
source_height: 1920,
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a single image through rupload + configure', async () => {
|
||||
const jpeg = createTempFile('private-single.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
return new Response('{"upload_id":"111","status":"ok"}', { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"ABC123"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [jpeg],
|
||||
caption: 'private single',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 111,
|
||||
fetcher,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toContain('https://i.instagram.com/rupload_igphoto/fb_uploader_111');
|
||||
expect(calls[0]?.init?.headers).toMatchObject({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'X-Entity-Length': String(fs.statSync(jpeg).size),
|
||||
'X-Entity-Name': 'fb_uploader_111',
|
||||
'X-IG-App-ID': '936619743392459',
|
||||
});
|
||||
expect(calls[1]?.url).toBe('https://www.instagram.com/api/v1/media/configure/');
|
||||
expect(String(calls[1]?.init?.body || '')).toContain('upload_id=111');
|
||||
expect(response).toEqual({ code: 'ABC123', uploadIds: ['111'] });
|
||||
});
|
||||
|
||||
it('publishes a single image story through rupload + configure_to_story', async () => {
|
||||
const jpeg = createTempFile('private-story.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
return new Response('{"upload_id":"111","status":"ok"}', { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"pk":"1234567890"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishStoryViaPrivateApi({
|
||||
page: {} as never,
|
||||
mediaItem: { type: 'image', filePath: jpeg },
|
||||
content: '',
|
||||
currentUserId: '61236465677',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 111,
|
||||
fetcher,
|
||||
prepareMediaAsset: async () => ({
|
||||
type: 'image',
|
||||
asset: {
|
||||
filePath: jpeg,
|
||||
fileName: path.basename(jpeg),
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
byteLength: fs.statSync(jpeg).size,
|
||||
bytes: fs.readFileSync(jpeg),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_111');
|
||||
expect(calls[1]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/');
|
||||
expect(String(calls[1]?.init?.body || '')).toContain('signed_body=');
|
||||
expect(response).toEqual({ mediaPk: '1234567890', uploadId: '111' });
|
||||
});
|
||||
|
||||
it('publishes a single video story through rupload + cover + configure_to_story?video=1', async () => {
|
||||
const video = createTempFile('private-story.mp4', Buffer.from('story-video'));
|
||||
const coverBytes = Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9',
|
||||
'hex',
|
||||
);
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igvideo/')) {
|
||||
return new Response('{"upload_id":"222","status":"ok"}', { status: 200 });
|
||||
}
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
return new Response('{"upload_id":"222","status":"ok"}', { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"pk":"9988776655"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishStoryViaPrivateApi({
|
||||
page: {} as never,
|
||||
mediaItem: { type: 'video', filePath: video },
|
||||
content: '',
|
||||
currentUserId: '61236465677',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 222,
|
||||
fetcher,
|
||||
prepareMediaAsset: async () => ({
|
||||
type: 'video',
|
||||
asset: {
|
||||
filePath: video,
|
||||
fileName: path.basename(video),
|
||||
mimeType: 'video/mp4',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 12500,
|
||||
byteLength: fs.statSync(video).size,
|
||||
bytes: fs.readFileSync(video),
|
||||
coverImage: {
|
||||
filePath: '/tmp/cover.jpg',
|
||||
fileName: 'cover.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
byteLength: coverBytes.length,
|
||||
bytes: coverBytes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls[0]?.url).toContain('/rupload_igvideo/fb_uploader_222');
|
||||
expect(calls[1]?.url).toContain('/rupload_igphoto/fb_uploader_222');
|
||||
expect(calls[2]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/');
|
||||
expect(calls[3]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/?video=1');
|
||||
expect(String(calls[2]?.init?.body || '')).toContain('signed_body=');
|
||||
expect(String(calls[3]?.init?.body || '')).toContain('signed_body=');
|
||||
expect(response).toEqual({ mediaPk: '9988776655', uploadId: '222' });
|
||||
});
|
||||
|
||||
it('publishes a carousel through rupload + configure_sidecar', async () => {
|
||||
const first = createTempFile('private-carousel-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(200 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"SIDE123"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 200,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2]?.url).toBe('https://www.instagram.com/api/v1/media/configure_sidecar/');
|
||||
expect(JSON.parse(String(calls[2]?.init?.body || '{}'))).toMatchObject({
|
||||
caption: 'private carousel',
|
||||
client_sidecar_id: '200',
|
||||
children_metadata: [{ upload_id: '201' }, { upload_id: '202' }],
|
||||
});
|
||||
expect(response).toEqual({ code: 'SIDE123', uploadIds: ['201', '202'] });
|
||||
});
|
||||
|
||||
it('uses prepared assets when private carousel upload needs aspect-ratio normalization', async () => {
|
||||
const first = createTempFile('private-carousel-normalize-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-normalize-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(400 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"SIDEPAD"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const preparedBytes = Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000007FD000009FC08060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
);
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel normalized',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 400,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => {
|
||||
if (filePath === second) {
|
||||
return {
|
||||
filePath: '/tmp/normalized.png',
|
||||
fileName: 'normalized.png',
|
||||
mimeType: 'image/png',
|
||||
width: 2045,
|
||||
height: 2556,
|
||||
byteLength: preparedBytes.length,
|
||||
bytes: preparedBytes,
|
||||
cleanupPath: '/tmp/normalized.png',
|
||||
};
|
||||
}
|
||||
return readImageAsset(filePath);
|
||||
},
|
||||
});
|
||||
|
||||
const secondUploadHeaders = calls[1]?.init?.headers ?? {};
|
||||
expect(JSON.parse(String(secondUploadHeaders['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
|
||||
upload_media_width: 2045,
|
||||
upload_media_height: 2556,
|
||||
});
|
||||
expect(response).toEqual({ code: 'SIDEPAD', uploadIds: ['401', '402'] });
|
||||
});
|
||||
|
||||
it('includes the response body when configure_sidecar returns a 400', async () => {
|
||||
const first = createTempFile('private-carousel-error-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-error-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL) => {
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(300 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"message":"children_metadata invalid"}', { status: 400 });
|
||||
};
|
||||
|
||||
await expect(publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 300,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
})).rejects.toThrow('children_metadata invalid');
|
||||
});
|
||||
|
||||
it('retries transient rupload fetch failures and still completes the carousel publish', async () => {
|
||||
const first = createTempFile('private-carousel-retry-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-retry-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: string[] = [];
|
||||
let firstUploadAttempts = 0;
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL) => {
|
||||
const value = String(url);
|
||||
calls.push(value);
|
||||
if (value.includes('/rupload_igphoto/')) {
|
||||
firstUploadAttempts += value.includes('fb_uploader_501') ? 1 : 0;
|
||||
if (value.includes('fb_uploader_501') && firstUploadAttempts === 1) {
|
||||
throw new TypeError('fetch failed');
|
||||
}
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(500 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"SIDERETRY"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel retry',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 500,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
});
|
||||
|
||||
expect(calls.filter((url) => url.includes('fb_uploader_501'))).toHaveLength(2);
|
||||
expect(response).toEqual({ code: 'SIDERETRY', uploadIds: ['501', '502'] });
|
||||
});
|
||||
|
||||
it('does not retry transient configure_sidecar fetch failures to avoid duplicate posts', async () => {
|
||||
const first = createTempFile('private-carousel-no-retry-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-no-retry-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: string[] = [];
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL) => {
|
||||
const value = String(url);
|
||||
calls.push(value);
|
||||
if (value.includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(600 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
throw new TypeError('fetch failed');
|
||||
};
|
||||
|
||||
await expect(publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private no retry configure',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 600,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
})).rejects.toThrow('fetch failed');
|
||||
|
||||
expect(calls.filter((url) => url.includes('configure_sidecar'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishes a mixed image/video carousel and polls configure_sidecar until transcoding finishes', async () => {
|
||||
const image = createTempFile('mixed-private-image.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const video = createTempFile('mixed-private-video.mp4', Buffer.from('video-binary'));
|
||||
const coverBytes = Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080168028003012200021101031101FFD9',
|
||||
'hex',
|
||||
);
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
let configureAttempts = 0;
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
const value = String(url);
|
||||
calls.push({ url: value, init });
|
||||
if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_701')) {
|
||||
return new Response('{"upload_id":"701","status":"ok"}', { status: 200 });
|
||||
}
|
||||
if (value.includes('/rupload_igvideo/') && value.includes('fb_uploader_702')) {
|
||||
return new Response('{"media_id":17944674009157009,"status":"ok"}', { status: 200 });
|
||||
}
|
||||
if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_702')) {
|
||||
return new Response('{"upload_id":"702","status":"ok"}', { status: 200 });
|
||||
}
|
||||
configureAttempts += 1;
|
||||
if (configureAttempts === 1) {
|
||||
return new Response('{"message":"Transcode not finished yet.","status":"fail"}', { status: 202 });
|
||||
}
|
||||
return new Response('{"status":"ok","media":{"code":"MIXEDSIDE123"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishMediaViaPrivateApi({
|
||||
page: {} as never,
|
||||
mediaItems: [
|
||||
{ type: 'image', filePath: image },
|
||||
{ type: 'video', filePath: video },
|
||||
],
|
||||
caption: 'mixed private carousel',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 700,
|
||||
fetcher,
|
||||
prepareMediaAsset: async (item) => {
|
||||
if (item.type === 'image') {
|
||||
return {
|
||||
type: 'image' as const,
|
||||
asset: readImageAsset(item.filePath),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'video' as const,
|
||||
asset: {
|
||||
filePath: item.filePath,
|
||||
fileName: 'mixed-private-video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
width: 640,
|
||||
height: 360,
|
||||
durationMs: 28245,
|
||||
byteLength: 12,
|
||||
bytes: Buffer.from('video-binary'),
|
||||
coverImage: {
|
||||
filePath: '/tmp/mixed-private-cover.jpg',
|
||||
fileName: 'mixed-private-cover.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 640,
|
||||
height: 360,
|
||||
byteLength: coverBytes.length,
|
||||
bytes: coverBytes,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
waitMs: async () => undefined,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(5);
|
||||
expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_701');
|
||||
expect(calls[1]?.url).toContain('/rupload_igvideo/fb_uploader_702');
|
||||
expect(calls[2]?.url).toContain('/rupload_igphoto/fb_uploader_702');
|
||||
expect(JSON.parse(String(calls[1]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
|
||||
media_type: 2,
|
||||
upload_id: '702',
|
||||
upload_media_width: 640,
|
||||
upload_media_height: 360,
|
||||
upload_media_duration_ms: 28245,
|
||||
video_edit_params: {
|
||||
crop_width: 360,
|
||||
crop_height: 360,
|
||||
crop_x1: 140,
|
||||
crop_y1: 0,
|
||||
trim_start: 0,
|
||||
trim_end: 28.245,
|
||||
mute: false,
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(String(calls[2]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
|
||||
media_type: 2,
|
||||
upload_id: '702',
|
||||
upload_media_width: 640,
|
||||
upload_media_height: 360,
|
||||
});
|
||||
expect(JSON.parse(String(calls[3]?.init?.body || '{}'))).toMatchObject({
|
||||
caption: 'mixed private carousel',
|
||||
client_sidecar_id: '700',
|
||||
children_metadata: [{ upload_id: '701' }, { upload_id: '702' }],
|
||||
});
|
||||
expect(response).toEqual({ code: 'MIXEDSIDE123', uploadIds: ['701', '702'] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TRACE_OUTPUT_PATH = path.join(os.tmpdir(), 'instagram_post_protocol_trace.json');
|
||||
|
||||
import type { BrowserCookie, IPage } from '../../../types.js';
|
||||
import {
|
||||
buildInstallInstagramProtocolCaptureJs,
|
||||
buildReadInstagramProtocolCaptureJs,
|
||||
dumpInstagramProtocolCaptureIfEnabled,
|
||||
instagramPrivateApiFetch,
|
||||
installInstagramProtocolCapture,
|
||||
readInstagramProtocolCapture,
|
||||
} from './protocol-capture.js';
|
||||
|
||||
describe('instagram protocol capture helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.OPENCLI_INSTAGRAM_CAPTURE;
|
||||
try { fs.rmSync(TRACE_OUTPUT_PATH, { force: true }); } catch {}
|
||||
});
|
||||
|
||||
it('installs the protocol capture patch in page context', async () => {
|
||||
const evaluate = vi.fn().mockResolvedValue({ ok: true });
|
||||
const page = { evaluate } as unknown as IPage;
|
||||
|
||||
await installInstagramProtocolCapture(page);
|
||||
|
||||
expect(evaluate).toHaveBeenCalledTimes(1);
|
||||
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture');
|
||||
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('/media/configure_sidecar/');
|
||||
});
|
||||
|
||||
it('prefers native page network capture when available', async () => {
|
||||
const startNetworkCapture = vi.fn().mockResolvedValue(undefined);
|
||||
const evaluate = vi.fn();
|
||||
const page = { startNetworkCapture, evaluate } as unknown as IPage;
|
||||
|
||||
await installInstagramProtocolCapture(page);
|
||||
|
||||
expect(startNetworkCapture).toHaveBeenCalledTimes(1);
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads and normalizes captured protocol entries', async () => {
|
||||
const evaluate = vi.fn().mockResolvedValue({
|
||||
data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }],
|
||||
errors: ['ignored'],
|
||||
});
|
||||
const page = { evaluate } as unknown as IPage;
|
||||
|
||||
const result = await readInstagramProtocolCapture(page);
|
||||
|
||||
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture');
|
||||
expect(result).toEqual({
|
||||
data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }],
|
||||
errors: ['ignored'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers native page network capture reads when available', async () => {
|
||||
const readNetworkCapture = vi.fn().mockResolvedValue([
|
||||
{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' },
|
||||
]);
|
||||
const evaluate = vi.fn();
|
||||
const page = { readNetworkCapture, evaluate } as unknown as IPage;
|
||||
|
||||
const result = await readInstagramProtocolCapture(page);
|
||||
|
||||
expect(readNetworkCapture).toHaveBeenCalledTimes(1);
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
data: [{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' }],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('dumps protocol traces to /tmp only when capture env is enabled', async () => {
|
||||
process.env.OPENCLI_INSTAGRAM_CAPTURE = '1';
|
||||
const page = {
|
||||
evaluate: vi.fn().mockResolvedValue({
|
||||
data: [{ kind: 'fetch', url: 'https://www.instagram.com/rupload_igphoto/test' }],
|
||||
errors: [],
|
||||
}),
|
||||
} as unknown as IPage;
|
||||
|
||||
await dumpInstagramProtocolCaptureIfEnabled(page);
|
||||
|
||||
const raw = fs.readFileSync(TRACE_OUTPUT_PATH, 'utf8');
|
||||
expect(raw).toContain('rupload_igphoto');
|
||||
});
|
||||
|
||||
it('does not dump protocol traces when capture env is disabled', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn(),
|
||||
} as unknown as IPage;
|
||||
|
||||
await dumpInstagramProtocolCaptureIfEnabled(page);
|
||||
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(TRACE_OUTPUT_PATH)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram private api fetch', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses browser cookies to build instagram private api requests', async () => {
|
||||
const getCookies = vi.fn()
|
||||
.mockResolvedValueOnce([{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'csrftoken', value: 'csrf', domain: '.instagram.com' } satisfies BrowserCookie,
|
||||
{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie,
|
||||
]);
|
||||
const evaluate = vi.fn().mockResolvedValue({
|
||||
appId: 'dynamic-app-id',
|
||||
csrfToken: 'csrf',
|
||||
instagramAjax: 'dynamic-rollout',
|
||||
});
|
||||
const page = { getCookies, evaluate } as unknown as IPage;
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await instagramPrivateApiFetch(page, 'https://www.instagram.com/api/v1/media/configure/', {
|
||||
method: 'POST',
|
||||
body: 'caption=test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://www.instagram.com/api/v1/media/configure/',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'X-CSRFToken': 'csrf',
|
||||
'X-IG-App-ID': 'dynamic-app-id',
|
||||
'Cookie': expect.stringContaining('sessionid=sess'),
|
||||
}),
|
||||
body: 'caption=test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes stable browser-side JS builders', () => {
|
||||
expect(buildInstallInstagramProtocolCaptureJs()).toContain('/rupload_igphoto/');
|
||||
expect(buildReadInstagramProtocolCaptureJs()).toContain('__opencli_ig_protocol_capture');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { BrowserCookie, IPage } from '../../../types.js';
|
||||
import { resolveInstagramRuntimeInfo } from './runtime-info.js';
|
||||
|
||||
const DEFAULT_CAPTURE_VAR = '__opencli_ig_protocol_capture';
|
||||
const DEFAULT_CAPTURE_ERRORS_VAR = '__opencli_ig_protocol_capture_errors';
|
||||
const TRACE_OUTPUT_PATH = path.join(os.tmpdir(), 'instagram_post_protocol_trace.json');
|
||||
const INSTAGRAM_PROTOCOL_CAPTURE_PATTERN = [
|
||||
'/rupload_igphoto/',
|
||||
'/rupload_igvideo/',
|
||||
'/api/v1/',
|
||||
'/media/configure/',
|
||||
'/media/configure_sidecar/',
|
||||
'/media/configure_to_story/',
|
||||
'/api/graphql/',
|
||||
].join('|');
|
||||
|
||||
export interface InstagramProtocolCaptureEntry {
|
||||
kind: 'fetch' | 'xhr';
|
||||
url: string;
|
||||
method: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBodyKind?: string;
|
||||
requestBodyPreview?: string;
|
||||
responseStatus?: number;
|
||||
responseContentType?: string;
|
||||
responsePreview?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function buildInstallInstagramProtocolCaptureJs(
|
||||
captureVar: string = DEFAULT_CAPTURE_VAR,
|
||||
captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR,
|
||||
): string {
|
||||
return `
|
||||
(() => {
|
||||
const CAPTURE_VAR = ${JSON.stringify(captureVar)};
|
||||
const CAPTURE_ERRORS_VAR = ${JSON.stringify(captureErrorsVar)};
|
||||
const PATCH_GUARD = CAPTURE_VAR + '_patched';
|
||||
const FILTERS = [
|
||||
'/rupload_igphoto/',
|
||||
'/rupload_igvideo/',
|
||||
'/api/v1/',
|
||||
'/media/configure/',
|
||||
'/media/configure_sidecar/',
|
||||
'/media/configure_to_story/',
|
||||
'/api/graphql/',
|
||||
];
|
||||
|
||||
const shouldCapture = (url) => {
|
||||
const value = String(url || '');
|
||||
return FILTERS.some((filter) => value.includes(filter));
|
||||
};
|
||||
|
||||
const normalizeHeaders = (headersLike) => {
|
||||
const out = {};
|
||||
try {
|
||||
if (!headersLike) return out;
|
||||
if (headersLike instanceof Headers) {
|
||||
headersLike.forEach((value, key) => { out[key] = value; });
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(headersLike)) {
|
||||
for (const pair of headersLike) {
|
||||
if (Array.isArray(pair) && pair.length >= 2) out[String(pair[0])] = String(pair[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof headersLike === 'object') {
|
||||
for (const [key, value] of Object.entries(headersLike)) out[key] = String(value);
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
};
|
||||
|
||||
const summarizeBody = async (body) => {
|
||||
if (body == null) return { kind: 'empty', preview: '' };
|
||||
try {
|
||||
if (typeof body === 'string') {
|
||||
return { kind: 'string', preview: body.slice(0, 1000) };
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
return { kind: 'urlencoded', preview: body.toString().slice(0, 1000) };
|
||||
}
|
||||
if (body instanceof FormData) {
|
||||
const parts = [];
|
||||
for (const [key, value] of body.entries()) {
|
||||
if (value instanceof File) {
|
||||
parts.push(key + '=File(' + value.name + ',' + value.type + ',' + value.size + ')');
|
||||
} else {
|
||||
parts.push(key + '=' + String(value));
|
||||
}
|
||||
}
|
||||
return { kind: 'formdata', preview: parts.join('&').slice(0, 2000) };
|
||||
}
|
||||
if (body instanceof Blob) {
|
||||
return { kind: 'blob', preview: 'Blob(' + body.type + ',' + body.size + ')' };
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return { kind: 'arraybuffer', preview: 'ArrayBuffer(' + body.byteLength + ')' };
|
||||
}
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return { kind: 'typed-array', preview: body.constructor.name + '(' + body.byteLength + ')' };
|
||||
}
|
||||
return { kind: typeof body, preview: String(body).slice(0, 1000) };
|
||||
} catch (error) {
|
||||
return { kind: 'unknown', preview: 'body-preview-error:' + String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const capture = async (kind, url, method, headers, body, response) => {
|
||||
if (!shouldCapture(url)) return;
|
||||
try {
|
||||
const bodyInfo = await summarizeBody(body);
|
||||
const contentType = response?.headers?.get?.('content-type') || '';
|
||||
let responsePreview = '';
|
||||
try {
|
||||
if (response && typeof response.clone === 'function') {
|
||||
const clone = response.clone();
|
||||
responsePreview = (await clone.text()).slice(0, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
responsePreview = 'response-preview-error:' + String(error);
|
||||
}
|
||||
window[CAPTURE_VAR].push({
|
||||
kind,
|
||||
url: String(url || ''),
|
||||
method: String(method || 'GET').toUpperCase(),
|
||||
requestHeaders: normalizeHeaders(headers),
|
||||
requestBodyKind: bodyInfo.kind,
|
||||
requestBodyPreview: bodyInfo.preview,
|
||||
responseStatus: response?.status,
|
||||
responseContentType: contentType,
|
||||
responsePreview,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
window[CAPTURE_ERRORS_VAR].push(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
if (!Array.isArray(window[CAPTURE_VAR])) window[CAPTURE_VAR] = [];
|
||||
if (!Array.isArray(window[CAPTURE_ERRORS_VAR])) window[CAPTURE_ERRORS_VAR] = [];
|
||||
if (window[PATCH_GUARD]) return { ok: true };
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const input = args[0];
|
||||
const init = args[1] || {};
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof Request
|
||||
? input.url
|
||||
: String(input || '');
|
||||
const method = init.method || (input instanceof Request ? input.method : 'GET');
|
||||
const headers = init.headers || (input instanceof Request ? input.headers : undefined);
|
||||
const body = init.body || (input instanceof Request ? input.body : undefined);
|
||||
const response = await origFetch.apply(this, args);
|
||||
capture('fetch', url, method, headers, body, response);
|
||||
return response;
|
||||
};
|
||||
|
||||
const origOpen = XMLHttpRequest.prototype.open;
|
||||
const origSend = XMLHttpRequest.prototype.send;
|
||||
const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this.__opencli_method = method;
|
||||
this.__opencli_url = url;
|
||||
this.__opencli_headers = {};
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
|
||||
try {
|
||||
this.__opencli_headers = this.__opencli_headers || {};
|
||||
this.__opencli_headers[String(name)] = String(value);
|
||||
} catch {}
|
||||
return origSetRequestHeader.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
this.addEventListener('load', () => {
|
||||
if (!shouldCapture(this.__opencli_url)) return;
|
||||
try {
|
||||
window[CAPTURE_VAR].push({
|
||||
kind: 'xhr',
|
||||
url: String(this.__opencli_url || ''),
|
||||
method: String(this.__opencli_method || 'GET').toUpperCase(),
|
||||
requestHeaders: this.__opencli_headers || {},
|
||||
requestBodyKind: body == null ? 'empty' : (body instanceof FormData ? 'formdata' : typeof body),
|
||||
requestBodyPreview: body == null ? '' : (body instanceof FormData ? '[formdata]' : String(body).slice(0, 2000)),
|
||||
responseStatus: this.status,
|
||||
responseContentType: this.getResponseHeader('content-type') || '',
|
||||
responsePreview: String(this.responseText || '').slice(0, 4000),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
window[CAPTURE_ERRORS_VAR].push(String(error));
|
||||
}
|
||||
});
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
window[PATCH_GUARD] = true;
|
||||
return { ok: true };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildReadInstagramProtocolCaptureJs(
|
||||
captureVar: string = DEFAULT_CAPTURE_VAR,
|
||||
captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR,
|
||||
): string {
|
||||
return `
|
||||
(() => {
|
||||
const data = Array.isArray(window[${JSON.stringify(captureVar)}]) ? window[${JSON.stringify(captureVar)}] : [];
|
||||
const errors = Array.isArray(window[${JSON.stringify(captureErrorsVar)}]) ? window[${JSON.stringify(captureErrorsVar)}] : [];
|
||||
window[${JSON.stringify(captureVar)}] = [];
|
||||
window[${JSON.stringify(captureErrorsVar)}] = [];
|
||||
return { data, errors };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export async function installInstagramProtocolCapture(page: IPage): Promise<void> {
|
||||
if (typeof page.startNetworkCapture === 'function') {
|
||||
try {
|
||||
await page.startNetworkCapture(INSTAGRAM_PROTOCOL_CAPTURE_PATTERN);
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Unknown action') && !message.includes('network-capture')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.evaluate(buildInstallInstagramProtocolCaptureJs());
|
||||
}
|
||||
|
||||
export async function readInstagramProtocolCapture(page: IPage): Promise<{
|
||||
data: InstagramProtocolCaptureEntry[];
|
||||
errors: string[];
|
||||
}> {
|
||||
if (typeof page.readNetworkCapture === 'function') {
|
||||
try {
|
||||
const data = await page.readNetworkCapture();
|
||||
return {
|
||||
data: Array.isArray(data) ? data as InstagramProtocolCaptureEntry[] : [],
|
||||
errors: [],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Unknown action') && !message.includes('network-capture')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await page.evaluate(buildReadInstagramProtocolCaptureJs()) as {
|
||||
data?: InstagramProtocolCaptureEntry[];
|
||||
errors?: string[];
|
||||
};
|
||||
return {
|
||||
data: Array.isArray(result?.data) ? result.data : [],
|
||||
errors: Array.isArray(result?.errors) ? result.errors : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function dumpInstagramProtocolCaptureIfEnabled(page: IPage): Promise<void> {
|
||||
if (process.env.OPENCLI_INSTAGRAM_CAPTURE !== '1') return;
|
||||
const payload = await readInstagramProtocolCapture(page);
|
||||
fs.writeFileSync(TRACE_OUTPUT_PATH, JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function buildCookieHeader(cookies: BrowserCookie[]): string {
|
||||
return cookies
|
||||
.filter((cookie) => cookie?.name && cookie?.value)
|
||||
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export async function instagramPrivateApiFetch(
|
||||
page: IPage,
|
||||
input: string | URL,
|
||||
init: {
|
||||
method?: 'GET' | 'POST';
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const url = String(input);
|
||||
const [urlCookies, domainCookies] = await Promise.all([
|
||||
page.getCookies({ url }),
|
||||
page.getCookies({ domain: 'instagram.com' }),
|
||||
]);
|
||||
const merged = new Map<string, BrowserCookie>();
|
||||
for (const cookie of domainCookies) merged.set(cookie.name, cookie);
|
||||
for (const cookie of urlCookies) merged.set(cookie.name, cookie);
|
||||
const cookieHeader = buildCookieHeader(Array.from(merged.values()));
|
||||
const csrf = merged.get('csrftoken')?.value || '';
|
||||
const initHeaders = init.headers ?? {};
|
||||
const requestedAppIdHeader = Object.entries(initHeaders).find(([key]) => key.toLowerCase() === 'x-ig-app-id')?.[1] || '';
|
||||
const runtimeInfo = requestedAppIdHeader ? null : await resolveInstagramRuntimeInfo(page);
|
||||
const appId = requestedAppIdHeader || runtimeInfo?.appId || '';
|
||||
const hasContentType = Object.keys(init.headers ?? {}).some((key) => key.toLowerCase() === 'content-type');
|
||||
|
||||
return fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'X-CSRFToken': csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://www.instagram.com',
|
||||
'Referer': 'https://www.instagram.com/',
|
||||
...(appId ? { 'X-IG-App-ID': appId } : {}),
|
||||
...(cookieHeader ? { 'Cookie': cookieHeader } : {}),
|
||||
...(typeof init.body === 'string' && !hasContentType ? { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } : {}),
|
||||
...initHeaders,
|
||||
},
|
||||
...(init.body !== undefined ? { body: init.body as BodyInit } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BrowserCookie, IPage } from '../../../types.js';
|
||||
|
||||
export interface InstagramRuntimeInfo {
|
||||
appId: string;
|
||||
csrfToken: string;
|
||||
instagramAjax: string;
|
||||
}
|
||||
|
||||
function pickMatch(input: string, patterns: RegExp[]): string {
|
||||
for (const pattern of patterns) {
|
||||
const match = input.match(pattern);
|
||||
if (!match) continue;
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
if (match[index]) return match[index]!;
|
||||
}
|
||||
return match[0] || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function extractInstagramRuntimeInfo(html: string): InstagramRuntimeInfo {
|
||||
return {
|
||||
appId: pickMatch(html, [
|
||||
/"X-IG-App-ID":"(\d+)"/,
|
||||
/"appId":"(\d+)"/,
|
||||
/"app_id":"(\d+)"/,
|
||||
/"instagramWebAppId":"(\d+)"/,
|
||||
]),
|
||||
csrfToken: pickMatch(html, [
|
||||
/"csrf_token":"([^"]+)"/,
|
||||
/"csrfToken":"([^"]+)"/,
|
||||
]),
|
||||
instagramAjax: pickMatch(html, [
|
||||
/"rollout_hash":"([^"]+)"/,
|
||||
/"X-Instagram-AJAX":"([^"]+)"/,
|
||||
/"Instagram-AJAX":"([^"]+)"/,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReadInstagramRuntimeInfoJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
const pick = (patterns) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(new RegExp(pattern, 'i'));
|
||||
if (!match) continue;
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
if (match[index]) return match[index];
|
||||
}
|
||||
return match[0] || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return {
|
||||
appId: pick([
|
||||
'"X-IG-App-ID":"(\\\\d+)"',
|
||||
'"appId":"(\\\\d+)"',
|
||||
'"app_id":"(\\\\d+)"',
|
||||
'"instagramWebAppId":"(\\\\d+)"',
|
||||
]),
|
||||
csrfToken: pick([
|
||||
'"csrf_token":"([^"]+)"',
|
||||
'"csrfToken":"([^"]+)"',
|
||||
]),
|
||||
instagramAjax: pick([
|
||||
'"rollout_hash":"([^"]+)"',
|
||||
'"X-Instagram-AJAX":"([^"]+)"',
|
||||
'"Instagram-AJAX":"([^"]+)"',
|
||||
]),
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function getCookieValue(cookies: BrowserCookie[], name: string): string {
|
||||
return cookies.find((cookie) => cookie.name === name)?.value || '';
|
||||
}
|
||||
|
||||
export async function resolveInstagramRuntimeInfo(page: IPage): Promise<InstagramRuntimeInfo> {
|
||||
const [runtime, cookies] = await Promise.all([
|
||||
page.evaluate(buildReadInstagramRuntimeInfoJs()) as Promise<InstagramRuntimeInfo>,
|
||||
page.getCookies({ domain: 'instagram.com' }),
|
||||
]);
|
||||
return {
|
||||
appId: runtime?.appId || '',
|
||||
csrfToken: runtime?.csrfToken || getCookieValue(cookies, 'csrftoken') || '',
|
||||
instagramAjax: runtime?.instagramAjax || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArgumentError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import './note.js';
|
||||
|
||||
function createPageMock(): IPage {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(undefined),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
insertText: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentUrl: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
describe('instagram note registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers the note command with a required positional content arg', () => {
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && arg.required)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing note content before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
|
||||
await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects blank note content before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
|
||||
await expect(cmd!.func!(page, { content: ' ' })).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects note content longer than 60 characters before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
|
||||
await expect(cmd!.func!(page, { content: 'x'.repeat(61) })).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publishes a note through the web inbox mutation', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
vi.mocked(page.evaluate).mockResolvedValue({
|
||||
ok: true,
|
||||
noteId: '17849203563031468',
|
||||
});
|
||||
|
||||
const rows = await cmd!.func!(page, { content: 'hello note' }) as Array<Record<string, string>>;
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.instagram.com/direct/inbox/');
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
||||
expect(rows).toEqual([{
|
||||
status: '✅ Posted',
|
||||
detail: 'Instagram note published successfully',
|
||||
noteId: '17849203563031468',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
type InstagramNoteSuccessRow = {
|
||||
status: string;
|
||||
detail: string;
|
||||
noteId: string;
|
||||
};
|
||||
|
||||
type BrowserNoteResult = {
|
||||
ok?: boolean;
|
||||
stage?: string;
|
||||
status?: number;
|
||||
text?: string;
|
||||
noteId?: string;
|
||||
};
|
||||
|
||||
const INSTAGRAM_INBOX_URL = 'https://www.instagram.com/direct/inbox/';
|
||||
const INSTAGRAM_NOTE_DOC_ID = '25155183657506484';
|
||||
const INSTAGRAM_NOTE_MUTATION_NAME = 'usePolarisCreateInboxTrayItemSubmitMutation';
|
||||
const INSTAGRAM_NOTE_ROOT_FIELD = 'xdt_create_inbox_tray_item';
|
||||
|
||||
function requirePage(page: IPage | null): IPage {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for instagram note');
|
||||
return page;
|
||||
}
|
||||
|
||||
function validateInstagramNoteArgs(kwargs: Record<string, unknown>): void {
|
||||
if (kwargs.content === undefined) {
|
||||
throw new ArgumentError(
|
||||
'Argument "content" is required.',
|
||||
'Provide a note text, for example: opencli instagram note "hello"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstagramNoteContent(kwargs: Record<string, unknown>): string {
|
||||
const content = String(kwargs.content ?? '').trim();
|
||||
if (!content) {
|
||||
throw new ArgumentError(
|
||||
'Instagram note content cannot be empty.',
|
||||
'Provide a non-empty note text, for example: opencli instagram note "hello"',
|
||||
);
|
||||
}
|
||||
if (Array.from(content).length > 60) {
|
||||
throw new ArgumentError(
|
||||
'Instagram note content must be 60 characters or fewer.',
|
||||
'Shorten the note text and try again.',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function buildNoteSuccessResult(noteId: string): InstagramNoteSuccessRow[] {
|
||||
return [{
|
||||
status: '✅ Posted',
|
||||
detail: 'Instagram note published successfully',
|
||||
noteId,
|
||||
}];
|
||||
}
|
||||
|
||||
function buildPublishInstagramNoteJs(content: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
const input = ${JSON.stringify({ content })};
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
const scripts = Array.from(document.scripts || [])
|
||||
.map((script) => script.textContent || '')
|
||||
.join('\\n');
|
||||
const source = html + '\\n' + scripts;
|
||||
const pick = (patterns) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = source.match(pattern);
|
||||
if (!match) continue;
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
if (match[index]) return match[index];
|
||||
}
|
||||
return match[0] || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const prefix = name + '=';
|
||||
const part = document.cookie
|
||||
.split('; ')
|
||||
.find((cookie) => cookie.startsWith(prefix));
|
||||
return part ? decodeURIComponent(part.slice(prefix.length)) : '';
|
||||
};
|
||||
const actorId = pick([
|
||||
/"actorID":"(\\d+)"/,
|
||||
/"actor_id":"(\\d+)"/,
|
||||
/"viewerId":"(\\d+)"/,
|
||||
]);
|
||||
const fbDtsg = pick([
|
||||
/(NAF[a-zA-Z0-9:_-]{20,})/,
|
||||
/(NAf[a-zA-Z0-9:_-]{20,})/,
|
||||
]);
|
||||
const lsd = pick([
|
||||
/"LSD",\\[\\],\\{"token":"([^"]+)"\\}/,
|
||||
/"lsd":"([^"]+)"/,
|
||||
]);
|
||||
const appId = pick([
|
||||
/"X-IG-App-ID":"(\\d+)"/,
|
||||
/"instagramWebAppId":"(\\d+)"/,
|
||||
/"appId":"(\\d+)"/,
|
||||
]);
|
||||
const asbdId = pick([
|
||||
/"X-ASBD-ID":"(\\d+)"/,
|
||||
/"asbd_id":"(\\d+)"/,
|
||||
]);
|
||||
const spinR = pick([/"__spin_r":(\\d+)/]);
|
||||
const spinB = pick([/"__spin_b":"([^"]+)"/]);
|
||||
const spinT = pick([/"__spin_t":(\\d+)/]);
|
||||
const csrfToken = readCookie('csrftoken') || pick([
|
||||
/"csrf_token":"([^"]+)"/,
|
||||
/"csrfToken":"([^"]+)"/,
|
||||
]);
|
||||
const jazoest = fbDtsg
|
||||
? '2' + Array.from(fbDtsg).reduce((total, char) => total + char.charCodeAt(0), 0)
|
||||
: '';
|
||||
|
||||
if (!actorId || !fbDtsg || !lsd || !appId || !csrfToken || !spinR || !spinB || !spinT || !jazoest) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'config',
|
||||
text: JSON.stringify({
|
||||
actorId: Boolean(actorId),
|
||||
fbDtsg: Boolean(fbDtsg),
|
||||
lsd: Boolean(lsd),
|
||||
appId: Boolean(appId),
|
||||
csrfToken: Boolean(csrfToken),
|
||||
spinR: Boolean(spinR),
|
||||
spinB: Boolean(spinB),
|
||||
spinT: Boolean(spinT),
|
||||
jazoest: Boolean(jazoest),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
actor_id: actorId,
|
||||
client_mutation_id: '1',
|
||||
additional_params: {
|
||||
note_create_params: {
|
||||
note_style: 0,
|
||||
text: input.content,
|
||||
},
|
||||
},
|
||||
audience: 0,
|
||||
inbox_tray_item_type: 'note',
|
||||
},
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('av', actorId);
|
||||
body.set('__user', '0');
|
||||
body.set('__a', '1');
|
||||
body.set('__req', '1');
|
||||
body.set('__hs', '');
|
||||
body.set('dpr', String(window.devicePixelRatio || 1));
|
||||
body.set('__ccg', 'UNKNOWN');
|
||||
body.set('__rev', spinR);
|
||||
body.set('__s', '');
|
||||
body.set('__hsi', '');
|
||||
body.set('__dyn', '');
|
||||
body.set('__csr', '');
|
||||
body.set('__comet_req', '7');
|
||||
body.set('fb_dtsg', fbDtsg);
|
||||
body.set('jazoest', jazoest);
|
||||
body.set('lsd', lsd);
|
||||
body.set('__spin_r', spinR);
|
||||
body.set('__spin_b', spinB);
|
||||
body.set('__spin_t', spinT);
|
||||
body.set('fb_api_caller_class', 'RelayModern');
|
||||
body.set('fb_api_req_friendly_name', ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)});
|
||||
body.set('variables', JSON.stringify(variables));
|
||||
body.set('server_timestamps', 'true');
|
||||
body.set('doc_id', ${JSON.stringify(INSTAGRAM_NOTE_DOC_ID)});
|
||||
|
||||
const headers = {
|
||||
Accept: '*/*',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-ASBD-ID': asbdId || undefined,
|
||||
'X-CSRFToken': csrfToken,
|
||||
'X-FB-Friendly-Name': ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)},
|
||||
'X-FB-LSD': lsd,
|
||||
'X-IG-App-ID': appId,
|
||||
'X-Root-Field-Name': ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)},
|
||||
};
|
||||
|
||||
const response = await fetch('/graphql/query', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: body.toString(),
|
||||
});
|
||||
const text = await response.text();
|
||||
const normalizedText = text.replace(/^for \\(;;\\);?/, '').trim();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(normalizedText);
|
||||
} catch {}
|
||||
|
||||
const rootField = ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)};
|
||||
const note = data?.data?.[rootField]?.inbox_tray_item;
|
||||
const noteId = String(note?.inbox_tray_item_id || note?.id || '');
|
||||
if (response.ok && noteId) {
|
||||
return {
|
||||
ok: true,
|
||||
stage: 'publish',
|
||||
noteId,
|
||||
text: String(note?.note_dict?.text || input.content || ''),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'publish',
|
||||
status: response.status,
|
||||
text: normalizedText || text,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'note',
|
||||
description: 'Publish a text Instagram note',
|
||||
domain: 'www.instagram.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
timeoutSeconds: 120,
|
||||
args: [
|
||||
{ name: 'content', positional: true, required: true, help: 'Note text (max 60 characters)' },
|
||||
],
|
||||
columns: ['status', 'detail', 'noteId'],
|
||||
validateArgs: validateInstagramNoteArgs,
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
const browserPage = requirePage(page);
|
||||
const content = normalizeInstagramNoteContent(kwargs as Record<string, unknown>);
|
||||
await browserPage.goto(INSTAGRAM_INBOX_URL);
|
||||
await browserPage.wait({ time: 2 });
|
||||
const result = await browserPage.evaluate(buildPublishInstagramNoteJs(content)) as BrowserNoteResult;
|
||||
if (!result?.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`Instagram note publish failed at ${String(result?.stage || 'unknown')}: ${String(result?.text || 'unknown error')}`,
|
||||
);
|
||||
}
|
||||
return buildNoteSuccessResult(String(result.noteId || ''));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import * as privatePublish from './_shared/private-publish.js';
|
||||
import { buildClickActionJs, buildEnsureComposerOpenJs, buildInspectUploadStageJs, buildPublishStatusProbeJs } from './post.js';
|
||||
import './post.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempImage(name = 'demo.jpg', bytes = Buffer.from([0xff, 0xd8, 0xff, 0xd9])): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-video-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createPageMock(evaluateResults: unknown[], overrides: Partial<IPage> = {}): IPage {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
insertText: undefined,
|
||||
getCurrentUrl: vi.fn().mockResolvedValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('instagram auth detection', () => {
|
||||
it('does not treat generic homepage text containing "log in" as an auth failure', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
};
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
|
||||
globalState.document = {
|
||||
body: { innerText: 'Suggested for you Log in to see more content' },
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
} as unknown as Document;
|
||||
globalState.window = { location: { pathname: '/' } } as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildEnsureComposerOpenJs()) as { ok: boolean; reason?: string }).toEqual({ ok: true });
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram publish status detection', () => {
|
||||
it('does not treat unrelated page text as share failure while the sharing dialog is still visible', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {}
|
||||
|
||||
const visibleDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
visibleDialog.textContent = 'Sharing';
|
||||
visibleDialog.querySelector = () => null;
|
||||
visibleDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [visibleDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
location: { href: 'https://www.instagram.com/' },
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
|
||||
ok: false,
|
||||
failed: false,
|
||||
settled: false,
|
||||
url: '',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat a stale visible error dialog as share failure while sharing is still in progress', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {}
|
||||
|
||||
const sharingDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
sharingDialog.textContent = 'Sharing';
|
||||
sharingDialog.querySelector = () => null;
|
||||
sharingDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again';
|
||||
staleErrorDialog.querySelector = () => null;
|
||||
staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharingDialog, staleErrorDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
location: { href: 'https://www.instagram.com/' },
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
|
||||
ok: false,
|
||||
failed: false,
|
||||
settled: false,
|
||||
url: '',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers explicit post-shared success over stale visible error text', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {}
|
||||
|
||||
const sharedDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
sharedDialog.textContent = 'Post shared Your post has been shared.';
|
||||
sharedDialog.querySelector = () => null;
|
||||
sharedDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again';
|
||||
staleErrorDialog.querySelector = () => null;
|
||||
staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharedDialog, staleErrorDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
location: { href: 'https://www.instagram.com/' },
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
|
||||
ok: true,
|
||||
failed: false,
|
||||
settled: false,
|
||||
url: '',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram click action detection', () => {
|
||||
it('matches aria-label-only Next buttons in the media dialog', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {
|
||||
textContent = '';
|
||||
ariaLabel = '';
|
||||
clicked = false;
|
||||
querySelectorAll = (_selector: string) => [] as unknown[];
|
||||
querySelector = (_selector: string) => null as unknown;
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'aria-label') return this.ariaLabel || null;
|
||||
return null;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 40 };
|
||||
}
|
||||
click() {
|
||||
this.clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const nextButton = new MockHTMLElement();
|
||||
nextButton.ariaLabel = 'Next';
|
||||
|
||||
const dialog = new MockHTMLElement();
|
||||
dialog.textContent = 'Crop Back Select crop Open media gallery';
|
||||
dialog.querySelector = (selector: string) => selector === 'input[type="file"]' ? {} as Element : null;
|
||||
dialog.querySelectorAll = (selector: string) => selector === 'button, div[role="button"]' ? [nextButton] : [];
|
||||
|
||||
const body = new MockHTMLElement();
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [dialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean; label?: string }).toEqual({
|
||||
ok: true,
|
||||
label: 'Next',
|
||||
});
|
||||
expect(nextButton.clicked).toBe(true);
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not click a body-level Next button when media scope has no matching dialog controls', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {
|
||||
textContent = '';
|
||||
ariaLabel = '';
|
||||
clicked = false;
|
||||
children: unknown[] = [];
|
||||
querySelectorAll = (_selector: string) => this.children;
|
||||
querySelector = (_selector: string) => null as unknown;
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'aria-label') return this.ariaLabel || null;
|
||||
return null;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 40 };
|
||||
}
|
||||
click() {
|
||||
this.clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const bodyNext = new MockHTMLElement();
|
||||
bodyNext.ariaLabel = 'Next';
|
||||
|
||||
const errorDialog = new MockHTMLElement();
|
||||
errorDialog.textContent = 'Something went wrong Try again';
|
||||
errorDialog.children = [];
|
||||
|
||||
const body = new MockHTMLElement();
|
||||
body.children = [bodyNext];
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [errorDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean }).toEqual({ ok: false });
|
||||
expect(bodyNext.clicked).toBe(false);
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram upload stage detection', () => {
|
||||
it('does not treat a body-level Next button as upload preview when the visible dialog is an error', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {
|
||||
textContent = '';
|
||||
ariaLabel = '';
|
||||
children: unknown[] = [];
|
||||
querySelectorAll = (_selector: string) => this.children;
|
||||
querySelector = (_selector: string) => null as unknown;
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'aria-label') return this.ariaLabel || null;
|
||||
return null;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 40 };
|
||||
}
|
||||
}
|
||||
|
||||
const bodyNext = new MockHTMLElement();
|
||||
bodyNext.ariaLabel = 'Next';
|
||||
|
||||
const errorDialog = new MockHTMLElement();
|
||||
errorDialog.textContent = 'Something went wrong. Please try again. Try again';
|
||||
|
||||
const body = new MockHTMLElement();
|
||||
body.children = [bodyNext];
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === '[role="dialog"]') return [errorDialog];
|
||||
return [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildInspectUploadStageJs()) as { state: string; detail: string }).toEqual({
|
||||
state: 'failed',
|
||||
detail: 'Something went wrong. Please try again. Try again',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram post registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
|
||||
apiContext: {
|
||||
asbdId: '',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: '',
|
||||
instagramAjax: '1036523242',
|
||||
webSessionId: '',
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers the post command with a required-value media arg', () => {
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.timeoutSeconds).toBe(300);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'content' && !arg.required && arg.positional)).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes via private API and returns the post URL', async () => {
|
||||
const imagePath = createTempImage('private-default.jpg');
|
||||
const privateSpy = vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockResolvedValueOnce({
|
||||
code: 'PRIVATEDEFAULT123',
|
||||
uploadIds: ['111'],
|
||||
});
|
||||
const page = createPageMock([], {
|
||||
evaluate: vi.fn(async () => ({ ok: true })),
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
const result = await cmd!.func!(page, { media: imagePath, content: 'private default' });
|
||||
|
||||
expect(privateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(page.setFileInput).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single image post shared successfully',
|
||||
url: 'https://www.instagram.com/p/PRIVATEDEFAULT123/',
|
||||
},
|
||||
]);
|
||||
privateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('publishes mixed-media posts via private API and preserves input order', async () => {
|
||||
const imagePath = createTempImage('mixed-default.jpg');
|
||||
const videoPath = createTempVideo('mixed-default.mp4');
|
||||
const privateSpy = vi.spyOn(privatePublish, 'publishMediaViaPrivateApi').mockResolvedValueOnce({
|
||||
code: 'MIXEDPRIVATE123',
|
||||
uploadIds: ['111', '222'],
|
||||
});
|
||||
const page = createPageMock([], {
|
||||
evaluate: vi.fn(async () => ({ ok: true })),
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
const result = await cmd!.func!(page, {
|
||||
media: `${imagePath},${videoPath}`,
|
||||
content: 'mixed private default',
|
||||
});
|
||||
|
||||
expect(privateSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mediaItems: [
|
||||
{ type: 'image', filePath: imagePath },
|
||||
{ type: 'video', filePath: videoPath },
|
||||
],
|
||||
caption: 'mixed private default',
|
||||
}));
|
||||
expect(page.setFileInput).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: '2-item mixed-media carousel post shared successfully',
|
||||
url: 'https://www.instagram.com/p/MIXEDPRIVATE123/',
|
||||
},
|
||||
]);
|
||||
privateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects missing --media before browser work', async () => {
|
||||
const page = createPageMock([]);
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
content: 'missing media',
|
||||
})).rejects.toThrow('Argument "media" is required.');
|
||||
});
|
||||
|
||||
it('rejects empty or invalid --media inputs', async () => {
|
||||
const imagePath = createTempImage('invalid-media-image.jpg');
|
||||
const page = createPageMock([]);
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
media: '',
|
||||
})).rejects.toThrow('Argument "media" is required.');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
media: `${imagePath},/tmp/does-not-exist.mp4`,
|
||||
})).rejects.toThrow('Media file not found');
|
||||
});
|
||||
|
||||
it('propagates private API errors directly', async () => {
|
||||
const imagePath = createTempImage('private-fail.jpg');
|
||||
vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockRejectedValueOnce(
|
||||
new CommandExecutionError('Instagram private publish configure failed: 400'),
|
||||
);
|
||||
const page = createPageMock([], {
|
||||
evaluate: vi.fn(async () => ({ ok: true })),
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
media: imagePath,
|
||||
content: 'should fail',
|
||||
})).rejects.toThrow('Instagram private publish configure failed: 400');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user