Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0da77a5773 | |||
| 5b3cd56d6d | |||
| 72362c0bfb | |||
| 7aafd4af59 | |||
| a5abd3769f | |||
| 8070960444 | |||
| b1c0bcb464 | |||
| e18e0ed7a4 | |||
| c161f0f9f0 | |||
| dcad060230 | |||
| ff84d19ded | |||
| f594e500a8 | |||
| f2a3ee6ee4 | |||
| f377ec000c | |||
| 988908f348 | |||
| 2b623b35b6 | |||
| 6cdcb9dd51 | |||
| 835c146fb7 | |||
| fc818b3c2c | |||
| 0ce46b15bb | |||
| 37f1b46a77 | |||
| 2d005d14a8 | |||
| 1708626731 | |||
| 5fe081b28c | |||
| 0c75ab3f7e |
+1
-1
@@ -23,4 +23,4 @@ docs/.vitepress/cache
|
||||
# Database files
|
||||
*.db
|
||||
autoresearch/results/
|
||||
extension/dist/
|
||||
autoresearch-results.tsv
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
|
||||
> Zero risk · Reuse Chrome/Chromium login · AI-powered discovery · Universal CLI Hub
|
||||
|
||||
[](./README.zh-CN.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
@@ -23,7 +23,7 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i
|
||||
- **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.
|
||||
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
|
||||
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `operate` controls the browser directly.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
@@ -39,7 +39,7 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i
|
||||
|
||||
### 1. Install Browser Bridge Extension
|
||||
|
||||
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome/Chromium extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
|
||||
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
|
||||
@@ -115,9 +115,9 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
- **Chrome or Chromium** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome/Chromium login session. You must be logged into the target website in Chrome or Chromium before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
@@ -130,8 +130,10 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
| **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` |
|
||||
|
||||
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
@@ -265,9 +267,9 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions` in Chrome or Chromium.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
|
||||
- **Empty data or 'Unauthorized' error** — Your Chrome login session may have expired. Navigate to the target site and log in again.
|
||||
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
|
||||
- **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs.
|
||||
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
|
||||
|
||||
|
||||
+11
-9
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
> 零风控 · 复用 Chrome/Chromium 登录 · AI 自动发现接口 · 全能 CLI 枢纽
|
||||
|
||||
[](./README.md)
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
@@ -25,7 +25,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
- **浏览器自动化** — `operate` 赋予 AI Agent 直接操控浏览器的能力:点击、输入、提取、截图,任意交互皆可脚本化
|
||||
- **网页转 CLI** — 将任意网站变成确定性命令行工具:73+ 预置适配器,或用 `opencli record` 沉淀自己的操作
|
||||
- **多站点覆盖** — 73+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **零风控** — 复用 Chrome/Chromium 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略,`operate` 直接控制浏览器
|
||||
@@ -35,11 +35,11 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
|
||||
- **Chrome 或 Chromium** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com)
|
||||
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome/Chromium 登录状态。运行命令前,你必须已在 Chrome 或 Chromium 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
|
||||
### Browser Bridge 扩展配置
|
||||
|
||||
@@ -47,7 +47,7 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
|
||||
|
||||
**方式一:下载构建好的安装包(推荐)**
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`。
|
||||
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
2. 解压后在 Chrome 或 Chromium 中打开 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹。
|
||||
|
||||
**方式二:加载源码(针对开发者)**
|
||||
@@ -195,7 +195,9 @@ 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` | 浏览器 |
|
||||
|
||||
73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
@@ -385,11 +387,11 @@ opencli cascade https://api.example.com/data
|
||||
## 常见问题排查
|
||||
|
||||
- **"Extension not connected" 报错**
|
||||
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- 确保你当前的 Chrome 或 Chromium 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
|
||||
- 其他 Chrome 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
|
||||
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
|
||||
- **返回空数据,或者报错 "Unauthorized"**
|
||||
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
|
||||
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 (如 parseArgs, fs 等)**
|
||||
- 确保 Node.js 版本 `>= 20`。
|
||||
- **Daemon 问题**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "extract-title-iana",
|
||||
"steps": [
|
||||
"opencli operate open https://www.iana.org",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent\""
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -67,7 +67,7 @@
|
||||
"name": "extract-github-readme-heading",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/vercel/next.js",
|
||||
"opencli operate eval \"document.querySelector('article h1, article h2')?.textContent?.trim()\""
|
||||
"opencli operate eval \"document.querySelector('[data-testid=readme] h1, [data-testid=readme] h2, #readme h1, #readme h2, article h1, article h2, .markdown-body h1, .markdown-body h2')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -88,7 +88,8 @@
|
||||
"name": "extract-npm-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/package/express",
|
||||
"opencli operate eval \"document.querySelector('p[class*=description], [data-testid=package-description], #readme p')?.textContent?.trim()\""
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -142,7 +143,7 @@
|
||||
"name": "list-quotes-3",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote, [class*=quote]')].slice(0,3).map(el=>({text:(el.querySelector('.text, [class*=text]')?.textContent)||(el.querySelector('span')?.textContent),author:(el.querySelector('.author, [class*=author]')?.textContent)||(el.querySelector('small')?.textContent)})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -164,7 +165,7 @@
|
||||
"name": "list-github-trending",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,3).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' '),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,3).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim()),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -175,7 +176,7 @@
|
||||
"name": "list-github-trending-lang",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,5).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' ')})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,5).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim())})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -207,8 +208,7 @@
|
||||
{
|
||||
"name": "search-google",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com",
|
||||
"opencli operate eval \"document.querySelector('textarea[name=q], input[name=q]').value='opencli github';document.querySelector('form').submit();'submitted'\"",
|
||||
"opencli operate open https://www.google.com/search?q=opencli+github",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
|
||||
],
|
||||
@@ -249,8 +249,7 @@
|
||||
{
|
||||
"name": "search-wiki",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org",
|
||||
"opencli operate eval \"document.querySelector('input[name=search]').value='Rust programming language';document.querySelector('form#searchform, form[role=search]').submit();'submitted'\"",
|
||||
"opencli operate open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
@@ -263,11 +262,9 @@
|
||||
{
|
||||
"name": "search-npm",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com",
|
||||
"opencli operate state",
|
||||
"opencli operate type 1 \"react\"",
|
||||
"opencli operate keys Enter",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
"opencli operate open https://www.npmjs.com/search?q=react",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3, .package-list-item h3, a[class*=package] h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -294,7 +291,7 @@
|
||||
"opencli operate open https://example.com",
|
||||
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
"opencli operate eval \"document.title + ' ' + location.href\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
@@ -328,8 +325,8 @@
|
||||
"name": "nav-click-wiki-link",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
|
||||
"opencli operate eval \"document.querySelector('#toc a, .toc a, [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History, #History ~ p')?.textContent?.slice(0,100)\""
|
||||
"opencli operate eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -479,7 +476,7 @@
|
||||
"name": "form-textarea",
|
||||
"steps": [
|
||||
"opencli operate open https://httpbin.org/forms/post",
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments]');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments], textarea[name=delivery], textarea');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
@@ -580,7 +577,8 @@
|
||||
"name": "bench-imdb-matrix",
|
||||
"steps": [
|
||||
"opencli operate open https://www.imdb.com/title/tt0133093/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1 span, [data-testid=hero__pageTitle] span')?.textContent,year:document.querySelector('a[href*=releaseinfo], [data-testid=hero-title-block__metadata] a')?.textContent})\""
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:debug — Hypothesis-driven debugging for specific failing tasks.
|
||||
*
|
||||
* Scientific method: Gather → Hypothesize → Test → Classify → Log → Repeat
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/debug.ts --task extract-npm-description
|
||||
* npx tsx autoresearch/commands/debug.ts --task bench-imdb-matrix --iterations 5
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
const TASKS_FILE = join(__dirname, '..', 'browse-tasks.json');
|
||||
const DEBUG_LOG = join(ROOT, 'debug-results.tsv');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: { type: string; value?: string; minLength?: number; pattern?: string };
|
||||
}
|
||||
|
||||
function exec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT, timeout: 30_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function initLog(): void {
|
||||
if (!existsSync(DEBUG_LOG)) {
|
||||
writeFileSync(DEBUG_LOG, '# AutoResearch Debug Log\niteration\ttask\thypothesis\tresult\tverdict\tdescription\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(iteration: number, task: string, hypothesis: string, result: string, verdict: string, description: string): void {
|
||||
appendFileSync(DEBUG_LOG, `${iteration}\t${task}\t${hypothesis}\t${result}\t${verdict}\t${description}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const taskName = args.task;
|
||||
const maxIterations = args.iterations ?? 10;
|
||||
|
||||
if (!taskName) {
|
||||
console.error('Usage: npx tsx autoresearch/commands/debug.ts --task <task-name> [--iterations N]');
|
||||
console.error('\nAvailable tasks:');
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
// Show only failing tasks
|
||||
for (const task of tasks) {
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
let lastOutput = '';
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
const passed = lastOutput.trim().length > 0; // simplified check
|
||||
if (!passed) console.error(` ✗ ${task.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const task = tasks.find(t => t.name === taskName);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${taskName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
|
||||
console.log(` Steps: ${task.steps.length}`);
|
||||
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
|
||||
console.log(` Max iterations: ${maxIterations}\n`);
|
||||
|
||||
initLog();
|
||||
|
||||
// Phase 1: Gather — run the task and capture output
|
||||
console.log('Phase 1: Gathering symptoms...');
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
|
||||
lastOutput = exec(step);
|
||||
if (i < task.steps.length - 1) {
|
||||
console.log(` → ${lastOutput.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
|
||||
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
|
||||
|
||||
// Phase 2: Hypothesize + investigate via Claude Code
|
||||
for (let iter = 1; iter <= maxIterations; iter++) {
|
||||
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
|
||||
|
||||
const prompt = `You are debugging a failing browser automation task.
|
||||
|
||||
## Task: ${taskName}
|
||||
Steps:
|
||||
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
## Judge criteria
|
||||
${JSON.stringify(task.judge)}
|
||||
|
||||
## Last output
|
||||
${lastOutput.slice(0, 500)}
|
||||
|
||||
## Instructions
|
||||
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
|
||||
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
|
||||
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
|
||||
4. If CONFIRMED: describe the root cause and suggest a fix
|
||||
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
|
||||
|
||||
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
|
||||
// Extract hypothesis and result
|
||||
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
|
||||
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
|
||||
|
||||
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
|
||||
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
|
||||
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
|
||||
|
||||
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
|
||||
console.log(` Verdict: ${verdict} — ${description.slice(0, 100)}`);
|
||||
|
||||
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
|
||||
|
||||
if (verdict === 'CONFIRMED') {
|
||||
console.log(`\n✅ Root cause found at iteration ${iter}!`);
|
||||
console.log(` ${description}`);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(` Error: ${err.message?.slice(0, 100)}`);
|
||||
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
|
||||
}
|
||||
|
||||
// Re-run task for fresh output
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
}
|
||||
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:fix — Iterative error elimination.
|
||||
*
|
||||
* Auto-detects broken state (build → test → browse tests) and iteratively
|
||||
* fixes errors one at a time. Stops when error count reaches 0.
|
||||
*
|
||||
* Priority: build errors → test failures → browse task failures
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/fix.ts
|
||||
* npx tsx autoresearch/commands/fix.ts --iterations 10
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function exec(cmd: string): { ok: boolean; output: string } {
|
||||
try {
|
||||
const output = execSync(cmd, {
|
||||
cwd: ROOT, timeout: 120_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return { ok: true, output };
|
||||
} catch (err: any) {
|
||||
return { ok: false, output: (err.stdout ?? '') + '\n' + (err.stderr ?? '') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect current broken state and return verify command + error count */
|
||||
function detectBrokenState(): { verify: string; errors: number; description: string } | null {
|
||||
// 1. Build
|
||||
const build = exec('npm run build 2>&1');
|
||||
if (!build.ok) {
|
||||
const errorCount = (build.output.match(/error TS/g) || []).length || 1;
|
||||
return {
|
||||
verify: 'npm run build 2>&1 | grep -c "error TS" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} TypeScript build error(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Tests
|
||||
const test = exec('npm test 2>&1');
|
||||
if (!test.ok) {
|
||||
const failMatch = test.output.match(/(\d+)\s+fail/i);
|
||||
const errorCount = failMatch ? parseInt(failMatch[1], 10) : 1;
|
||||
return {
|
||||
verify: 'npm test 2>&1 | grep -oP "\\d+(?= fail)" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} test failure(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Browse tests
|
||||
const browse = exec('npx tsx autoresearch/eval-browse.ts 2>&1');
|
||||
const scoreMatch = browse.output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const passed = parseInt(scoreMatch[1], 10);
|
||||
const total = parseInt(scoreMatch[2], 10);
|
||||
const failures = total - passed;
|
||||
if (failures > 0) {
|
||||
return {
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
errors: failures,
|
||||
description: `${failures} browse task failure(s) (${passed}/${total})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null; // all clean
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const maxIterations = args.iterations ?? 20;
|
||||
|
||||
console.log('\n🔧 AutoResearch Fix — Detecting broken state...\n');
|
||||
|
||||
const broken = detectBrokenState();
|
||||
if (!broken) {
|
||||
console.log(' ✓ All clean — nothing to fix!\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Found: ${broken.description}`);
|
||||
console.log(` Verify: ${broken.verify}\n`);
|
||||
|
||||
const config = {
|
||||
goal: `Fix all errors: ${broken.description}`,
|
||||
scope: ['src/**/*.ts', 'extension/src/**/*.ts'],
|
||||
metric: 'error_count',
|
||||
direction: 'lower' as const,
|
||||
verify: broken.verify,
|
||||
guard: 'npm run build',
|
||||
iterations: maxIterations,
|
||||
minDelta: 1,
|
||||
};
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: async (ctx: ModifyContext) => {
|
||||
const prompt = `Fix ONE error. Current error count: ${ctx.currentMetric}. Goal: 0 errors.
|
||||
|
||||
Read the error output, understand the root cause, and make ONE focused fix.
|
||||
Do NOT fix multiple unrelated errors at once.
|
||||
Do NOT modify test files.
|
||||
|
||||
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await engine.run();
|
||||
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
|
||||
if (finalMetric === 0) {
|
||||
console.log('\n✅ All errors fixed!\n');
|
||||
} else {
|
||||
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:plan — Interactive configuration wizard.
|
||||
*
|
||||
* Walks through goal, scope, metric, verify, guard settings
|
||||
* and outputs a ready-to-paste run command.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/plan.ts
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔬 AutoResearch — Configuration Wizard\n');
|
||||
|
||||
// Offer presets first
|
||||
const presetNames = Object.keys(PRESETS);
|
||||
console.log('Available presets:');
|
||||
presetNames.forEach((name, i) => {
|
||||
console.log(` [${i + 1}] ${name} — ${PRESETS[name].goal}`);
|
||||
});
|
||||
console.log(` [0] Custom config\n`);
|
||||
|
||||
const choice = await ask('Choose preset or 0 for custom: ');
|
||||
const idx = parseInt(choice, 10);
|
||||
|
||||
if (idx > 0 && idx <= presetNames.length) {
|
||||
const name = presetNames[idx - 1];
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
const iterFlag = iterations ? ` --iterations ${iterations}` : '';
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` npx tsx autoresearch/commands/run.ts --preset ${name}${iterFlag}\n`);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom config
|
||||
const goal = await ask('Goal (what to improve): ');
|
||||
const scope = await ask('Scope (file globs, comma-separated): ');
|
||||
const metric = await ask('Metric name (e.g. pass_count, coverage): ');
|
||||
const direction = await ask('Direction (higher/lower): ') as 'higher' | 'lower';
|
||||
const verify = await ask('Verify command (must output a number): ');
|
||||
|
||||
// Dry-run verify
|
||||
console.log('\n Dry-running verify command...');
|
||||
try {
|
||||
const output = execSync(verify, { cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
const { extractMetric } = await import('../config.js');
|
||||
const value = extractMetric(output);
|
||||
if (value != null) {
|
||||
console.log(` ✓ Verify works — current ${metric}: ${value}`);
|
||||
} else {
|
||||
console.log(` ⚠ Verify ran but no number extracted from output:\n ${output.slice(0, 200)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(` ✗ Verify failed: ${err.message?.slice(0, 100)}`);
|
||||
}
|
||||
|
||||
const guard = await ask('Guard command (optional, press Enter to skip): ');
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
|
||||
const parts = ['npx tsx autoresearch/commands/run.ts'];
|
||||
parts.push(`--goal "${goal}"`);
|
||||
parts.push(`--scope "${scope}"`);
|
||||
parts.push(`--metric "${metric}"`);
|
||||
parts.push(`--direction ${direction}`);
|
||||
parts.push(`--verify "${verify}"`);
|
||||
if (guard) parts.push(`--guard "${guard}"`);
|
||||
if (iterations) parts.push(`--iterations ${iterations}`);
|
||||
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` ${parts.join(' \\\n ')}\n`);
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch — Main autonomous iteration loop.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
|
||||
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
|
||||
*
|
||||
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
|
||||
* Engine handles commit, verify, guard, keep/discard, and logging.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, type AutoResearchConfig } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
|
||||
const recent = ctx.recentLog.slice(-10).map(r =>
|
||||
` ${r.status.padEnd(12)} ${r.description}`
|
||||
).join('\n');
|
||||
|
||||
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
|
||||
|
||||
## Goal
|
||||
${config.goal}
|
||||
|
||||
## Current State
|
||||
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
|
||||
- Iteration: ${ctx.iteration}
|
||||
- Consecutive discards: ${ctx.consecutiveDiscards}
|
||||
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
|
||||
|
||||
## Recent History
|
||||
${recent || ' (no history yet)'}
|
||||
|
||||
## Git Log (recent experiments)
|
||||
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
|
||||
|
||||
## Scope (files you can modify)
|
||||
${ctx.scopeFiles.join('\n')}
|
||||
|
||||
## Rules
|
||||
1. Make ONE atomic change (one logical intent, even if multiple files)
|
||||
2. Read the failing test output or code BEFORE modifying
|
||||
3. DO NOT modify test files or the verify command
|
||||
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
|
||||
5. If previous approach was discarded, try something DIFFERENT
|
||||
6. Focus on the specific failures — read error messages carefully`;
|
||||
}
|
||||
|
||||
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
|
||||
const prompt = buildModifyPrompt(ctx, config);
|
||||
|
||||
console.log(' Claude Code making a change...');
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{
|
||||
cwd: ROOT,
|
||||
timeout: 300_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}
|
||||
).trim();
|
||||
|
||||
// Extract description from Claude's response (last non-empty line or summary)
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
|
||||
return desc.slice(0, 120);
|
||||
} catch (err: any) {
|
||||
console.error(' Claude Code failed:', err.message?.slice(0, 100));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Resolve config from preset or CLI args
|
||||
let config: AutoResearchConfig;
|
||||
if (args.preset) {
|
||||
config = PRESETS[args.preset];
|
||||
if (!config) {
|
||||
console.error(`Unknown preset: ${args.preset}`);
|
||||
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Allow CLI overrides
|
||||
if (args.iterations != null) config = { ...config, iterations: args.iterations };
|
||||
if (args.guard != null) config = { ...config, guard: args.guard };
|
||||
} else if (args.goal && args.verify) {
|
||||
config = {
|
||||
goal: args.goal,
|
||||
scope: args.scope ?? ['src/**/*.ts'],
|
||||
metric: args.metric ?? 'score',
|
||||
direction: args.direction ?? 'higher',
|
||||
verify: args.verify,
|
||||
guard: args.guard,
|
||||
iterations: args.iterations,
|
||||
minDelta: args.minDelta,
|
||||
};
|
||||
} else {
|
||||
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
|
||||
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
|
||||
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 AutoResearch: ${config.goal}`);
|
||||
console.log(` Metric: ${config.metric} (${config.direction})`);
|
||||
console.log(` Verify: ${config.verify}`);
|
||||
console.log(` Guard: ${config.guard ?? '(none)'}`);
|
||||
console.log(` Iterations: ${config.iterations ?? '∞'}`);
|
||||
console.log('');
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: (ctx) => modify(ctx, config),
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
await engine.run();
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AutoResearch Configuration — type definitions and CLI parsing.
|
||||
*
|
||||
* Based on Karpathy's autoresearch: constraint + mechanical metric + unbounded loop.
|
||||
*/
|
||||
|
||||
export interface AutoResearchConfig {
|
||||
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
|
||||
goal: string;
|
||||
/** Glob patterns for files the agent can modify */
|
||||
scope: string[];
|
||||
/** What the metric measures, e.g. "pass_count" */
|
||||
metric: string;
|
||||
/** Whether improvement means the number goes up or down */
|
||||
direction: 'higher' | 'lower';
|
||||
/** Shell command that outputs a number (the metric value) */
|
||||
verify: string;
|
||||
/** Optional guard command — must pass for a keep decision */
|
||||
guard?: string;
|
||||
/** Max iterations (undefined = unbounded) */
|
||||
iterations?: number;
|
||||
/** Minimum delta to count as real improvement (noise filter) */
|
||||
minDelta?: number;
|
||||
}
|
||||
|
||||
export type IterationStatus =
|
||||
| 'baseline'
|
||||
| 'keep'
|
||||
| 'keep (reworked)'
|
||||
| 'discard'
|
||||
| 'crash'
|
||||
| 'no-op'
|
||||
| 'hook-blocked';
|
||||
|
||||
export interface IterationResult {
|
||||
iteration: number;
|
||||
commit: string;
|
||||
metric: number;
|
||||
delta: number;
|
||||
guard: 'pass' | 'fail' | '-';
|
||||
status: IterationStatus;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
|
||||
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
|
||||
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (arg) {
|
||||
case '--preset': config.preset = next; i++; break;
|
||||
case '--goal': config.goal = next; i++; break;
|
||||
case '--scope': config.scope = next?.split(','); i++; break;
|
||||
case '--metric': config.metric = next; i++; break;
|
||||
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
|
||||
case '--verify': config.verify = next; i++; break;
|
||||
case '--guard': config.guard = next; i++; break;
|
||||
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
|
||||
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
|
||||
case '--task': config.task = next; i++; break;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Extract a number from command output using common patterns */
|
||||
export function extractMetric(output: string): number | null {
|
||||
// Try: last line that looks like a number
|
||||
const lines = output.trim().split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
|
||||
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
|
||||
if (scoreMatch) return parseFloat(scoreMatch[1]);
|
||||
const numMatch = line.match(/^[\d.]+$/);
|
||||
if (numMatch) return parseFloat(numMatch[0]);
|
||||
}
|
||||
// Fallback: first number in output
|
||||
const fallback = output.match(/(\d+(?:\.\d+)?)/);
|
||||
return fallback ? parseFloat(fallback[1]) : null;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* AutoResearch Engine — Karpathy's 8-phase autonomous iteration loop.
|
||||
*
|
||||
* Phase 0: Precondition checks (git clean, no locks)
|
||||
* Phase 1: Review (read scope files + log + git history)
|
||||
* Phase 2: Ideate (select next change based on history)
|
||||
* Phase 3: Modify (one atomic change — delegated to caller)
|
||||
* Phase 4: Commit (git add + commit with experiment prefix)
|
||||
* Phase 5: Verify (run verify command, extract metric)
|
||||
* Phase 5.5: Guard (optional regression check)
|
||||
* Phase 6: Decide (keep/discard/crash + rollback)
|
||||
* Phase 7: Log (append TSV)
|
||||
* Phase 8: Repeat
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
export interface EngineCallbacks {
|
||||
/** Called at Phase 2-3: review context, ideate, and make ONE change.
|
||||
* Return a one-sentence description of what was changed, or null to skip. */
|
||||
modify(context: ModifyContext): Promise<string | null>;
|
||||
|
||||
/** Called when engine needs to report status */
|
||||
onStatus?(msg: string): void;
|
||||
}
|
||||
|
||||
export interface ModifyContext {
|
||||
iteration: number;
|
||||
bestMetric: number;
|
||||
currentMetric: number;
|
||||
recentLog: IterationResult[];
|
||||
gitLog: string;
|
||||
scopeFiles: string[];
|
||||
consecutiveDiscards: number;
|
||||
stuckHint: string | null;
|
||||
}
|
||||
|
||||
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
|
||||
|
||||
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: opts?.cwd ?? ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function execStrict(cmd: string, opts?: { timeout?: number }): string {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private config: AutoResearchConfig;
|
||||
private logger: Logger;
|
||||
private callbacks: EngineCallbacks;
|
||||
private bestMetric: number = 0;
|
||||
private currentMetric: number = 0;
|
||||
private iteration: number = 0;
|
||||
|
||||
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
|
||||
this.config = config;
|
||||
this.logger = new Logger(logPath);
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
private log(msg: string): void {
|
||||
this.callbacks.onStatus?.(msg);
|
||||
}
|
||||
|
||||
/** Phase 0: Precondition checks */
|
||||
private checkPreconditions(): void {
|
||||
// Git repo exists
|
||||
try { execStrict('git rev-parse --git-dir'); }
|
||||
catch { throw new Error('Not a git repository'); }
|
||||
|
||||
// Clean working tree
|
||||
const status = exec('git status --porcelain');
|
||||
if (status) throw new Error(`Working tree not clean:\n${status}`);
|
||||
|
||||
// No stale locks
|
||||
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
|
||||
throw new Error('Stale .git/index.lock found — remove it first');
|
||||
}
|
||||
|
||||
// Not detached HEAD
|
||||
try { execStrict('git symbolic-ref HEAD'); }
|
||||
catch { throw new Error('Detached HEAD — checkout a branch first'); }
|
||||
}
|
||||
|
||||
/** Phase 5: Run verify command and extract metric */
|
||||
private runVerify(): number | null {
|
||||
this.log(' verify...');
|
||||
const output = exec(this.config.verify, { timeout: 300_000 });
|
||||
return extractMetric(output);
|
||||
}
|
||||
|
||||
/** Phase 5.5: Run guard command */
|
||||
private runGuard(): boolean {
|
||||
if (!this.config.guard) return true;
|
||||
this.log(' guard...');
|
||||
try {
|
||||
execStrict(this.config.guard, { timeout: 300_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 4: Commit changes */
|
||||
private commit(description: string): string | null {
|
||||
// Stage all changes in scope (but not untracked outside scope)
|
||||
exec('git add -A');
|
||||
const diff = exec('git diff --cached --quiet; echo $?');
|
||||
if (diff === '0') return null; // no changes
|
||||
|
||||
try {
|
||||
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
|
||||
return exec('git rev-parse --short HEAD');
|
||||
} catch {
|
||||
// Hook failure
|
||||
exec('git reset HEAD');
|
||||
return 'hook-blocked';
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 6: Rollback */
|
||||
private safeRevert(): void {
|
||||
try {
|
||||
execStrict('git revert HEAD --no-edit');
|
||||
} catch {
|
||||
exec('git revert --abort');
|
||||
exec('git reset --hard HEAD~1');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get stuck hint when >5 consecutive discards */
|
||||
private getStuckHint(discards: number): string | null {
|
||||
if (discards < 5) return null;
|
||||
const hints = [
|
||||
'Re-read ALL scope files from scratch. Try a completely different approach.',
|
||||
'Review entire results log — what worked before? Try combining successful changes.',
|
||||
'Try the OPPOSITE of what has been failing.',
|
||||
'Try a radical architectural change instead of incremental tweaks.',
|
||||
'Simplify — remove complexity rather than adding it.',
|
||||
];
|
||||
return hints[Math.min(discards - 5, hints.length - 1)];
|
||||
}
|
||||
|
||||
/** Run the main loop */
|
||||
async run(): Promise<IterationResult[]> {
|
||||
const results: IterationResult[] = [];
|
||||
|
||||
// Phase 0: Preconditions
|
||||
this.log('Phase 0: Precondition checks...');
|
||||
this.checkPreconditions();
|
||||
|
||||
// Initialize logger
|
||||
this.logger.init(this.config);
|
||||
|
||||
// Baseline measurement
|
||||
this.log('Measuring baseline...');
|
||||
const baseline = this.runVerify();
|
||||
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
|
||||
this.bestMetric = baseline;
|
||||
this.currentMetric = baseline;
|
||||
|
||||
const baselineCommit = exec('git rev-parse --short HEAD');
|
||||
const baselineResult: IterationResult = {
|
||||
iteration: 0,
|
||||
commit: baselineCommit,
|
||||
metric: baseline,
|
||||
delta: 0,
|
||||
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
|
||||
status: 'baseline',
|
||||
description: `initial state — ${this.config.metric} ${baseline}`,
|
||||
};
|
||||
this.logger.append(baselineResult);
|
||||
results.push(baselineResult);
|
||||
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
|
||||
|
||||
// Main loop
|
||||
const maxIter = this.config.iterations ?? Infinity;
|
||||
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
|
||||
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
|
||||
|
||||
// Phase 1: Review
|
||||
const gitLog = exec('git log --oneline -20');
|
||||
const recentLog = this.logger.readLast(20);
|
||||
const scopeFiles = this.config.scope;
|
||||
const consecutiveDiscards = this.logger.consecutiveDiscards();
|
||||
|
||||
// Phase 2-3: Ideate + Modify (delegated to callback)
|
||||
const context: ModifyContext = {
|
||||
iteration: this.iteration,
|
||||
bestMetric: this.bestMetric,
|
||||
currentMetric: this.currentMetric,
|
||||
recentLog,
|
||||
gitLog,
|
||||
scopeFiles,
|
||||
consecutiveDiscards,
|
||||
stuckHint: this.getStuckHint(consecutiveDiscards),
|
||||
};
|
||||
|
||||
let description: string | null;
|
||||
try {
|
||||
description = await this.callbacks.modify(context);
|
||||
} catch (err: any) {
|
||||
this.log(` modify error: ${err.message}`);
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `modify crashed: ${err.message?.slice(0, 80)}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: 'no changes made',
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 4: Commit
|
||||
this.log(` commit: ${description}`);
|
||||
const commitHash = this.commit(description);
|
||||
if (!commitHash) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: `no diff after: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
if (commitHash === 'hook-blocked') {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'hook-blocked',
|
||||
description: `hook rejected: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 5: Verify
|
||||
const metric = this.runVerify();
|
||||
if (metric == null) {
|
||||
this.log(' verify crashed — reverting');
|
||||
this.safeRevert();
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `verify crashed: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
const improved = this.config.direction === 'higher'
|
||||
? metric > this.bestMetric
|
||||
: metric < this.bestMetric;
|
||||
const delta = +(metric - this.bestMetric).toFixed(4);
|
||||
const absDelta = Math.abs(delta);
|
||||
const minDelta = this.config.minDelta ?? 0;
|
||||
|
||||
// Phase 5.5: Guard
|
||||
let guardResult: 'pass' | 'fail' | '-' = '-';
|
||||
if (this.config.guard && improved && absDelta >= minDelta) {
|
||||
guardResult = this.runGuard() ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
// Phase 6: Decide
|
||||
let status: IterationStatus;
|
||||
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
|
||||
status = 'keep';
|
||||
this.bestMetric = metric;
|
||||
this.currentMetric = metric;
|
||||
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
} else if (improved && guardResult === 'fail') {
|
||||
this.log(' guard failed — reverting');
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
this.log(` ✗ DISCARD (guard) — ${description}`);
|
||||
} else {
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
|
||||
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
}
|
||||
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: status === 'keep' ? commitHash : '-',
|
||||
metric,
|
||||
delta,
|
||||
guard: guardResult,
|
||||
status,
|
||||
description,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
|
||||
const discards = results.filter(r => r.status === 'discard');
|
||||
this.log(`\n${'━'.repeat(50)}`);
|
||||
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
|
||||
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Combined Test Suite Runner — runs browse + V2EX + Zhihu tasks.
|
||||
* Reports combined score for AutoResearch iteration.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-all.ts # Run all
|
||||
* npx tsx autoresearch/eval-all.ts --suite v2ex # Run one suite
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface SuiteResult {
|
||||
name: string;
|
||||
passed: number;
|
||||
total: number;
|
||||
failures: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function runSuite(name: string, script: string): SuiteResult {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const output = execSync(`npx tsx ${script}`, {
|
||||
cwd: ROOT,
|
||||
timeout: 600_000,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Parse SCORE=X/Y from output
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
|
||||
// Parse failures
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
} catch (err: any) {
|
||||
const output = err.stdout ?? '';
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleSuite = args.includes('--suite') ? args[args.indexOf('--suite') + 1] : null;
|
||||
|
||||
const suites = [
|
||||
{ name: 'browse', script: 'autoresearch/eval-browse.ts' },
|
||||
{ name: 'v2ex', script: 'autoresearch/eval-v2ex.ts' },
|
||||
{ name: 'zhihu', script: 'autoresearch/eval-zhihu.ts' },
|
||||
].filter(s => !singleSuite || s.name === singleSuite);
|
||||
|
||||
console.log(`\n🔬 Combined AutoResearch — ${suites.length} suites\n`);
|
||||
|
||||
const results: SuiteResult[] = [];
|
||||
for (const suite of suites) {
|
||||
console.log(` Running ${suite.name}...`);
|
||||
const result = runSuite(suite.name, suite.script);
|
||||
results.push(result);
|
||||
const icon = result.passed === result.total ? '✓' : '✗';
|
||||
console.log(` ${icon} ${result.name}: ${result.passed}/${result.total} (${Math.round(result.duration / 1000)}s)`);
|
||||
if (result.failures.length > 0) {
|
||||
for (const f of result.failures.slice(0, 5)) {
|
||||
console.log(` ✗ ${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.reduce((s, r) => s + r.passed, 0);
|
||||
const totalTasks = results.reduce((s, r) => s + r.total, 0);
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
const allFailures = results.flatMap(r => r.failures.map(f => `${r.name}:${f}`));
|
||||
|
||||
console.log(`\n${'━'.repeat(50)}`);
|
||||
console.log(` Combined: ${totalPassed}/${totalTasks}`);
|
||||
for (const r of results) {
|
||||
console.log(` ${r.name}: ${r.passed}/${r.total}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
if (allFailures.length > 0) {
|
||||
console.log(`\n All failures:`);
|
||||
for (const f of allFailures) console.log(` ✗ ${f}`);
|
||||
}
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('all-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `all-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${totalTasks}`,
|
||||
suites: Object.fromEntries(results.map(r => [r.name, `${r.passed}/${r.total}`])),
|
||||
failures: allFailures,
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(`\n Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${totalTasks}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -76,7 +76,7 @@ function runCommand(cmd: string): string {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 5: Publish Testing — end-to-end content creation via operate commands
|
||||
*
|
||||
* Tests the full chain: read content → navigate to platform → fill title+body → (optionally) publish → verify → cleanup
|
||||
*
|
||||
* Task types:
|
||||
* fill-only: navigate + fill fields + verify content was entered (safe, no side effects)
|
||||
* publish: full publish + verify + cleanup (deletes the post after verification)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-publish.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-publish.ts --task twitter-fill # Run single task
|
||||
* npx tsx autoresearch/eval-publish.ts --type fill-only # Run only fill tasks (safe)
|
||||
* npx tsx autoresearch/eval-publish.ts --type publish # Run only publish tasks (destructive)
|
||||
* npx tsx autoresearch/eval-publish.ts --platform twitter # Run only twitter tasks
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const TASKS_FILE = join(__dirname, 'publish-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface PublishTask {
|
||||
name: string;
|
||||
platform: string;
|
||||
type: 'fill-only' | 'publish';
|
||||
description: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
cleanup?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
platform: string;
|
||||
taskType: 'fill-only' | 'publish';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
cleanupResult?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern, 'i').test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: PublishTask): TaskResult {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Run main steps
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
process.stderr.write(` step ${i + 1}/${task.steps.length}: ${step.slice(0, 60)}...\n`);
|
||||
lastOutput = runCommand(step, 45000);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
// Run cleanup steps (if publish type and cleanup defined)
|
||||
let cleanupResult: string | undefined;
|
||||
if (task.cleanup && task.cleanup.length > 0) {
|
||||
process.stderr.write(` cleanup: ${task.cleanup.length} steps...\n`);
|
||||
let cleanupOutput = '';
|
||||
for (const step of task.cleanup) {
|
||||
cleanupOutput = runCommand(step, 30000);
|
||||
}
|
||||
cleanupResult = cleanupOutput.slice(0, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
cleanupResult,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const filterType = args.includes('--type') ? args[args.indexOf('--type') + 1] : null;
|
||||
const filterPlatform = args.includes('--platform') ? args[args.indexOf('--platform') + 1] : null;
|
||||
|
||||
const allTasks: PublishTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
let tasks = allTasks;
|
||||
|
||||
if (singleTask) tasks = tasks.filter(t => t.name === singleTask);
|
||||
if (filterType) tasks = tasks.filter(t => t.type === filterType);
|
||||
if (filterPlatform) tasks = tasks.filter(t => t.platform === filterPlatform);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`No tasks matched filters: task=${singleTask}, type=${filterType}, platform=${filterPlatform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fillTasks = tasks.filter(t => t.type === 'fill-only');
|
||||
const publishTasks = tasks.filter(t => t.type === 'publish');
|
||||
|
||||
console.log(`\n📝 Layer 5: Publish Testing — ${tasks.length} tasks`);
|
||||
console.log(` fill-only: ${fillTasks.length} | publish: ${publishTasks.length}`);
|
||||
console.log(` platforms: ${[...new Set(tasks.map(t => t.platform))].join(', ')}\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const icon = task.type === 'publish' ? '🚀' : '📋';
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${icon} ${task.name} (${task.platform})...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const status = result.passed ? '✓' : '✗';
|
||||
const cleanup = result.cleanupResult ? ` [cleanup: ${result.cleanupResult.slice(0, 30)}]` : '';
|
||||
console.log(` ${status} (${(result.duration / 1000).toFixed(1)}s)${cleanup}`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const fillPassed = results.filter(r => r.taskType === 'fill-only' && r.passed).length;
|
||||
const publishPassed = results.filter(r => r.taskType === 'publish' && r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
const fillTotal = results.filter(r => r.taskType === 'fill-only').length;
|
||||
const publishTotal = results.filter(r => r.taskType === 'publish').length;
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length}`);
|
||||
console.log(` fill-only: ${fillPassed}/${fillTotal}`);
|
||||
console.log(` publish: ${publishPassed}/${publishTotal}`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
// Platform breakdown
|
||||
const platforms = [...new Set(results.map(r => r.platform))];
|
||||
for (const p of platforms) {
|
||||
const pr = results.filter(r => r.platform === p);
|
||||
const pp = pr.filter(r => r.passed).length;
|
||||
console.log(` ${p}: ${pp}/${pr.length}`);
|
||||
}
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.platform}/${f.taskType}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('publish-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `publish-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
fillScore: `${fillPassed}/${fillTotal}`,
|
||||
publishScore: `${publishPassed}/${publishTotal}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 4: Save as CLI Testing — "Save as CLI" Pipeline
|
||||
*
|
||||
* Tests the full operate init → write adapter → operate verify flow.
|
||||
* Validates that browser exploration can be crystallized into reusable CLI adapters.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-save.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-save.ts --task hn-top # Run single task
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'save-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const USER_CLIS_DIR = join(homedir(), '.opencli', 'clis');
|
||||
|
||||
interface SaveTask {
|
||||
name: string;
|
||||
site: string;
|
||||
command: string;
|
||||
/** Inline adapter code (simple tasks) */
|
||||
adapter?: string;
|
||||
/** Path to adapter file relative to autoresearch/ dir (complex tasks — avoids JSON escape issues) */
|
||||
adapterFile?: string;
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
phase: 'init' | 'write' | 'verify' | 'judge';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
set: 'train' | 'test';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
// operate verify outputs table text; try JSON parse first, then count non-empty lines
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON — try line counting */ }
|
||||
// Table output: count data rows (skip header, separator, empty lines)
|
||||
const lines = output.split('\n').filter(l => l.trim() && !l.startsWith('─') && !l.startsWith('┌') && !l.startsWith('└') && !l.startsWith('├'));
|
||||
// Subtract header row
|
||||
const dataLines = lines.length > 1 ? lines.length - 1 : 0;
|
||||
return dataLines >= criteria.minLength;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
|
||||
/** Run a command, using local dist/main.js instead of global opencli for consistency */
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
// Use local build so tests always run against the current source
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupAdapter(site: string, command: string): void {
|
||||
const siteDir = join(USER_CLIS_DIR, site);
|
||||
const filePath = join(siteDir, `${command}.ts`);
|
||||
try {
|
||||
if (existsSync(filePath)) rmSync(filePath);
|
||||
// Remove site dir if empty
|
||||
if (existsSync(siteDir)) {
|
||||
const remaining = readdirSync(siteDir);
|
||||
if (remaining.length === 0) rmSync(siteDir, { recursive: true });
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function runTask(task: SaveTask): TaskResult {
|
||||
const start = Date.now();
|
||||
const { site, command } = task;
|
||||
const adapterDir = join(USER_CLIS_DIR, site);
|
||||
const adapterPath = join(adapterDir, `${command}.ts`);
|
||||
|
||||
// Cleanup any leftover from previous runs
|
||||
cleanupAdapter(site, command);
|
||||
|
||||
try {
|
||||
// Phase 1: init — create scaffold
|
||||
const initOutput = runCommand(`opencli operate init ${site}/${command}`);
|
||||
if (!existsSync(adapterPath)) {
|
||||
return {
|
||||
name: task.name, phase: 'init', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: `init failed: file not created. Output: ${initOutput.slice(0, 100)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: write — overwrite scaffold with real adapter code
|
||||
if (task.adapterFile) {
|
||||
// Read from file (complex adapters — avoids JSON string escape issues)
|
||||
const srcPath = join(__dirname, task.adapterFile);
|
||||
const code = readFileSync(srcPath, 'utf-8');
|
||||
writeFileSync(adapterPath, code, 'utf-8');
|
||||
} else if (task.adapter) {
|
||||
writeFileSync(adapterPath, task.adapter, 'utf-8');
|
||||
}
|
||||
|
||||
// Phase 3: verify — run the adapter via operate verify
|
||||
const verifyOutput = runCommand(
|
||||
`opencli operate verify ${site}/${command}`,
|
||||
45000, // longer timeout for network calls
|
||||
);
|
||||
|
||||
if (verifyOutput.includes('✗ Adapter failed')) {
|
||||
return {
|
||||
name: task.name, phase: 'verify', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: `verify failed: ${verifyOutput.slice(0, 200)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 4: judge — check output quality
|
||||
const passed = judge(task.judge, verifyOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
phase: 'judge',
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Judge failed on output: ${verifyOutput.slice(0, 150)}`,
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name, phase: 'verify', passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
set: task.set === 'test' ? 'test' : 'train',
|
||||
};
|
||||
} finally {
|
||||
// Always cleanup test adapters
|
||||
cleanupAdapter(site, command);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
|
||||
const allTasks: SaveTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const tasks = singleTask ? allTasks.filter(t => t.name === singleTask) : allTasks;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`Task "${singleTask}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🧪 Layer 4: Save as CLI — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
const phase = result.passed ? '' : ` (${result.phase})`;
|
||||
console.log(` ${icon}${phase} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const trainResults = results.filter(r => r.set === 'train');
|
||||
const testResults = results.filter(r => r.set === 'test');
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const trainPassed = trainResults.filter(r => r.passed).length;
|
||||
const testPassed = testResults.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length} (train: ${trainPassed}/${trainResults.length}, test: ${testPassed}/${testResults.length})`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.phase}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('save-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `save-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
trainScore: `${trainPassed}/${trainResults.length}`,
|
||||
testScore: `${testPassed}/${testResults.length}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -18,7 +18,6 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'skill-tasks.yaml');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md');
|
||||
|
||||
@@ -160,13 +159,20 @@ Always close the browser with 'opencli operate close' when done.`;
|
||||
}
|
||||
|
||||
function extractVerdict(text: string): { success: boolean; explanation: string } {
|
||||
// Try to find {"success": ...} JSON in the text
|
||||
const jsonMatches = text.match(/\{"success"\s*:\s*(true|false)\s*,\s*"explanation"\s*:\s*"([^"]*)"\s*\}/g);
|
||||
if (jsonMatches) {
|
||||
const last = jsonMatches[jsonMatches.length - 1];
|
||||
try {
|
||||
return JSON.parse(last);
|
||||
} catch { /* fall through */ }
|
||||
// Try to find and parse {"success": ...} JSON from the last occurrence
|
||||
const idx = text.lastIndexOf('{"success"');
|
||||
if (idx !== -1) {
|
||||
// Find the matching closing brace (handle escaped quotes in explanation)
|
||||
const sub = text.slice(idx);
|
||||
let braceCount = 0;
|
||||
let end = -1;
|
||||
for (let i = 0; i < sub.length; i++) {
|
||||
if (sub[i] === '{') braceCount++;
|
||||
else if (sub[i] === '}') { braceCount--; if (braceCount === 0) { end = i + 1; break; } }
|
||||
}
|
||||
if (end > 0) {
|
||||
try { return JSON.parse(sub.slice(0, end)); } catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for success indicators in text
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* V2EX Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task v2ex-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'v2ex-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name prefix pattern
|
||||
function getLayer(name: string): string {
|
||||
if (['v2ex-open-', 'v2ex-state-', 'v2ex-get-title', 'v2ex-click-tab', 'v2ex-scroll-down',
|
||||
'v2ex-get-first-', 'v2ex-eval-extract', 'v2ex-get-url', 'v2ex-back-nav', 'v2ex-wait-'].some(p => name.startsWith(p)))
|
||||
return 'L1-atomic';
|
||||
if (['v2ex-hot-topics', 'v2ex-node-list', 'v2ex-topic-meta', 'v2ex-node-topics',
|
||||
'v2ex-node-pagination', 'v2ex-tab-content', 'v2ex-topic-replies-extract',
|
||||
'v2ex-topic-reply-count', 'v2ex-member-info', 'v2ex-search-results'].includes(name))
|
||||
return 'L2-single-page';
|
||||
if (['v2ex-click-topic-read', 'v2ex-click-author', 'v2ex-navigate-node', 'v2ex-pagination-page2',
|
||||
'v2ex-topic-and-back', 'v2ex-tab-then-topic', 'v2ex-scroll-find-more',
|
||||
'v2ex-node-to-topic', 'v2ex-multi-tab-compare', 'v2ex-topic-reply-to-author'].some(p => name.startsWith(p)))
|
||||
return 'L3-multi-step';
|
||||
if (['v2ex-reply-', 'v2ex-favorite-', 'v2ex-thank-', 'v2ex-create-'].some(p => name.startsWith(p)))
|
||||
return 'L4-write';
|
||||
if (['v2ex-collect-', 'v2ex-multi-node-', 'v2ex-topic-deep-', 'v2ex-cross-page-', 'v2ex-full-'].some(p => name.startsWith(p)))
|
||||
return 'L5-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 V2EX Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('v2ex-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `v2ex-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Zhihu Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task zhihu-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'zhihu-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name
|
||||
function getLayer(name: string): string {
|
||||
const l1 = ['zhihu-open-home', 'zhihu-get-title', 'zhihu-state', 'zhihu-get-url', 'zhihu-scroll-down',
|
||||
'zhihu-click-tab-hot', 'zhihu-back-navigation', 'zhihu-wait-page-load', 'zhihu-keys-escape', 'zhihu-screenshot'];
|
||||
const l2 = ['zhihu-feed-titles', 'zhihu-hot-list', 'zhihu-hot-metrics', 'zhihu-nav-tabs',
|
||||
'zhihu-feed-with-authors', 'zhihu-feed-types', 'zhihu-user-avatar', 'zhihu-search-input-exists'];
|
||||
const l3 = ['zhihu-question-title', 'zhihu-question-meta', 'zhihu-first-answer', 'zhihu-answer-votes',
|
||||
'zhihu-question-buttons', 'zhihu-multiple-answers', 'zhihu-question-description', 'zhihu-answer-count-number'];
|
||||
const l4 = ['zhihu-hot-to-question', 'zhihu-feed-to-question', 'zhihu-question-to-author',
|
||||
'zhihu-search-navigate', 'zhihu-topic-page', 'zhihu-user-profile', 'zhihu-question-and-back', 'zhihu-scroll-load-more'];
|
||||
const l5 = ['zhihu-upvote-button-find', 'zhihu-follow-question-find', 'zhihu-comment-button-find',
|
||||
'zhihu-bookmark-find', 'zhihu-write-answer-btn', 'zhihu-share-find'];
|
||||
const l6 = ['zhihu-hot-read-answer-author', 'zhihu-hot-to-author-profile', 'zhihu-multi-hot-topics',
|
||||
'zhihu-search-then-read', 'zhihu-question-scroll-answers', 'zhihu-compare-tabs', 'zhihu-user-answers', 'zhihu-topic-questions'];
|
||||
const l7 = ['zhihu-search-basic', 'zhihu-search-people', 'zhihu-search-topic',
|
||||
'zhihu-search-click-result', 'zhihu-search-filter-answers', 'zhihu-search-and-back'];
|
||||
const l8 = ['zhihu-full-browse-workflow', 'zhihu-deep-author-chain', 'zhihu-cross-question-compare',
|
||||
'zhihu-search-read-chain', 'zhihu-3-page-chain', 'zhihu-hot-scroll-deep-read'];
|
||||
|
||||
if (l1.includes(name)) return 'L1-atomic';
|
||||
if (l2.includes(name)) return 'L2-feed';
|
||||
if (l3.includes(name)) return 'L3-question';
|
||||
if (l4.includes(name)) return 'L4-navigation';
|
||||
if (l5.includes(name)) return 'L5-write';
|
||||
if (l6.includes(name)) return 'L6-chain';
|
||||
if (l7.includes(name)) return 'L7-search';
|
||||
if (l8.includes(name)) return 'L8-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 Zhihu Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('zhihu-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `zhihu-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AutoResearch TSV Logger — append-only results log with metadata header.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import type { AutoResearchConfig, IterationResult } from './config.js';
|
||||
|
||||
const COLUMNS = ['iteration', 'commit', 'metric', 'delta', 'guard', 'status', 'description'];
|
||||
|
||||
export class Logger {
|
||||
constructor(private path: string) {}
|
||||
|
||||
/** Create the TSV file with metadata header */
|
||||
init(config: AutoResearchConfig): void {
|
||||
const header = [
|
||||
`# metric_direction: ${config.direction === 'higher' ? 'higher_is_better' : 'lower_is_better'}`,
|
||||
`# goal: ${config.goal}`,
|
||||
`# scope: ${config.scope.join(', ')}`,
|
||||
`# verify: ${config.verify}`,
|
||||
config.guard ? `# guard: ${config.guard}` : null,
|
||||
COLUMNS.join('\t'),
|
||||
].filter(Boolean).join('\n');
|
||||
writeFileSync(this.path, header + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Append one iteration result */
|
||||
append(result: IterationResult): void {
|
||||
const row = [
|
||||
result.iteration,
|
||||
result.commit,
|
||||
result.metric,
|
||||
result.delta >= 0 ? `+${result.delta}` : result.delta,
|
||||
result.guard,
|
||||
result.status,
|
||||
result.description,
|
||||
].join('\t');
|
||||
appendFileSync(this.path, row + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Read last N entries for pattern recognition */
|
||||
readLast(n: number): IterationResult[] {
|
||||
if (!existsSync(this.path)) return [];
|
||||
const lines = readFileSync(this.path, 'utf-8').split('\n')
|
||||
.filter(l => l && !l.startsWith('#') && !l.startsWith('iteration'));
|
||||
return lines.slice(-n).map(line => {
|
||||
const [iteration, commit, metric, delta, guard, status, ...desc] = line.split('\t');
|
||||
return {
|
||||
iteration: parseInt(iteration, 10),
|
||||
commit,
|
||||
metric: parseFloat(metric),
|
||||
delta: parseFloat(delta),
|
||||
guard: guard as 'pass' | 'fail' | '-',
|
||||
status: status as IterationResult['status'],
|
||||
description: desc.join('\t'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Count consecutive discards from the end */
|
||||
consecutiveDiscards(): number {
|
||||
const entries = this.readLast(20);
|
||||
let count = 0;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].status === 'discard') count++;
|
||||
else break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Preset: Combined Reliability (browse + V2EX + Zhihu)
|
||||
*
|
||||
* Optimizes across ALL test suites simultaneously.
|
||||
* Current baseline: 57/59 + 60/60 + 60/60 = 177/179
|
||||
* Target: 179/179 (100%)
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const combinedReliability: AutoResearchConfig = {
|
||||
goal: 'Fix all remaining test failures across browse + V2EX + Zhihu (177/179 → 179/179)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
'autoresearch/browse-tasks.json',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-all.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 10,
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export { operateReliability } from './operate-reliability.js';
|
||||
export { skillQuality } from './skill-quality.js';
|
||||
export { v2exReliability } from './v2ex-reliability.js';
|
||||
export { zhihuReliability } from './zhihu-reliability.js';
|
||||
export { combinedReliability } from './combined-reliability.js';
|
||||
export { saveReliability } from './save-reliability.js';
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
import { operateReliability } from './operate-reliability.js';
|
||||
import { skillQuality } from './skill-quality.js';
|
||||
import { v2exReliability } from './v2ex-reliability.js';
|
||||
import { zhihuReliability } from './zhihu-reliability.js';
|
||||
import { combinedReliability } from './combined-reliability.js';
|
||||
import { saveReliability } from './save-reliability.js';
|
||||
|
||||
export const PRESETS: Record<string, AutoResearchConfig> = {
|
||||
'operate-reliability': operateReliability,
|
||||
'skill-quality': skillQuality,
|
||||
'v2ex-reliability': v2exReliability,
|
||||
'zhihu-reliability': zhihuReliability,
|
||||
'combined': combinedReliability,
|
||||
'save-reliability': saveReliability,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: Operate Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
|
||||
* Metric: number of passing browse-tasks (out of 59).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const operateReliability: AutoResearchConfig = {
|
||||
goal: 'Increase operate command pass rate to 59/59 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Preset: Save as CLI Reliability
|
||||
*
|
||||
* Optimizes the "Save as CLI" pipeline: operate init → write adapter → run.
|
||||
* Covers PUBLIC (no auth) and COOKIE (browser session) strategies.
|
||||
* Metric: number of passing save-tasks.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const saveReliability: AutoResearchConfig = {
|
||||
goal: 'Increase "Save as CLI" pipeline pass rate to 100%. The flow is: operate init creates a scaffold, user writes adapter code, opencli discovers and runs it. Covers both PUBLIC (fetch API) and COOKIE (browser session) strategies. Focus on: init template correctness, user CLI discovery, adapter loading, verify command robustness, and browser session handling.',
|
||||
scope: [
|
||||
'src/cli.ts',
|
||||
'src/discovery.ts',
|
||||
'src/registry.ts',
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
'autoresearch/save-tasks.json',
|
||||
'autoresearch/save-adapters/*.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-save.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Preset: Skill E2E Quality
|
||||
*
|
||||
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Metric: number of passing skill-tasks (out of 35).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const skillQuality: AutoResearchConfig = {
|
||||
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
|
||||
scope: [
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 20,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: V2EX Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the V2EX-specific test suite.
|
||||
* 40 tasks across 5 difficulty layers (atomic → complex chain).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const v2exReliability: AutoResearchConfig = {
|
||||
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-v2ex.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Preset: Zhihu Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Zhihu test suite.
|
||||
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
|
||||
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const zhihuReliability: AutoResearchConfig = {
|
||||
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-zhihu.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"name": "twitter-fill-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to tweet composer, fill in content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval - fill only test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "3-step: open compose → paste text via ClipboardEvent → verify text in composer"
|
||||
},
|
||||
{
|
||||
"name": "twitter-post-and-delete",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Post a tweet, verify success, then delete it",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "6-step chain: open compose → paste text → click post → wait → verify toast → cleanup: find tweet → menu → delete → confirm"
|
||||
},
|
||||
{
|
||||
"name": "twitter-read-hn-then-post",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Read HN top story title, compose a tweet about it, post, then delete",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const title = document.title || 'HN Story'; const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Interesting from HN: ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "9-step cross-site chain: read HN title → navigate to twitter compose → paste content → post → verify → cleanup delete"
|
||||
},
|
||||
{
|
||||
"name": "twitter-reply-to-own-tweet",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to own profile, find latest tweet, open reply box, fill reply text",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: home → find first tweet → click reply → fill reply text → verify content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to a popular question, open answer editor, fill in answer content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这是一个 OpenCLI 发文测试,时间戳: ' + Date.now() + '</p><p>这段内容用于验证 operate 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to question → click '写回答' → find editor → fill rich content (title + body) → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-article",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhihu article editor (zhuanlan), fill title + body (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ta = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'); if (!ta) return 'no-title-input'; ta.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(ta, '[AutoTest] OpenCLI 发文能力验证 ' + Date.now()); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是 OpenCLI autoresearch 发文测试集的一部分。</p><p>测试链路:导航 → 填写标题 → 填写正文 → 验证内容。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'))?.value || ''; const body = document.querySelector('[contenteditable=true]')?.textContent || ''; return JSON.stringify({ title: title.slice(0, 50), body: body.slice(0, 50) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to zhuanlan editor → fill title textarea → fill rich text body → verify both title and body content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-hn-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Read HN top story, then navigate to zhihu question and fill an answer about it",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || document.querySelector('[data-zop-retarget=\\\"answer\\\"]'); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 分享一个来自 Hacker News 的有趣内容</p><p>这是一个跨平台内容搬运测试,时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step cross-site chain: read HN title → navigate zhihu question → click 写回答 → fill answer with HN content → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-thread-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to compose, type first tweet, add thread tweet, type second tweet, verify both",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 1 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'first-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const addBtn = document.querySelector('[data-testid=\\\"addButton\\\"]') || document.querySelector('[aria-label=\\\"Add post\\\"]'); if (addBtn) { addBtn.click(); return 'thread-added'; } return 'no-add-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const box = boxes[boxes.length - 1]; if (!box) return 'no-second-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 2 - continuation'); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'second-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const t1 = boxes[0]?.textContent || ''; const t2 = boxes[boxes.length - 1]?.textContent || ''; return JSON.stringify({ tweet1: t1, tweet2: t2 }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Thread tweet 2"
|
||||
},
|
||||
"note": "10-step thread compose: open composer → fill tweet 1 → click add thread → fill tweet 2 → verify both tweets present"
|
||||
},
|
||||
{
|
||||
"name": "twitter-quote-retweet-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to home, find first tweet, open retweet menu, select Quote, fill quote text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const retweet = tweet.querySelector('[data-testid=\\\"retweet\\\"]'); if (retweet) { retweet.click(); return 'retweet-menu-opened'; } return 'no-retweet-btn'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Quote') || item.textContent?.includes('引用')) { item.click(); return 'quote-selected'; } } return 'no-quote-option'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Quote retweet test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'quote-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Quote retweet test"
|
||||
},
|
||||
"note": "8-step quote retweet: home → find tweet → click retweet → select Quote → fill quote text → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-search-then-reply-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'opencli' on twitter, find first result, click reply, fill reply text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/search?q=opencli&src=typed_query&f=live",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply from search result ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'reply-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Reply from search"
|
||||
},
|
||||
"note": "8-step search-then-reply: navigate to search URL → verify results → click reply on first → fill reply → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'AI agent' on zhihu, click first question result, click 写回答, fill answer, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI%20agent",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-question-links'; const link = links[0]; const href = link.getAttribute('href'); return 'found: ' + href; })()\"",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-links'; const link = links[0]; const href = link.getAttribute('href'); const match = href.match(/\\\\/question\\\\/(\\\\d+)/); if (match) { window.location.href = 'https://www.zhihu.com/question/' + match[1]; return 'navigating-to-question'; } link.click(); return 'clicked-link'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || Array.from(document.querySelectorAll('a')).find(a => a.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] AI agent 搜索后回答测试 ' + Date.now() + '</p><p>这是通过搜索 → 进入问题 → 填写回答的完整链路测试。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "9-step search-then-answer: search zhihu → find question link → navigate → click 写回答 → fill answer → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-question-fill-comment",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to question page, scroll to first answer, click comment, fill comment text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const answer = document.querySelector('[data-testid=\\\"answer\\\"]') || document.querySelector('.AnswerItem') || document.querySelector('.List-item'); if (answer) { answer.scrollIntoView({ behavior: 'smooth', block: 'center' }); return 'answer-scrolled'; } return 'no-answer'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const commentBtns = document.querySelectorAll('button'); for (const btn of commentBtns) { if (btn.textContent?.match(/评论|条评论|comment/i)) { btn.click(); return 'comment-opened: ' + btn.textContent.trim(); } } const commentIcons = document.querySelectorAll('[data-testid=\\\"comment\\\"]') || []; for (const icon of commentIcons) { icon.click(); return 'comment-icon-clicked'; } return 'no-comment-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-comment-editor'; editor.focus(); if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { editor.value = '[AutoTest] 评论测试 ' + Date.now(); editor.dispatchEvent(new Event('input', { bubbles: true })); } else { editor.innerHTML = '<p>[AutoTest] 评论测试 ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); } return 'comment-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; return editor.value || editor.textContent || ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step comment chain: navigate question → scroll to answer → click comment → fill comment text → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-article-with-formatting",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhuanlan editor, fill title, fill body with multiple paragraphs and bold text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] 格式化文章测试 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是第一段:OpenCLI 格式化发文测试。</p><p><strong>[AutoTest-Bold] 这是加粗的第二段,用于验证富文本格式。</strong></p><p>这是第三段,包含普通文本内容,时间戳: ' + Date.now() + '。</p><p>这是第四段,测试多段落填充能力。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled-with-formatting'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; const hasBold = editor.querySelector('strong') || editor.querySelector('b'); const paragraphs = editor.querySelectorAll('p'); return JSON.stringify({ paragraphCount: paragraphs.length, hasBold: !!hasBold, preview: editor.textContent?.slice(0, 80) }); })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), bodyHasBold: body.includes('AutoTest-Bold'), bodyLength: body.length }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step formatted article: navigate editor → fill title → fill body with <strong> bold + 4 paragraphs → verify formatting + content"
|
||||
},
|
||||
{
|
||||
"name": "cross-zhihu-to-twitter",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read zhihu hot topic title, navigate to twitter compose, fill tweet with zhihu content, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const hotItem = document.querySelector('.HotItem-content a') || document.querySelector('.HotList-item a') || document.querySelector('[data-testid=\\\"hot-item\\\"] a') || document.querySelector('.HotItem a'); if (hotItem) return hotItem.textContent?.trim()?.slice(0, 60) || 'no-text'; const titles = document.querySelectorAll('h2'); for (const t of titles) { if (t.textContent?.trim().length > 5) return t.textContent.trim().slice(0, 60); } return 'no-hot-topic'; })()\"",
|
||||
"opencli operate state save zhihu_hot_title",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Zhihu热榜话题搬运: 知乎上正在热议的话题 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'tweet-filled-with-zhihu'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Zhihu热榜话题搬运"
|
||||
},
|
||||
"note": "10-step cross-platform: read zhihu hot → save state → navigate twitter compose → fill tweet with zhihu content → verify"
|
||||
},
|
||||
{
|
||||
"name": "cross-twitter-to-zhihu",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read twitter trending/explore topic, navigate to zhihu zhuanlan editor, fill title and body, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/explore/tabs/trending",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const trends = document.querySelectorAll('[data-testid=\\\"trend\\\"]'); if (trends.length > 0) { const first = trends[0]; return first.textContent?.trim()?.slice(0, 80) || 'no-text'; } const spans = document.querySelectorAll('span'); for (const s of spans) { if (s.textContent?.startsWith('#') || s.textContent?.includes('Trending')) { return s.textContent.trim().slice(0, 80); } } return 'no-trending-topic'; })()\"",
|
||||
"opencli operate state save twitter_trending",
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] Twitter热点搬运: 来自推特的热门话题 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这篇文章搬运自 Twitter 热门话题。</p><p>Twitter 上正在讨论的热门话题为大家带来了新的视角和思考。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), body: body.slice(0, 60) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Twitter热点搬运"
|
||||
},
|
||||
"note": "10-step cross-platform reverse: read twitter trending → save state → navigate zhihu editor → fill title + body → verify"
|
||||
}
|
||||
]
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Layer 4: Save as CLI — test the full save pipeline
|
||||
# Tests: operate init → write adapter → operate verify
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== Layer 4: Save as CLI ==="
|
||||
echo "Testing: init → write → verify pipeline"
|
||||
echo ""
|
||||
|
||||
npx tsx autoresearch/eval-save.ts "$@"
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-xhs',
|
||||
name: 'explore-deep',
|
||||
description: '小红书探索页深度提取 + 去重 + 按互动排序',
|
||||
domain: 'www.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Number of items' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit ?? 15;
|
||||
// Step 1: Navigate to explore page
|
||||
await page.goto('https://www.xiaohongshu.com/explore');
|
||||
// Step 2: Wait for initial content via MutationObserver
|
||||
await page.evaluate(`new Promise(function(resolve) {
|
||||
var check = function() { return document.querySelectorAll('section.note-item').length > 0; };
|
||||
if (check()) return resolve(true);
|
||||
var observer = new MutationObserver(function(m, obs) { if (check()) { obs.disconnect(); resolve(true); } });
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(function() { observer.disconnect(); resolve(false); }, 8000);
|
||||
})`);
|
||||
// Step 3: Multi-round adaptive scroll (early stop when no new content)
|
||||
let prevCount = 0;
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
||||
await page.wait(1.5);
|
||||
const count = await page.evaluate('document.querySelectorAll("section.note-item").length') as number;
|
||||
if (count >= limit * 2 || count === prevCount) break;
|
||||
prevCount = count;
|
||||
}
|
||||
// Step 4: Extract with noteId deduplication + parse likes as integers
|
||||
const result = await page.evaluate(`(function() {
|
||||
var seen = {};
|
||||
var items = [];
|
||||
document.querySelectorAll('section.note-item').forEach(function(el) {
|
||||
var linkEl = el.querySelector('a[href]');
|
||||
var href = linkEl ? linkEl.getAttribute('href') || '' : '';
|
||||
var m = href.match(/explore\\/([a-f0-9]+)/);
|
||||
var noteId = m ? m[1] : '';
|
||||
if (!noteId || seen[noteId]) return;
|
||||
seen[noteId] = true;
|
||||
var titleEl = el.querySelector('.title span') || el.querySelector('a.title');
|
||||
var authorEl = el.querySelector('.author-wrapper .name') || el.querySelector('.author .name');
|
||||
var likesEl = el.querySelector('.like-wrapper .count') || el.querySelector('.interact-container .count');
|
||||
var title = (titleEl ? titleEl.textContent || '' : '').trim();
|
||||
var author = (authorEl ? authorEl.textContent || '' : '').trim();
|
||||
var likesRaw = (likesEl ? likesEl.textContent || '0' : '0').trim();
|
||||
var likes = parseInt(likesRaw.replace(/[^0-9]/g, '')) || 0;
|
||||
items.push({ title: title, author: author, likes: likes, url: 'https://www.xiaohongshu.com/explore/' + noteId });
|
||||
});
|
||||
return items;
|
||||
})()`);
|
||||
// Step 5: Sort by likes descending
|
||||
const sorted = (result as any[] || []).sort((a: any, b: any) => b.likes - a.likes);
|
||||
// Step 6: Slice and format
|
||||
return sorted.slice(0, limit).map((item: any, i: number) => ({
|
||||
rank: i + 1, title: item.title, author: item.author, likes: String(item.likes), url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-xhs',
|
||||
name: 'note-comments',
|
||||
description: '小红书笔记详情 + 评论(多步合并输出)',
|
||||
domain: 'www.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'id', type: 'string', default: '6745a82f000000000800b6ed', positional: true, help: 'Note ID' },
|
||||
{ name: 'limit', type: 'int', default: 5, help: 'Max comments' },
|
||||
],
|
||||
columns: ['section', 'title', 'author', 'likes', 'text'],
|
||||
func: async (page, kwargs) => {
|
||||
const noteId = kwargs.id ?? '6745a82f000000000800b6ed';
|
||||
const commentLimit = kwargs.limit ?? 5;
|
||||
// Step 1: Navigate to note detail page
|
||||
await page.goto('https://www.xiaohongshu.com/explore/' + noteId);
|
||||
await page.wait(3);
|
||||
// Step 2: Extract note metadata (title, author, likes)
|
||||
const meta = await page.evaluate(`(function() {
|
||||
return {
|
||||
title: (document.querySelector('#detail-title') || document.querySelector('.title') || {}).textContent?.trim() || '',
|
||||
author: (document.querySelector('.author-container .username') || document.querySelector('.user-nickname') || {}).textContent?.trim() || '',
|
||||
likes: (document.querySelector('[data-type="like"] .count') || document.querySelector('.like-wrapper .count') || {}).textContent?.trim() || '0',
|
||||
};
|
||||
})()`) as any;
|
||||
// Step 3: Scroll the note container to trigger comment loading
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate(`(function() {
|
||||
var scroller = document.querySelector('.note-scroller') || document.querySelector('.container');
|
||||
if (scroller && scroller.scrollTo) { scroller.scrollTo(0, 99999); } else { window.scrollTo(0, document.body.scrollHeight); }
|
||||
})()`);
|
||||
await page.wait(1);
|
||||
}
|
||||
// Step 4: Extract comments from DOM
|
||||
const comments = await page.evaluate(`(function() {
|
||||
var results = [];
|
||||
var commentEls = document.querySelectorAll('.parent-comment, .comment-item-root');
|
||||
commentEls.forEach(function(el) {
|
||||
var item = el.querySelector('.comment-item') || el.querySelector('.comment-inner');
|
||||
if (!item) return;
|
||||
var authorEl = item.querySelector('.author-wrapper .name') || item.querySelector('.user-name');
|
||||
var textEl = item.querySelector('.content') || item.querySelector('.note-text');
|
||||
var likesEl = item.querySelector('.count');
|
||||
var author = (authorEl ? authorEl.textContent || '' : '').trim();
|
||||
var text = (textEl ? textEl.textContent || '' : '').replace(/\\s+/g, ' ').trim();
|
||||
var likes = (likesEl ? likesEl.textContent || '0' : '0').trim();
|
||||
if (text) results.push({ author: author, text: text.slice(0, 80), likes: likes });
|
||||
});
|
||||
return results;
|
||||
})()`) as any[];
|
||||
// Step 5: Merge note meta + comments into unified output
|
||||
const rows: any[] = [{ section: 'note', title: meta.title, author: meta.author, likes: meta.likes, text: '' }];
|
||||
for (const c of (comments || []).slice(0, commentLimit)) {
|
||||
rows.push({ section: 'comment', title: '', author: c.author, likes: c.likes, text: c.text });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-xhs',
|
||||
name: 'search-full',
|
||||
description: '小红书搜索 + 滚动加载 + 去重',
|
||||
domain: 'www.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', type: 'string', default: '咖啡', positional: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of results' },
|
||||
],
|
||||
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = encodeURIComponent(kwargs.query ?? '咖啡');
|
||||
const limit = kwargs.limit ?? 10;
|
||||
// Step 1: Navigate to search page
|
||||
await page.goto('https://www.xiaohongshu.com/search_result?keyword=' + query + '&source=web_search_result_notes');
|
||||
// Step 2: Wait for async render via MutationObserver
|
||||
await page.evaluate(`new Promise(function(resolve) {
|
||||
var check = function() { return document.querySelectorAll('section.note-item').length > 0; };
|
||||
if (check()) return resolve(true);
|
||||
var observer = new MutationObserver(function(m, obs) { if (check()) { obs.disconnect(); resolve(true); } });
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(function() { observer.disconnect(); resolve(false); }, 8000);
|
||||
})`);
|
||||
// Step 3: Scroll 3x to load more content
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
||||
await page.wait(1);
|
||||
}
|
||||
// Step 4: Extract from DOM with deduplication
|
||||
const result = await page.evaluate(`(function() {
|
||||
var seen = {};
|
||||
var items = [];
|
||||
document.querySelectorAll('section.note-item').forEach(function(el) {
|
||||
var linkEl = el.querySelector('a[href]');
|
||||
var href = linkEl ? linkEl.getAttribute('href') || '' : '';
|
||||
var m = href.match(/explore\\/([a-f0-9]+)/);
|
||||
var noteId = m ? m[1] : href;
|
||||
if (!noteId || seen[noteId]) return;
|
||||
seen[noteId] = true;
|
||||
var titleEl = el.querySelector('.title span') || el.querySelector('a.title');
|
||||
var authorEl = el.querySelector('.author-wrapper .name') || el.querySelector('.author .name');
|
||||
var likesEl = el.querySelector('.like-wrapper .count') || el.querySelector('.interact-container .count');
|
||||
if (titleEl) {
|
||||
items.push({
|
||||
title: (titleEl.textContent || '').trim(),
|
||||
author: (authorEl ? authorEl.textContent || '' : '').trim(),
|
||||
likes: (likesEl ? likesEl.textContent || '0' : '0').trim(),
|
||||
url: 'https://www.xiaohongshu.com' + href,
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
})()`);
|
||||
return (result as any[]).slice(0, limit).map((item: any, i: number) => ({
|
||||
rank: i + 1, title: item.title, author: item.author, likes: item.likes, url: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-zhihu',
|
||||
name: 'hot-detail',
|
||||
description: '知乎热榜 + 每个问题的第一个回答摘要',
|
||||
domain: 'www.zhihu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 5, help: 'Number of items' },
|
||||
],
|
||||
columns: ['rank', 'title', 'heat', 'top_answer_author', 'top_answer_excerpt'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit ?? 5;
|
||||
// Step 1: Navigate
|
||||
await page.goto('https://www.zhihu.com');
|
||||
await page.wait(2);
|
||||
// Step 2: Fetch hot list (handle 16+ digit IDs)
|
||||
const hotList = await page.evaluate(`(async () => {
|
||||
const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', { credentials: 'include' });
|
||||
const text = await res.text();
|
||||
const d = JSON.parse(text.replace(/("id"\\s*:\\s*)(\\d{16,})/g, '$1"$2"'));
|
||||
return (d?.data || []).map(item => {
|
||||
const t = item.target || {};
|
||||
return { qid: String(t.id || ''), title: t.title || '', heat: item.detail_text || '' };
|
||||
});
|
||||
})()`) as any[];
|
||||
// Step 3: For each hot question, fetch its top answer
|
||||
const items = hotList.slice(0, limit);
|
||||
const enriched = [];
|
||||
for (const item of items) {
|
||||
if (!item.qid) { enriched.push({ ...item, top_answer_author: '', top_answer_excerpt: '' }); continue; }
|
||||
const answer = await page.evaluate(`(async () => {
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${item.qid}/answers?limit=1&offset=0&sort_by=default&include=data[*].content,voteup_count,author', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
const a = d?.data?.[0];
|
||||
if (!a) return { author: '', excerpt: '' };
|
||||
return { author: a.author?.name || 'anonymous', excerpt: strip(a.content || '').slice(0, 120) };
|
||||
} catch { return { author: '', excerpt: '' }; }
|
||||
})()`) as any;
|
||||
enriched.push({ ...item, top_answer_author: answer.author, top_answer_excerpt: answer.excerpt });
|
||||
}
|
||||
// Step 4: Format output
|
||||
return enriched.map((item, i) => ({
|
||||
rank: i + 1, title: item.title, heat: item.heat,
|
||||
top_answer_author: item.top_answer_author, top_answer_excerpt: item.top_answer_excerpt,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-zhihu',
|
||||
name: 'question-full',
|
||||
description: '知乎问题 + 回答 + 相关推荐(三层数据合并)',
|
||||
domain: 'www.zhihu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'id', type: 'string', default: '19550225', positional: true, help: 'Question ID' },
|
||||
{ name: 'limit', type: 'int', default: 3, help: 'Number of answers' },
|
||||
],
|
||||
columns: ['section', 'title', 'author', 'votes', 'excerpt'],
|
||||
func: async (page, kwargs) => {
|
||||
const qid = kwargs.id ?? '19550225';
|
||||
const limit = kwargs.limit ?? 3;
|
||||
// Step 1: Navigate to question page
|
||||
await page.goto('https://www.zhihu.com/question/' + qid);
|
||||
await page.wait(2);
|
||||
// Step 2: Fetch question detail
|
||||
const question = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${qid}', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return { title: d.title || '', follower_count: d.follower_count || 0, answer_count: d.answer_count || 0 };
|
||||
} catch { return { title: '', follower_count: 0, answer_count: 0 }; }
|
||||
})()`) as any;
|
||||
// Step 3: Fetch top answers
|
||||
const answers = await page.evaluate(`(async () => {
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${qid}/answers?limit=${limit}&offset=0&sort_by=default&include=data[*].content,voteup_count,author', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data || []).map(a => ({ author: a.author?.name || 'anonymous', votes: a.voteup_count || 0, excerpt: strip(a.content || '').slice(0, 120) }));
|
||||
} catch { return []; }
|
||||
})()`) as any[];
|
||||
// Step 4: Fetch related questions
|
||||
const related = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${qid}/similar?limit=3', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data || []).map(q => ({ title: q.title || '', answer_count: q.answer_count || 0 }));
|
||||
} catch { return []; }
|
||||
})()`) as any[];
|
||||
// Step 5: Merge three layers into unified output
|
||||
const rows: any[] = [];
|
||||
rows.push({ section: 'question', title: question.title, author: '', votes: question.follower_count, excerpt: question.answer_count + ' answers' });
|
||||
for (const a of answers) {
|
||||
rows.push({ section: 'answer', title: '', author: a.author, votes: a.votes, excerpt: a.excerpt });
|
||||
}
|
||||
for (const r of related) {
|
||||
rows.push({ section: 'related', title: r.title, author: '', votes: 0, excerpt: r.answer_count + ' answers' });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
|
||||
cli({
|
||||
site: 'test-zhihu',
|
||||
name: 'search-detail',
|
||||
description: '知乎搜索 + 每条结果的问题统计',
|
||||
domain: 'www.zhihu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', type: 'string', default: 'AI', positional: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 5, help: 'Number of results' },
|
||||
],
|
||||
columns: ['rank', 'title', 'type', 'author', 'votes', 'answer_count', 'follower_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = kwargs.query ?? 'AI';
|
||||
const limit = kwargs.limit ?? 5;
|
||||
// Step 1: Navigate
|
||||
await page.goto('https://www.zhihu.com');
|
||||
await page.wait(2);
|
||||
// Step 2: Search API — filter results by type, extract question IDs
|
||||
const searchResults = await page.evaluate(`(async () => {
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent('${query}') + '&t=general&offset=0&limit=20', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data || []).filter(item => item.type === 'search_result').map(item => {
|
||||
const obj = item.object || {};
|
||||
const q = obj.question || {};
|
||||
const questionId = obj.type === 'answer' ? String(q.id || '') : obj.type === 'question' ? String(obj.id || '') : '';
|
||||
return { type: obj.type || '', title: strip(obj.title || q.name || ''), author: obj.author?.name || '', votes: obj.voteup_count || 0, questionId };
|
||||
});
|
||||
})()`) as any[];
|
||||
// Step 3: For each result, fetch question stats (answer_count, follower_count)
|
||||
const items = searchResults.slice(0, limit);
|
||||
const enriched = [];
|
||||
for (const item of items) {
|
||||
if (!item.questionId) { enriched.push({ ...item, answer_count: 0, follower_count: 0 }); continue; }
|
||||
const stats = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.zhihu.com/api/v4/questions/${item.questionId}', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return { answer_count: d.answer_count || 0, follower_count: d.follower_count || 0 };
|
||||
} catch { return { answer_count: 0, follower_count: 0 }; }
|
||||
})()`) as any;
|
||||
enriched.push({ ...item, answer_count: stats.answer_count, follower_count: stats.follower_count });
|
||||
}
|
||||
// Step 4: Format output
|
||||
return enriched.map((item, i) => ({
|
||||
rank: i + 1, title: item.title, type: item.type, author: item.author,
|
||||
votes: item.votes, answer_count: item.answer_count, follower_count: item.follower_count,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
[
|
||||
{
|
||||
"name": "httpbin-get",
|
||||
"site": "test-httpbin",
|
||||
"command": "get",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-httpbin',\n name: 'get',\n description: 'httpbin echo test',\n domain: 'httpbin.org',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [],\n columns: ['origin', 'url'],\n func: async () => {\n const res = await fetch('https://httpbin.org/get');\n const d = await res.json();\n return [{ origin: d.origin, url: d.url }];\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
},
|
||||
"note": "Simplest possible: httpbin echo, single row"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-posts",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "posts",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'posts',\n description: 'JSONPlaceholder posts',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of posts' },\n ],\n columns: ['id', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/posts');\n const posts = await res.json();\n return posts.slice(0, limit).map((p: any) => ({ id: p.id, title: p.title }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-users",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "users",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'users',\n description: 'JSONPlaceholder users',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of users' },\n ],\n columns: ['id', 'name', 'email'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/users');\n const users = await res.json();\n return users.slice(0, limit).map((u: any) => ({ id: u.id, name: u.name, email: u.email }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hn-top",
|
||||
"site": "test-hn",
|
||||
"command": "top",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'top',\n description: 'HackerNews top stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hn-ask",
|
||||
"site": "test-hn",
|
||||
"command": "ask",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'ask',\n description: 'HackerNews Ask HN stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/askstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wiki-summary",
|
||||
"site": "test-wiki",
|
||||
"command": "summary",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-wiki',\n name: 'summary',\n description: 'Wikipedia article summary',\n domain: 'en.wikipedia.org',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'title', type: 'string', default: 'JavaScript', positional: true, help: 'Article title' },\n ],\n columns: ['title', 'extract'],\n func: async (_page, kwargs) => {\n const title = encodeURIComponent(kwargs.title);\n const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${title}`);\n const d = await res.json();\n return [{ title: d.title, extract: d.extract?.slice(0, 200) }];\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "programming language"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lobsters-hot",
|
||||
"site": "test-lobsters",
|
||||
"command": "hot",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-lobsters',\n name: 'hot',\n description: 'Lobsters hottest stories',\n domain: 'lobste.rs',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['title', 'score', 'url'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://lobste.rs/hottest.json');\n const stories = await res.json();\n return stories.slice(0, limit).map((s: any) => ({\n title: s.title, score: s.score, url: s.short_id_url,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "devto-top",
|
||||
"site": "test-devto",
|
||||
"command": "top",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-devto',\n name: 'top',\n description: 'DEV.to top articles',\n domain: 'dev.to',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of articles' },\n ],\n columns: ['title', 'user', 'reactions'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://dev.to/api/articles?per_page=' + limit);\n const articles = await res.json();\n return articles.map((a: any) => ({\n title: a.title, user: a.user?.username, reactions: a.positive_reactions_count,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-with-top-answer",
|
||||
"site": "test-zhihu",
|
||||
"command": "hot-detail",
|
||||
"adapterFile": "save-adapters/zhihu-hot-detail.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "6-step chain: navigate → fetch hot list API → parse big-int IDs → loop N items → fetch answer API per question → strip HTML → merge"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-with-question-stats",
|
||||
"site": "test-zhihu",
|
||||
"command": "search-detail",
|
||||
"adapterFile": "save-adapters/zhihu-search-detail.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "7-step chain: navigate → search API → filter by type → extract question IDs → fetch question detail per result → merge stats → format"
|
||||
},
|
||||
{
|
||||
"name": "xhs-search-scroll-extract",
|
||||
"site": "test-xhs",
|
||||
"command": "search-full",
|
||||
"adapterFile": "save-adapters/xhs-search-full.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "6-step chain: navigate → MutationObserver wait → scroll 3x → DOM extract with URL dedup → slice + format"
|
||||
},
|
||||
{
|
||||
"name": "xhs-note-with-comments",
|
||||
"site": "test-xhs",
|
||||
"command": "note-comments",
|
||||
"adapterFile": "save-adapters/xhs-note-comments.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
},
|
||||
"note": "7-step chain: navigate → wait → extract note meta → scroll container 3x → extract comments DOM → merge note+comments → unified output"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-with-related",
|
||||
"site": "test-zhihu",
|
||||
"command": "question-full",
|
||||
"adapterFile": "save-adapters/zhihu-question-full.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
},
|
||||
"note": "8-step chain: navigate → wait → fetch question detail → fetch answers → strip HTML → fetch related questions → merge 3 layers → format"
|
||||
},
|
||||
{
|
||||
"name": "xhs-explore-scroll-dedupe",
|
||||
"site": "test-xhs",
|
||||
"command": "explore-deep",
|
||||
"adapterFile": "save-adapters/xhs-explore-deep.ts",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "8-step chain: navigate → MutationObserver wait → adaptive scroll → DOM extract with dedup → parse likes → sort desc → slice → format"
|
||||
},
|
||||
{
|
||||
"name": "hn-new",
|
||||
"site": "test-hn",
|
||||
"command": "new",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'new',\n description: 'HackerNews newest stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/newstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews new stories using same Firebase API as hn-top/hn-ask"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-todos",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "todos",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'todos',\n description: 'JSONPlaceholder todos',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of todos' },\n ],\n columns: ['id', 'title', 'completed'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/todos');\n const todos = await res.json();\n return todos.slice(0, limit).map((t: any) => ({ id: t.id, title: t.title, completed: t.completed }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder todos — same base domain as posts/users, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "hn-show",
|
||||
"site": "test-hn",
|
||||
"command": "show",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'show',\n description: 'HackerNews Show HN stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/showstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews show stories using same Firebase API as hn-top/hn-ask/hn-new"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-comments",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "comments",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'comments',\n description: 'JSONPlaceholder comments',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of comments' },\n ],\n columns: ['id', 'name', 'email'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/comments');\n const comments = await res.json();\n return comments.slice(0, limit).map((c: any) => ({ id: c.id, name: c.name, email: c.email }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder comments — same base domain as posts/users/todos, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-albums",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "albums",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'albums',\n description: 'JSONPlaceholder albums',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of albums' },\n ],\n columns: ['id', 'userId', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/albums');\n const albums = await res.json();\n return albums.slice(0, limit).map((a: any) => ({ id: a.id, userId: a.userId, title: a.title }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder albums — same base domain as posts/users/todos/comments, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-photos",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "photos",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'photos',\n description: 'JSONPlaceholder photos',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of photos' },\n ],\n columns: ['id', 'albumId', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/photos');\n const photos = await res.json();\n return photos.slice(0, limit).map((p: any) => ({ id: p.id, albumId: p.albumId, title: p.title }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder photos — same base domain as posts/users/todos/comments/albums, different endpoint"
|
||||
},
|
||||
{
|
||||
"name": "hn-best",
|
||||
"site": "test-hn",
|
||||
"command": "best",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'best',\n description: 'HackerNews best stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/beststories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews best stories using same Firebase API as hn-top/hn-ask/hn-new/hn-show"
|
||||
},
|
||||
{
|
||||
"name": "hn-jobs",
|
||||
"site": "test-hn",
|
||||
"command": "jobs",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'jobs',\n description: 'HackerNews job stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of jobs' },\n ],\n columns: ['rank', 'title', 'url'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/jobstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, url: item.url ?? '',\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews job listings using same Firebase API as other HN adapters"
|
||||
},
|
||||
{
|
||||
"name": "restcountries-list",
|
||||
"site": "test-restcountries",
|
||||
"command": "list",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-restcountries',\n name: 'list',\n description: 'REST Countries list',\n domain: 'restcountries.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of countries' },\n ],\n columns: ['name', 'capital', 'region'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://restcountries.com/v3.1/all?fields=name,capital,region');\n const countries = await res.json();\n return countries.slice(0, limit).map((c: any) => ({\n name: c.name?.common ?? '',\n capital: c.capital?.[0] ?? '',\n region: c.region ?? '',\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: REST Countries API — stable, no-auth, returns 250 countries with name/capital/region"
|
||||
},
|
||||
{
|
||||
"name": "nager-holidays",
|
||||
"site": "test-nager",
|
||||
"command": "holidays",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-nager',\n name: 'holidays',\n description: 'US public holidays for current year',\n domain: 'date.nager.at',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of holidays' },\n ],\n columns: ['date', 'name', 'type'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const year = new Date().getFullYear();\n const res = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/US`);\n const holidays = await res.json();\n return holidays.slice(0, limit).map((h: any) => ({\n date: h.date,\n name: h.name,\n type: (h.types || []).join(','),\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Nager public holidays API — stable, no-auth, returns US federal holidays by year"
|
||||
},
|
||||
{
|
||||
"name": "catfact-list",
|
||||
"site": "test-catfact",
|
||||
"command": "facts",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-catfact',\n name: 'facts',\n description: 'Random cat facts',\n domain: 'catfact.ninja',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of facts' },\n ],\n columns: ['fact', 'length'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch(`https://catfact.ninja/facts?limit=${limit}`);\n const d = await res.json();\n return d.data.map((item: any) => ({\n fact: item.fact.slice(0, 100),\n length: item.length,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: catfact.ninja facts API — stable, no-auth, returns random cat facts"
|
||||
},
|
||||
{
|
||||
"name": "opentdb-trivia",
|
||||
"site": "test-opentdb",
|
||||
"command": "easy",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-opentdb',\n name: 'easy',\n description: 'Easy trivia questions from Open Trivia DB',\n domain: 'opentdb.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of questions' },\n ],\n columns: ['category', 'question', 'answer'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch(`https://opentdb.com/api.php?amount=${limit}&difficulty=easy&type=multiple`);\n const d = await res.json();\n return d.results.map((q: any) => ({\n category: q.category,\n question: q.question.replace(/"/g, '\"').replace(/'/g, \"'\").slice(0, 80),\n answer: q.correct_answer,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Open Trivia DB API — stable, no-auth, returns trivia questions with correct answers"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,899 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-tab",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-first-topic-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-extract-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "v2ex.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait selector \"a[href^='/t/']\"",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 2: Single Page Tasks (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-list",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-pagination-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"max\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=jobs",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-replies-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-count",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-member-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Livid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-search-results",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 3: Multi-Step (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-topic-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-navigate-node-from-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-pagination-page2",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "p=2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-then-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-find-more",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-to-topic-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-tab-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 4: Write Operations (5 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-type-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoResearch test reply"
|
||||
},
|
||||
"note": "Types into reply box but does NOT submit"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-favorite-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "favorite|收藏"
|
||||
},
|
||||
"note": "Finds favorite link but does NOT click it"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-thank-reply-find",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"found\":\\d+"
|
||||
},
|
||||
"note": "Finds thank buttons but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "\"textarea\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-create-topic-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/new",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"note": "Detects create topic form elements, does NOT submit"
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 5: Complex Chain (5 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-collect-hot-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-node-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-cross-page-data-collect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-full-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 6: State + Click Interaction (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate click 1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-tab-tech",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+|no-ref"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-count-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-scroll-state",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-type-search-box",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-value-after-type",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-screenshot-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-html-selector",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get html --selector h1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait text V2EX"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "found|appeared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-3-pages",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-navigate-extract-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-multi-node-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-topic-replies-pagination",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-member-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-search-navigate-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-tab-topic-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-node-page2-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-full-interaction",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-deep-5-step",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
|
||||
"opencli operate back",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA navigation, timing, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/go/python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-after-click",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on topic page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-and-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-concurrent-eval",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.*\"url\":.*\"links\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-unicode-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Agent-Style: state + click + type (no eval for interaction) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-first-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
},
|
||||
"note": "Finds the index of first topic link via data-opencli-ref"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-type-search",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate type 3 TypeScript",
|
||||
"opencli operate get value 3"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
},
|
||||
"note": "Types into search box using state index"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-navigate-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-has-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-after-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 800",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "page_scroll: [\\d.]+↑"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,848 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "知乎"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-state",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[@?\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-click-tab-hot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "zhihu\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate wait text 推荐",
|
||||
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "pressed|Pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-screenshot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate screenshot /tmp/zhihu-test.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-list",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-metrics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-nav-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "推荐"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-with-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-types",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"question\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-avatar",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-input-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search found"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L3: Question Page Operations (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-first-answer",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"content\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-votes",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-buttons",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multiple-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-count-number",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L4: Multi-Step Navigation (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-page",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-load-more",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L5: Write Operations (6 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-upvote-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "赞同"
|
||||
},
|
||||
"note": "Finds upvote button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-follow-question-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "关注问题"
|
||||
},
|
||||
"note": "Finds follow button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-comment-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "评论"
|
||||
},
|
||||
"note": "Finds comment button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-bookmark-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "收藏|found"
|
||||
},
|
||||
"note": "Finds bookmark button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-write-answer-btn",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "写回答"
|
||||
},
|
||||
"note": "Finds write answer button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-share-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "分享"
|
||||
},
|
||||
"note": "Finds share button but does NOT click"
|
||||
},
|
||||
{
|
||||
"_comment": "=== L6: Long Chain Workflows (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-read-answer-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multi-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-scroll-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-compare-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-questions",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L7: Search Workflows (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-basic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-people",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-click-result",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-filter-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"total\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-full-browse-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-deep-author-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"profile\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-cross-question-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"q1_title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-read-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-3-page-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-scroll-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA lazy load, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/people/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-click-verify-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on question page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-lazy-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-extract-structured",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-answer-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"answerCount\":\\d+"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,615 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.24.6",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@algolia/abtesting": ["@algolia/abtesting@1.15.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA=="],
|
||||
|
||||
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
|
||||
|
||||
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
|
||||
|
||||
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
|
||||
|
||||
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
|
||||
|
||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ=="],
|
||||
|
||||
"@algolia/client-analytics": ["@algolia/client-analytics@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q=="],
|
||||
|
||||
"@algolia/client-common": ["@algolia/client-common@5.49.2", "", {}, "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw=="],
|
||||
|
||||
"@algolia/client-insights": ["@algolia/client-insights@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA=="],
|
||||
|
||||
"@algolia/client-personalization": ["@algolia/client-personalization@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA=="],
|
||||
|
||||
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA=="],
|
||||
|
||||
"@algolia/client-search": ["@algolia/client-search@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg=="],
|
||||
|
||||
"@algolia/ingestion": ["@algolia/ingestion@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw=="],
|
||||
|
||||
"@algolia/monitoring": ["@algolia/monitoring@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg=="],
|
||||
|
||||
"@algolia/recommend": ["@algolia/recommend@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA=="],
|
||||
|
||||
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA=="],
|
||||
|
||||
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ=="],
|
||||
|
||||
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
|
||||
|
||||
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
|
||||
|
||||
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
||||
|
||||
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.74", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
||||
|
||||
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
||||
|
||||
"algoliasearch": ["algoliasearch@5.49.2", "", { "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", "@algolia/client-analytics": "5.49.2", "@algolia/client-common": "5.49.2", "@algolia/client-insights": "5.49.2", "@algolia/client-personalization": "5.49.2", "@algolia/client-query-suggestions": "5.49.2", "@algolia/client-search": "5.49.2", "@algolia/ingestion": "1.49.2", "@algolia/monitoring": "1.49.2", "@algolia/recommend": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": "bin/esbuild" }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
|
||||
|
||||
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3"], "bin": "bin/vitepress.js" }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="],
|
||||
|
||||
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" } }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export default defineConfig({
|
||||
{ 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' },
|
||||
@@ -91,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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -30,6 +30,7 @@ Run `opencli list` for the live registry.
|
||||
| **[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 |
|
||||
@@ -54,6 +55,7 @@ Run `opencli list` for the live registry.
|
||||
| **[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
|
||||
|
||||
|
||||
@@ -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
|
||||
Vendored
+739
@@ -0,0 +1,739 @@
|
||||
const DAEMON_PORT = 19825;
|
||||
const DAEMON_HOST = "localhost";
|
||||
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
||||
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
||||
const WS_RECONNECT_BASE_DELAY = 2e3;
|
||||
const WS_RECONNECT_MAX_DELAY = 5e3;
|
||||
|
||||
const attached = /* @__PURE__ */ new Set();
|
||||
function isDebuggableUrl$1(url) {
|
||||
if (!url) return true;
|
||||
return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
|
||||
}
|
||||
async function ensureAttached(tabId, aggressiveRetry = false) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!isDebuggableUrl$1(tab.url)) {
|
||||
attached.delete(tabId);
|
||||
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
|
||||
attached.delete(tabId);
|
||||
throw new Error(`Tab ${tabId} no longer exists`);
|
||||
}
|
||||
if (attached.has(tabId)) {
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
||||
expression: "1",
|
||||
returnByValue: true
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
attached.delete(tabId);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
} catch {
|
||||
}
|
||||
await chrome.debugger.attach({ tabId }, "1.3");
|
||||
lastError = "";
|
||||
break;
|
||||
} catch (e) {
|
||||
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));
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!isDebuggableUrl$1(tab.url)) {
|
||||
lastError = `Tab URL changed to ${tab.url} during retry`;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
lastError = `Tab ${tabId} no longer exists`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
let finalUrl = "unknown";
|
||||
let finalWindowId = "unknown";
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
finalUrl = tab.url ?? "undefined";
|
||||
finalWindowId = String(tab.windowId);
|
||||
} catch {
|
||||
}
|
||||
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" : "";
|
||||
throw new Error(`attach failed: ${lastError}${hint}`);
|
||||
}
|
||||
attached.add(tabId);
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
async function evaluate(tabId, expression, aggressiveRetry = false) {
|
||||
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
|
||||
});
|
||||
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);
|
||||
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);
|
||||
const retryMs = isNavigateError ? 200 : 500;
|
||||
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw new Error("evaluate: max retries exhausted");
|
||||
}
|
||||
const evaluateAsync = evaluate;
|
||||
async function screenshot(tabId, options = {}) {
|
||||
await ensureAttached(tabId);
|
||||
const format = options.format ?? "png";
|
||||
if (options.fullPage) {
|
||||
const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
|
||||
const size = metrics.cssContentSize || metrics.contentSize;
|
||||
if (size) {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
||||
mobile: false,
|
||||
width: Math.ceil(size.width),
|
||||
height: Math.ceil(size.height),
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
const params = { format };
|
||||
if (format === "jpeg" && options.quality !== void 0) {
|
||||
params.quality = Math.max(0, Math.min(100, options.quality));
|
||||
}
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params);
|
||||
return result.data;
|
||||
} finally {
|
||||
if (options.fullPage) {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
async function setFileInputFiles(tabId, files, selector) {
|
||||
await ensureAttached(tabId);
|
||||
await chrome.debugger.sendCommand({ tabId }, "DOM.enable");
|
||||
const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument");
|
||||
const query = selector || 'input[type="file"]';
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", {
|
||||
nodeId: doc.root.nodeId,
|
||||
selector: query
|
||||
});
|
||||
if (!result.nodeId) {
|
||||
throw new Error(`No element found matching selector: ${query}`);
|
||||
}
|
||||
await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", {
|
||||
files,
|
||||
nodeId: result.nodeId
|
||||
});
|
||||
}
|
||||
async function detach(tabId) {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function registerListeners() {
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
attached.delete(tabId);
|
||||
});
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
||||
if (info.url && !isDebuggableUrl$1(info.url)) {
|
||||
await detach(tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
const _origLog = console.log.bind(console);
|
||||
const _origWarn = console.warn.bind(console);
|
||||
const _origError = console.error.bind(console);
|
||||
function forwardLog(level, args) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
||||
ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() }));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
console.log = (...args) => {
|
||||
_origLog(...args);
|
||||
forwardLog("info", args);
|
||||
};
|
||||
console.warn = (...args) => {
|
||||
_origWarn(...args);
|
||||
forwardLog("warn", args);
|
||||
};
|
||||
console.error = (...args) => {
|
||||
_origError(...args);
|
||||
forwardLog("error", args);
|
||||
};
|
||||
async function connect() {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
||||
try {
|
||||
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
|
||||
if (!res.ok) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws = new WebSocket(DAEMON_WS_URL);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
console.log("[opencli] Connected to daemon");
|
||||
reconnectAttempts = 0;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
|
||||
};
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const command = JSON.parse(event.data);
|
||||
const result = await handleCommand(command);
|
||||
ws?.send(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
console.error("[opencli] Message handling error:", err);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
console.log("[opencli] Disconnected from daemon");
|
||||
ws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
const MAX_EAGER_ATTEMPTS = 6;
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectAttempts++;
|
||||
if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return;
|
||||
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, delay);
|
||||
}
|
||||
const automationSessions = /* @__PURE__ */ new Map();
|
||||
const WINDOW_IDLE_TIMEOUT = 3e4;
|
||||
function getWorkspaceKey(workspace) {
|
||||
return workspace?.trim() || "default";
|
||||
}
|
||||
function resetWindowIdleTimer(workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
} catch {
|
||||
}
|
||||
automationSessions.delete(workspace);
|
||||
}, WINDOW_IDLE_TIMEOUT);
|
||||
}
|
||||
async function getAutomationWindow(workspace, initialUrl) {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing) {
|
||||
try {
|
||||
await chrome.windows.get(existing.windowId);
|
||||
return existing.windowId;
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE;
|
||||
const win = await chrome.windows.create({
|
||||
url: startUrl,
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
type: "normal"
|
||||
});
|
||||
const session = {
|
||||
windowId: win.id,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
const tabs = await chrome.tabs.query({ windowId: win.id });
|
||||
if (tabs[0]?.id) {
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 500);
|
||||
const listener = (tabId, info) => {
|
||||
if (tabId === tabs[0].id && info.status === "complete") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
if (tabs[0].status === "complete") {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else {
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
return session.windowId;
|
||||
}
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
for (const [workspace, session] of automationSessions.entries()) {
|
||||
if (session.windowId === windowId) {
|
||||
console.log(`[opencli] Automation window closed (${workspace})`);
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
});
|
||||
let initialized = false;
|
||||
function initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
||||
registerListeners();
|
||||
void connect();
|
||||
console.log("[opencli] OpenCLI extension initialized");
|
||||
}
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") void connect();
|
||||
});
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === "getStatus") {
|
||||
sendResponse({
|
||||
connected: ws?.readyState === WebSocket.OPEN,
|
||||
reconnecting: reconnectTimer !== null
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
async function handleCommand(cmd) {
|
||||
const workspace = getWorkspaceKey(cmd.workspace);
|
||||
resetWindowIdleTimer(workspace);
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case "exec":
|
||||
return await handleExec(cmd, workspace);
|
||||
case "navigate":
|
||||
return await handleNavigate(cmd, workspace);
|
||||
case "tabs":
|
||||
return await handleTabs(cmd, workspace);
|
||||
case "cookies":
|
||||
return await handleCookies(cmd);
|
||||
case "screenshot":
|
||||
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);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
};
|
||||
}
|
||||
}
|
||||
const BLANK_PAGE = "about:blank";
|
||||
function isDebuggableUrl(url) {
|
||||
if (!url) return true;
|
||||
return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
|
||||
}
|
||||
function isSafeNavigationUrl(url) {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
}
|
||||
function normalizeUrlForComparison(url) {
|
||||
if (!url) return "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") {
|
||||
parsed.port = "";
|
||||
}
|
||||
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
|
||||
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
function isTargetUrl(currentUrl, targetUrl) {
|
||||
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
||||
}
|
||||
async function resolveTab(tabId, workspace, initialUrl) {
|
||||
if (tabId !== void 0) {
|
||||
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, tab };
|
||||
if (session && !matchesSession && isDebuggableUrl(tab.url)) {
|
||||
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)) {
|
||||
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
||||
}
|
||||
}
|
||||
const windowId = await getAutomationWindow(workspace, initialUrl);
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
|
||||
if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab };
|
||||
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 { tabId: reuseTab.id, tab: updated };
|
||||
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
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 { tabId: newTab.id, tab: newTab };
|
||||
}
|
||||
async function resolveTabId(tabId, workspace, initialUrl) {
|
||||
const resolved = await resolveTab(tabId, workspace, initialUrl);
|
||||
return resolved.tabId;
|
||||
}
|
||||
async function listAutomationTabs(workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function listAutomationWebTabs(workspace) {
|
||||
const tabs = await listAutomationTabs(workspace);
|
||||
return tabs.filter((tab) => isDebuggableUrl(tab.url));
|
||||
}
|
||||
async function handleExec(cmd, workspace) {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const aggressive = workspace.startsWith("operate:");
|
||||
const data = await 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) };
|
||||
}
|
||||
}
|
||||
async function handleNavigate(cmd, workspace) {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
||||
if (!isSafeNavigationUrl(cmd.url)) {
|
||||
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
||||
}
|
||||
const resolved = await resolveTab(cmd.tabId, workspace, cmd.url);
|
||||
const tabId = resolved.tabId;
|
||||
const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
|
||||
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
|
||||
const targetUrl = cmd.url;
|
||||
if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false }
|
||||
};
|
||||
}
|
||||
await detach(tabId);
|
||||
await chrome.tabs.update(tabId, { url: targetUrl });
|
||||
let timedOut = false;
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let checkTimer = null;
|
||||
let timeoutTimer = null;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
if (checkTimer) clearTimeout(checkTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
resolve();
|
||||
};
|
||||
const isNavigationDone = (url) => {
|
||||
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
|
||||
};
|
||||
const listener = (id, info, tab2) => {
|
||||
if (id !== tabId) return;
|
||||
if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
checkTimer = setTimeout(async () => {
|
||||
try {
|
||||
const currentTab = await chrome.tabs.get(tabId);
|
||||
if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) {
|
||||
finish();
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}, 100);
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
||||
finish();
|
||||
}, 15e3);
|
||||
});
|
||||
let tab = await chrome.tabs.get(tabId);
|
||||
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,
|
||||
data: { title: tab.title, url: tab.url, tabId, timedOut }
|
||||
};
|
||||
}
|
||||
async function handleTabs(cmd, workspace) {
|
||||
switch (cmd.op) {
|
||||
case "list": {
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const data = tabs.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
active: t.active
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case "new": {
|
||||
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
|
||||
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
||||
}
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case "close": {
|
||||
if (cmd.index !== void 0) {
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
await detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
await detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
}
|
||||
case "select": {
|
||||
if (cmd.index === void 0 && cmd.tabId === void 0)
|
||||
return { id: cmd.id, ok: false, error: "Missing index or tabId" };
|
||||
if (cmd.tabId !== void 0) {
|
||||
const session = automationSessions.get(workspace);
|
||||
let tab;
|
||||
try {
|
||||
tab = await chrome.tabs.get(cmd.tabId);
|
||||
} catch {
|
||||
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` };
|
||||
}
|
||||
if (!session || tab.windowId !== session.windowId) {
|
||||
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` };
|
||||
}
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
||||
}
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
||||
}
|
||||
}
|
||||
async function handleCookies(cmd) {
|
||||
if (!cmd.domain && !cmd.url) {
|
||||
return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" };
|
||||
}
|
||||
const details = {};
|
||||
if (cmd.domain) details.domain = cmd.domain;
|
||||
if (cmd.url) details.url = cmd.url;
|
||||
const cookies = await chrome.cookies.getAll(details);
|
||||
const data = cookies.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
expirationDate: c.expirationDate
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
async function handleScreenshot(cmd, workspace) {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await screenshot(tabId, {
|
||||
format: cmd.format,
|
||||
quality: cmd.quality,
|
||||
fullPage: cmd.fullPage
|
||||
});
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
const CDP_ALLOWLIST = /* @__PURE__ */ 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, workspace) {
|
||||
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 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, workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
}
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
return { id: cmd.id, ok: true, data: { closed: true } };
|
||||
}
|
||||
async function handleSetFileInput(cmd, workspace) {
|
||||
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
|
||||
return { id: cmd.id, ok: false, error: "Missing or empty files array" };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
await setFileInputFiles(tabId, cmd.files, cmd.selector);
|
||||
return { id: cmd.id, ok: true, data: { count: cmd.files.length } };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
async function handleSessions(cmd) {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
workspace,
|
||||
windowId: session.windowId,
|
||||
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
|
||||
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
|
||||
})));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
@@ -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/';
|
||||
|
||||
+68
-21
@@ -181,8 +181,27 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
// Brief delay to let Chrome load the initial 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;
|
||||
}
|
||||
|
||||
@@ -317,30 +336,37 @@ 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.
|
||||
* @param initialUrl — passed to getAutomationWindow for first-time window creation.
|
||||
* 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, initialUrl?: 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`);
|
||||
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
|
||||
if (session && !matchesSession && 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`);
|
||||
}
|
||||
}
|
||||
@@ -351,17 +377,16 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia
|
||||
// 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
|
||||
@@ -371,7 +396,13 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia
|
||||
// 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[]> {
|
||||
@@ -408,9 +439,10 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
|
||||
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
|
||||
}
|
||||
// Pass target URL so that first-time window creation can start on the right domain
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace, cmd.url);
|
||||
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;
|
||||
|
||||
@@ -481,7 +513,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,
|
||||
|
||||
+16
-3
@@ -79,6 +79,16 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f
|
||||
}
|
||||
|
||||
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'
|
||||
: '';
|
||||
@@ -121,11 +131,14 @@ export async function evaluate(tabId: number, expression: string, aggressiveRetr
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Only retry on attach/debugger errors, not on JS eval errors
|
||||
const isAttachError = msg.includes('attach failed') || msg.includes('Debugger is not attached')
|
||||
|| msg.includes('chrome-extension://') || msg.includes('Target closed');
|
||||
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
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// SPA navigations recover quickly; debugger detach needs longer
|
||||
const retryMs = isNavigateError ? 200 : 500;
|
||||
await new Promise(resolve => setTimeout(resolve, retryMs));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -217,7 +217,7 @@ function main() {
|
||||
console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m');
|
||||
console.log(' Browser commands (bilibili, zhihu, twitter...) require the extension:');
|
||||
console.log(' 1. Download: https://github.com/jackwener/opencli/releases');
|
||||
console.log(' 2. Open chrome://extensions → enable Developer Mode → Load unpacked');
|
||||
console.log(' 2. In Chrome or Chromium, open chrome://extensions → enable Developer Mode → Load unpacked');
|
||||
console.log('');
|
||||
console.log(' Then run \x1b[36mopencli doctor\x1b[0m to verify.');
|
||||
console.log('');
|
||||
|
||||
@@ -22,9 +22,10 @@ Requires: Chrome running + OpenCLI Browser Bridge extension installed.
|
||||
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 safe commands with `&&`** — `type 3 "a" && type 4 "b" && click 7` is one call instead of three. But always run `state` first to get correct indices before chaining.
|
||||
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. **Prefer `network` to discover APIs** — most sites have JSON APIs. API-based adapters are more reliable than DOM scraping.
|
||||
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
|
||||
|
||||
@@ -38,13 +39,24 @@ Requires: Chrome running + OpenCLI Browser Bridge extension installed.
|
||||
|
||||
Commands can be chained with `&&`. The browser persists via daemon, so chaining is safe.
|
||||
|
||||
**Safe to chain** — these don't change the page structure:
|
||||
**Always chain when possible** — fewer tool calls = faster completion:
|
||||
```bash
|
||||
# Fill multiple fields then submit
|
||||
# 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
|
||||
|
||||
# Open and inspect
|
||||
opencli operate open https://example.com && opencli operate state
|
||||
# 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):
|
||||
@@ -103,13 +115,17 @@ opencli operate keys "Enter" # Press key (Enter, Escape, Tab, Control
|
||||
|
||||
### Wait
|
||||
|
||||
Three variants — use the right one for the situation:
|
||||
|
||||
```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
|
||||
opencli operate wait time 3 # Wait N seconds (fixed delay)
|
||||
opencli operate wait selector ".loaded" # Wait until element appears in DOM
|
||||
opencli operate wait selector ".spinner" --timeout 5000 # With timeout (default 30s)
|
||||
opencli operate wait text "Success" # Wait until text appears on page
|
||||
```
|
||||
|
||||
**When to wait**: After `open` on SPAs, after `click` that triggers async loading, before `eval` on dynamically rendered content.
|
||||
|
||||
### Extract (free & instant, read-only)
|
||||
|
||||
Use `eval` ONLY for reading data. Never use it to click, type, or navigate.
|
||||
@@ -122,6 +138,16 @@ opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e
|
||||
opencli operate eval "(function(){ const items = [...document.querySelectorAll('.item')]; return JSON.stringify(items.map(e => e.textContent)); })()"
|
||||
```
|
||||
|
||||
**Selector safety**: Always use fallback selectors — `querySelector` returns `null` on miss:
|
||||
```bash
|
||||
# BAD: crashes if selector misses
|
||||
opencli operate eval "document.querySelector('.title').textContent"
|
||||
|
||||
# GOOD: fallback with || or ?.
|
||||
opencli operate eval "(document.querySelector('.title') || document.querySelector('h1') || {textContent:''}).textContent"
|
||||
opencli operate eval "document.querySelector('.title')?.textContent ?? 'not found'"
|
||||
```
|
||||
|
||||
### Network (API Discovery)
|
||||
|
||||
```bash
|
||||
@@ -133,10 +159,14 @@ 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
|
||||
opencli operate init hn/top # Generate adapter scaffold at ~/.opencli/clis/hn/top.ts
|
||||
opencli operate verify hn/top # Test the adapter (adds --limit 3 only if `limit` arg is defined)
|
||||
```
|
||||
|
||||
- `init` auto-detects the domain from the active browser session (no need to specify it)
|
||||
- `init` creates the file + populates `site`, `name`, `domain`, and `columns` from current page
|
||||
- `verify` runs the adapter end-to-end and prints output; if no `limit` arg exists in the adapter, it won't pass `--limit 3`
|
||||
|
||||
### Session
|
||||
|
||||
```bash
|
||||
@@ -240,6 +270,32 @@ Save to `~/.opencli/clis/<site>/<command>.ts` → immediately available as `open
|
||||
4. **Use `network` to find APIs** — JSON APIs are more reliable than DOM scraping
|
||||
5. **Alias**: `opencli op` is shorthand for `opencli operate`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **`form.submit()` fails in automation** — Don't use `form.submit()` or `eval` to submit forms. Navigate directly to the search URL instead:
|
||||
```bash
|
||||
# BAD: form.submit() often silently fails
|
||||
opencli operate eval "document.querySelector('form').submit()"
|
||||
# GOOD: construct the URL and navigate
|
||||
opencli operate open "https://github.com/search?q=opencli&type=repositories"
|
||||
```
|
||||
|
||||
2. **GitHub DOM changes frequently** — Prefer `data-testid` attributes when available; they are more stable than class names or tag structure.
|
||||
|
||||
3. **SPA pages need `wait` before extraction** — After `open` or `click` on single-page apps, the DOM isn't ready immediately. Always `wait selector` or `wait text` before `eval`.
|
||||
|
||||
4. **Use `state` before clicking** — Run `opencli operate state` to inspect available interactive elements and their indices. Never guess indices from memory.
|
||||
|
||||
5. **`evaluate` runs in browser context** — `page.evaluate()` in adapters executes inside the browser. Node.js APIs (`fs`, `path`, `process`) are NOT available. Use `fetch()` for network calls, DOM APIs for page data.
|
||||
|
||||
6. **Backticks in `page.evaluate` break JSON storage** — When writing adapters that will be stored/transported as JSON, avoid template literals inside `page.evaluate`. Use string concatenation or function-style evaluate:
|
||||
```typescript
|
||||
// BAD: template literal backticks break when adapter is in JSON
|
||||
page.evaluate(`document.querySelector("${selector}")`)
|
||||
// GOOD: function-style evaluate
|
||||
page.evaluate((sel) => document.querySelector(sel), selector)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Fix |
|
||||
|
||||
+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) ──
|
||||
|
||||
@@ -131,17 +133,30 @@ export abstract class BasePage implements IPage {
|
||||
|
||||
async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
|
||||
const snapshotJs = generateSnapshotJs({
|
||||
viewportExpand: opts.viewportExpand ?? 800,
|
||||
viewportExpand: opts.viewportExpand ?? 2000,
|
||||
maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
|
||||
interactiveOnly: opts.interactive ?? false,
|
||||
maxTextLength: opts.maxTextLength ?? 120,
|
||||
includeScrollInfo: true,
|
||||
bboxDedup: true,
|
||||
previousHashes: this._prevSnapshotHashes,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.evaluate(snapshotJs);
|
||||
} catch {
|
||||
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 (err) {
|
||||
// Log snapshot failure for debugging, then fallback to basic accessibility tree
|
||||
if (process.env.DEBUG_SNAPSHOT) {
|
||||
console.error('[snapshot] DOM snapshot failed, falling back to accessibility tree:', (err as Error)?.message?.slice(0, 200));
|
||||
}
|
||||
return this._basicSnapshot(opts);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -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,14 +60,17 @@ 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');
|
||||
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
|
||||
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -76,7 +79,7 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
}
|
||||
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.',
|
||||
'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,10 +113,10 @@ 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.',
|
||||
'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,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;
|
||||
|
||||
|
||||
@@ -407,7 +407,8 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
|
||||
function isSearchElement(el) {
|
||||
// Check class names for search indicators
|
||||
const className = el.className?.toLowerCase() || '';
|
||||
// Note: SVG elements have className as SVGAnimatedString (not a string), use baseVal
|
||||
const className = (typeof el.className === 'string' ? el.className : el.className?.baseVal || '').toLowerCase();
|
||||
const classes = className.split(/\\s+/).filter(Boolean);
|
||||
for (const cls of classes) {
|
||||
const cleaned = cls.replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
+17
-18
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -302,7 +302,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
|
||||
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 snapshot = await page.snapshot({ viewportExpand: 2000 });
|
||||
const url = await page.getCurrentUrl?.() ?? '';
|
||||
console.log(`URL: ${url}\n`);
|
||||
console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
|
||||
@@ -605,19 +605,25 @@ cli({
|
||||
console.log(`🔍 Verifying ${name}...\n`);
|
||||
console.log(` Loading: ${filePath}`);
|
||||
|
||||
// Read adapter to check if it defines a 'limit' arg
|
||||
const adapterSrc = fs.readFileSync(filePath, 'utf-8');
|
||||
const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
|
||||
const limitFlag = hasLimitArg ? ' --limit 3' : '';
|
||||
const verifyCmd = `node dist/main.js ${site} ${command}${limitFlag}`;
|
||||
|
||||
try {
|
||||
const output = execSync(`node dist/main.js ${site} ${command} --limit 3`, {
|
||||
const output = execSync(verifyCmd, {
|
||||
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(` Executing: opencli ${site} ${command}${limitFlag}\n`);
|
||||
console.log(output);
|
||||
console.log(`\n ✓ Adapter works!`);
|
||||
} catch (err: any) {
|
||||
console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
|
||||
console.log(` Executing: opencli ${site} ${command}${limitFlag}\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.`);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './bestsellers.js';
|
||||
import { __test__ } from './rankings.js';
|
||||
|
||||
describe('amazon bestsellers normalization', () => {
|
||||
it('normalizes bestseller cards and infers review counts from card text', () => {
|
||||
const result = __test__.normalizeBestsellerCandidate({
|
||||
const result = __test__.normalizeRankingCandidate({
|
||||
asin: 'B0DR31GC3D',
|
||||
title: '',
|
||||
href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs',
|
||||
@@ -11,7 +11,16 @@ describe('amazon bestsellers normalization', () => {
|
||||
rating_text: '4.3 out of 5 stars',
|
||||
review_count_text: '',
|
||||
card_text: 'Desk Shelves Desktop Organizer Shlef\n4.3 out of 5 stars\n435\n$25.92',
|
||||
}, 2, 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves', 'https://www.amazon.com/example');
|
||||
}, {
|
||||
listType: 'bestsellers',
|
||||
rankFallback: 2,
|
||||
listTitle: 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves',
|
||||
sourceUrl: 'https://www.amazon.com/example',
|
||||
categoryTitle: null,
|
||||
categoryUrl: 'https://www.amazon.com/example',
|
||||
categoryPath: [],
|
||||
visibleCategoryLinks: [],
|
||||
});
|
||||
|
||||
expect(result.rank).toBe(2);
|
||||
expect(result.asin).toBe('B0DR31GC3D');
|
||||
|
||||
@@ -1,180 +1,8 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import {
|
||||
buildProvenance,
|
||||
cleanText,
|
||||
extractAsin,
|
||||
extractReviewCountFromCardText,
|
||||
firstMeaningfulLine,
|
||||
normalizeProductUrl,
|
||||
parsePriceText,
|
||||
parseRatingValue,
|
||||
parseReviewCount,
|
||||
resolveBestsellersUrl,
|
||||
uniqueNonEmpty,
|
||||
assertUsableState,
|
||||
gotoAndReadState,
|
||||
} from './shared.js';
|
||||
import { cli } from '../../registry.js';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
|
||||
interface BestsellersPagePayload {
|
||||
href?: string;
|
||||
title?: string;
|
||||
list_title?: string;
|
||||
cards?: Array<{
|
||||
rank_text?: string | null;
|
||||
asin?: string | null;
|
||||
title?: string | null;
|
||||
href?: string | null;
|
||||
price_text?: string | null;
|
||||
rating_text?: string | null;
|
||||
review_count_text?: string | null;
|
||||
card_text?: string | null;
|
||||
}>;
|
||||
page_links?: string[];
|
||||
}
|
||||
|
||||
function normalizeBestsellerCandidate(
|
||||
candidate: NonNullable<BestsellersPagePayload['cards']>[number],
|
||||
rank: number,
|
||||
listTitle: string | null,
|
||||
sourceUrl: string,
|
||||
): Record<string, unknown> {
|
||||
const productUrl = normalizeProductUrl(candidate.href);
|
||||
const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
|
||||
const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text);
|
||||
const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text);
|
||||
const ratingText = cleanText(candidate.rating_text) || null;
|
||||
const reviewCountText = cleanText(candidate.review_count_text)
|
||||
|| extractReviewCountFromCardText(candidate.card_text)
|
||||
|| null;
|
||||
const provenance = buildProvenance(sourceUrl);
|
||||
|
||||
return {
|
||||
rank,
|
||||
asin,
|
||||
title: title || null,
|
||||
product_url: productUrl,
|
||||
list_title: listTitle,
|
||||
...provenance,
|
||||
price_text: price.price_text,
|
||||
price_value: price.price_value,
|
||||
currency: price.currency,
|
||||
rating_text: ratingText,
|
||||
rating_value: parseRatingValue(ratingText),
|
||||
review_count_text: reviewCountText,
|
||||
review_count: parseReviewCount(reviewCountText),
|
||||
};
|
||||
}
|
||||
|
||||
async function readBestsellersPage(page: IPage, url: string): Promise<BestsellersPagePayload> {
|
||||
const state = await gotoAndReadState(page, url, 2500, 'bestsellers');
|
||||
assertUsableState(state, 'bestsellers');
|
||||
|
||||
return await page.evaluate(`
|
||||
(() => ({
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
list_title:
|
||||
document.querySelector('#zg_banner_text')?.textContent
|
||||
|| document.querySelector('h1')?.textContent
|
||||
|| '',
|
||||
cards: Array.from(document.querySelectorAll('.p13n-sc-uncoverable-faceout'))
|
||||
.map((card) => ({
|
||||
rank_text:
|
||||
card.querySelector('.zg-bdg-text')?.textContent
|
||||
|| card.querySelector('[class*="rank"]')?.textContent
|
||||
|| '',
|
||||
asin: card.id || '',
|
||||
title:
|
||||
card.querySelector('[class*="line-clamp"]')?.textContent
|
||||
|| card.querySelector('img')?.getAttribute('alt')
|
||||
|| '',
|
||||
href: card.querySelector('a[href*="/dp/"]')?.href || '',
|
||||
price_text: card.querySelector('.a-price .a-offscreen')?.textContent || '',
|
||||
rating_text: card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label') || '',
|
||||
review_count_text:
|
||||
card.querySelector('a[href*="#customerReviews"]')?.textContent
|
||||
|| card.querySelector('.a-size-small')?.textContent
|
||||
|| '',
|
||||
card_text: card.innerText || '',
|
||||
})),
|
||||
page_links: Array.from(document.querySelectorAll('li.a-normal a, li.a-selected a'))
|
||||
.map((anchor) => anchor.href || '')
|
||||
.filter((href) => /\\/zgbs\\//.test(href) && /(?:[?&]pg=|ref=zg_bs_pg_)/.test(href)),
|
||||
}))()
|
||||
`) as BestsellersPagePayload;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'amazon',
|
||||
name: 'bestsellers',
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'bestsellers',
|
||||
listType: 'bestsellers',
|
||||
description: 'Amazon Best Sellers pages for category candidate discovery',
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{
|
||||
name: 'input',
|
||||
positional: true,
|
||||
help: 'Best sellers URL or /zgbs path. Omit to use the root Best Sellers page.',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'int',
|
||||
default: 100,
|
||||
help: 'Maximum number of ranked items to return (default 100)',
|
||||
},
|
||||
],
|
||||
columns: ['rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = Math.max(1, Number(kwargs.limit) || 100);
|
||||
const initialUrl = resolveBestsellersUrl(typeof kwargs.input === 'string' ? kwargs.input : undefined);
|
||||
|
||||
const queue = [initialUrl];
|
||||
const visited = new Set<string>();
|
||||
const seenAsins = new Set<string>();
|
||||
const results: Record<string, unknown>[] = [];
|
||||
let listTitle: string | null = null;
|
||||
|
||||
while (queue.length > 0 && results.length < limit) {
|
||||
const nextUrl = queue.shift()!;
|
||||
if (visited.has(nextUrl)) continue;
|
||||
visited.add(nextUrl);
|
||||
|
||||
const payload = await readBestsellersPage(page, nextUrl);
|
||||
const sourceUrl = cleanText(payload.href) || nextUrl;
|
||||
listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle;
|
||||
const cards = payload.cards ?? [];
|
||||
|
||||
for (const card of cards) {
|
||||
const normalized = normalizeBestsellerCandidate(card, results.length + 1, listTitle, sourceUrl);
|
||||
const asin = cleanText(String(normalized.asin ?? ''));
|
||||
if (!asin || seenAsins.has(asin)) continue;
|
||||
seenAsins.add(asin);
|
||||
results.push(normalized);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
const pageLinks = uniqueNonEmpty(payload.page_links ?? []);
|
||||
for (const href of pageLinks) {
|
||||
if (!visited.has(href) && !queue.includes(href)) {
|
||||
queue.push(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new CommandExecutionError(
|
||||
'amazon bestsellers did not expose any ranked items',
|
||||
'Open the same best sellers page in Chrome, verify it is a real Amazon ranking page, and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeBestsellerCandidate,
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cli } from '../../registry.js';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'movers-shakers',
|
||||
listType: 'movers_shakers',
|
||||
description: 'Amazon Movers & Shakers pages for short-term growth signals',
|
||||
}));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cli } from '../../registry.js';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'new-releases',
|
||||
listType: 'new_releases',
|
||||
description: 'Amazon New Releases pages for early momentum discovery',
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './rankings.js';
|
||||
|
||||
describe('amazon rankings helpers', () => {
|
||||
it('normalizes ranking candidates with unified schema', () => {
|
||||
const result = __test__.normalizeRankingCandidate(
|
||||
{
|
||||
rank_text: '#3',
|
||||
asin: 'B0DR31GC3D',
|
||||
title: 'Desk Shelves Desktop Organizer',
|
||||
href: 'https://www.amazon.com/dp/B0DR31GC3D/ref=zg_bs',
|
||||
price_text: '$25.92',
|
||||
rating_text: '4.3 out of 5 stars',
|
||||
review_count_text: '435',
|
||||
},
|
||||
{
|
||||
listType: 'new_releases',
|
||||
rankFallback: 3,
|
||||
listTitle: 'Amazon New Releases',
|
||||
sourceUrl: 'https://www.amazon.com/gp/new-releases',
|
||||
categoryTitle: 'Home & Kitchen',
|
||||
categoryUrl: 'https://www.amazon.com/gp/new-releases/home-garden',
|
||||
categoryPath: ['Home & Kitchen'],
|
||||
visibleCategoryLinks: [{ title: 'Storage', url: 'https://www.amazon.com/gp/new-releases/storage', node_id: null }],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.list_type).toBe('new_releases');
|
||||
expect(result.rank).toBe(3);
|
||||
expect(result.asin).toBe('B0DR31GC3D');
|
||||
expect(result.product_url).toBe('https://www.amazon.com/dp/B0DR31GC3D');
|
||||
expect(result.category_title).toBe('Home & Kitchen');
|
||||
expect(result.visible_category_links).toEqual([
|
||||
{ title: 'Storage', url: 'https://www.amazon.com/gp/new-releases/storage', node_id: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates category links and parses rank fallback', () => {
|
||||
const links = __test__.normalizeVisibleCategoryLinks([
|
||||
{ title: 'Kitchen', url: '/gp/new-releases/home-garden' },
|
||||
{ title: 'Kitchen', url: 'https://www.amazon.com/gp/new-releases/home-garden' },
|
||||
{ title: 'Storage', url: '/gp/new-releases/storage', node_id: '1064954' },
|
||||
]);
|
||||
expect(links.length).toBe(2);
|
||||
expect(__test__.parseRank('N/A', 8)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { Strategy, type CliOptions } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import {
|
||||
assertUsableState,
|
||||
buildProvenance,
|
||||
cleanText,
|
||||
extractAsin,
|
||||
extractCategoryNodeId,
|
||||
extractReviewCountFromCardText,
|
||||
firstMeaningfulLine,
|
||||
gotoAndReadState,
|
||||
isRankingPaginationUrl,
|
||||
normalizeProductUrl,
|
||||
parsePriceText,
|
||||
parseRatingValue,
|
||||
parseReviewCount,
|
||||
resolveRankingUrl,
|
||||
toAbsoluteAmazonUrl,
|
||||
uniqueNonEmpty,
|
||||
type AmazonRankingListType,
|
||||
} from './shared.js';
|
||||
|
||||
export interface RankingCardPayload {
|
||||
rank_text?: string | null;
|
||||
asin?: string | null;
|
||||
title?: string | null;
|
||||
href?: string | null;
|
||||
price_text?: string | null;
|
||||
rating_text?: string | null;
|
||||
review_count_text?: string | null;
|
||||
card_text?: string | null;
|
||||
}
|
||||
|
||||
interface RankingPagePayload {
|
||||
href?: string;
|
||||
title?: string;
|
||||
list_title?: string;
|
||||
category_title?: string;
|
||||
category_path?: string[];
|
||||
cards?: RankingCardPayload[];
|
||||
page_links?: string[];
|
||||
visible_category_links?: Array<{
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
node_id?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RankingCommandDefinition {
|
||||
commandName: string;
|
||||
listType: AmazonRankingListType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RankingNormalizeContext {
|
||||
listType: AmazonRankingListType;
|
||||
rankFallback: number;
|
||||
listTitle: string | null;
|
||||
sourceUrl: string;
|
||||
categoryTitle: string | null;
|
||||
categoryUrl: string | null;
|
||||
categoryPath: string[];
|
||||
visibleCategoryLinks: Array<{ title: string; url: string; node_id: string | null }>;
|
||||
}
|
||||
|
||||
function parseRank(rawRank: string | null | undefined, fallback: number): number {
|
||||
const normalized = cleanText(rawRank);
|
||||
const match = normalized.match(/(\d{1,4})/);
|
||||
if (!match) return fallback;
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeVisibleCategoryLinks(
|
||||
links: RankingPagePayload['visible_category_links'],
|
||||
): Array<{ title: string; url: string; node_id: string | null }> {
|
||||
const normalized = (links ?? [])
|
||||
.map((entry) => ({
|
||||
title: cleanText(entry?.title),
|
||||
url: toAbsoluteAmazonUrl(entry?.url) ?? '',
|
||||
node_id: cleanText(entry?.node_id) || extractCategoryNodeId(entry?.url) || null,
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.title) && Boolean(entry.url));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped: Array<{ title: string; url: string; node_id: string | null }> = [];
|
||||
for (const entry of normalized) {
|
||||
if (seen.has(entry.url)) continue;
|
||||
seen.add(entry.url);
|
||||
deduped.push(entry);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function normalizeRankingCandidate(
|
||||
candidate: RankingCardPayload,
|
||||
context: RankingNormalizeContext,
|
||||
): Record<string, unknown> {
|
||||
const productUrl = normalizeProductUrl(candidate.href);
|
||||
const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
|
||||
const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text);
|
||||
const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text);
|
||||
const ratingText = cleanText(candidate.rating_text) || null;
|
||||
const reviewCountText = cleanText(candidate.review_count_text)
|
||||
|| extractReviewCountFromCardText(candidate.card_text)
|
||||
|| null;
|
||||
const provenance = buildProvenance(context.sourceUrl);
|
||||
const categoryUrl = context.categoryUrl || context.sourceUrl;
|
||||
|
||||
return {
|
||||
list_type: context.listType,
|
||||
rank: parseRank(candidate.rank_text, context.rankFallback),
|
||||
asin,
|
||||
title: title || null,
|
||||
product_url: productUrl,
|
||||
price_text: price.price_text,
|
||||
price_value: price.price_value,
|
||||
currency: price.currency,
|
||||
rating_text: ratingText,
|
||||
rating_value: parseRatingValue(ratingText),
|
||||
review_count_text: reviewCountText,
|
||||
review_count: parseReviewCount(reviewCountText),
|
||||
list_title: context.listTitle,
|
||||
category_title: context.categoryTitle,
|
||||
category_url: categoryUrl,
|
||||
category_node_id: extractCategoryNodeId(categoryUrl),
|
||||
category_path: context.categoryPath,
|
||||
visible_category_links: context.visibleCategoryLinks,
|
||||
...provenance,
|
||||
};
|
||||
}
|
||||
|
||||
async function readRankingPage(
|
||||
page: IPage,
|
||||
listType: AmazonRankingListType,
|
||||
url: string,
|
||||
): Promise<RankingPagePayload> {
|
||||
const state = await gotoAndReadState(page, url, 2500, listType);
|
||||
assertUsableState(state, listType);
|
||||
|
||||
return await page.evaluate(`
|
||||
(() => ({
|
||||
href: window.location.href,
|
||||
title: document.title || '',
|
||||
list_title:
|
||||
document.querySelector('#zg_banner_text')?.textContent
|
||||
|| document.querySelector('h1')?.textContent
|
||||
|| '',
|
||||
category_title:
|
||||
document.querySelector('#zg_browseRoot .zg_selected')?.textContent
|
||||
|| document.querySelector('#wayfinding-breadcrumbs_feature_div ul li:last-child')?.textContent
|
||||
|| document.querySelector('#wayfinding-breadcrumbs_container ul li:last-child')?.textContent
|
||||
|| '',
|
||||
category_path: Array.from(document.querySelectorAll(
|
||||
'#zg_browseRoot ul li a, #zg_browseRoot ul li span, ' +
|
||||
'#wayfinding-breadcrumbs_feature_div ul li a, #wayfinding-breadcrumbs_feature_div ul li span.a-list-item, ' +
|
||||
'#wayfinding-breadcrumbs_container ul li a, #wayfinding-breadcrumbs_container ul li span.a-list-item'
|
||||
))
|
||||
.map((entry) => (entry.textContent || '').trim())
|
||||
.filter(Boolean),
|
||||
cards: Array.from(document.querySelectorAll(
|
||||
'.p13n-sc-uncoverable-faceout, .zg-grid-general-faceout, [data-asin][class*="p13n"]'
|
||||
)).map((card) => ({
|
||||
rank_text:
|
||||
card.querySelector('.zg-bdg-text')?.textContent
|
||||
|| card.querySelector('[class*="rank"]')?.textContent
|
||||
|| '',
|
||||
asin:
|
||||
card.getAttribute('data-asin')
|
||||
|| card.getAttribute('id')
|
||||
|| '',
|
||||
title:
|
||||
card.querySelector('[class*="line-clamp"]')?.textContent
|
||||
|| card.querySelector('img')?.getAttribute('alt')
|
||||
|| card.querySelector('a[href*="/dp/"]')?.textContent
|
||||
|| '',
|
||||
href:
|
||||
card.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]')?.href
|
||||
|| '',
|
||||
price_text:
|
||||
card.querySelector('.a-price .a-offscreen')?.textContent
|
||||
|| card.querySelector('.a-color-price')?.textContent
|
||||
|| '',
|
||||
rating_text:
|
||||
card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label')
|
||||
|| '',
|
||||
review_count_text:
|
||||
card.querySelector('a[href*="#customerReviews"]')?.textContent
|
||||
|| card.querySelector('.a-size-small')?.textContent
|
||||
|| '',
|
||||
card_text: card.innerText || '',
|
||||
})),
|
||||
page_links: Array.from(document.querySelectorAll('.a-pagination a[href], li.a-normal a[href], li.a-selected a[href]'))
|
||||
.map((anchor) => anchor.href || '')
|
||||
.filter(Boolean),
|
||||
visible_category_links: Array.from(document.querySelectorAll(
|
||||
'#zg_browseRoot a[href], #zg-left-col a[href], [class*="zg-browse"] a[href]'
|
||||
)).map((anchor) => ({
|
||||
title: (anchor.textContent || '').trim(),
|
||||
url: anchor.href || '',
|
||||
node_id:
|
||||
anchor.getAttribute('data-node-id')
|
||||
|| anchor.dataset?.nodeid
|
||||
|| '',
|
||||
}))
|
||||
.filter((entry) => entry.title && entry.url),
|
||||
}))()
|
||||
`) as RankingPagePayload;
|
||||
}
|
||||
|
||||
function createEmptyResultHint(commandName: string): string {
|
||||
return [
|
||||
`Open the same Amazon ${commandName} page in shared Chrome and verify ranked items are visible.`,
|
||||
'If the page shows a robot check, clear it manually and retry.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function createRankingCliOptions(definition: RankingCommandDefinition): CliOptions {
|
||||
return {
|
||||
site: 'amazon',
|
||||
name: definition.commandName,
|
||||
description: definition.description,
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{
|
||||
name: 'input',
|
||||
positional: true,
|
||||
help: 'Ranking URL or supported Amazon path. Omit to use the list root.',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'int',
|
||||
default: 100,
|
||||
help: 'Maximum number of ranked items to return (default 100)',
|
||||
},
|
||||
],
|
||||
columns: ['list_type', 'rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = Math.max(1, Number(kwargs.limit) || 100);
|
||||
const initialUrl = resolveRankingUrl(definition.listType, typeof kwargs.input === 'string' ? kwargs.input : undefined);
|
||||
|
||||
const queue = [initialUrl];
|
||||
const visited = new Set<string>();
|
||||
const seenEntityKeys = new Set<string>();
|
||||
const results: Record<string, unknown>[] = [];
|
||||
let listTitle: string | null = null;
|
||||
|
||||
while (queue.length > 0 && results.length < limit) {
|
||||
const nextUrl = queue.shift()!;
|
||||
if (visited.has(nextUrl)) continue;
|
||||
visited.add(nextUrl);
|
||||
|
||||
const payload = await readRankingPage(page, definition.listType, nextUrl);
|
||||
const sourceUrl = cleanText(payload.href) || nextUrl;
|
||||
listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle;
|
||||
const categoryPath = uniqueNonEmpty(payload.category_path ?? []);
|
||||
const categoryTitle = cleanText(payload.category_title)
|
||||
|| (categoryPath.length > 0 ? categoryPath[categoryPath.length - 1] : '');
|
||||
const visibleCategoryLinks = normalizeVisibleCategoryLinks(payload.visible_category_links);
|
||||
const cards = payload.cards ?? [];
|
||||
|
||||
for (const card of cards) {
|
||||
const normalized = normalizeRankingCandidate(card, {
|
||||
listType: definition.listType,
|
||||
rankFallback: results.length + 1,
|
||||
listTitle,
|
||||
sourceUrl,
|
||||
categoryTitle: categoryTitle || null,
|
||||
categoryUrl: sourceUrl,
|
||||
categoryPath,
|
||||
visibleCategoryLinks,
|
||||
});
|
||||
|
||||
const dedupeKey = cleanText(String(normalized.asin ?? ''))
|
||||
|| cleanText(String(normalized.product_url ?? ''));
|
||||
if (dedupeKey && seenEntityKeys.has(dedupeKey)) continue;
|
||||
if (dedupeKey) seenEntityKeys.add(dedupeKey);
|
||||
|
||||
results.push(normalized);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
const pageLinks = uniqueNonEmpty(payload.page_links ?? []);
|
||||
for (const href of pageLinks) {
|
||||
const absolute = toAbsoluteAmazonUrl(href);
|
||||
if (!absolute || !isRankingPaginationUrl(definition.listType, absolute)) continue;
|
||||
if (!visited.has(absolute) && !queue.includes(absolute)) {
|
||||
queue.push(absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new CommandExecutionError(
|
||||
`amazon ${definition.commandName} did not expose any ranked items`,
|
||||
createEmptyResultHint(definition.commandName),
|
||||
);
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
parseRank,
|
||||
normalizeVisibleCategoryLinks,
|
||||
normalizeRankingCandidate,
|
||||
};
|
||||
@@ -34,4 +34,20 @@ describe('amazon shared helpers', () => {
|
||||
expect(__test__.resolveBestsellersUrl('/Best-Sellers/zgbs')).toBe('https://www.amazon.com/Best-Sellers/zgbs');
|
||||
expect(() => __test__.resolveBestsellersUrl('desk shelf organizer')).toThrow('amazon bestsellers expects a best sellers URL or /zgbs path');
|
||||
});
|
||||
|
||||
it('resolves and validates all ranking list URLs', () => {
|
||||
expect(__test__.resolveRankingUrl('new_releases')).toBe('https://www.amazon.com/gp/new-releases');
|
||||
expect(__test__.resolveRankingUrl('movers_shakers')).toBe('https://www.amazon.com/gp/movers-and-shakers');
|
||||
expect(__test__.resolveRankingUrl('new_releases', '/gp/new-releases/kitchen')).toBe('https://www.amazon.com/gp/new-releases/kitchen');
|
||||
expect(__test__.resolveRankingUrl(
|
||||
'bestsellers',
|
||||
'https://www.amazon.com/Best-Sellers/zgbs/ref=zg_bsnr_tab_bs',
|
||||
)).toBe('https://www.amazon.com/Best-Sellers/zgbs');
|
||||
expect(() => __test__.resolveRankingUrl('movers_shakers', 'https://example.com/gp/movers-and-shakers')).toThrow('Invalid Amazon URL');
|
||||
});
|
||||
|
||||
it('extracts category node id from URL best effort', () => {
|
||||
expect(__test__.extractCategoryNodeId('https://www.amazon.com/Best-Sellers-Home-Kitchen/zgbs/home-garden/3744371')).toBe('3744371');
|
||||
expect(__test__.extractCategoryNodeId('https://www.amazon.com/s?k=desk+organizer&rh=n%3A1064954')).toBe('1064954');
|
||||
});
|
||||
});
|
||||
|
||||
+134
-12
@@ -5,6 +5,8 @@ export const SITE = 'amazon';
|
||||
export const DOMAIN = 'amazon.com';
|
||||
export const HOME_URL = 'https://www.amazon.com/';
|
||||
export const BESTSELLERS_URL = 'https://www.amazon.com/Best-Sellers/zgbs';
|
||||
export const NEW_RELEASES_URL = 'https://www.amazon.com/gp/new-releases';
|
||||
export const MOVERS_SHAKERS_URL = 'https://www.amazon.com/gp/movers-and-shakers';
|
||||
export const SEARCH_URL_PREFIX = 'https://www.amazon.com/s?k=';
|
||||
export const PRODUCT_URL_PREFIX = 'https://www.amazon.com/dp/';
|
||||
export const DISCUSSION_URL_PREFIX = 'https://www.amazon.com/product-reviews/';
|
||||
@@ -28,6 +30,40 @@ const ROBOT_TEXT_PATTERNS = [
|
||||
'To discuss automated access to Amazon data please contact',
|
||||
];
|
||||
|
||||
export type AmazonRankingListType = 'bestsellers' | 'new_releases' | 'movers_shakers';
|
||||
|
||||
interface AmazonRankingSpec {
|
||||
commandName: string;
|
||||
rootUrl: string;
|
||||
pathPattern: RegExp;
|
||||
invalidInputMessage: string;
|
||||
invalidInputHint: string;
|
||||
}
|
||||
|
||||
const AMAZON_RANKING_SPECS: Record<AmazonRankingListType, AmazonRankingSpec> = {
|
||||
bestsellers: {
|
||||
commandName: 'bestsellers',
|
||||
rootUrl: BESTSELLERS_URL,
|
||||
pathPattern: /(?:^|\/)zgbs(?:\/|$)/i,
|
||||
invalidInputMessage: 'amazon bestsellers expects a best sellers URL or /zgbs path',
|
||||
invalidInputHint: 'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
|
||||
},
|
||||
new_releases: {
|
||||
commandName: 'new-releases',
|
||||
rootUrl: NEW_RELEASES_URL,
|
||||
pathPattern: /\/gp\/new-releases(?:\/|$)/i,
|
||||
invalidInputMessage: 'amazon new-releases expects a new releases URL or /gp/new-releases path',
|
||||
invalidInputHint: 'Example: opencli amazon new-releases https://www.amazon.com/gp/new-releases',
|
||||
},
|
||||
movers_shakers: {
|
||||
commandName: 'movers-shakers',
|
||||
rootUrl: MOVERS_SHAKERS_URL,
|
||||
pathPattern: /\/gp\/movers-and-shakers(?:\/|$)/i,
|
||||
invalidInputMessage: 'amazon movers-shakers expects a movers-and-shakers URL or /gp/movers-and-shakers path',
|
||||
invalidInputHint: 'Example: opencli amazon movers-shakers https://www.amazon.com/gp/movers-and-shakers',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProvenanceFields {
|
||||
source_url: string;
|
||||
fetched_at: string;
|
||||
@@ -115,23 +151,105 @@ export function buildDiscussionUrl(input: string): string {
|
||||
return `${DISCUSSION_URL_PREFIX}${asin}`;
|
||||
}
|
||||
|
||||
export function resolveBestsellersUrl(input?: string): string {
|
||||
function getRankingSpec(listType: AmazonRankingListType): AmazonRankingSpec {
|
||||
return AMAZON_RANKING_SPECS[listType];
|
||||
}
|
||||
|
||||
export function isSupportedRankingPath(listType: AmazonRankingListType, inputUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(inputUrl);
|
||||
return getRankingSpec(listType).pathPattern.test(url.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRankingUrl(listType: AmazonRankingListType, input?: string): string {
|
||||
const spec = getRankingSpec(listType);
|
||||
const normalized = cleanText(input);
|
||||
if (!normalized) return BESTSELLERS_URL;
|
||||
if (normalized === 'root') return BESTSELLERS_URL;
|
||||
if (!normalized || normalized === 'root') return spec.rootUrl;
|
||||
|
||||
let candidateUrl: string;
|
||||
if (normalized.startsWith('/')) {
|
||||
return new URL(normalized, HOME_URL).toString();
|
||||
candidateUrl = new URL(normalized, HOME_URL).toString();
|
||||
} else if (/^https?:\/\//i.test(normalized)) {
|
||||
candidateUrl = canonicalizeAmazonUrl(normalized);
|
||||
} else if (normalized.includes('amazon.') && normalized.includes('/')) {
|
||||
candidateUrl = canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
|
||||
} else {
|
||||
throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
|
||||
}
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return canonicalizeAmazonUrl(normalized);
|
||||
|
||||
if (!isSupportedRankingPath(listType, candidateUrl)) {
|
||||
throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
|
||||
}
|
||||
if (normalized.includes('/zgbs/')) {
|
||||
return canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
|
||||
return normalizeRankingInputUrl(candidateUrl);
|
||||
}
|
||||
|
||||
function normalizeRankingInputUrl(inputUrl: string): string {
|
||||
try {
|
||||
const url = new URL(inputUrl);
|
||||
const normalizedPathSegments = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.filter((segment) => !/^ref=/i.test(segment));
|
||||
url.pathname = `/${normalizedPathSegments.join('/')}`;
|
||||
url.hash = '';
|
||||
// Ranking pages are frequently shared with tracking refs that can land on unstable variants.
|
||||
// Dropping ref keeps the canonical ranking path while preserving useful params (for example pg=2).
|
||||
url.searchParams.delete('ref');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return inputUrl;
|
||||
}
|
||||
throw new ArgumentError(
|
||||
'amazon bestsellers expects a best sellers URL or /zgbs path',
|
||||
'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
|
||||
);
|
||||
}
|
||||
|
||||
export function isRankingPaginationUrl(listType: AmazonRankingListType, inputUrl: string): boolean {
|
||||
const absolute = toAbsoluteAmazonUrl(inputUrl);
|
||||
if (!absolute || !isSupportedRankingPath(listType, absolute)) return false;
|
||||
|
||||
try {
|
||||
const url = new URL(absolute);
|
||||
const ref = cleanText(url.searchParams.get('ref')).toLowerCase();
|
||||
// pg= query param is the most reliable pagination indicator across all ranking lists
|
||||
return url.searchParams.has('pg')
|
||||
|| /(?:^|_)pg(?:_|$)/.test(ref)
|
||||
// Amazon ranking pagination refs: zg_bs_pg_ (bestsellers), zg_bsnr_pg_ (new releases), zg_bsms_pg_ (movers & shakers)
|
||||
|| /zg_bs(?:nr|ms)?_pg_/.test(ref);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractCategoryNodeId(inputUrl: string | null | undefined): string | null {
|
||||
const absolute = toAbsoluteAmazonUrl(inputUrl);
|
||||
if (!absolute) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(absolute);
|
||||
|
||||
for (const key of ['node', 'nodeid', 'nodeId', 'browseNode']) {
|
||||
const value = cleanText(url.searchParams.get(key));
|
||||
if (/^\d{4,}$/.test(value)) return value;
|
||||
}
|
||||
|
||||
const rhValue = cleanText(url.searchParams.get('rh'));
|
||||
const rhMatch = decodeURIComponent(rhValue).match(/(?:^|,)\s*n:(\d{4,})(?:,|$)/i);
|
||||
if (rhMatch) return rhMatch[1];
|
||||
|
||||
const pathMatches = [...url.pathname.matchAll(/\/(\d{4,})(?=\/|$)/g)];
|
||||
if (pathMatches.length > 0) {
|
||||
return pathMatches[pathMatches.length - 1][1];
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveBestsellersUrl(input?: string): string {
|
||||
return resolveRankingUrl('bestsellers', input);
|
||||
}
|
||||
|
||||
export function canonicalizeAmazonUrl(input: string): string {
|
||||
@@ -305,6 +423,10 @@ export const __test__ = {
|
||||
buildProductUrl,
|
||||
buildDiscussionUrl,
|
||||
resolveBestsellersUrl,
|
||||
resolveRankingUrl,
|
||||
isSupportedRankingPath,
|
||||
isRankingPaginationUrl,
|
||||
extractCategoryNodeId,
|
||||
parsePriceText,
|
||||
parseRatingValue,
|
||||
parseReviewCount,
|
||||
|
||||
@@ -4,7 +4,8 @@ const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./utils.js')>()),
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
@@ -58,8 +59,8 @@ describe('bilibili comments', () => {
|
||||
it('throws when aid cannot be resolved', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
|
||||
|
||||
await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
|
||||
'Cannot resolve aid for bvid: BV_invalid',
|
||||
await expect(command!.func!({} as any, { bvid: 'BVinvalid123', limit: 5 })).rejects.toThrow(
|
||||
'Cannot resolve aid for bvid: BVinvalid123',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { apiGet } from './utils.js';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -18,7 +18,7 @@ cli({
|
||||
],
|
||||
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
|
||||
func: async (page, kwargs) => {
|
||||
const bvid = String(kwargs.bvid).trim();
|
||||
const bvid = await resolveBvid(kwargs.bvid);
|
||||
const limit = Math.min(Number(kwargs.limit) || 20, 50);
|
||||
|
||||
// Resolve bvid → aid (required by reply API)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { checkYtdlp, sanitizeFilename } from '../../download/index.js';
|
||||
import { downloadMedia } from '../../download/media-download.js';
|
||||
import { resolveBvid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -25,7 +26,7 @@ cli({
|
||||
],
|
||||
columns: ['bvid', 'title', 'status', 'size'],
|
||||
func: async (page, kwargs) => {
|
||||
const bvid = kwargs.bvid;
|
||||
const bvid = await resolveBvid(kwargs.bvid);
|
||||
const output = kwargs.output;
|
||||
const quality = kwargs.quality;
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
vi.mock('./utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./utils.js')>()),
|
||||
apiGet: mockApiGet,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { apiGet } from './utils.js';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -15,8 +15,9 @@ cli({
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
|
||||
const bvid = await resolveBvid(kwargs.bvid);
|
||||
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
|
||||
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
|
||||
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
|
||||
|
||||
// 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
|
||||
const cid = await page.evaluate(`(async () => {
|
||||
@@ -31,7 +32,7 @@ cli({
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
|
||||
const payload = await apiGet(page, '/x/player/wbi/v2', {
|
||||
params: { bvid: kwargs.bvid, cid },
|
||||
params: { bvid, cid },
|
||||
signed: true, // 开启 wbi_sign 自动签名
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveBvid } from './utils.js';
|
||||
|
||||
describe('resolveBvid', () => {
|
||||
it('passes through a valid BV ID', async () => {
|
||||
expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
|
||||
});
|
||||
|
||||
it('passes through BV ID with surrounding whitespace', async () => {
|
||||
expect(await resolveBvid(' BV1MV9NBtENN ')).toBe('BV1MV9NBtENN');
|
||||
});
|
||||
|
||||
it('handles non-string input via String() coercion', async () => {
|
||||
expect(await resolveBvid('BV123abc' as any)).toBe('BV123abc');
|
||||
});
|
||||
|
||||
it('rejects invalid input that cannot be resolved', async () => {
|
||||
// A random string that b23.tv won't resolve — should timeout or fail
|
||||
await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,36 @@
|
||||
* Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution.
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
/**
|
||||
* Resolve Bilibili short URL / short code to BV ID.
|
||||
* Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa
|
||||
*/
|
||||
export function resolveBvid(input: unknown): Promise<string> {
|
||||
const trimmed = String(input).trim();
|
||||
if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) {
|
||||
return Promise.resolve(trimmed);
|
||||
}
|
||||
const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, '');
|
||||
const url = 'https://b23.tv/' + shortCode;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (res) => {
|
||||
const location = res.headers.location;
|
||||
if (location) {
|
||||
const match = location.match(/\/video\/(BV[A-Za-z0-9]+)/);
|
||||
if (match) { res.resume(); resolve(match[1]); return; }
|
||||
}
|
||||
res.resume();
|
||||
reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
|
||||
});
|
||||
}
|
||||
|
||||
const MIXIN_KEY_ENC_TAB = [
|
||||
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
|
||||
33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,
|
||||
|
||||
+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"]');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './chat.js';
|
||||
|
||||
describe('xianyu chat helpers', () => {
|
||||
it('builds goofish im urls from ids', () => {
|
||||
expect(__test__.buildChatUrl('1038951278192', '3650092411')).toBe(
|
||||
'https://www.goofish.com/im?itemId=1038951278192&peerUserId=3650092411',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes numeric ids', () => {
|
||||
expect(__test__.normalizeNumericId('1038951278192', 'item_id', '1038951278192')).toBe('1038951278192');
|
||||
expect(__test__.normalizeNumericId(3650092411, 'user_id', '3650092411')).toBe('3650092411');
|
||||
});
|
||||
|
||||
it('rejects non-numeric ids', () => {
|
||||
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow();
|
||||
expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { AuthRequiredError, SelectorError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { normalizeNumericId } from './utils.js';
|
||||
|
||||
function buildChatUrl(itemId: string, peerUserId: string): string {
|
||||
return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`;
|
||||
}
|
||||
|
||||
function buildExtractChatStateEvaluate(): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const bodyText = document.body?.innerText || '';
|
||||
const requiresAuth = /请先登录|登录后/.test(bodyText);
|
||||
|
||||
const textarea = document.querySelector('textarea');
|
||||
const sendButton = Array.from(document.querySelectorAll('button'))
|
||||
.find((btn) => clean(btn.textContent || '') === '发送');
|
||||
const topbar = document.querySelector('[class*="message-topbar"]');
|
||||
const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
|
||||
.find((el) => el.closest('main'));
|
||||
const itemTitleNode =
|
||||
document.querySelector('[class*="container"] [class*="title"]')
|
||||
|| document.querySelector('[class*="item-main-info"] [class*="desc"]')
|
||||
|| document.querySelector('[class*="headSkuInfo"]')
|
||||
|| itemCard?.querySelector('[class*="title"]')
|
||||
|| itemCard?.previousElementSibling?.querySelector?.('[class*="title"]');
|
||||
|
||||
const messageRoot = document.querySelector('#message-list-scrollable');
|
||||
const visibleMessages = Array.from(
|
||||
(messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]')
|
||||
).map((el) => clean(el.textContent || ''))
|
||||
.filter(Boolean)
|
||||
.filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text))
|
||||
.filter((text) => !/^消息\\d*\\+?$/.test(text))
|
||||
.slice(-20);
|
||||
|
||||
return {
|
||||
requiresAuth,
|
||||
title: clean(document.title || ''),
|
||||
peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''),
|
||||
peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''),
|
||||
item_title: clean(itemTitleNode?.textContent || ''),
|
||||
item_url: itemCard?.href || '',
|
||||
price: clean(itemCard?.querySelector('[class*="money"]')?.textContent || ''),
|
||||
location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''),
|
||||
can_input: Boolean(textarea && !textarea.disabled),
|
||||
can_send: Boolean(sendButton),
|
||||
visible_messages: visibleMessages,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSendMessageEvaluate(text: string): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const textarea = document.querySelector('textarea');
|
||||
if (!textarea || textarea.disabled) {
|
||||
return { ok: false, reason: 'input-not-found' };
|
||||
}
|
||||
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (!setter) {
|
||||
return { ok: false, reason: 'textarea-setter-not-found' };
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
setter.call(textarea, ${JSON.stringify(text)});
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
const sendButton = Array.from(document.querySelectorAll('button'))
|
||||
.find((btn) => clean(btn.textContent || '') === '发送');
|
||||
if (!sendButton) {
|
||||
return { ok: false, reason: 'send-button-not-found' };
|
||||
}
|
||||
|
||||
sendButton.click();
|
||||
return { ok: true };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xianyu',
|
||||
name: 'chat',
|
||||
description: '打开闲鱼聊一聊会话,并可选发送消息',
|
||||
domain: 'www.goofish.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
|
||||
{ name: 'user_id', required: true, positional: true, help: '聊一聊对方的 user_id / peerUserId' },
|
||||
{ name: 'text', help: 'Message to send after opening the chat' },
|
||||
],
|
||||
columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192');
|
||||
const userId = normalizeNumericId(kwargs.user_id, 'user_id', '3650092411');
|
||||
const url = buildChatUrl(itemId, userId);
|
||||
const text = String(kwargs.text || '').trim();
|
||||
|
||||
await page.goto(url);
|
||||
await page.wait(2);
|
||||
|
||||
const state = await page.evaluate(buildExtractChatStateEvaluate()) as {
|
||||
requiresAuth?: boolean;
|
||||
title?: string;
|
||||
peer_name?: string;
|
||||
peer_masked_id?: string;
|
||||
item_title?: string;
|
||||
item_url?: string;
|
||||
price?: string;
|
||||
location?: string;
|
||||
can_input?: boolean;
|
||||
can_send?: boolean;
|
||||
visible_messages?: string[];
|
||||
};
|
||||
|
||||
if (state?.requiresAuth) {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
|
||||
}
|
||||
|
||||
if (!state?.can_input) {
|
||||
throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return [{
|
||||
status: 'ready',
|
||||
peer_name: state.peer_name || '',
|
||||
item_title: state.item_title || '',
|
||||
price: state.price || '',
|
||||
location: state.location || '',
|
||||
message: (state.visible_messages || []).slice(-1)[0] || '',
|
||||
peer_user_id: userId,
|
||||
item_id: itemId,
|
||||
url,
|
||||
item_url: state.item_url || '',
|
||||
}];
|
||||
}
|
||||
|
||||
const sent = await page.evaluate(buildSendMessageEvaluate(text)) as {
|
||||
ok?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
if (!sent?.ok) {
|
||||
throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
return [{
|
||||
status: 'sent',
|
||||
peer_name: state.peer_name || '',
|
||||
item_title: state.item_title || '',
|
||||
price: state.price || '',
|
||||
location: state.location || '',
|
||||
message: text,
|
||||
peer_user_id: userId,
|
||||
item_id: itemId,
|
||||
url,
|
||||
item_url: state.item_url || '',
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeNumericId,
|
||||
buildChatUrl,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { __test__ } from './item.js';
|
||||
import './item.js';
|
||||
|
||||
function createPageMock(evaluateResult: unknown): IPage {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe('xianyu item helpers', () => {
|
||||
it('normalizes numeric item ids', () => {
|
||||
expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976');
|
||||
expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976');
|
||||
});
|
||||
|
||||
it('builds item urls', () => {
|
||||
expect(__test__.buildItemUrl('1040754408976')).toBe(
|
||||
'https://www.goofish.com/item?id=1040754408976',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid item ids', () => {
|
||||
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xianyu item command', () => {
|
||||
const command = getRegistry().get('xianyu/item');
|
||||
|
||||
it('throws AuthRequiredError on login wall before mtop is available', async () => {
|
||||
const page = createPageMock({ error: 'auth-required' });
|
||||
await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError on verification or risk-control pages', async () => {
|
||||
const page = createPageMock({ error: 'blocked' });
|
||||
await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('keeps SelectorError for true mtop initialization failures', async () => {
|
||||
const page = createPageMock({ error: 'mtop-not-ready' });
|
||||
await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(SelectorError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { normalizeNumericId } from './utils.js';
|
||||
|
||||
function buildItemUrl(itemId: string): string {
|
||||
return `https://www.goofish.com/item?id=${encodeURIComponent(itemId)}`;
|
||||
}
|
||||
|
||||
function buildFetchItemEvaluate(itemId: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
|
||||
const extractRetCode = (ret) => {
|
||||
const first = Array.isArray(ret) ? ret[0] : '';
|
||||
return clean(first).split('::')[0] || '';
|
||||
};
|
||||
|
||||
const waitFor = async (predicate, timeoutMs = 5000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const bodyText = document.body?.innerText || '';
|
||||
if (/请先登录|登录后/.test(bodyText)) {
|
||||
return { error: 'auth-required' };
|
||||
}
|
||||
|
||||
if (/验证码|安全验证|异常访问/.test(bodyText)) {
|
||||
return { error: 'blocked' };
|
||||
}
|
||||
|
||||
await waitFor(() => window.lib?.mtop?.request);
|
||||
if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') {
|
||||
return { error: 'mtop-not-ready' };
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await window.lib.mtop.request({
|
||||
api: 'mtop.taobao.idle.pc.detail',
|
||||
data: { itemId: ${JSON.stringify(itemId)} },
|
||||
type: 'POST',
|
||||
v: '1.0',
|
||||
dataType: 'json',
|
||||
needLogin: false,
|
||||
needLoginPC: false,
|
||||
sessionOption: 'AutoLoginOnly',
|
||||
ecode: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const ret = error?.ret || [];
|
||||
return {
|
||||
error: 'mtop-request-failed',
|
||||
error_code: extractRetCode(ret),
|
||||
error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || error),
|
||||
};
|
||||
}
|
||||
|
||||
const retCode = extractRetCode(response?.ret || []);
|
||||
if (retCode && retCode !== 'SUCCESS') {
|
||||
return {
|
||||
error: 'mtop-response-error',
|
||||
error_code: retCode,
|
||||
error_message: clean((response?.ret || []).join(' | ')),
|
||||
};
|
||||
}
|
||||
|
||||
const data = response?.data || {};
|
||||
const item = data.itemDO || {};
|
||||
const seller = data.sellerDO || {};
|
||||
const labels = Array.isArray(item.itemLabelExtList) ? item.itemLabelExtList : [];
|
||||
const findLabel = (name) => labels.find((label) => clean(label.propertyText) === name)?.text || '';
|
||||
const images = Array.isArray(item.imageInfos)
|
||||
? item.imageInfos.map((entry) => entry?.url).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
item_id: clean(item.itemId || ${JSON.stringify(itemId)}),
|
||||
title: clean(item.title || ''),
|
||||
description: clean(item.desc || ''),
|
||||
price: clean('¥' + (item.soldPrice || item.defaultPrice || '')).replace(/^¥\\s*$/, ''),
|
||||
original_price: clean(item.originalPrice || ''),
|
||||
want_count: String(item.wantCnt ?? ''),
|
||||
collect_count: String(item.collectCnt ?? ''),
|
||||
browse_count: String(item.browseCnt ?? ''),
|
||||
status: clean(item.itemStatusStr || ''),
|
||||
condition: clean(findLabel('成色')),
|
||||
brand: clean(findLabel('品牌')),
|
||||
category: clean(findLabel('分类')),
|
||||
location: clean(seller.publishCity || seller.city || ''),
|
||||
seller_name: clean(seller.nick || seller.uniqueName || ''),
|
||||
seller_id: String(seller.sellerId || ''),
|
||||
seller_score: clean(seller.xianyuSummary || ''),
|
||||
reply_ratio_24h: clean(seller.replyRatio24h || ''),
|
||||
reply_interval: clean(seller.replyInterval || ''),
|
||||
item_url: ${JSON.stringify(buildItemUrl(itemId))},
|
||||
seller_url: seller.sellerId ? 'https://www.goofish.com/personal?userId=' + seller.sellerId : '',
|
||||
image_count: String(images.length),
|
||||
image_urls: images,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xianyu',
|
||||
name: 'item',
|
||||
description: '查看闲鱼商品详情',
|
||||
domain: 'www.goofish.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
|
||||
],
|
||||
columns: ['item_id', 'title', 'price', 'condition', 'brand', 'location', 'seller_name', 'want_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1040754408976');
|
||||
|
||||
await page.goto(buildItemUrl(itemId));
|
||||
await page.wait(2);
|
||||
|
||||
const result = await page.evaluate(buildFetchItemEvaluate(itemId)) as {
|
||||
error?: string;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
title?: string;
|
||||
item_id?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
if (result?.error === 'auth-required') {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session');
|
||||
}
|
||||
|
||||
if (result?.error === 'blocked') {
|
||||
throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control');
|
||||
}
|
||||
|
||||
if (result?.error === 'mtop-not-ready') {
|
||||
throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
|
||||
}
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据');
|
||||
}
|
||||
|
||||
const errorCode = String(result.error_code || '');
|
||||
const errorMessage = String(result.error_message || '');
|
||||
if (/FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED|FAIL_SYS/.test(errorCode) || /FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED/.test(errorMessage)) {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session');
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new EmptyResultError('xianyu item', errorMessage || `Xianyu item detail request failed: ${result.error}`);
|
||||
}
|
||||
|
||||
if (!String(result.title || '').trim()) {
|
||||
throw new EmptyResultError('xianyu item', 'No item detail was returned for the specified item_id');
|
||||
}
|
||||
|
||||
return [result];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeNumericId,
|
||||
buildItemUrl,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './search.js';
|
||||
|
||||
describe('xianyu search helpers', () => {
|
||||
it('normalizes limit into supported range', () => {
|
||||
expect(__test__.normalizeLimit(undefined)).toBe(20);
|
||||
expect(__test__.normalizeLimit(0)).toBe(1);
|
||||
expect(__test__.normalizeLimit(3.8)).toBe(3);
|
||||
expect(__test__.normalizeLimit(999)).toBe(__test__.MAX_LIMIT);
|
||||
});
|
||||
|
||||
it('builds search URLs with encoded queries', () => {
|
||||
expect(__test__.buildSearchUrl('笔记本电脑')).toBe(
|
||||
'https://www.goofish.com/search?q=%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%B5%E8%84%91',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts item ids from detail URLs', () => {
|
||||
expect(__test__.itemIdFromUrl('https://www.goofish.com/item?id=954988715389&categoryId=126854525')).toBe('954988715389');
|
||||
expect(__test__.itemIdFromUrl('https://www.goofish.com/search?q=test')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
function normalizeLimit(value: unknown): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 20;
|
||||
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n)));
|
||||
}
|
||||
|
||||
function buildSearchUrl(query: string): string {
|
||||
return `https://www.goofish.com/search?q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
function itemIdFromUrl(url: string): string {
|
||||
const match = url.match(/[?&]id=(\d+)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function buildExtractResultsEvaluate(limit: number): string {
|
||||
return `
|
||||
(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const waitFor = async (predicate, timeoutMs = 8000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await wait(150);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const selectors = {
|
||||
card: 'a[href*="/item?id="]',
|
||||
title: '[class*="row1-wrap-title"], [class*="main-title"]',
|
||||
attrs: '[class*="row2-wrap-cpv"] span[class*="cpv--"]',
|
||||
priceWrap: '[class*="price-wrap"]',
|
||||
priceNum: '[class*="number"]',
|
||||
priceDec: '[class*="decimal"]',
|
||||
priceDesc: '[class*="price-desc"] [title], [class*="price-desc"] [style*="line-through"]',
|
||||
sellerWrap: '[class*="row4-wrap-seller"]',
|
||||
sellerText: '[class*="seller-text"]',
|
||||
badge: '[class*="credit-container"] [title], [class*="credit-container"] span',
|
||||
};
|
||||
|
||||
await waitFor(() => {
|
||||
const bodyText = document.body?.innerText || '';
|
||||
return Boolean(
|
||||
document.querySelector(selectors.card)
|
||||
|| /请先登录|登录后|验证码|安全验证|异常访问/.test(bodyText)
|
||||
|| /暂无相关宝贝|未找到相关宝贝|没有找到/.test(bodyText)
|
||||
);
|
||||
});
|
||||
|
||||
const bodyText = document.body?.innerText || '';
|
||||
const requiresAuth = /请先登录|登录后/.test(bodyText);
|
||||
const blocked = /验证码|安全验证|异常访问/.test(bodyText);
|
||||
const empty = /暂无相关宝贝|未找到相关宝贝|没有找到/.test(bodyText);
|
||||
|
||||
const items = Array.from(document.querySelectorAll(selectors.card))
|
||||
.slice(0, ${limit})
|
||||
.map((card) => {
|
||||
const href = card.href || card.getAttribute('href') || '';
|
||||
const title = clean(card.querySelector(selectors.title)?.textContent || '');
|
||||
const attrs = Array.from(card.querySelectorAll(selectors.attrs))
|
||||
.map((node) => clean(node.textContent || ''))
|
||||
.filter(Boolean);
|
||||
const priceWrap = card.querySelector(selectors.priceWrap);
|
||||
const priceNumber = clean(priceWrap?.querySelector(selectors.priceNum)?.textContent || '');
|
||||
const priceDecimal = clean(priceWrap?.querySelector(selectors.priceDec)?.textContent || '');
|
||||
const location = clean(card.querySelector(selectors.sellerWrap)?.querySelector(selectors.sellerText)?.textContent || '');
|
||||
const originalPriceNode = card.querySelector(selectors.priceDesc);
|
||||
const badgeNode = card.querySelector(selectors.badge);
|
||||
|
||||
return {
|
||||
title,
|
||||
url: href,
|
||||
item_id: '',
|
||||
price: clean('¥' + priceNumber + priceDecimal).replace(/^¥\\s*$/, ''),
|
||||
original_price: clean(originalPriceNode?.getAttribute('title') || originalPriceNode?.textContent || ''),
|
||||
condition: attrs[0] || '',
|
||||
brand: attrs[1] || '',
|
||||
extra: attrs.slice(2).join(' | '),
|
||||
location,
|
||||
badge: clean(badgeNode?.getAttribute('title') || badgeNode?.textContent || ''),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url);
|
||||
|
||||
return { requiresAuth, blocked, empty, items };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xianyu',
|
||||
name: 'search',
|
||||
description: '搜索闲鱼商品',
|
||||
domain: 'www.goofish.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results to return' },
|
||||
],
|
||||
columns: ['item_id', 'rank', 'title', 'price', 'condition', 'brand', 'location', 'badge', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = String(kwargs.query || '').trim();
|
||||
const limit = normalizeLimit(kwargs.limit);
|
||||
|
||||
await page.goto(buildSearchUrl(query));
|
||||
await page.wait(2);
|
||||
await page.autoScroll({ times: 2 });
|
||||
|
||||
const payload = await page.evaluate(buildExtractResultsEvaluate(limit)) as {
|
||||
requiresAuth?: boolean;
|
||||
blocked?: boolean;
|
||||
empty?: boolean;
|
||||
items?: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
if (payload?.requiresAuth) {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu search results require a logged-in browser session');
|
||||
}
|
||||
|
||||
if (payload?.blocked) {
|
||||
throw new EmptyResultError('xianyu search', 'Xianyu returned a verification page or blocked the current browser session');
|
||||
}
|
||||
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
if (!items.length && !payload?.empty) {
|
||||
throw new EmptyResultError('xianyu search', 'No item cards were found on the current Xianyu search page');
|
||||
}
|
||||
|
||||
return items.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
...item,
|
||||
item_id: itemIdFromUrl(item.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
MAX_LIMIT,
|
||||
normalizeLimit,
|
||||
buildSearchUrl,
|
||||
itemIdFromUrl,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ArgumentError } from '../../errors.js';
|
||||
|
||||
export function normalizeNumericId(value: unknown, label: string, example: string): string {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!/^\d+$/.test(normalized)) {
|
||||
throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -39,8 +39,8 @@ describe('xiaohongshu search', () => {
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: early login-wall check (returns true)
|
||||
true,
|
||||
// First evaluate: MutationObserver wait (login wall detected)
|
||||
'login_wall',
|
||||
]);
|
||||
|
||||
await expect(cmd!.func!(page, { query: '特斯拉', limit: 5 })).rejects.toThrow(
|
||||
@@ -61,21 +61,18 @@ describe('xiaohongshu search', () => {
|
||||
'https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40?xsec_token=user-token&xsec_source=pc_search';
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: early login-wall check (returns false → no wall)
|
||||
false,
|
||||
// Second evaluate: main DOM extraction
|
||||
{
|
||||
loginWall: false,
|
||||
results: [
|
||||
{
|
||||
title: '某鱼买FSD被坑了4万',
|
||||
author: '随风',
|
||||
likes: '261',
|
||||
url: detailUrl,
|
||||
author_url: authorUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
// First evaluate: MutationObserver wait (content appeared)
|
||||
'content',
|
||||
// Second evaluate: main DOM extraction (returns array directly)
|
||||
[
|
||||
{
|
||||
title: '某鱼买FSD被坑了4万',
|
||||
author: '随风',
|
||||
likes: '261',
|
||||
url: detailUrl,
|
||||
author_url: authorUrl,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await cmd!.func!(page, { query: '特斯拉', limit: 1 });
|
||||
@@ -101,35 +98,32 @@ describe('xiaohongshu search', () => {
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: early login-wall check (returns false → no wall)
|
||||
false,
|
||||
// Second evaluate: main DOM extraction
|
||||
{
|
||||
loginWall: false,
|
||||
results: [
|
||||
{
|
||||
title: 'Result A',
|
||||
author: 'UserA',
|
||||
likes: '10',
|
||||
url: 'https://www.xiaohongshu.com/search_result/aaa',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
author: 'UserB',
|
||||
likes: '5',
|
||||
url: 'https://www.xiaohongshu.com/search_result/bbb',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: 'Result C',
|
||||
author: 'UserC',
|
||||
likes: '3',
|
||||
url: 'https://www.xiaohongshu.com/search_result/ccc',
|
||||
author_url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
// First evaluate: MutationObserver wait (content appeared)
|
||||
'content',
|
||||
// Second evaluate: main DOM extraction (returns array directly)
|
||||
[
|
||||
{
|
||||
title: 'Result A',
|
||||
author: 'UserA',
|
||||
likes: '10',
|
||||
url: 'https://www.xiaohongshu.com/search_result/aaa',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
author: 'UserB',
|
||||
likes: '5',
|
||||
url: 'https://www.xiaohongshu.com/search_result/bbb',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: 'Result C',
|
||||
author: 'UserC',
|
||||
likes: '3',
|
||||
url: 'https://www.xiaohongshu.com/search_result/ccc',
|
||||
author_url: '',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = (await cmd!.func!(page, { query: '测试', limit: 1 })) as any[];
|
||||
@@ -138,6 +132,25 @@ describe('xiaohongshu search', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
|
||||
});
|
||||
|
||||
it('waits for content via MutationObserver before extracting', async () => {
|
||||
const cmd = getRegistry().get('xiaohongshu/search');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: MutationObserver wait (content appeared)
|
||||
'content',
|
||||
// Second evaluate: extraction (returns empty array)
|
||||
[],
|
||||
]);
|
||||
|
||||
const result = (await cmd!.func!(page, { query: '测试等待', limit: 5 })) as any[];
|
||||
expect(result).toHaveLength(0);
|
||||
// Only one navigation, no retry
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
// Two evaluate calls: wait + extraction
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteIdToDate (ObjectID timestamp parsing)', () => {
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
/**
|
||||
* Wait for search results or login wall using MutationObserver (max 5s).
|
||||
* Returns 'content' if note items appeared, 'login_wall' if login gate
|
||||
* detected, or 'timeout' if neither appeared within the deadline.
|
||||
*/
|
||||
const WAIT_FOR_CONTENT_JS = `
|
||||
new Promise((resolve) => {
|
||||
const detect = () => {
|
||||
if (document.querySelector('section.note-item')) return 'content';
|
||||
if (/登录后查看搜索结果/.test(document.body?.innerText || '')) return 'login_wall';
|
||||
return null;
|
||||
};
|
||||
const found = detect();
|
||||
if (found) return resolve(found);
|
||||
const observer = new MutationObserver(() => {
|
||||
const result = detect();
|
||||
if (result) { observer.disconnect(); resolve(result); }
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 5000);
|
||||
})
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract approximate publish date from a Xiaohongshu note URL.
|
||||
* XHS note IDs follow MongoDB ObjectID format where the first 8 hex
|
||||
@@ -42,15 +65,13 @@ cli({
|
||||
await page.goto(
|
||||
`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`
|
||||
);
|
||||
await page.wait(3);
|
||||
|
||||
// Early login-wall detection: XHS may show a login gate instead of
|
||||
// results. Check *before* autoScroll to avoid crashing on a page
|
||||
// that has no meaningful content to scroll through.
|
||||
const loginCheck = await page.evaluate(`
|
||||
(() => /登录后查看搜索结果/.test(document.body?.innerText || ''))()
|
||||
`);
|
||||
if (loginCheck) {
|
||||
// Wait for search results to render (or login wall to appear).
|
||||
// Uses MutationObserver to resolve as soon as content appears,
|
||||
// instead of a fixed delay + blind retry.
|
||||
const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
|
||||
|
||||
if (waitResult === 'login_wall') {
|
||||
throw new AuthRequiredError(
|
||||
'www.xiaohongshu.com',
|
||||
'Xiaohongshu search results are blocked behind a login wall',
|
||||
@@ -62,8 +83,6 @@ cli({
|
||||
|
||||
const payload = await page.evaluate(`
|
||||
(() => {
|
||||
const loginWall = /登录后查看搜索结果/.test(document.body.innerText || '');
|
||||
|
||||
const normalizeUrl = (href) => {
|
||||
if (!href) return '';
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
||||
@@ -107,20 +126,11 @@ cli({
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
loginWall,
|
||||
results,
|
||||
};
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
|
||||
if ((payload as any).loginWall) {
|
||||
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
||||
}
|
||||
|
||||
const data: any[] = Array.isArray((payload as any).results) ? (payload as any).results : [];
|
||||
const data: any[] = Array.isArray(payload) ? (payload as any[]) : [];
|
||||
return data
|
||||
.filter((item: any) => item.title)
|
||||
.slice(0, kwargs.limit)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, CommandExecutionError, TimeoutError } from '../../errors.js';
|
||||
import { __test__ } from './ask.js';
|
||||
import { askCommand } from './ask.js';
|
||||
|
||||
describe('yuanbao ask helpers', () => {
|
||||
describe('isOnYuanbao', () => {
|
||||
const fakePage = (url: string | Error): IPage =>
|
||||
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
|
||||
|
||||
it('returns true for yuanbao.tencent.com URLs', async () => {
|
||||
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/'))).toBe(true);
|
||||
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/chat/abc'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-yuanbao domains', async () => {
|
||||
expect(await __test__.isOnYuanbao(fakePage('https://example.com/?next=yuanbao.tencent.com'))).toBe(false);
|
||||
expect(await __test__.isOnYuanbao(fakePage('about:blank'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when evaluate throws', async () => {
|
||||
expect(await __test__.isOnYuanbao(fakePage(new Error('detached')))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes echoed prompt prefixes from transcript additions', () => {
|
||||
expect(__test__.sanitizeYuanbaoResponseText('你好\n你好,我是元宝。', '你好')).toBe('你好,我是元宝。');
|
||||
});
|
||||
|
||||
it('filters transient in-progress assistant placeholders', () => {
|
||||
expect(__test__.sanitizeYuanbaoResponseText('正在搜索资料', '张雪机车相关的股票有哪些?')).toBe('');
|
||||
});
|
||||
|
||||
it('normalizes boolean flags with explicit defaults', () => {
|
||||
expect(__test__.normalizeBooleanFlag(undefined, true)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag(undefined, false)).toBe(false);
|
||||
expect(__test__.normalizeBooleanFlag('true', false)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('1', false)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('yes', false)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('false', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores baseline lines and echoed prompts when collecting additions', () => {
|
||||
const response = __test__.collectYuanbaoTranscriptAdditions(
|
||||
['旧消息'],
|
||||
['旧消息', '你好', '你好\n你好,我是元宝。'],
|
||||
'你好',
|
||||
);
|
||||
|
||||
expect(response).toBe('你好,我是元宝。');
|
||||
});
|
||||
|
||||
it('prefers fresh assistant messages over echoed prompts and older messages', () => {
|
||||
const response = __test__.pickLatestYuanbaoAssistantCandidate(
|
||||
['旧回复', '你好', '你好!我是元宝,由腾讯推出的AI助手。'],
|
||||
1,
|
||||
'你好',
|
||||
);
|
||||
|
||||
expect(response).toBe('你好!我是元宝,由腾讯推出的AI助手。');
|
||||
});
|
||||
|
||||
it('converts assistant html tables to markdown tables via turndown', () => {
|
||||
const markdown = __test__.convertYuanbaoHtmlToMarkdown(`
|
||||
<h3>核心产业链概念股一览</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>细分赛道</th><th>核心标的</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>光模块</td><td>中际旭创</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`);
|
||||
|
||||
expect(markdown).toContain('### 核心产业链概念股一览');
|
||||
expect(markdown).toContain('| 细分赛道 | 核心标的 |');
|
||||
expect(markdown).toContain('| --- | --- |');
|
||||
expect(markdown).toContain('| 光模块 | 中际旭创 |');
|
||||
});
|
||||
|
||||
it('tracks stabilization by incrementing repeats and resetting on changes', () => {
|
||||
expect(__test__.updateStableState('', 0, '第一段')).toEqual({
|
||||
previousText: '第一段',
|
||||
stableCount: 0,
|
||||
});
|
||||
|
||||
expect(__test__.updateStableState('第一段', 0, '第一段')).toEqual({
|
||||
previousText: '第一段',
|
||||
stableCount: 1,
|
||||
});
|
||||
|
||||
expect(__test__.updateStableState('第一段', 1, '第二段')).toEqual({
|
||||
previousText: '第二段',
|
||||
stableCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createAskPageMock(overrides: {
|
||||
currentUrl?: string;
|
||||
hasLoginGate?: boolean;
|
||||
sendResult?: { ok?: boolean; reason?: string; detail?: string; action?: string };
|
||||
} = {}): IPage {
|
||||
const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/';
|
||||
const hasLoginGate = overrides.hasLoginGate ?? false;
|
||||
const sendResult = overrides.sendResult;
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockImplementation(async (script: string) => {
|
||||
if (script === 'window.location.href') return currentUrl;
|
||||
if (script.includes('微信扫码登录')) return hasLoginGate;
|
||||
if (script.includes('[dt-button-id="internet_search"]')) return { found: false, enabled: false };
|
||||
if (script.includes('[dt-button-id="deep_think"]')) return { found: false, enabled: false };
|
||||
if (script.includes('.agent-chat__list__item--ai')) return [];
|
||||
if (script.includes('const stopLines = new Set([')) return [];
|
||||
if (script.includes('Failed to insert the prompt into the Yuanbao composer.')) {
|
||||
return sendResult ?? { ok: true, action: 'click' };
|
||||
}
|
||||
throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`);
|
||||
}),
|
||||
} as unknown as IPage;
|
||||
}
|
||||
|
||||
describe('yuanbao ask command', () => {
|
||||
it('throws AuthRequiredError when Yuanbao shows a login gate before sending', async () => {
|
||||
const page = createAskPageMock({ hasLoginGate: true });
|
||||
|
||||
await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
|
||||
.rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when the prompt cannot be sent', async () => {
|
||||
const page = createAskPageMock({
|
||||
sendResult: {
|
||||
ok: false,
|
||||
reason: 'Yuanbao composer was not found.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws TimeoutError when no response arrives before timeout', async () => {
|
||||
const page = createAskPageMock({
|
||||
sendResult: { ok: true, action: 'click' },
|
||||
});
|
||||
|
||||
await expect(askCommand.func!(page, { prompt: '你好', timeout: '-1', search: true, think: false }))
|
||||
.rejects.toBeInstanceOf(TimeoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import TurndownService from 'turndown';
|
||||
import { CommandExecutionError, TimeoutError } from '../../errors.js';
|
||||
import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, isOnYuanbao, ensureYuanbaoPage, hasLoginGate } from './shared.js';
|
||||
|
||||
const YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS = 2;
|
||||
const YUANBAO_MIN_WAIT_MS = 8_000;
|
||||
const YUANBAO_STABLE_POLLS_REQUIRED = 3;
|
||||
|
||||
type YuanbaoSendResult = {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
reason?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type YuanbaoToggleState = {
|
||||
enabled: boolean;
|
||||
found: boolean;
|
||||
};
|
||||
|
||||
function sendFailure(reason?: string, detail?: string) {
|
||||
const suffix = detail ? ` Detail: ${detail}` : '';
|
||||
return new CommandExecutionError(
|
||||
`${reason || 'Unknown Yuanbao send failure.'}${suffix}`,
|
||||
'Make sure the Yuanbao chat composer is visible and ready before retrying.',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value == null || value === '') return fallback;
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function createYuanbaoTurndown(): TurndownService {
|
||||
const td = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
|
||||
td.addRule('linebreak', {
|
||||
filter: 'br',
|
||||
replacement: () => '\n',
|
||||
});
|
||||
|
||||
td.addRule('table', {
|
||||
filter: 'table',
|
||||
replacement: (content) => `\n\n${content}\n\n`,
|
||||
});
|
||||
|
||||
td.addRule('tableSection', {
|
||||
filter: ['thead', 'tbody', 'tfoot'],
|
||||
replacement: (content) => content,
|
||||
});
|
||||
|
||||
td.addRule('tableRow', {
|
||||
filter: 'tr',
|
||||
replacement: (content, node) => {
|
||||
const element = node as Element;
|
||||
const cells = Array.from(element.children);
|
||||
const isHeaderRow = element.parentElement?.tagName === 'THEAD'
|
||||
|| (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH'));
|
||||
|
||||
const row = `${content}\n`;
|
||||
if (!isHeaderRow) return row;
|
||||
|
||||
const separator = `| ${cells.map(() => '---').join(' | ')} |\n`;
|
||||
return `${row}${separator}`;
|
||||
},
|
||||
});
|
||||
|
||||
td.addRule('tableCell', {
|
||||
filter: ['th', 'td'],
|
||||
replacement: (content, node) => {
|
||||
const element = node as Element;
|
||||
const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0;
|
||||
const prefix = index === 0 ? '| ' : ' ';
|
||||
return `${prefix}${content.trim()} |`;
|
||||
},
|
||||
});
|
||||
|
||||
return td;
|
||||
}
|
||||
|
||||
const yuanbaoTurndown = createYuanbaoTurndown();
|
||||
|
||||
export function convertYuanbaoHtmlToMarkdown(value: string): string {
|
||||
const markdown = yuanbaoTurndown.turndown(value || '');
|
||||
return markdown
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeYuanbaoResponseText(value: string, promptText: string): string {
|
||||
let sanitized = value
|
||||
.replace(/内容由AI生成,仅供参考/gi, '')
|
||||
.replace(/重新回答/gi, '')
|
||||
.trim();
|
||||
|
||||
if (/^(正在搜索资料|搜索资料中|正在思考|思考中)[.。…]*$/u.test(sanitized)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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)) {
|
||||
sanitized = sanitized.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function collectYuanbaoTranscriptAdditions(
|
||||
beforeLines: string[],
|
||||
currentLines: string[],
|
||||
promptText: string,
|
||||
): string {
|
||||
const beforeSet = new Set(beforeLines);
|
||||
const additions = currentLines
|
||||
.filter((line) => !beforeSet.has(line))
|
||||
.map((line) => sanitizeYuanbaoResponseText(line, promptText))
|
||||
.filter((line) => line && line !== promptText);
|
||||
|
||||
return additions.join('\n').trim();
|
||||
}
|
||||
|
||||
export function pickLatestYuanbaoAssistantCandidate(
|
||||
messages: string[],
|
||||
baselineCount: number,
|
||||
promptText: string,
|
||||
): string {
|
||||
const freshMessages = messages
|
||||
.slice(Math.max(0, baselineCount))
|
||||
.map((message) => sanitizeYuanbaoResponseText(message, promptText))
|
||||
.filter(Boolean);
|
||||
|
||||
for (let i = freshMessages.length - 1; i >= 0; i -= 1) {
|
||||
if (freshMessages[i] !== promptText.trim()) return freshMessages[i];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function updateStableState(previousText: string, stableCount: number, nextText: string) {
|
||||
if (!nextText) return { previousText: '', stableCount: 0 };
|
||||
if (nextText === previousText) return { previousText, stableCount: stableCount + 1 };
|
||||
return { previousText: nextText, stableCount: 0 };
|
||||
}
|
||||
|
||||
|
||||
function getTranscriptLinesScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '')
|
||||
.replace(/\\u00a0/g, ' ')
|
||||
.replace(/\\n{3,}/g, '\\n\\n')
|
||||
.trim();
|
||||
|
||||
const root = (
|
||||
document.querySelector('.agent-dialogue__content--common')
|
||||
|| document.querySelector('.agent-dialogue__content')
|
||||
|| document.querySelector('.agent-dialogue')
|
||||
|| document.body
|
||||
).cloneNode(true);
|
||||
|
||||
const removableSelectors = [
|
||||
'.agent-dialogue__content--common__input',
|
||||
'.agent-dialogue__tool',
|
||||
'.agent-dialogue__content-copyright',
|
||||
'.index_chatLandingBox__G7hAT',
|
||||
'.index_chatLandingBoxMobile__J8i8v',
|
||||
'.index_chatLandingHintList__M69Lr',
|
||||
'.yb-nav',
|
||||
'.agent-dialogue__content--common__input .ql-toolbar',
|
||||
'.agent-dialogue__content--common__input .ql-container',
|
||||
'.agent-dialogue__content--common__input .ql-editor',
|
||||
'[role="dialog"]',
|
||||
'iframe',
|
||||
'button',
|
||||
'script',
|
||||
'style',
|
||||
'noscript',
|
||||
];
|
||||
|
||||
for (const selector of removableSelectors) {
|
||||
root.querySelectorAll(selector).forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
const stopLines = new Set([
|
||||
'元宝',
|
||||
'DeepSeek',
|
||||
'深度思考',
|
||||
'联网搜索',
|
||||
'工具',
|
||||
'登录',
|
||||
'安装电脑版',
|
||||
'内容由AI生成,仅供参考',
|
||||
'有问题,尽管问,shift+enter换行',
|
||||
'立即创建团队',
|
||||
'微信',
|
||||
'手机',
|
||||
'QQ',
|
||||
'微信扫码登录',
|
||||
'扫码默认已阅读并同意',
|
||||
'用户服务协议',
|
||||
'隐私协议',
|
||||
]);
|
||||
|
||||
const noisyPatterns = [
|
||||
/^支持文件格式[::]/,
|
||||
/^文件拖动到此处即可上传/,
|
||||
/^下载元宝电脑版/,
|
||||
];
|
||||
|
||||
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)));
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
async function getYuanbaoTranscriptLines(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(getTranscriptLinesScript());
|
||||
return Array.isArray(result) ? result.map(normalizeText).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function getYuanbaoAssistantMessages(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const roots = Array.from(document.querySelectorAll('.agent-chat__list__item--ai'))
|
||||
.filter((node) => isVisible(node));
|
||||
|
||||
return roots.map((root) => {
|
||||
const doneContent = root.querySelector('.hyc-content-md-done');
|
||||
const markdownContent = doneContent || root.querySelector('.hyc-content-md');
|
||||
const speechContent = root.querySelector('.agent-chat__speech-text');
|
||||
const bubbleContent = root.querySelector('.agent-chat__bubble__content');
|
||||
const content = markdownContent || speechContent || bubbleContent;
|
||||
|
||||
if (content instanceof HTMLElement) {
|
||||
return content.innerHTML || content.textContent || '';
|
||||
}
|
||||
|
||||
return root instanceof HTMLElement ? (root.innerHTML || root.textContent || '') : '';
|
||||
}).filter(Boolean);
|
||||
})()`);
|
||||
|
||||
return Array.isArray(result)
|
||||
? result
|
||||
.map((value) => convertYuanbaoHtmlToMarkdown(typeof value === 'string' ? value : ''))
|
||||
.map(normalizeText)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function getYuanbaoInternetSearchState(page: IPage): Promise<YuanbaoToggleState> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="internet_search"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (!(button instanceof HTMLElement)) return { found: false, enabled: false };
|
||||
|
||||
const attr = button.getAttribute('dt-internet-search') || '';
|
||||
const className = button.className || '';
|
||||
return {
|
||||
found: true,
|
||||
enabled: attr === 'openInternetSearch' || className.includes('index_v2_active__'),
|
||||
};
|
||||
})()`);
|
||||
|
||||
return result as YuanbaoToggleState;
|
||||
}
|
||||
|
||||
async function setYuanbaoInternetSearch(page: IPage, enabled: boolean): Promise<void> {
|
||||
const current = await getYuanbaoInternetSearchState(page);
|
||||
if (!current.found || current.enabled === enabled) return;
|
||||
|
||||
await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="internet_search"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
})()`);
|
||||
|
||||
await page.wait(0.5);
|
||||
}
|
||||
|
||||
async function getYuanbaoDeepThinkState(page: IPage): Promise<YuanbaoToggleState> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="deep_think"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (!(button instanceof HTMLElement)) return { found: false, enabled: false };
|
||||
|
||||
const className = button.className || '';
|
||||
return {
|
||||
found: true,
|
||||
enabled: className.includes('ThinkSelector_selected__'),
|
||||
};
|
||||
})()`);
|
||||
|
||||
return result as YuanbaoToggleState;
|
||||
}
|
||||
|
||||
async function setYuanbaoDeepThink(page: IPage, enabled: boolean): Promise<void> {
|
||||
const current = await getYuanbaoDeepThinkState(page);
|
||||
if (!current.found || current.enabled === enabled) return;
|
||||
|
||||
await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="deep_think"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
})()`);
|
||||
|
||||
await page.wait(0.5);
|
||||
}
|
||||
|
||||
async function sendYuanbaoMessage(page: IPage, prompt: string): Promise<YuanbaoSendResult> {
|
||||
return await page.evaluate(`(async () => {
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const composer = Array.from(document.querySelectorAll('.ql-editor[contenteditable="true"], .ql-editor, [contenteditable="true"]'))
|
||||
.find(isVisible);
|
||||
|
||||
if (!(composer instanceof HTMLElement)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Yuanbao composer was not found.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
composer.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(composer);
|
||||
range.collapse(false);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
composer.textContent = '';
|
||||
document.execCommand('insertText', false, ${JSON.stringify(prompt)});
|
||||
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(prompt)}, inputType: 'insertText' }));
|
||||
await waitFor(200);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Failed to insert the prompt into the Yuanbao composer.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
const submit = Array.from(document.querySelectorAll('a[class*="send-btn"], button[class*="send-btn"]'))
|
||||
.find((node) => {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) return false;
|
||||
const className = node.className || '';
|
||||
if (typeof className === 'string' && className.includes('disabled')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (submit instanceof HTMLElement) {
|
||||
submit.click();
|
||||
return { ok: true, action: 'click' };
|
||||
}
|
||||
|
||||
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 { ok: true, action: 'enter' };
|
||||
})()`) as YuanbaoSendResult;
|
||||
}
|
||||
|
||||
async function waitForYuanbaoResponse(
|
||||
page: IPage,
|
||||
baselineAssistantCount: number,
|
||||
beforeLines: string[],
|
||||
prompt: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<string | null | 'blocked'> {
|
||||
const startTime = Date.now();
|
||||
let previousText = '';
|
||||
let stableCount = 0;
|
||||
let latestCandidate = '';
|
||||
|
||||
while (Date.now() - startTime < timeoutSeconds * 1000) {
|
||||
await page.wait(YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS);
|
||||
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
|
||||
const assistantMessages = await getYuanbaoAssistantMessages(page);
|
||||
const assistantCandidate = pickLatestYuanbaoAssistantCandidate(
|
||||
assistantMessages,
|
||||
baselineAssistantCount,
|
||||
prompt,
|
||||
);
|
||||
|
||||
const candidate = assistantCandidate || collectYuanbaoTranscriptAdditions(
|
||||
beforeLines,
|
||||
await getYuanbaoTranscriptLines(page),
|
||||
prompt,
|
||||
);
|
||||
|
||||
if (!candidate) continue;
|
||||
|
||||
latestCandidate = candidate;
|
||||
const nextState = updateStableState(previousText, stableCount, candidate);
|
||||
previousText = nextState.previousText;
|
||||
stableCount = nextState.stableCount;
|
||||
|
||||
const waitedLongEnough = Date.now() - startTime >= YUANBAO_MIN_WAIT_MS;
|
||||
if (waitedLongEnough && stableCount >= YUANBAO_STABLE_POLLS_REQUIRED) return candidate;
|
||||
}
|
||||
|
||||
return latestCandidate || null;
|
||||
}
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'yuanbao',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt to Yuanbao web chat and wait for the assistant response',
|
||||
domain: YUANBAO_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: 'search', type: 'boolean', required: false, help: 'Enable Yuanbao internet search (default: true)', default: true },
|
||||
{ name: 'think', type: 'boolean', required: false, help: 'Enable Yuanbao deep thinking (default: false)', default: false },
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const prompt = kwargs.prompt as string;
|
||||
const timeout = parseInt(kwargs.timeout as string, 10) || 60;
|
||||
const useSearch = normalizeBooleanFlag(kwargs.search, true);
|
||||
const useThink = normalizeBooleanFlag(kwargs.think, false);
|
||||
|
||||
await ensureYuanbaoPage(page);
|
||||
if (await hasLoginGate(page)) {
|
||||
throw authRequired('Yuanbao opened a login gate before sending the prompt.');
|
||||
}
|
||||
await setYuanbaoInternetSearch(page, useSearch);
|
||||
await setYuanbaoDeepThink(page, useThink);
|
||||
const beforeAssistantMessages = await getYuanbaoAssistantMessages(page);
|
||||
const beforeLines = await getYuanbaoTranscriptLines(page);
|
||||
const sendResult = await sendYuanbaoMessage(page, prompt);
|
||||
|
||||
if (!sendResult?.ok) {
|
||||
if (await hasLoginGate(page)) {
|
||||
throw authRequired('Yuanbao opened a login gate instead of accepting the prompt.');
|
||||
}
|
||||
throw sendFailure(sendResult?.reason, sendResult?.detail);
|
||||
}
|
||||
|
||||
const response = await waitForYuanbaoResponse(
|
||||
page,
|
||||
beforeAssistantMessages.length,
|
||||
beforeLines,
|
||||
prompt,
|
||||
timeout,
|
||||
);
|
||||
|
||||
if (response === 'blocked') {
|
||||
throw authRequired('Yuanbao opened a login gate instead of returning a chat response.');
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new TimeoutError(
|
||||
'yuanbao ask',
|
||||
timeout,
|
||||
'No Yuanbao response was observed before the timeout. Retry with --timeout, and verify the current browser session is still interactive.',
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{ Role: 'User', Text: prompt },
|
||||
{ Role: 'Assistant', Text: response },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
collectYuanbaoTranscriptAdditions,
|
||||
convertYuanbaoHtmlToMarkdown,
|
||||
isOnYuanbao,
|
||||
normalizeBooleanFlag,
|
||||
pickLatestYuanbaoAssistantCandidate,
|
||||
sanitizeYuanbaoResponseText,
|
||||
updateStableState,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
import { newCommand } from './new.js';
|
||||
|
||||
function createNewPageMock(overrides: {
|
||||
currentUrl?: string;
|
||||
triggerAction?: 'clicked' | 'navigate';
|
||||
hasLoginGate?: boolean;
|
||||
composerText?: string;
|
||||
} = {}): IPage {
|
||||
const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/';
|
||||
const triggerAction = overrides.triggerAction ?? 'clicked';
|
||||
const hasLoginGate = overrides.hasLoginGate ?? false;
|
||||
const composerText = overrides.composerText ?? '';
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockImplementation(async (script: string) => {
|
||||
if (script === 'window.location.href') return currentUrl;
|
||||
if (script.includes('微信扫码登录')) return hasLoginGate;
|
||||
if (script.includes('.ql-editor, [contenteditable="true"]')) return composerText;
|
||||
if (script.includes('const trigger = Array.from(document.querySelectorAll')) return triggerAction;
|
||||
throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`);
|
||||
}),
|
||||
} as unknown as IPage;
|
||||
}
|
||||
|
||||
describe('yuanbao new command', () => {
|
||||
it('throws AuthRequiredError when Yuanbao shows a login gate', async () => {
|
||||
const page = createNewPageMock({ hasLoginGate: true });
|
||||
|
||||
await expect(newCommand.func!(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, ensureYuanbaoPage, hasLoginGate } from './shared.js';
|
||||
|
||||
async function getCurrentUrl(page: IPage): Promise<string> {
|
||||
const result = await page.evaluate('window.location.href').catch(() => '');
|
||||
return typeof result === 'string' ? result : '';
|
||||
}
|
||||
|
||||
async function getComposerText(page: IPage): Promise<string> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
const composer = document.querySelector('.ql-editor, [contenteditable="true"]');
|
||||
return composer ? (composer.textContent || '').trim() : '';
|
||||
})()`);
|
||||
|
||||
return typeof result === 'string' ? result.trim() : '';
|
||||
}
|
||||
|
||||
async function startNewYuanbaoChat(page: IPage): Promise<'clicked' | 'navigate' | 'blocked'> {
|
||||
await ensureYuanbaoPage(page);
|
||||
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
|
||||
const beforeUrl = await getCurrentUrl(page);
|
||||
const action = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const trigger = Array.from(document.querySelectorAll('.yb-common-nav__trigger[data-desc="new-chat"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (trigger instanceof HTMLElement) {
|
||||
trigger.click();
|
||||
return 'clicked';
|
||||
}
|
||||
|
||||
return 'navigate';
|
||||
})()`) as 'clicked' | 'navigate';
|
||||
|
||||
if (action === 'navigate') {
|
||||
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
return 'navigate';
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
|
||||
const afterUrl = await getCurrentUrl(page);
|
||||
const composerText = await getComposerText(page);
|
||||
if (afterUrl !== beforeUrl || !composerText) return 'clicked';
|
||||
|
||||
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
return 'navigate';
|
||||
}
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'yuanbao',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in Yuanbao web chat',
|
||||
domain: YUANBAO_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Action'],
|
||||
func: async (page: IPage) => {
|
||||
const action = await startNewYuanbaoChat(page);
|
||||
|
||||
if (action === 'blocked') {
|
||||
throw authRequired('Yuanbao opened a login gate instead of starting a new chat.');
|
||||
}
|
||||
|
||||
return [{
|
||||
Status: 'Success',
|
||||
Action: action === 'navigate' ? 'Reloaded Yuanbao homepage as fallback' : 'Clicked New chat',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
export const YUANBAO_DOMAIN = 'yuanbao.tencent.com';
|
||||
export const YUANBAO_URL = 'https://yuanbao.tencent.com/';
|
||||
|
||||
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing yuanbao.tencent.com browser session.';
|
||||
|
||||
/**
|
||||
* Reusable visibility check for injected browser scripts.
|
||||
* Embed in page.evaluate strings via `${IS_VISIBLE_JS}`.
|
||||
*/
|
||||
export const IS_VISIBLE_JS = `const isVisible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden';
|
||||
};`;
|
||||
|
||||
export function authRequired(message: string) {
|
||||
return new AuthRequiredError(YUANBAO_DOMAIN, `${message} ${SESSION_HINT}`);
|
||||
}
|
||||
|
||||
export async function isOnYuanbao(page: IPage): Promise<boolean> {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return hostname === YUANBAO_DOMAIN || hostname.endsWith(`.${YUANBAO_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureYuanbaoPage(page: IPage): Promise<void> {
|
||||
if (!(await isOnYuanbao(page))) {
|
||||
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasLoginGate(page: IPage): Promise<boolean> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
const bodyText = document.body.innerText || '';
|
||||
const hasWechatLoginText = bodyText.includes('微信扫码登录');
|
||||
const hasWechatIframe = Array.from(document.querySelectorAll('iframe'))
|
||||
.some((frame) => (frame.getAttribute('src') || '').includes('open.weixin.qq.com/connect/qrconnect'));
|
||||
|
||||
return hasWechatLoginText || hasWechatIframe;
|
||||
})()`);
|
||||
|
||||
return Boolean(result);
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
|
||||
const verbose = optionsRecord.verbose === true;
|
||||
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
|
||||
const formatExplicit = subCmd.getOptionValueSource('format') === 'cli';
|
||||
if (verbose) process.env.OPENCLI_VERBOSE = '1';
|
||||
if (cmd.deprecated) {
|
||||
const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`;
|
||||
@@ -109,7 +110,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
}
|
||||
|
||||
const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
|
||||
if (format === 'table' && resolved.defaultFormat) {
|
||||
if (!formatExplicit && format === 'table' && resolved.defaultFormat) {
|
||||
format = resolved.defaultFormat;
|
||||
}
|
||||
|
||||
@@ -118,6 +119,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
}
|
||||
renderOutput(result, {
|
||||
fmt: format,
|
||||
fmtExplicit: formatExplicit,
|
||||
columns: resolved.columns,
|
||||
title: `${resolved.site}/${resolved.name}`,
|
||||
elapsed: (Date.now() - startTime) / 1000,
|
||||
@@ -209,7 +211,7 @@ async function renderError(err: unknown, cmdName: string, verbose: boolean): Pro
|
||||
if (err instanceof AuthRequiredError) {
|
||||
console.error(chalk.red(`🔒 Not logged in to ${err.domain}`));
|
||||
// Respect custom hints set by the adapter; fall back to generic guidance.
|
||||
console.error(chalk.yellow(`→ ${err.hint ?? `Open Chrome and log in to https://${err.domain}, then retry.`}`));
|
||||
console.error(chalk.yellow(`→ ${err.hint ?? `Open Chrome or Chromium and log in to https://${err.domain}, then retry.`}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,7 +272,7 @@ async function renderError(err: unknown, cmdName: string, verbose: boolean): Pro
|
||||
|
||||
if (kind === 'auth') {
|
||||
console.error(chalk.red(`🔒 ${msg}`));
|
||||
console.error(chalk.yellow('→ Open Chrome, log in to the target site, then retry.'));
|
||||
console.error(chalk.yellow('→ Open Chrome or Chromium, log in to the target site, then retry.'));
|
||||
return;
|
||||
}
|
||||
if (kind === 'http') {
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
}
|
||||
if (status.running && !status.extensionConnected) {
|
||||
issues.push(
|
||||
'Daemon is running but the Chrome extension is not connected.\n' +
|
||||
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
|
||||
'Please install the opencli Browser Bridge extension:\n' +
|
||||
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ export class AuthRequiredError extends CliError {
|
||||
super(
|
||||
'AUTH_REQUIRED',
|
||||
message ?? `Not logged in to ${domain}`,
|
||||
`Please open Chrome and log in to https://${domain}`,
|
||||
`Please open Chrome or Chromium and log in to https://${domain}`,
|
||||
EXIT_CODES.NOPERM,
|
||||
);
|
||||
this.domain = domain;
|
||||
|
||||
+16
-4
@@ -21,7 +21,7 @@ import { emitHook, type HookContext } from './hooks.js';
|
||||
import { checkDaemonStatus } from './browser/discover.js';
|
||||
import { log } from './logger.js';
|
||||
import { isElectronApp } from './electron-apps.js';
|
||||
import { resolveElectronEndpoint } from './launcher.js';
|
||||
import { probeCDP, resolveElectronEndpoint } from './launcher.js';
|
||||
|
||||
const _loadedModules = new Set<string>();
|
||||
|
||||
@@ -158,8 +158,20 @@ export async function executeCommand(
|
||||
let cdpEndpoint: string | undefined;
|
||||
|
||||
if (electron) {
|
||||
// Electron apps: auto-detect, prompt restart if needed, launch with CDP
|
||||
cdpEndpoint = await resolveElectronEndpoint(cmd.site);
|
||||
// Electron apps: respect manual endpoint override, then try auto-detect
|
||||
const manualEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (manualEndpoint) {
|
||||
const port = Number(new URL(manualEndpoint).port);
|
||||
if (!await probeCDP(port)) {
|
||||
throw new CommandExecutionError(
|
||||
`CDP not reachable at ${manualEndpoint}`,
|
||||
'Check that the app is running with --remote-debugging-port and the endpoint is correct.',
|
||||
);
|
||||
}
|
||||
cdpEndpoint = manualEndpoint;
|
||||
} else {
|
||||
cdpEndpoint = await resolveElectronEndpoint(cmd.site);
|
||||
}
|
||||
} else {
|
||||
// Browser Bridge: fail-fast when daemon is up but extension is missing.
|
||||
// 300ms timeout avoids a full 2s wait on cold-start.
|
||||
@@ -169,7 +181,7 @@ export async function executeCommand(
|
||||
'Browser Bridge extension not connected',
|
||||
'Install the Browser Bridge:\n' +
|
||||
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
||||
' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
|
||||
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
|
||||
' Then run: opencli doctor',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('detectProcess', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when pgrep finds a process', () => {
|
||||
it.skipIf(process.platform === 'win32')('returns true when pgrep finds a process', () => {
|
||||
cp.execFileSync.mockReturnValue('12345\n');
|
||||
const result = detectProcess('Cursor');
|
||||
expect(result).toBe(true);
|
||||
|
||||
+13
-1
@@ -46,6 +46,7 @@ export function probeCDP(port: number, timeoutMs: number = PROBE_TIMEOUT_MS): Pr
|
||||
* Uses pgrep on macOS/Linux.
|
||||
*/
|
||||
export function detectProcess(processName: string): boolean {
|
||||
if (process.platform === 'win32') return false; // pgrep not available on Windows
|
||||
try {
|
||||
execFileSync('pgrep', ['-x', processName], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
return true;
|
||||
@@ -58,6 +59,7 @@ export function detectProcess(processName: string): boolean {
|
||||
* Kill a process by name. Sends SIGTERM first, then SIGKILL after grace period.
|
||||
*/
|
||||
export function killProcess(processName: string): void {
|
||||
if (process.platform === 'win32') return; // pkill not available on Windows
|
||||
try {
|
||||
execFileSync('pkill', ['-x', processName], { stdio: 'pipe' });
|
||||
} catch {
|
||||
@@ -138,7 +140,17 @@ export async function resolveElectronEndpoint(site: string): Promise<string> {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
// Step 2: Running without CDP?
|
||||
// Step 2: Running without CDP? (process detection requires Unix tools)
|
||||
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
||||
throw new CommandExecutionError(
|
||||
`${label} is not reachable on CDP port ${port}.`,
|
||||
`Auto-launch is not yet supported on ${process.platform}.\n` +
|
||||
`Start ${label} manually with --remote-debugging-port=${port}, then either:\n` +
|
||||
` • Set OPENCLI_CDP_ENDPOINT=http://127.0.0.1:${port}\n` +
|
||||
` • Or just re-run the command once ${label} is listening on port ${port}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const isRunning = detectProcess(processName);
|
||||
if (isRunning) {
|
||||
log.debug(`[launcher] ${label} is running but CDP not available`);
|
||||
|
||||
+50
-90
@@ -1,109 +1,69 @@
|
||||
/**
|
||||
* Tests for output.ts: render function format coverage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render } from './output.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe('output TTY detection', () => {
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
const originalEnv = process.env.OUTPUT;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe('render', () => {
|
||||
it('renders JSON output', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
const output = log.mock.calls[0]?.[0];
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('renders Markdown table output', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
|
||||
const calls = log.mock.calls.map(c => c[0]);
|
||||
expect(calls[0]).toContain('| name | score |');
|
||||
expect(calls[1]).toContain('| --- | --- |');
|
||||
expect(calls[2]).toContain('| Alice | 100 |');
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true });
|
||||
if (originalEnv === undefined) delete process.env.OUTPUT;
|
||||
else process.env.OUTPUT = originalEnv;
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders CSV output with proper quoting', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
|
||||
const calls = log.mock.calls.map(c => c[0]);
|
||||
// Header
|
||||
expect(calls[0]).toBe('name,value');
|
||||
// Values with commas/quotes are quoted
|
||||
expect(calls[1]).toContain('"Alice, Bob"');
|
||||
expect(calls[1]).toContain('"say ""hi"""');
|
||||
it('outputs YAML in non-TTY when format is default table', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
// commanderAdapter always passes fmt:'table' as default — this must still trigger downgrade
|
||||
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
|
||||
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(out).toContain('name: alice');
|
||||
expect(out).toContain('score: 10');
|
||||
});
|
||||
|
||||
it('handles null and undefined data', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render(null, { fmt: 'json' });
|
||||
expect(log).toHaveBeenCalledWith(null);
|
||||
it('outputs table in TTY when format is default table', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
||||
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
|
||||
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(out).toContain('alice');
|
||||
});
|
||||
|
||||
it('renders single object as single-row table', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render({ title: 'Test' }, { fmt: 'json' });
|
||||
const output = log.mock.calls[0]?.[0];
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed).toEqual({ title: 'Test' });
|
||||
it('respects explicit -f json even in non-TTY', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
render([{ name: 'alice' }], { fmt: 'json' });
|
||||
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
|
||||
});
|
||||
|
||||
it('handles empty array gracefully', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([], { fmt: 'table' });
|
||||
// Should show "(no data)" for empty arrays
|
||||
expect(log).toHaveBeenCalled();
|
||||
it('OUTPUT env var overrides default table in non-TTY', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
process.env.OUTPUT = 'json';
|
||||
render([{ name: 'alice' }], { fmt: 'table' });
|
||||
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
|
||||
});
|
||||
|
||||
it('uses custom columns for CSV', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
|
||||
const calls = log.mock.calls.map(c => c[0]);
|
||||
expect(calls[0]).toBe('a,c');
|
||||
expect(calls[1]).toBe('1,3');
|
||||
it('explicit -f flag takes precedence over OUTPUT env var', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
process.env.OUTPUT = 'json';
|
||||
render([{ name: 'alice' }], { fmt: 'csv', fmtExplicit: true });
|
||||
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(out).toContain('name');
|
||||
expect(out).toContain('alice');
|
||||
expect(out).not.toContain('"name"'); // not JSON
|
||||
});
|
||||
|
||||
it('renders YAML output', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
expect(log.mock.calls[0]?.[0]).toContain('- title: Hello');
|
||||
expect(log.mock.calls[0]?.[0]).toContain('rank: 1');
|
||||
});
|
||||
|
||||
it('renders yml alias as YAML output', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render({ title: 'Hello' }, { fmt: 'yml' });
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
|
||||
});
|
||||
|
||||
it('handles null values in CSV cells', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ name: 'test', value: null }], { fmt: 'csv' });
|
||||
const calls = log.mock.calls.map(c => c[0]);
|
||||
expect(calls[1]).toBe('test,');
|
||||
});
|
||||
|
||||
it('renders single-field rows in plain mode as the bare value', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ response: 'Gemini says hi' }], { fmt: 'plain' });
|
||||
expect(log).toHaveBeenCalledWith('Gemini says hi');
|
||||
});
|
||||
|
||||
it('renders multi-field rows in plain mode as key-value lines', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render([{ status: 'ok', file: '~/tmp/a.png', link: 'https://example.com' }], { fmt: 'plain' });
|
||||
const calls = log.mock.calls.map(c => c[0]);
|
||||
expect(calls).toEqual([
|
||||
'status: ok',
|
||||
'file: ~/tmp/a.png',
|
||||
'link: https://example.com',
|
||||
]);
|
||||
it('explicit -f table overrides non-TTY auto-downgrade', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
|
||||
const out = logSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
// Should be table output, not YAML
|
||||
expect(out).not.toContain('name: alice');
|
||||
expect(out).toContain('alice');
|
||||
});
|
||||
});
|
||||
|
||||
+10
-1
@@ -8,6 +8,8 @@ import yaml from 'js-yaml';
|
||||
|
||||
export interface RenderOptions {
|
||||
fmt?: string;
|
||||
/** True when the user explicitly passed -f on the command line */
|
||||
fmtExplicit?: boolean;
|
||||
columns?: string[];
|
||||
title?: string;
|
||||
elapsed?: number;
|
||||
@@ -26,7 +28,14 @@ function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): s
|
||||
}
|
||||
|
||||
export function render(data: unknown, opts: RenderOptions = {}): void {
|
||||
const fmt = opts.fmt ?? 'table';
|
||||
let fmt = opts.fmt ?? 'table';
|
||||
// Non-TTY auto-downgrade only when format was NOT explicitly passed by user.
|
||||
// Priority: explicit -f (any value) > OUTPUT env var > TTY auto-detect > table
|
||||
if (!opts.fmtExplicit) {
|
||||
const envFmt = process.env.OUTPUT?.trim().toLowerCase();
|
||||
if (envFmt) fmt = envFmt;
|
||||
else if (fmt === 'table' && !process.stdout.isTTY) fmt = 'yaml';
|
||||
}
|
||||
if (data === null || data === undefined) {
|
||||
console.log(data);
|
||||
return;
|
||||
|
||||
@@ -105,6 +105,15 @@ describe('login-required commands — graceful failure', () => {
|
||||
await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json']);
|
||||
}, 60_000);
|
||||
|
||||
// ── yuanbao (requires login) ──
|
||||
it('yuanbao new fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['yuanbao', 'new', '-f', 'json']);
|
||||
}, 60_000);
|
||||
|
||||
it('yuanbao ask fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['yuanbao', 'ask', '你好', '-f', 'json']);
|
||||
}, 60_000);
|
||||
|
||||
// ── pixiv (requires login) ──
|
||||
it('pixiv ranking fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['pixiv', 'ranking', '--limit', '3', '-f', 'json']);
|
||||
|
||||
Reference in New Issue
Block a user