diff --git a/README.md b/README.md index 7588d57..3fc6d5c 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,10 @@ [![npm](https://img.shields.io/npm/v/@jackwener/opencli)](https://www.npmjs.com/package/@jackwener/opencli) -OpenCLI turns any website into a command-line tool by bridging your Chrome browser through [Playwright MCP](https://github.com/nichochar/playwright-mcp). No passwords stored, no tokens leaked โ€” it just rides your existing browser session. +A CLI tool that turns **any website** into a command-line interface. **28+ commands** across **16 sites** โ€” bilibili, zhihu, xiaohongshu, twitter, reddit, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube โ€” powered by browser session reuse and AI-native discovery. ## โœจ Highlights -- ๐ŸŒ **25+ commands, 8 sites** โ€” Bilibili, Zhihu, GitHub, Twitter/X, Reddit, V2EX, Xiaohongshu, Hacker News - ๐Ÿ” **Account-safe** โ€” Reuses Chrome's logged-in state; your credentials never leave the browser - ๐Ÿค– **AI Agent ready** โ€” `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies - ๐Ÿ“ **Declarative YAML** โ€” Most adapters are ~30 lines of YAML pipeline @@ -86,9 +85,17 @@ Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at a | **xiaohongshu** | `search` `notifications` `feed` | ๐Ÿ” Browser | | **twitter** | `trending` | ๐Ÿ” Browser | | **reddit** | `hot` | ๐Ÿ” Browser | +| **weibo** | `hot` | ๐Ÿ” Browser | +| **boss** | `search` | ๐Ÿ” Browser | +| **youtube** | `search` | ๐Ÿ” Browser | +| **yahoo-finance** | `quote` | ๐Ÿ” Browser | +| **reuters** | `search` | ๐Ÿ” Browser | +| **smzdm** | `search` | ๐Ÿ” Browser | +| **ctrip** | `search` | ๐Ÿ” Browser | | **github** | `trending` `search` | ๐Ÿ” / ๐ŸŒ | | **v2ex** | `hot` `latest` `topic` | ๐ŸŒ Public | | **hackernews** | `top` | ๐ŸŒ Public | +| **bbc** | `news` | ๐ŸŒ Public | ## ๐ŸŽจ Output Formats diff --git a/README.zh-CN.md b/README.zh-CN.md index 47cae17..dd850b4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,7 @@ OpenCLI ้€š่ฟ‡ Chrome ๆต่งˆๅ™จ + [Playwright MCP Bridge](https://github.com/nic ## โœจ ไบฎ็‚น -- ๐ŸŒ **25+ ๅ‘ฝไปค๏ผŒ8 ไธช็ซ™็‚น** โ€” B็ซ™ใ€็ŸฅไนŽใ€GitHubใ€Twitter/Xใ€Redditใ€V2EXใ€ๅฐ็บขไนฆใ€Hacker News +- ๐ŸŒ **28+ ๅ‘ฝไปค๏ผŒ16 ไธช็ซ™็‚น** โ€” B็ซ™ใ€็ŸฅไนŽใ€ๅฐ็บขไนฆใ€Twitterใ€Redditใ€GitHubใ€V2EXใ€Hacker Newsใ€BBCใ€ๅพฎๅšใ€BOSS็›ด่˜ใ€Yahoo Financeใ€่ทฏ้€็คพใ€ไป€ไนˆๅ€ผๅพ—ไนฐใ€ๆบ็จ‹ใ€YouTube - ๐Ÿ” **้›ถ้ฃŽๆŽง** โ€” ๅค็”จ Chrome ็™ปๅฝ•ๆ€๏ผŒๆ— ้œ€ๅญ˜ๅ‚จไปปไฝ•ๅ‡ญ่ฏ - ๐Ÿค– **AI ๅŽŸ็”Ÿ** โ€” `explore` ่‡ชๅŠจๅ‘็Žฐ API๏ผŒ`synthesize` ็”Ÿๆˆ้€‚้…ๅ™จ๏ผŒ`cascade` ๆŽขๆต‹่ฎค่ฏ็ญ–็•ฅ - ๐Ÿ“ **ๅฃฐๆ˜Žๅผ YAML** โ€” ๅคง้ƒจๅˆ†้€‚้…ๅ™จๅช้œ€ ~30 ่กŒ YAML @@ -86,9 +86,17 @@ npm install -g @jackwener/opencli@latest | **xiaohongshu** | `search` `notifications` `feed` | ๐Ÿ” ๆต่งˆๅ™จ | | **twitter** | `trending` | ๐Ÿ” ๆต่งˆๅ™จ | | **reddit** | `hot` | ๐Ÿ” ๆต่งˆๅ™จ | +| **weibo** | `hot` | ๐Ÿ” ๆต่งˆๅ™จ | +| **boss** | `search` | ๐Ÿ” ๆต่งˆๅ™จ | +| **youtube** | `search` | ๐Ÿ” ๆต่งˆๅ™จ | +| **yahoo-finance** | `quote` | ๐Ÿ” ๆต่งˆๅ™จ | +| **reuters** | `search` | ๐Ÿ” ๆต่งˆๅ™จ | +| **smzdm** | `search` | ๐Ÿ” ๆต่งˆๅ™จ | +| **ctrip** | `search` | ๐Ÿ” ๆต่งˆๅ™จ | | **github** | `trending` `search` | ๐Ÿ” / ๐ŸŒ | | **v2ex** | `hot` `latest` `topic` | ๐ŸŒ ๅ…ฌๅ…ฑ API | | **hackernews** | `top` | ๐ŸŒ ๅ…ฌๅ…ฑ API | +| **bbc** | `news` | ๐ŸŒ ๅ…ฌๅ…ฑ API | ## ๐ŸŽจ ่พ“ๅ‡บๆ ผๅผ diff --git a/SKILL.md b/SKILL.md index 375ff6d..893de82 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,6 +78,30 @@ opencli v2ex topic --id 1024 # ไธป้ข˜่ฏฆๆƒ… # Hacker News (public) opencli hackernews top --limit 10 # Top stories + +# BBC (public) +opencli bbc news --limit 10 # BBC News RSS headlines + +# ๅพฎๅš (browser) +opencli weibo hot --limit 10 # ๅพฎๅš็ƒญๆœ + +# BOSS็›ด่˜ (browser) +opencli boss search --query "AI agent" # ๆœ็ดข่Œไฝ + +# YouTube (browser) +opencli youtube search --query "rust" # ๆœ็ดข่ง†้ข‘ + +# Yahoo Finance (browser) +opencli yahoo-finance quote --symbol AAPL # ่‚ก็ฅจ่กŒๆƒ… + +# Reuters (browser) +opencli reuters search --query "AI" # ่ทฏ้€็คพๆœ็ดข + +# ไป€ไนˆๅ€ผๅพ—ไนฐ (browser) +opencli smzdm search --keyword "่€ณๆœบ" # ๆœ็ดขๅฅฝไปท + +# ๆบ็จ‹ (browser) +opencli ctrip search --query "ไธ‰ไบš" # ๆœ็ดข็›ฎ็š„ๅœฐ ``` ### Management Commands diff --git a/package-lock.json b/package-lock.json index 1afaa01..0d48f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.10", "tsx": "^4.19.3", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^4.1.0" } }, "node_modules/@colors/colors": { @@ -35,6 +36,40 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -477,6 +512,50 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@playwright/mcp": { "version": "0.0.68", "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz", @@ -494,6 +573,311 @@ "node": ">=18" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -507,10 +891,124 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -526,6 +1024,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -562,12 +1080,36 @@ "node": ">=18" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -610,6 +1152,44 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -659,6 +1239,335 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.59.0-alpha-1771104257000", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz", @@ -706,6 +1615,35 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -716,6 +1654,71 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -742,12 +1745,65 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -782,6 +1838,185 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "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-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index f5ae59b..0defc91 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "start": "node dist/main.js", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [ "cli", @@ -44,6 +46,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.10", "tsx": "^4.19.3", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^4.1.0" } } diff --git a/src/bilibili.ts b/src/bilibili.ts index 9429ae0..3cf65bb 100644 --- a/src/bilibili.ts +++ b/src/bilibili.ts @@ -2,6 +2,8 @@ * Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution. */ +import type { IPage } from './types.js'; + 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, @@ -17,7 +19,7 @@ export function payloadData(payload: any): any { return payload?.data ?? payload; } -async function getNavData(page: any): Promise { +async function getNavData(page: IPage): Promise { return page.evaluate(` async () => { const res = await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include' }); @@ -26,7 +28,7 @@ async function getNavData(page: any): Promise { `); } -async function getWbiKeys(page: any): Promise<{ imgKey: string; subKey: string }> { +async function getWbiKeys(page: IPage): Promise<{ imgKey: string; subKey: string }> { const nav = await getNavData(page); const wbiImg = nav?.data?.wbi_img ?? {}; const imgUrl = wbiImg.img_url ?? ''; @@ -47,7 +49,7 @@ async function md5(text: string): Promise { } export async function wbiSign( - page: any, + page: IPage, params: Record, ): Promise> { const { imgKey, subKey } = await getWbiKeys(page); @@ -65,7 +67,7 @@ export async function wbiSign( } export async function apiGet( - page: any, + page: IPage, path: string, opts: { params?: Record; signed?: boolean } = {}, ): Promise { @@ -81,7 +83,7 @@ export async function apiGet( return fetchJson(page, url); } -export async function fetchJson(page: any, url: string): Promise { +export async function fetchJson(page: IPage, url: string): Promise { const escapedUrl = url.replace(/"/g, '\\"'); return page.evaluate(` async () => { @@ -91,14 +93,14 @@ export async function fetchJson(page: any, url: string): Promise { `); } -export async function getSelfUid(page: any): Promise { +export async function getSelfUid(page: IPage): Promise { const nav = await getNavData(page); const mid = nav?.data?.mid; if (!mid) throw new Error('Not logged in to Bilibili'); return String(mid); } -export async function resolveUid(page: any, input: string): Promise { +export async function resolveUid(page: IPage, input: string): Promise { if (/^\d+$/.test(input)) return input; // Search for user by name const payload = await apiGet(page, '/x/web-interface/wbi/search/type', { diff --git a/src/browser.ts b/src/browser.ts index 310b0c2..68b06ff 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -10,6 +10,10 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { formatSnapshot } from './snapshotFormatter.js'; +// Read version from package.json (single source of truth) +const __browser_dirname = path.dirname(fileURLToPath(import.meta.url)); +const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })(); + const EXTENSION_LOCK_TIMEOUT = parseInt(process.env.OPENCLI_EXTENSION_LOCK_TIMEOUT ?? '120', 10); const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INTERVAL ?? '1', 10); const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10); @@ -21,10 +25,12 @@ function jsonRpcRequest(method: string, params: Record = {}): strin return JSON.stringify({ jsonrpc: '2.0', id: _nextId++, method, params }) + '\n'; } +import type { IPage } from './types.js'; + /** * Page abstraction wrapping JSON-RPC calls to Playwright MCP. */ -export class Page { +export class Page implements IPage { constructor(private _send: (msg: string) => void, private _recv: () => Promise) {} async call(method: string, params: Record = {}): Promise { @@ -173,7 +179,7 @@ export class PlaywrightMCP { const initMsg = jsonRpcRequest('initialize', { protocolVersion: '2024-11-05', capabilities: {}, - clientInfo: { name: 'opencli', version: '0.1.0' }, + clientInfo: { name: 'opencli', version: PKG_VERSION }, }); this._proc.stdin?.write(initMsg); diff --git a/src/cascade.ts b/src/cascade.ts index bb523b1..129356e 100644 --- a/src/cascade.ts +++ b/src/cascade.ts @@ -11,6 +11,7 @@ */ import { Strategy } from './registry.js'; +import type { IPage } from './types.js'; /** Strategy cascade order (simplest โ†’ most complex) */ const CASCADE_ORDER: Strategy[] = [ @@ -41,7 +42,7 @@ interface CascadeResult { * Returns whether the probe succeeded and basic response info. */ export async function probeEndpoint( - page: any, + page: IPage, url: string, strategy: Strategy, opts: { timeout?: number } = {}, @@ -168,7 +169,7 @@ export async function probeEndpoint( * Returns the simplest working strategy. */ export async function cascadeProbe( - page: any, + page: IPage, url: string, opts: { maxStrategy?: Strategy; timeout?: number } = {}, ): Promise { diff --git a/src/clis/bbc/news.ts b/src/clis/bbc/news.ts new file mode 100644 index 0000000..f1c2cb3 --- /dev/null +++ b/src/clis/bbc/news.ts @@ -0,0 +1,42 @@ +/** + * BBC News headlines โ€” public RSS feed, no browser needed. + * Source: bb-sites/bbc/news.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'bbc', + name: 'news', + description: 'BBC News headlines (RSS)', + domain: 'www.bbc.com', + strategy: Strategy.PUBLIC, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' }, + ], + columns: ['rank', 'title', 'description', 'url'], + func: async (page, kwargs) => { + const count = Math.min(kwargs.limit || 20, 50); + const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml'); + if (!resp.ok) return []; + const xml = await resp.text(); + // Simple XML parsing without DOMParser (works in Node) + const items: any[] = []; + const itemRegex = /([\s\S]*?)<\/item>/g; + let match; + while ((match = itemRegex.exec(xml)) && items.length < count) { + const block = match[1]; + const title = block.match(/<!\[CDATA\[(.*?)\]\]>|<title>(.*?)<\/title>/)?.[1] || block.match(/<title>(.*?)<\/title>/)?.[1] || ''; + const desc = block.match(/<description><!\[CDATA\[(.*?)\]\]>|<description>(.*?)<\/description>/)?.[1] || block.match(/<description>(.*?)<\/description>/)?.[1] || ''; + const link = block.match(/<link>(.*?)<\/link>/)?.[1] || block.match(/<guid[^>]*>(.*?)<\/guid>/)?.[1] || ''; + if (title) { + items.push({ + rank: items.length + 1, + title: title.trim(), + description: desc.trim().substring(0, 200), + url: link.trim(), + }); + } + } + return items; + }, +}); diff --git a/src/clis/boss/search.ts b/src/clis/boss/search.ts new file mode 100644 index 0000000..1bf16cf --- /dev/null +++ b/src/clis/boss/search.ts @@ -0,0 +1,47 @@ +/** + * BOSS็›ด่˜ job search โ€” browser cookie API. + * Source: bb-sites/boss/search.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'boss', + name: 'search', + description: 'BOSS็›ด่˜ๆœ็ดข่Œไฝ', + domain: 'www.zhipin.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', required: true, help: 'Search keyword (e.g. AI agent, ๅ‰็ซฏ)' }, + { name: 'city', default: '101010100', help: 'City code (101010100=ๅŒ—ไบฌ, 101020100=ไธŠๆตท, 101210100=ๆญๅทž, 101280100=ๅนฟๅทž)' }, + { name: 'limit', type: 'int', default: 15, help: 'Number of results' }, + ], + columns: ['name', 'salary', 'company', 'city', 'experience', 'degree', 'boss', 'url'], + func: async (page, kwargs) => { + await page.goto('https://www.zhipin.com'); + await page.wait(2); + const data = await page.evaluate(` + (async () => { + const params = new URLSearchParams({ + scene: '1', query: '${kwargs.query.replace(/'/g, "\\'")}', + city: '${kwargs.city || '101010100'}', page: '1', pageSize: '15', + experience: '', degree: '', payType: '', partTime: '', + industry: '', scale: '', stage: '', position: '', + jobType: '', salary: '', multiBusinessDistrict: '', multiSubway: '' + }); + const resp = await fetch('/wapi/zpgeek/search/joblist.json?' + params.toString(), {credentials: 'include'}); + if (!resp.ok) return {error: 'HTTP ' + resp.status}; + const d = await resp.json(); + if (d.code !== 0) return {error: d.message || 'API error'}; + const zpData = d.zpData || {}; + return (zpData.jobList || []).map(j => ({ + name: j.jobName, salary: j.salaryDesc, company: j.brandName, + city: j.cityName, experience: j.jobExperience, degree: j.jobDegree, + boss: j.bossName + ' ยท ' + j.bossTitle, + url: j.encryptJobId ? 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html' : '' + })); + })() + `); + if (!Array.isArray(data)) return []; + return data.slice(0, kwargs.limit || 15); + }, +}); diff --git a/src/clis/ctrip/search.ts b/src/clis/ctrip/search.ts new file mode 100644 index 0000000..ec5f403 --- /dev/null +++ b/src/clis/ctrip/search.ts @@ -0,0 +1,62 @@ +/** + * ๆบ็จ‹ๆ—…่กŒๆœ็ดข โ€” browser cookie, multi-strategy. + * Source: bb-sites/ctrip/search.js (simplified to suggestion API) + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'ctrip', + name: 'search', + description: 'ๆบ็จ‹ๆ—…่กŒๆœ็ดข', + domain: 'www.ctrip.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', required: true, help: 'Search keyword (city or attraction)' }, + { name: 'limit', type: 'int', default: 15, help: 'Number of results' }, + ], + columns: ['rank', 'name', 'type', 'score', 'price', 'url'], + func: async (page, kwargs) => { + const limit = kwargs.limit || 15; + await page.goto('https://www.ctrip.com'); + await page.wait(2); + const data = await page.evaluate(` + (async () => { + const query = '${kwargs.query.replace(/'/g, "\\'")}'; + const limit = ${limit}; + + // Strategy 1: Suggestion API + try { + const suggestUrl = 'https://m.ctrip.com/restapi/h5api/searchapp/search?action=onekeyali&keyword=' + encodeURIComponent(query); + const resp = await fetch(suggestUrl, {credentials: 'include'}); + if (resp.ok) { + const d = await resp.json(); + const raw = d.data || d.result || d; + if (raw && typeof raw === 'object') { + // Flatten all result categories + const items = []; + for (const key of Object.keys(raw)) { + const list = Array.isArray(raw[key]) ? raw[key] : []; + for (const item of list) { + if (items.length >= limit) break; + items.push({ + rank: items.length + 1, + name: item.word || item.name || item.title || '', + type: item.type || item.tpName || key, + score: item.score || '', + price: item.price || item.minPrice || '', + url: item.url || item.surl || '', + }); + } + } + if (items.length > 0) return items; + } + } + } catch(e) {} + + return {error: 'No results for: ' + query}; + })() + `); + if (!Array.isArray(data)) return []; + return data; + }, +}); diff --git a/src/clis/github/search.ts b/src/clis/github/search.ts deleted file mode 100644 index 3d1b9d3..0000000 --- a/src/clis/github/search.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { cli, Strategy } from '../../registry.js'; - -cli({ - site: 'github', name: 'search', description: 'Search GitHub repositories', domain: 'github.com', strategy: Strategy.PUBLIC, browser: false, - args: [ - { name: 'keyword', required: true, help: 'Search keyword' }, - { name: 'sort', default: 'stars', help: 'Sort by: stars, forks, updated' }, - { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, - ], - columns: ['rank', 'name', 'stars', 'language', 'description'], - func: async (_page, kwargs) => { - const { keyword, sort = 'stars', limit = 20 } = kwargs; - const resp = await fetch(`https://api.github.com/search/repositories?${new URLSearchParams({ q: keyword, sort, order: 'desc', per_page: String(Math.min(Number(limit), 100)) })}`, { - headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'opencli/0.1' }, - }); - const data = await resp.json() as any; - return (data.items ?? []).slice(0, Number(limit)).map((item: any, i: number) => ({ - rank: i + 1, name: item.full_name, stars: item.stargazers_count, language: item.language ?? '', description: (item.description ?? '').slice(0, 80), - })); - }, -}); diff --git a/src/clis/github/trending.yaml b/src/clis/github/trending.yaml deleted file mode 100644 index 33aeb4e..0000000 --- a/src/clis/github/trending.yaml +++ /dev/null @@ -1,58 +0,0 @@ -site: github -name: trending -description: GitHub trending repositories -domain: github.com - -args: - language: - type: str - default: "" - description: "Programming language filter (e.g. python, rust)" - since: - type: str - default: daily - description: "Time range: daily, weekly, monthly" - limit: - type: int - default: 20 - description: Number of repos - -pipeline: - - evaluate: | - (async () => { - const lang = '${{ args.language }}' ? '/${{ args.language }}' : ''; - const res = await fetch(`https://github.com/trending${lang}?since=${{ args.since }}`, { - headers: { 'Accept': 'text/html' } - }); - const html = await res.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const rows = doc.querySelectorAll('article.Box-row'); - return Array.from(rows).map(row => { - const nameEl = row.querySelector('h2 a'); - const descEl = row.querySelector('p'); - const langEl = row.querySelector('[itemprop="programmingLanguage"]'); - const starsEl = row.querySelectorAll('a.Link--muted'); - const todayEl = row.querySelector('span.d-inline-block.float-sm-right'); - return { - repo: nameEl?.textContent?.trim()?.replace(/\s+/g, '') || '', - description: descEl?.textContent?.trim() || '', - language: langEl?.textContent?.trim() || '', - stars: starsEl[0]?.textContent?.trim() || '', - forks: starsEl[1]?.textContent?.trim() || '', - today: todayEl?.textContent?.trim() || '' - }; - }); - })() - - - map: - rank: ${{ index + 1 }} - repo: ${{ item.repo }} - description: ${{ item.description }} - language: ${{ item.language }} - stars: ${{ item.stars }} - today: ${{ item.today }} - - - limit: ${{ args.limit }} - -columns: [rank, repo, language, stars, today] diff --git a/src/clis/index.ts b/src/clis/index.ts index 4165fdd..4f30505 100644 --- a/src/clis/index.ts +++ b/src/clis/index.ts @@ -20,3 +20,27 @@ import './zhihu/question.js'; // xiaohongshu import './xiaohongshu/search.js'; + +// bbc +import './bbc/news.js'; + +// weibo +import './weibo/hot.js'; + +// boss +import './boss/search.js'; + +// yahoo-finance +import './yahoo-finance/quote.js'; + +// reuters +import './reuters/search.js'; + +// smzdm +import './smzdm/search.js'; + +// ctrip +import './ctrip/search.js'; + +// youtube +import './youtube/search.js'; diff --git a/src/clis/reuters/search.ts b/src/clis/reuters/search.ts new file mode 100644 index 0000000..30fbba4 --- /dev/null +++ b/src/clis/reuters/search.ts @@ -0,0 +1,52 @@ +/** + * Reuters news search โ€” API with HTML fallback. + * Source: bb-sites/reuters/search.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'reuters', + name: 'search', + description: 'Reuters ่ทฏ้€็คพๆ–ฐ้—ปๆœ็ดข', + domain: 'www.reuters.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', required: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of results (max 40)' }, + ], + columns: ['rank', 'title', 'date', 'section', 'url'], + func: async (page, kwargs) => { + const count = Math.min(kwargs.limit || 10, 40); + await page.goto('https://www.reuters.com'); + await page.wait(2); + const data = await page.evaluate(` + (async () => { + const count = ${count}; + const apiQuery = JSON.stringify({ + keyword: '${kwargs.query.replace(/'/g, "\\'")}', + offset: 0, orderby: 'display_date:desc', size: count, website: 'reuters' + }); + const apiUrl = 'https://www.reuters.com/pf/api/v3/content/fetch/articles-by-search-v2?query=' + encodeURIComponent(apiQuery); + try { + const resp = await fetch(apiUrl, {credentials: 'include'}); + if (resp.ok) { + const data = await resp.json(); + const articles = data.result?.articles || data.articles || []; + if (articles.length > 0) { + return articles.slice(0, count).map((a, i) => ({ + rank: i + 1, + title: a.title || a.headlines?.basic || '', + date: (a.display_date || a.published_time || '').split('T')[0], + section: a.taxonomy?.section?.name || '', + url: a.canonical_url ? 'https://www.reuters.com' + a.canonical_url : '', + })); + } + } + } catch(e) {} + return {error: 'Reuters API unavailable'}; + })() + `); + if (!Array.isArray(data)) return []; + return data; + }, +}); diff --git a/src/clis/smzdm/search.ts b/src/clis/smzdm/search.ts new file mode 100644 index 0000000..e69b609 --- /dev/null +++ b/src/clis/smzdm/search.ts @@ -0,0 +1,66 @@ +/** + * ไป€ไนˆๅ€ผๅพ—ไนฐๆœ็ดขๅฅฝไปท โ€” browser cookie, HTML parse. + * Source: bb-sites/smzdm/search.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'smzdm', + name: 'search', + description: 'ไป€ไนˆๅ€ผๅพ—ไนฐๆœ็ดขๅฅฝไปท', + domain: 'www.smzdm.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'keyword', required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + ], + columns: ['rank', 'title', 'price', 'mall', 'comments', 'url'], + func: async (page, kwargs) => { + const q = encodeURIComponent(kwargs.keyword); + const limit = kwargs.limit || 20; + await page.goto('https://www.smzdm.com'); + await page.wait(2); + const data = await page.evaluate(` + (async () => { + const q = '${q}'; + const limit = ${limit}; + // Try youhui channel first, then home + for (const channel of ['youhui', 'home']) { + try { + const resp = await fetch('https://search.smzdm.com/ajax/?c=' + channel + '&s=' + q + '&p=1&v=b', { + credentials: 'include', + headers: {'X-Requested-With': 'XMLHttpRequest'} + }); + if (!resp.ok) continue; + const html = await resp.text(); + if (html.indexOf('feed-row-wide') === -1) continue; + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const items = doc.querySelectorAll('li.feed-row-wide'); + const results = []; + items.forEach((li, i) => { + if (results.length >= limit) return; + const titleEl = li.querySelector('h5.feed-block-title > a') + || li.querySelector('h5 > a'); + if (!titleEl) return; + const title = (titleEl.getAttribute('title') || titleEl.textContent || '').trim(); + const url = titleEl.getAttribute('href') || ''; + const priceEl = li.querySelector('.z-highlight'); + const price = priceEl ? priceEl.textContent.trim() : ''; + let mall = ''; + const extrasSpan = li.querySelector('.z-feed-foot-r .feed-block-extras span'); + if (extrasSpan) mall = extrasSpan.textContent.trim(); + const commentEl = li.querySelector('.feed-btn-comment'); + const comments = commentEl ? parseInt(commentEl.textContent.trim()) || 0 : 0; + results.push({rank: results.length + 1, title, price, mall, comments, url}); + }); + if (results.length > 0) return results; + } catch(e) { continue; } + } + return {error: 'No results'}; + })() + `); + if (!Array.isArray(data)) return []; + return data; + }, +}); diff --git a/src/clis/weibo/hot.ts b/src/clis/weibo/hot.ts new file mode 100644 index 0000000..f8bad21 --- /dev/null +++ b/src/clis/weibo/hot.ts @@ -0,0 +1,41 @@ +/** + * Weibo hot search โ€” browser cookie API. + * Source: bb-sites/weibo/hot.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'weibo', + name: 'hot', + description: 'ๅพฎๅš็ƒญๆœ', + domain: 'weibo.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'limit', type: 'int', default: 30, help: 'Number of items (max 50)' }, + ], + columns: ['rank', 'word', 'hot_value', 'category', 'label', 'url'], + func: async (page, kwargs) => { + const count = Math.min(kwargs.limit || 30, 50); + await page.goto('https://weibo.com'); + await page.wait(2); + const data = await page.evaluate(` + (async () => { + const resp = await fetch('/ajax/statuses/hot_band', {credentials: 'include'}); + if (!resp.ok) return {error: 'HTTP ' + resp.status}; + const data = await resp.json(); + if (!data.ok) return {error: 'API error'}; + const bandList = data.data?.band_list || []; + return bandList.map((item, i) => ({ + rank: item.realpos || (i + 1), + word: item.word, + hot_value: item.num || 0, + category: item.category || '', + label: item.label_name || '', + url: 'https://s.weibo.com/weibo?q=' + encodeURIComponent('#' + item.word + '#') + })); + })() + `); + if (!Array.isArray(data)) return []; + return data.slice(0, count); + }, +}); diff --git a/src/clis/yahoo-finance/quote.ts b/src/clis/yahoo-finance/quote.ts new file mode 100644 index 0000000..ca875e9 --- /dev/null +++ b/src/clis/yahoo-finance/quote.ts @@ -0,0 +1,74 @@ +/** + * Yahoo Finance stock quote โ€” multi-strategy API fallback. + * Source: bb-sites/yahoo-finance/quote.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'yahoo-finance', + name: 'quote', + description: 'Yahoo Finance ่‚ก็ฅจ่กŒๆƒ…', + domain: 'finance.yahoo.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL, MSFT, TSLA)' }, + ], + columns: ['symbol', 'name', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], + func: async (page, kwargs) => { + const symbol = kwargs.symbol.toUpperCase().trim(); + await page.goto(`https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}/`); + await page.wait(3); + const data = await page.evaluate(` + (async () => { + const sym = '${symbol}'; + + // Strategy 1: v8 chart API + try { + const chartUrl = 'https://query1.finance.yahoo.com/v8/finance/chart/' + encodeURIComponent(sym) + '?interval=1d&range=1d'; + const resp = await fetch(chartUrl); + if (resp.ok) { + const d = await resp.json(); + const chart = d?.chart?.result?.[0]; + if (chart) { + const meta = chart.meta || {}; + const prevClose = meta.previousClose || meta.chartPreviousClose; + const price = meta.regularMarketPrice; + const change = price != null && prevClose != null ? (price - prevClose) : null; + const changePct = change != null && prevClose ? ((change / prevClose) * 100) : null; + return { + symbol: meta.symbol || sym, name: meta.shortName || meta.longName || sym, + price: price != null ? Number(price.toFixed(2)) : null, + change: change != null ? change.toFixed(2) : null, + changePercent: changePct != null ? changePct.toFixed(2) + '%' : null, + open: chart.indicators?.quote?.[0]?.open?.[0] || null, + high: meta.regularMarketDayHigh || null, + low: meta.regularMarketDayLow || null, + volume: meta.regularMarketVolume || null, + marketCap: null, currency: meta.currency, exchange: meta.exchangeName, + }; + } + } + } catch(e) {} + + // Strategy 2: Parse from page + const titleEl = document.querySelector('title'); + const priceEl = document.querySelector('[data-testid="qsp-price"]'); + const changeEl = document.querySelector('[data-testid="qsp-price-change"]'); + const changePctEl = document.querySelector('[data-testid="qsp-price-change-percent"]'); + if (priceEl) { + return { + symbol: sym, + name: titleEl ? titleEl.textContent.split('(')[0].trim() : sym, + price: priceEl.textContent.replace(/,/g, ''), + change: changeEl ? changeEl.textContent : null, + changePercent: changePctEl ? changePctEl.textContent : null, + open: null, high: null, low: null, volume: null, marketCap: null, + }; + } + return {error: 'Could not fetch quote for ' + sym}; + })() + `); + if (!data || data.error) return []; + return [data]; + }, +}); diff --git a/src/clis/youtube/search.ts b/src/clis/youtube/search.ts new file mode 100644 index 0000000..3cdbfef --- /dev/null +++ b/src/clis/youtube/search.ts @@ -0,0 +1,60 @@ +/** + * YouTube search โ€” innertube API via browser session. + * Source: bb-sites/youtube/search.js + */ +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: 'youtube', + name: 'search', + description: 'Search YouTube videos', + domain: 'www.youtube.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', required: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' }, + ], + columns: ['rank', 'title', 'channel', 'views', 'duration', 'url'], + func: async (page, kwargs) => { + const limit = Math.min(kwargs.limit || 20, 50); + await page.goto('https://www.youtube.com'); + await page.wait(2); + const data = await page.evaluate(` + (async () => { + const cfg = window.ytcfg?.data_ || {}; + const apiKey = cfg.INNERTUBE_API_KEY; + const context = cfg.INNERTUBE_CONTEXT; + if (!apiKey || !context) return {error: 'YouTube config not found'}; + + const resp = await fetch('/youtubei/v1/search?key=' + apiKey + '&prettyPrint=false', { + method: 'POST', credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({context, query: '${kwargs.query.replace(/'/g, "\\'")}'}) + }); + if (!resp.ok) return {error: 'HTTP ' + resp.status}; + + const data = await resp.json(); + const contents = data.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || []; + const videos = []; + for (const section of contents) { + for (const item of (section.itemSectionRenderer?.contents || [])) { + if (item.videoRenderer && videos.length < ${limit}) { + const v = item.videoRenderer; + videos.push({ + rank: videos.length + 1, + title: v.title?.runs?.[0]?.text || '', + channel: v.ownerText?.runs?.[0]?.text || '', + views: v.viewCountText?.simpleText || v.shortViewCountText?.simpleText || '', + duration: v.lengthText?.simpleText || 'LIVE', + url: 'https://www.youtube.com/watch?v=' + v.videoId + }); + } + } + } + return videos; + })() + `); + if (!Array.isArray(data)) return []; + return data; + }, +}); diff --git a/src/engine.ts b/src/engine.ts index b77b3cf..14aa005 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import yaml from 'js-yaml'; import { type CliCommand, type Arg, Strategy, registerCommand } from './registry.js'; +import type { IPage } from './types.js'; import { executePipeline } from './pipeline.js'; export function discoverClis(...dirs: string[]): void { @@ -72,7 +73,7 @@ function registerYamlCli(filePath: string, defaultSite: string): void { export async function executeCommand( cmd: CliCommand, - page: any, + page: IPage | null, kwargs: Record<string, any>, debug: boolean = false, ): Promise<any> { diff --git a/src/explore.ts b/src/explore.ts index 3f1e1f3..a222f16 100644 --- a/src/explore.ts +++ b/src/explore.ts @@ -477,7 +477,7 @@ export function renderExploreSummary(result: Record<string, any>): string { return lines.join('\n'); } -async function readPageMetadata(page: any): Promise<{ url: string; title: string }> { +async function readPageMetadata(page: any /* IPage */): Promise<{ url: string; title: string }> { try { const result = await page.evaluate(`() => ({ url: window.location.href, title: document.title || '' })`); if (result && typeof result === 'object') return { url: String(result.url ?? ''), title: String(result.title ?? '') }; diff --git a/src/generate.ts b/src/generate.ts index 3e14351..cc669f4 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -10,7 +10,9 @@ import { exploreUrl } from './explore.js'; import { synthesizeFromExplore } from './synthesize.js'; -import { registerCandidates } from './register.js'; + +// TODO: implement real CLI registration (copy candidate YAML to user clis dir) +function registerCandidates(_opts: any): any { return { ok: true, count: 0 }; } const CAPABILITY_ALIASES: Record<string, string[]> = { search: ['search', 'ๆœ็ดข', 'ๆŸฅๆ‰พ', 'query', 'keyword'], diff --git a/src/main.ts b/src/main.ts index 44d2027..71dddce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ * opencli โ€” Make any website your CLI. AI-powered. */ +import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,10 +21,14 @@ const __dirname = path.dirname(__filename); const BUILTIN_CLIS = path.resolve(__dirname, 'clis'); const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis'); +// Read version from package.json (single source of truth) +const pkgJsonPath = path.resolve(__dirname, '..', 'package.json'); +const PKG_VERSION = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version ?? '0.0.0'; + discoverClis(BUILTIN_CLIS, USER_CLIS); const program = new Command(); -program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version('0.1.0'); +program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION); // โ”€โ”€ Built-in commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -49,10 +54,7 @@ program.command('validate').description('Validate CLI definitions').argument('[t program.command('verify').description('Validate + smoke test').argument('[target]').option('--smoke', 'Run smoke tests', false) .action(async (target, opts) => { const { verifyClis, renderVerifyReport } = await import('./verify.js'); const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); console.log(renderVerifyReport(r)); process.exitCode = r.ok ? 0 : 1; }); -program.command('explore').description('Explore a website').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3') - .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: PlaywrightMCP, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait) }))); }); - -program.command('probe').description('Probe a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3') +program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3') .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: PlaywrightMCP, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait) }))); }); program.command('synthesize').description('Synthesize CLIs from explore').argument('<target>').option('--top <n>', '', '3') diff --git a/src/pipeline.ts b/src/pipeline.ts index 928652c..ebc46f7 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,532 +1,8 @@ /** - * YAML pipeline executor. - * Steps: fetch, navigate, evaluate, map, filter, sort, limit, select, snapshot, click, type, wait, press, intercept. + * YAML pipeline executor โ€” re-exports from modular pipeline system. + * + * This file exists for backward compatibility. All logic has been + * refactored into src/pipeline/ with modular step handlers. */ -import chalk from 'chalk'; - -export interface PipelineContext { - args?: Record<string, any>; - debug?: boolean; -} - -export async function executePipeline( - page: any, - pipeline: any[], - ctx: PipelineContext = {}, -): Promise<any> { - const args = ctx.args ?? {}; - const debug = ctx.debug ?? false; - let data: any = null; - const total = pipeline.length; - - for (let i = 0; i < pipeline.length; i++) { - const step = pipeline[i]; - if (!step || typeof step !== 'object') continue; - for (const [op, params] of Object.entries(step)) { - if (debug) debugStepStart(i + 1, total, op, params); - data = await executeStep(page, op, params, data, args); - // Detect error objects returned by steps (e.g. tap store not found) - if (data && typeof data === 'object' && !Array.isArray(data) && data.error) { - process.stderr.write(` ${chalk.yellow('โš ')} ${chalk.yellow(op)}: ${data.error}\n`); - if (data.hint) process.stderr.write(` ${chalk.dim('๐Ÿ’ก')} ${chalk.dim(data.hint)}\n`); - } - if (debug) debugStepResult(op, data); - } - } - return data; -} - -function normalizeEvaluateSource(source: string): string { - const stripped = source.trim(); - if (!stripped) return '() => undefined'; - if (stripped.startsWith('(') && stripped.endsWith(')()')) return `() => (${stripped})`; - if (/^(async\s+)?\([^)]*\)\s*=>/.test(stripped)) return stripped; - if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(stripped)) return stripped; - if (stripped.startsWith('function ') || stripped.startsWith('async function ')) return stripped; - return `() => (${stripped})`; -} - -function debugStepStart(stepNum: number, total: number, op: string, params: any): void { - let preview = ''; - if (typeof params === 'string') { - preview = params.length <= 80 ? ` โ†’ ${params}` : ` โ†’ ${params.slice(0, 77)}...`; - } else if (params && typeof params === 'object' && !Array.isArray(params)) { - preview = ` (${Object.keys(params).join(', ')})`; - } - process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`); -} - -function debugStepResult(op: string, data: any): void { - if (data === null || data === undefined) { - process.stderr.write(` ${chalk.dim('โ†’ (no data)')}\n`); - } else if (Array.isArray(data)) { - process.stderr.write(` ${chalk.dim(`โ†’ ${data.length} items`)}\n`); - } else if (typeof data === 'object') { - const keys = Object.keys(data).slice(0, 5); - process.stderr.write(` ${chalk.dim(`โ†’ dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`)}\n`); - } else if (typeof data === 'string') { - const p = data.slice(0, 60).replace(/\n/g, '\\n'); - process.stderr.write(` ${chalk.dim(`โ†’ "${p}${data.length > 60 ? '...' : ''}"`)}\n`); - } else { - process.stderr.write(` ${chalk.dim(`โ†’ ${typeof data}`)}\n`); - } -} - -// Single URL fetch helper -async function fetchSingle( - page: any, url: string, method: string, - queryParams: Record<string, any>, headers: Record<string, any>, - args: Record<string, any>, data: any, -): Promise<any> { - const renderedParams: Record<string, string> = {}; - for (const [k, v] of Object.entries(queryParams)) renderedParams[k] = String(render(v, { args, data })); - const renderedHeaders: Record<string, string> = {}; - for (const [k, v] of Object.entries(headers)) renderedHeaders[k] = String(render(v, { args, data })); - - let finalUrl = url; - if (Object.keys(renderedParams).length > 0) { - const qs = new URLSearchParams(renderedParams).toString(); - finalUrl = `${finalUrl}${finalUrl.includes('?') ? '&' : '?'}${qs}`; - } - - if (page === null) { - const resp = await fetch(finalUrl, { method: method.toUpperCase(), headers: renderedHeaders }); - return resp.json(); - } - - const headersJs = JSON.stringify(renderedHeaders); - const escapedUrl = finalUrl.replace(/"/g, '\\"'); - return page.evaluate(` - async () => { - const resp = await fetch("${escapedUrl}", { - method: "${method}", headers: ${headersJs}, credentials: "include" - }); - return await resp.json(); - } - `); -} - -async function executeStep(page: any, op: string, params: any, data: any, args: Record<string, any>): Promise<any> { - switch (op) { - case 'navigate': { - const url = render(params, { args, data }); - await page.goto(String(url)); - return data; - } - case 'fetch': { - const urlOrObj = typeof params === 'string' ? params : (params?.url ?? ''); - const method = params?.method ?? 'GET'; - const queryParams: Record<string, any> = params?.params ?? {}; - const headers: Record<string, any> = params?.headers ?? {}; - const urlTemplate = String(urlOrObj); - - // Per-item fetch when data is array and URL references item - if (Array.isArray(data) && urlTemplate.includes('item')) { - const results: any[] = []; - for (let i = 0; i < data.length; i++) { - const itemUrl = String(render(urlTemplate, { args, data, item: data[i], index: i })); - results.push(await fetchSingle(page, itemUrl, method, queryParams, headers, args, data)); - } - return results; - } - const url = render(urlOrObj, { args, data }); - return fetchSingle(page, String(url), method, queryParams, headers, args, data); - } - case 'select': { - const pathStr = String(render(params, { args, data })); - if (data && typeof data === 'object') { - let current = data; - for (const part of pathStr.split('.')) { - if (current && typeof current === 'object' && !Array.isArray(current)) current = (current as any)[part]; - else if (Array.isArray(current) && /^\d+$/.test(part)) current = current[parseInt(part, 10)]; - else return null; - } - return current; - } - return data; - } - case 'evaluate': { - const js = String(render(params, { args, data })); - let result = await page.evaluate(normalizeEvaluateSource(js)); - // MCP may return JSON as a string โ€” auto-parse it - if (typeof result === 'string') { - const trimmed = result.trim(); - if ((trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) { - try { result = JSON.parse(trimmed); } catch {} - } - } - return result; - } - case 'snapshot': { - const opts = (typeof params === 'object' && params) ? params : {}; - return page.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false }); - } - case 'click': { - await page.click(String(render(params, { args, data })).replace(/^@/, '')); - return data; - } - case 'type': { - if (typeof params === 'object' && params) { - const ref = String(render(params.ref ?? '', { args, data })).replace(/^@/, ''); - const text = String(render(params.text ?? '', { args, data })); - await page.typeText(ref, text); - if (params.submit) await page.pressKey('Enter'); - } - return data; - } - case 'wait': { - if (typeof params === 'number') await page.wait(params); - else if (typeof params === 'object' && params) { - if ('text' in params) { - const timeout = params.timeout ?? 10; - const start = Date.now(); - while ((Date.now() - start) / 1000 < timeout) { - const snap = await page.snapshot({ raw: true }); - if (typeof snap === 'string' && snap.includes(params.text)) break; - await page.wait(0.5); - } - } else if ('time' in params) await page.wait(Number(params.time)); - } else if (typeof params === 'string') await page.wait(Number(render(params, { args, data }))); - return data; - } - case 'press': { - await page.pressKey(String(render(params, { args, data }))); - return data; - } - case 'map': { - if (!data || typeof data !== 'object') return data; - let items: any[] = Array.isArray(data) ? data : [data]; - if (!Array.isArray(data) && typeof data === 'object' && 'data' in data) items = data.data; - const result: any[] = []; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const row: Record<string, any> = {}; - for (const [key, template] of Object.entries(params)) row[key] = render(template, { args, data, item, index: i }); - result.push(row); - } - return result; - } - case 'filter': { - if (!Array.isArray(data)) return data; - return data.filter((item, i) => evalExpr(String(params), { args, item, index: i })); - } - case 'sort': { - if (!Array.isArray(data)) return data; - const key = typeof params === 'object' ? (params.by ?? '') : String(params); - const reverse = typeof params === 'object' ? params.order === 'desc' : false; - return [...data].sort((a, b) => { const va = a[key] ?? ''; const vb = b[key] ?? ''; const cmp = va < vb ? -1 : va > vb ? 1 : 0; return reverse ? -cmp : cmp; }); - } - case 'limit': { - if (!Array.isArray(data)) return data; - return data.slice(0, Number(render(params, { args, data }))); - } - case 'intercept': { - // Declarative XHR interception step - // Usage: - // intercept: - // trigger: "navigate:https://..." | "evaluate:store.note.fetch()" | "click:ref" - // capture: "api/pattern" # URL substring to match - // timeout: 5 # seconds to wait for matching request - // select: "data.items" # optional: extract sub-path from response - const cfg = typeof params === 'object' ? params : {}; - const trigger = cfg.trigger ?? ''; - const capturePattern = cfg.capture ?? ''; - const timeout = cfg.timeout ?? 8; - const selectPath = cfg.select ?? null; - - if (!capturePattern) return data; - - // Step 1: Execute the trigger action - if (trigger.startsWith('navigate:')) { - const url = render(trigger.slice('navigate:'.length), { args, data }); - await page.goto(String(url)); - } else if (trigger.startsWith('evaluate:')) { - const js = trigger.slice('evaluate:'.length); - await page.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string)); - } else if (trigger.startsWith('click:')) { - const ref = render(trigger.slice('click:'.length), { args, data }); - await page.click(String(ref).replace(/^@/, '')); - } else if (trigger === 'scroll') { - await page.scroll('down'); - } - - // Step 2: Wait a bit for network requests to fire - await page.wait(Math.min(timeout, 3)); - - // Step 3: Get network requests and find matching ones - const rawNetwork = await page.networkRequests(false); - const matchingResponses: any[] = []; - - if (typeof rawNetwork === 'string') { - // Parse the network output to find matching URLs - const lines = rawNetwork.split('\n'); - for (const line of lines) { - const match = line.match(/\[?(GET|POST)\]?\s+(\S+)\s*(?:=>|โ†’)\s*\[?(\d+)\]?/i); - if (match) { - const [, method, url, status] = match; - if (url.includes(capturePattern) && status === '200') { - // Re-fetch the matching URL to get the response body - try { - const body = await page.evaluate(` - async () => { - try { - const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' }); - if (!resp.ok) return null; - return await resp.json(); - } catch { return null; } - } - `); - if (body) matchingResponses.push(body); - } catch {} - } - } - } - } - - // Step 4: Select from response if specified - let result = matchingResponses.length === 1 ? matchingResponses[0] : - matchingResponses.length > 1 ? matchingResponses : data; - - if (selectPath && result) { - let current = result; - for (const part of String(selectPath).split('.')) { - if (current && typeof current === 'object' && !Array.isArray(current)) { - current = current[part]; - } else break; - } - result = current ?? result; - } - - return result; - } - case 'tap': { - // โ”€โ”€ Declarative Store Action Bridge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Usage: - // tap: - // store: feed # Pinia/Vuex store name - // action: fetchFeeds # Store action to call - // args: [] # Optional args to pass to action - // capture: homefeed # URL pattern to capture response - // timeout: 5 # Seconds to wait for network (default: 5) - // select: data.items # Optional: extract sub-path from response - // framework: pinia # Optional: pinia | vuex (auto-detected if omitted) - // - // Generates a self-contained IIFE that: - // 1. Injects fetch + XHR dual interception proxy - // 2. Finds the Pinia/Vuex store and calls the action - // 3. Captures the response matching the URL pattern - // 4. Auto-cleans up interception in finally block - // 5. Returns the captured data (optionally sub-selected) - - const cfg = typeof params === 'object' ? params : {}; - const storeName = String(render(cfg.store ?? '', { args, data })); - const actionName = String(render(cfg.action ?? '', { args, data })); - const capturePattern = String(render(cfg.capture ?? '', { args, data })); - const timeout = cfg.timeout ?? 5; - const selectPath = cfg.select ? String(render(cfg.select, { args, data })) : null; - const framework = cfg.framework ?? null; // auto-detect if null - const actionArgs = cfg.args ?? []; - - if (!storeName || !actionName) throw new Error('tap: store and action are required'); - - // Build select chain for the captured response - const selectChain = selectPath - ? selectPath.split('.').map((p: string) => `?.[${JSON.stringify(p)}]`).join('') - : ''; - - // Serialize action arguments - const actionArgsRendered = actionArgs.map((a: any) => { - const rendered = render(a, { args, data }); - return JSON.stringify(rendered); - }); - const actionCall = actionArgsRendered.length - ? `store[${JSON.stringify(actionName)}](${actionArgsRendered.join(', ')})` - : `store[${JSON.stringify(actionName)}]()`; - - const js = ` - async () => { - // โ”€โ”€ 1. Setup capture proxy (fetch + XHR dual interception) โ”€โ”€ - let captured = null; - const capturePattern = ${JSON.stringify(capturePattern)}; - - // Intercept fetch API - const origFetch = window.fetch; - window.fetch = async function(...fetchArgs) { - const resp = await origFetch.apply(this, fetchArgs); - try { - const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0] - : fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]); - if (capturePattern && url.includes(capturePattern) && !captured) { - try { captured = await resp.clone().json(); } catch {} - } - } catch {} - return resp; - }; - - // Intercept XMLHttpRequest - const origXhrOpen = XMLHttpRequest.prototype.open; - const origXhrSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function(method, url) { - this.__tapUrl = String(url); - return origXhrOpen.apply(this, arguments); - }; - XMLHttpRequest.prototype.send = function(body) { - if (capturePattern && this.__tapUrl?.includes(capturePattern)) { - const xhr = this; - const origHandler = xhr.onreadystatechange; - xhr.onreadystatechange = function() { - if (xhr.readyState === 4 && !captured) { - try { captured = JSON.parse(xhr.responseText); } catch {} - } - if (origHandler) origHandler.apply(this, arguments); - }; - // Also handle onload - const origOnload = xhr.onload; - xhr.onload = function() { - if (!captured) { try { captured = JSON.parse(xhr.responseText); } catch {} } - if (origOnload) origOnload.apply(this, arguments); - }; - } - return origXhrSend.apply(this, arguments); - }; - - try { - // โ”€โ”€ 2. Find store โ”€โ”€ - let store = null; - const storeName = ${JSON.stringify(storeName)}; - const fw = ${JSON.stringify(framework)}; - - // Auto-detect framework if not specified - const app = document.querySelector('#app'); - if (!fw || fw === 'pinia') { - // Try Pinia (Vue 3) - try { - const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia; - if (pinia?._s) store = pinia._s.get(storeName); - } catch {} - } - if (!store && (!fw || fw === 'vuex')) { - // Try Vuex (Vue 2/3) - try { - const vuexStore = app?.__vue_app__?.config?.globalProperties?.$store - ?? app?.__vue__?.$store; - if (vuexStore) { - // Vuex doesn't have named stores like Pinia, dispatch action - store = { [${JSON.stringify(actionName)}]: (...a) => vuexStore.dispatch(storeName + '/' + ${JSON.stringify(actionName)}, ...a) }; - } - } catch {} - } - - if (!store) return { error: 'Store not found: ' + storeName, hint: 'Page may not be fully loaded or store name may be incorrect' }; - if (typeof store[${JSON.stringify(actionName)}] !== 'function') { - return { error: 'Action not found: ' + ${JSON.stringify(actionName)} + ' on store ' + storeName, - hint: 'Available: ' + Object.keys(store).filter(k => typeof store[k] === 'function' && !k.startsWith('$') && !k.startsWith('_')).join(', ') }; - } - - // โ”€โ”€ 3. Call store action โ”€โ”€ - await ${actionCall}; - - // โ”€โ”€ 4. Wait for network response โ”€โ”€ - const deadline = Date.now() + ${timeout} * 1000; - while (!captured && Date.now() < deadline) { - await new Promise(r => setTimeout(r, 200)); - } - } finally { - // โ”€โ”€ 5. Always restore originals โ”€โ”€ - window.fetch = origFetch; - XMLHttpRequest.prototype.open = origXhrOpen; - XMLHttpRequest.prototype.send = origXhrSend; - } - - if (!captured) return { error: 'No matching response captured for pattern: ' + capturePattern }; - return captured${selectChain} ?? captured; - } - `; - - return page.evaluate(js); - } - default: return data; - } -} - -// Template engine: ${{ ... }} -interface RenderContext { args?: Record<string, any>; data?: any; item?: any; index?: number; } - -function render(template: any, ctx: RenderContext): any { - if (typeof template !== 'string') return template; - const fullMatch = template.match(/^\$\{\{\s*(.*?)\s*\}\}$/); - if (fullMatch) return evalExpr(fullMatch[1].trim(), ctx); - return template.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_m, expr) => String(evalExpr(expr.trim(), ctx))); -} - -function evalExpr(expr: string, ctx: RenderContext): any { - const args = ctx.args ?? {}; - const item = ctx.item ?? {}; - const data = ctx.data; - const index = ctx.index ?? 0; - - // Default filter: args.limit | default(20) - if (expr.includes('|') && expr.includes('default(')) { - const [mainExpr, rest] = expr.split('|', 2); - const defaultMatch = rest.match(/default\((.+?)\)/); - const defaultVal = defaultMatch ? defaultMatch[1] : null; - const result = resolvePath(mainExpr.trim(), { args, item, data, index }); - if (result === null || result === undefined) { - if (defaultVal !== null) { - const intVal = parseInt(defaultVal!, 10); - if (!isNaN(intVal) && String(intVal) === defaultVal!.trim()) return intVal; - return defaultVal!.replace(/^['"]|['"]$/g, ''); - } - } - return result; - } - - // Arithmetic: index + 1 - const arithMatch = expr.match(/^([\w][\w.]*)\s*([+\-*/])\s*(\d+)$/); - if (arithMatch) { - const [, varName, op, numStr] = arithMatch; - const val = resolvePath(varName, { args, item, data, index }); - if (val !== null && val !== undefined) { - const numVal = Number(val); const num = Number(numStr); - if (!isNaN(numVal)) { - switch (op) { - case '+': return numVal + num; case '-': return numVal - num; - case '*': return numVal * num; case '/': return num !== 0 ? numVal / num : 0; - } - } - } - } - - // JS-like fallback expression: item.tweetCount || 'N/A' - const orMatch = expr.match(/^(.+?)\s*\|\|\s*(.+)$/); - if (orMatch) { - const left = evalExpr(orMatch[1].trim(), ctx); - if (left) return left; - const right = orMatch[2].trim(); - return right.replace(/^['"]|['"]$/g, ''); - } - - return resolvePath(expr, { args, item, data, index }); -} - -function resolvePath(pathStr: string, ctx: RenderContext): any { - const args = ctx.args ?? {}; - const item = ctx.item ?? {}; - const data = ctx.data; - const index = ctx.index ?? 0; - const parts = pathStr.split('.'); - const rootName = parts[0]; - let obj: any; let rest: string[]; - if (rootName === 'args') { obj = args; rest = parts.slice(1); } - else if (rootName === 'item') { obj = item; rest = parts.slice(1); } - else if (rootName === 'data') { obj = data; rest = parts.slice(1); } - else if (rootName === 'index') return index; - else { obj = item; rest = parts; } - for (const part of rest) { - if (obj && typeof obj === 'object' && !Array.isArray(obj)) obj = obj[part]; - else if (Array.isArray(obj) && /^\d+$/.test(part)) obj = obj[parseInt(part, 10)]; - else return null; - } - return obj; -} +export { executePipeline, type PipelineContext } from './pipeline/index.js'; diff --git a/src/pipeline/executor.ts b/src/pipeline/executor.ts new file mode 100644 index 0000000..c478fda --- /dev/null +++ b/src/pipeline/executor.ts @@ -0,0 +1,98 @@ +/** + * Pipeline executor: runs YAML pipeline steps sequentially. + */ + +import chalk from 'chalk'; +import type { IPage } from '../types.js'; +import { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate } from './steps/browser.js'; +import { stepFetch } from './steps/fetch.js'; +import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js'; +import { stepIntercept } from './steps/intercept.js'; +import { stepTap } from './steps/tap.js'; + +export interface PipelineContext { + args?: Record<string, any>; + debug?: boolean; +} + +/** Step handler signature */ +type StepHandler = (page: IPage | null, params: any, data: any, args: Record<string, any>) => Promise<any>; + +/** Registry of all available step handlers */ +const STEP_HANDLERS: Record<string, StepHandler> = { + navigate: stepNavigate as StepHandler, + fetch: stepFetch, + select: stepSelect as StepHandler, + evaluate: stepEvaluate as StepHandler, + snapshot: stepSnapshot as StepHandler, + click: stepClick as StepHandler, + type: stepType as StepHandler, + wait: stepWait as StepHandler, + press: stepPress as StepHandler, + map: stepMap as StepHandler, + filter: stepFilter as StepHandler, + sort: stepSort as StepHandler, + limit: stepLimit as StepHandler, + intercept: stepIntercept as StepHandler, + tap: stepTap as StepHandler, +}; + +export async function executePipeline( + page: IPage | null, + pipeline: any[], + ctx: PipelineContext = {}, +): Promise<any> { + const args = ctx.args ?? {}; + const debug = ctx.debug ?? false; + let data: any = null; + const total = pipeline.length; + + for (let i = 0; i < pipeline.length; i++) { + const step = pipeline[i]; + if (!step || typeof step !== 'object') continue; + for (const [op, params] of Object.entries(step)) { + if (debug) debugStepStart(i + 1, total, op, params); + + const handler = STEP_HANDLERS[op]; + if (handler) { + data = await handler(page, params, data, args); + } else { + if (debug) process.stderr.write(` ${chalk.yellow('โš ')} Unknown step: ${op}\n`); + } + + // Detect error objects returned by steps (e.g. tap store not found) + if (data && typeof data === 'object' && !Array.isArray(data) && data.error) { + process.stderr.write(` ${chalk.yellow('โš ')} ${chalk.yellow(op)}: ${data.error}\n`); + if (data.hint) process.stderr.write(` ${chalk.dim('๐Ÿ’ก')} ${chalk.dim(data.hint)}\n`); + } + if (debug) debugStepResult(op, data); + } + } + return data; +} + +function debugStepStart(stepNum: number, total: number, op: string, params: any): void { + let preview = ''; + if (typeof params === 'string') { + preview = params.length <= 80 ? ` โ†’ ${params}` : ` โ†’ ${params.slice(0, 77)}...`; + } else if (params && typeof params === 'object' && !Array.isArray(params)) { + preview = ` (${Object.keys(params).join(', ')})`; + } + process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`); +} + +function debugStepResult(op: string, data: any): void { + if (data === null || data === undefined) { + process.stderr.write(` ${chalk.dim('โ†’ (no data)')}\n`); + } else if (Array.isArray(data)) { + process.stderr.write(` ${chalk.dim(`โ†’ ${data.length} items`)}\n`); + } else if (typeof data === 'object') { + const keys = Object.keys(data).slice(0, 5); + process.stderr.write(` ${chalk.dim(`โ†’ dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`)}\n`); + } else if (typeof data === 'string') { + const p = data.slice(0, 60).replace(/\n/g, '\\n'); + process.stderr.write(` ${chalk.dim(`โ†’ "${p}${data.length > 60 ? '...' : ''}"`)}\n`); + } else { + process.stderr.write(` ${chalk.dim(`โ†’ ${typeof data}`)}\n`); + } +} diff --git a/src/pipeline/index.ts b/src/pipeline/index.ts new file mode 100644 index 0000000..c9e1cd6 --- /dev/null +++ b/src/pipeline/index.ts @@ -0,0 +1,6 @@ +/** + * Pipeline module: re-exports for backward compatibility. + */ + +export { executePipeline, type PipelineContext } from './executor.js'; +export { render, evalExpr, resolvePath, normalizeEvaluateSource, type RenderContext } from './template.js'; diff --git a/src/pipeline/steps/browser.ts b/src/pipeline/steps/browser.ts new file mode 100644 index 0000000..6e3fce7 --- /dev/null +++ b/src/pipeline/steps/browser.ts @@ -0,0 +1,67 @@ +/** + * Pipeline step: navigate, click, type, wait, press, snapshot. + * Browser interaction primitives. + */ + +import type { IPage } from '../../types.js'; +import { render, normalizeEvaluateSource } from '../template.js'; + +export async function stepNavigate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + const url = render(params, { args, data }); + await page.goto(String(url)); + return data; +} + +export async function stepClick(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + await page.click(String(render(params, { args, data })).replace(/^@/, '')); + return data; +} + +export async function stepType(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + if (typeof params === 'object' && params) { + const ref = String(render(params.ref ?? '', { args, data })).replace(/^@/, ''); + const text = String(render(params.text ?? '', { args, data })); + await page.typeText(ref, text); + if (params.submit) await page.pressKey('Enter'); + } + return data; +} + +export async function stepWait(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + if (typeof params === 'number') await page.wait(params); + else if (typeof params === 'object' && params) { + if ('text' in params) { + const timeout = params.timeout ?? 10; + const start = Date.now(); + while ((Date.now() - start) / 1000 < timeout) { + const snap = await page.snapshot({ raw: true }); + if (typeof snap === 'string' && snap.includes(params.text)) break; + await page.wait(0.5); + } + } else if ('time' in params) await page.wait(Number(params.time)); + } else if (typeof params === 'string') await page.wait(Number(render(params, { args, data }))); + return data; +} + +export async function stepPress(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + await page.pressKey(String(render(params, { args, data }))); + return data; +} + +export async function stepSnapshot(page: IPage, params: any, _data: any, _args: Record<string, any>): Promise<any> { + const opts = (typeof params === 'object' && params) ? params : {}; + return page.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false }); +} + +export async function stepEvaluate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + const js = String(render(params, { args, data })); + let result = await page.evaluate(normalizeEvaluateSource(js)); + // MCP may return JSON as a string โ€” auto-parse it + if (typeof result === 'string') { + const trimmed = result.trim(); + if ((trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) { + try { result = JSON.parse(trimmed); } catch {} + } + } + return result; +} diff --git a/src/pipeline/steps/fetch.ts b/src/pipeline/steps/fetch.ts new file mode 100644 index 0000000..0d8f36e --- /dev/null +++ b/src/pipeline/steps/fetch.ts @@ -0,0 +1,60 @@ +/** + * Pipeline step: fetch โ€” HTTP API requests. + */ + +import type { IPage } from '../../types.js'; +import { render } from '../template.js'; + +/** Single URL fetch helper */ +async function fetchSingle( + page: IPage | null, url: string, method: string, + queryParams: Record<string, any>, headers: Record<string, any>, + args: Record<string, any>, data: any, +): Promise<any> { + const renderedParams: Record<string, string> = {}; + for (const [k, v] of Object.entries(queryParams)) renderedParams[k] = String(render(v, { args, data })); + const renderedHeaders: Record<string, string> = {}; + for (const [k, v] of Object.entries(headers)) renderedHeaders[k] = String(render(v, { args, data })); + + let finalUrl = url; + if (Object.keys(renderedParams).length > 0) { + const qs = new URLSearchParams(renderedParams).toString(); + finalUrl = `${finalUrl}${finalUrl.includes('?') ? '&' : '?'}${qs}`; + } + + if (page === null) { + const resp = await fetch(finalUrl, { method: method.toUpperCase(), headers: renderedHeaders }); + return resp.json(); + } + + const headersJs = JSON.stringify(renderedHeaders); + const escapedUrl = finalUrl.replace(/"/g, '\\"'); + return page.evaluate(` + async () => { + const resp = await fetch("${escapedUrl}", { + method: "${method}", headers: ${headersJs}, credentials: "include" + }); + return await resp.json(); + } + `); +} + +export async function stepFetch(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> { + const urlOrObj = typeof params === 'string' ? params : (params?.url ?? ''); + const method = params?.method ?? 'GET'; + const queryParams: Record<string, any> = params?.params ?? {}; + const headers: Record<string, any> = params?.headers ?? {}; + const urlTemplate = String(urlOrObj); + + // Per-item fetch when data is array and URL references item + if (Array.isArray(data) && urlTemplate.includes('item')) { + const results: any[] = []; + for (let i = 0; i < data.length; i++) { + const itemUrl = String(render(urlTemplate, { args, data, item: data[i], index: i })); + results.push(await fetchSingle(page, itemUrl, method, queryParams, headers, args, data)); + } + return results; + } + const url = render(urlOrObj, { args, data }); + return fetchSingle(page, String(url), method, queryParams, headers, args, data); +} diff --git a/src/pipeline/steps/intercept.ts b/src/pipeline/steps/intercept.ts new file mode 100644 index 0000000..1962f9f --- /dev/null +++ b/src/pipeline/steps/intercept.ts @@ -0,0 +1,78 @@ +/** + * Pipeline step: intercept โ€” declarative XHR interception. + */ + +import type { IPage } from '../../types.js'; +import { render } from '../template.js'; + +export async function stepIntercept(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + const cfg = typeof params === 'object' ? params : {}; + const trigger = cfg.trigger ?? ''; + const capturePattern = cfg.capture ?? ''; + const timeout = cfg.timeout ?? 8; + const selectPath = cfg.select ?? null; + + if (!capturePattern) return data; + + // Step 1: Execute the trigger action + if (trigger.startsWith('navigate:')) { + const url = render(trigger.slice('navigate:'.length), { args, data }); + await page.goto(String(url)); + } else if (trigger.startsWith('evaluate:')) { + const js = trigger.slice('evaluate:'.length); + const { normalizeEvaluateSource } = await import('../template.js'); + await page.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string)); + } else if (trigger.startsWith('click:')) { + const ref = render(trigger.slice('click:'.length), { args, data }); + await page.click(String(ref).replace(/^@/, '')); + } else if (trigger === 'scroll') { + await page.scroll('down'); + } + + // Step 2: Wait a bit for network requests to fire + await page.wait(Math.min(timeout, 3)); + + // Step 3: Get network requests and find matching ones + const rawNetwork = await page.networkRequests(false); + const matchingResponses: any[] = []; + + if (typeof rawNetwork === 'string') { + const lines = rawNetwork.split('\n'); + for (const line of lines) { + const match = line.match(/\[?(GET|POST)\]?\s+(\S+)\s*(?:=>|โ†’)\s*\[?(\d+)\]?/i); + if (match) { + const [, , url, status] = match; + if (url.includes(capturePattern) && status === '200') { + try { + const body = await page.evaluate(` + async () => { + try { + const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' }); + if (!resp.ok) return null; + return await resp.json(); + } catch { return null; } + } + `); + if (body) matchingResponses.push(body); + } catch {} + } + } + } + } + + // Step 4: Select from response if specified + let result = matchingResponses.length === 1 ? matchingResponses[0] : + matchingResponses.length > 1 ? matchingResponses : data; + + if (selectPath && result) { + let current = result; + for (const part of String(selectPath).split('.')) { + if (current && typeof current === 'object' && !Array.isArray(current)) { + current = current[part]; + } else break; + } + result = current ?? result; + } + + return result; +} diff --git a/src/pipeline/steps/tap.ts b/src/pipeline/steps/tap.ts new file mode 100644 index 0000000..6211c0b --- /dev/null +++ b/src/pipeline/steps/tap.ts @@ -0,0 +1,137 @@ +/** + * Pipeline step: tap โ€” declarative Store Action Bridge. + * + * Generates a self-contained IIFE that: + * 1. Injects fetch + XHR dual interception proxy + * 2. Finds the Pinia/Vuex store and calls the action + * 3. Captures the response matching the URL pattern + * 4. Auto-cleans up interception in finally block + * 5. Returns the captured data (optionally sub-selected) + */ + +import type { IPage } from '../../types.js'; +import { render } from '../template.js'; + +export async function stepTap(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> { + const cfg = typeof params === 'object' ? params : {}; + const storeName = String(render(cfg.store ?? '', { args, data })); + const actionName = String(render(cfg.action ?? '', { args, data })); + const capturePattern = String(render(cfg.capture ?? '', { args, data })); + const timeout = cfg.timeout ?? 5; + const selectPath = cfg.select ? String(render(cfg.select, { args, data })) : null; + const framework = cfg.framework ?? null; + const actionArgs = cfg.args ?? []; + + if (!storeName || !actionName) throw new Error('tap: store and action are required'); + + // Build select chain for the captured response + const selectChain = selectPath + ? selectPath.split('.').map((p: string) => `?.[${JSON.stringify(p)}]`).join('') + : ''; + + // Serialize action arguments + const actionArgsRendered = actionArgs.map((a: any) => { + const rendered = render(a, { args, data }); + return JSON.stringify(rendered); + }); + const actionCall = actionArgsRendered.length + ? `store[${JSON.stringify(actionName)}](${actionArgsRendered.join(', ')})` + : `store[${JSON.stringify(actionName)}]()`; + + const js = ` + async () => { + // โ”€โ”€ 1. Setup capture proxy (fetch + XHR dual interception) โ”€โ”€ + let captured = null; + const capturePattern = ${JSON.stringify(capturePattern)}; + + // Intercept fetch API + const origFetch = window.fetch; + window.fetch = async function(...fetchArgs) { + const resp = await origFetch.apply(this, fetchArgs); + try { + const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0] + : fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]); + if (capturePattern && url.includes(capturePattern) && !captured) { + try { captured = await resp.clone().json(); } catch {} + } + } catch {} + return resp; + }; + + // Intercept XMLHttpRequest + const origXhrOpen = XMLHttpRequest.prototype.open; + const origXhrSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__tapUrl = String(url); + return origXhrOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function(body) { + if (capturePattern && this.__tapUrl?.includes(capturePattern)) { + const xhr = this; + const origHandler = xhr.onreadystatechange; + xhr.onreadystatechange = function() { + if (xhr.readyState === 4 && !captured) { + try { captured = JSON.parse(xhr.responseText); } catch {} + } + if (origHandler) origHandler.apply(this, arguments); + }; + const origOnload = xhr.onload; + xhr.onload = function() { + if (!captured) { try { captured = JSON.parse(xhr.responseText); } catch {} } + if (origOnload) origOnload.apply(this, arguments); + }; + } + return origXhrSend.apply(this, arguments); + }; + + try { + // โ”€โ”€ 2. Find store โ”€โ”€ + let store = null; + const storeName = ${JSON.stringify(storeName)}; + const fw = ${JSON.stringify(framework)}; + + const app = document.querySelector('#app'); + if (!fw || fw === 'pinia') { + try { + const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia; + if (pinia?._s) store = pinia._s.get(storeName); + } catch {} + } + if (!store && (!fw || fw === 'vuex')) { + try { + const vuexStore = app?.__vue_app__?.config?.globalProperties?.$store + ?? app?.__vue__?.$store; + if (vuexStore) { + store = { [${JSON.stringify(actionName)}]: (...a) => vuexStore.dispatch(storeName + '/' + ${JSON.stringify(actionName)}, ...a) }; + } + } catch {} + } + + if (!store) return { error: 'Store not found: ' + storeName, hint: 'Page may not be fully loaded or store name may be incorrect' }; + if (typeof store[${JSON.stringify(actionName)}] !== 'function') { + return { error: 'Action not found: ' + ${JSON.stringify(actionName)} + ' on store ' + storeName, + hint: 'Available: ' + Object.keys(store).filter(k => typeof store[k] === 'function' && !k.startsWith('$') && !k.startsWith('_')).join(', ') }; + } + + // โ”€โ”€ 3. Call store action โ”€โ”€ + await ${actionCall}; + + // โ”€โ”€ 4. Wait for network response โ”€โ”€ + const deadline = Date.now() + ${timeout} * 1000; + while (!captured && Date.now() < deadline) { + await new Promise(r => setTimeout(r, 200)); + } + } finally { + // โ”€โ”€ 5. Always restore originals โ”€โ”€ + window.fetch = origFetch; + XMLHttpRequest.prototype.open = origXhrOpen; + XMLHttpRequest.prototype.send = origXhrSend; + } + + if (!captured) return { error: 'No matching response captured for pattern: ' + capturePattern }; + return captured${selectChain} ?? captured; + } + `; + + return page.evaluate(js); +} diff --git a/src/pipeline/steps/transform.ts b/src/pipeline/steps/transform.ts new file mode 100644 index 0000000..cc90900 --- /dev/null +++ b/src/pipeline/steps/transform.ts @@ -0,0 +1,50 @@ +/** + * Pipeline steps: data transforms โ€” select, map, filter, sort, limit. + */ + +import { render, evalExpr } from '../template.js'; + +export async function stepSelect(_page: any, params: any, data: any, args: Record<string, any>): Promise<any> { + const pathStr = String(render(params, { args, data })); + if (data && typeof data === 'object') { + let current = data; + for (const part of pathStr.split('.')) { + if (current && typeof current === 'object' && !Array.isArray(current)) current = (current as any)[part]; + else if (Array.isArray(current) && /^\d+$/.test(part)) current = current[parseInt(part, 10)]; + else return null; + } + return current; + } + return data; +} + +export async function stepMap(_page: any, params: any, data: any, args: Record<string, any>): Promise<any> { + if (!data || typeof data !== 'object') return data; + let items: any[] = Array.isArray(data) ? data : [data]; + if (!Array.isArray(data) && typeof data === 'object' && 'data' in data) items = data.data; + const result: any[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const row: Record<string, any> = {}; + for (const [key, template] of Object.entries(params)) row[key] = render(template, { args, data, item, index: i }); + result.push(row); + } + return result; +} + +export async function stepFilter(_page: any, params: any, data: any, args: Record<string, any>): Promise<any> { + if (!Array.isArray(data)) return data; + return data.filter((item, i) => evalExpr(String(params), { args, item, index: i })); +} + +export async function stepSort(_page: any, params: any, data: any, _args: Record<string, any>): Promise<any> { + if (!Array.isArray(data)) return data; + const key = typeof params === 'object' ? (params.by ?? '') : String(params); + const reverse = typeof params === 'object' ? params.order === 'desc' : false; + return [...data].sort((a, b) => { const va = a[key] ?? ''; const vb = b[key] ?? ''; const cmp = va < vb ? -1 : va > vb ? 1 : 0; return reverse ? -cmp : cmp; }); +} + +export async function stepLimit(_page: any, params: any, data: any, args: Record<string, any>): Promise<any> { + if (!Array.isArray(data)) return data; + return data.slice(0, Number(render(params, { args, data }))); +} diff --git a/src/pipeline/template.test.ts b/src/pipeline/template.test.ts new file mode 100644 index 0000000..0ce4b6a --- /dev/null +++ b/src/pipeline/template.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the pipeline template engine: render, evalExpr, resolvePath. + */ + +import { describe, it, expect } from 'vitest'; +import { render, evalExpr, resolvePath, normalizeEvaluateSource } from './template.js'; + +describe('resolvePath', () => { + it('resolves args path', () => { + expect(resolvePath('args.limit', { args: { limit: 20 } })).toBe(20); + }); + it('resolves nested args path', () => { + expect(resolvePath('args.query.keyword', { args: { query: { keyword: 'test' } } })).toBe('test'); + }); + it('resolves item path', () => { + expect(resolvePath('item.title', { item: { title: 'Hello' } })).toBe('Hello'); + }); + it('resolves implicit item path (no prefix)', () => { + expect(resolvePath('title', { item: { title: 'World' } })).toBe('World'); + }); + it('resolves index', () => { + expect(resolvePath('index', { index: 5 })).toBe(5); + }); + it('resolves data path', () => { + expect(resolvePath('data.items', { data: { items: [1, 2, 3] } })).toEqual([1, 2, 3]); + }); + it('returns null for missing path', () => { + expect(resolvePath('args.missing', { args: {} })).toBeNull(); + }); + it('resolves array index', () => { + expect(resolvePath('data.0', { data: ['a', 'b'] })).toBe('a'); + }); +}); + +describe('evalExpr', () => { + it('evaluates default filter', () => { + expect(evalExpr('args.limit | default(20)', { args: {} })).toBe(20); + }); + it('uses actual value over default', () => { + expect(evalExpr('args.limit | default(20)', { args: { limit: 10 } })).toBe(10); + }); + it('evaluates string default', () => { + expect(evalExpr("args.name | default('unknown')", { args: {} })).toBe('unknown'); + }); + it('evaluates arithmetic: index + 1', () => { + expect(evalExpr('index + 1', { index: 0 })).toBe(1); + }); + it('evaluates arithmetic: index * 2', () => { + expect(evalExpr('index * 2', { index: 5 })).toBe(10); + }); + it('evaluates || fallback', () => { + expect(evalExpr("item.name || 'N/A'", { item: {} })).toBe('N/A'); + }); + it('evaluates || with truthy left', () => { + expect(evalExpr("item.name || 'N/A'", { item: { name: 'Alice' } })).toBe('Alice'); + }); + it('resolves simple path', () => { + expect(evalExpr('item.title', { item: { title: 'Test' } })).toBe('Test'); + }); +}); + +describe('render', () => { + it('renders full expression', () => { + expect(render('${{ args.limit }}', { args: { limit: 30 } })).toBe(30); + }); + it('renders inline expression in string', () => { + expect(render('Hello ${{ item.name }}!', { item: { name: 'World' } })).toBe('Hello World!'); + }); + it('renders multiple inline expressions', () => { + expect(render('${{ item.a }}-${{ item.b }}', { item: { a: 1, b: 2 } })).toBe('1-2'); + }); + it('returns non-string values as-is', () => { + expect(render(42, {})).toBe(42); + expect(render(null, {})).toBeNull(); + expect(render(undefined, {})).toBeUndefined(); + }); + it('returns full expression result as native type', () => { + expect(render('${{ args.list }}', { args: { list: [1, 2, 3] } })).toEqual([1, 2, 3]); + }); + it('renders URL template', () => { + expect(render('https://api.example.com/search?q=${{ args.keyword }}', { args: { keyword: 'test' } })).toBe('https://api.example.com/search?q=test'); + }); +}); + +describe('normalizeEvaluateSource', () => { + it('wraps bare expression', () => { + expect(normalizeEvaluateSource('document.title')).toBe('() => (document.title)'); + }); + it('passes through arrow function', () => { + expect(normalizeEvaluateSource('() => 42')).toBe('() => 42'); + }); + it('passes through async arrow function', () => { + const src = 'async () => { return 1; }'; + expect(normalizeEvaluateSource(src)).toBe(src); + }); + it('passes through named function', () => { + const src = 'function foo() { return 1; }'; + expect(normalizeEvaluateSource(src)).toBe(src); + }); + it('wraps IIFE pattern', () => { + const src = '(async () => { return 1; })()'; + expect(normalizeEvaluateSource(src)).toBe(`() => (${src})`); + }); + it('handles empty string', () => { + expect(normalizeEvaluateSource('')).toBe('() => undefined'); + }); +}); diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts new file mode 100644 index 0000000..9d21395 --- /dev/null +++ b/src/pipeline/template.ts @@ -0,0 +1,101 @@ +/** + * Pipeline template engine: ${{ ... }} expression rendering. + */ + +export interface RenderContext { + args?: Record<string, any>; + data?: any; + item?: any; + index?: number; +} + +export function render(template: any, ctx: RenderContext): any { + if (typeof template !== 'string') return template; + const fullMatch = template.match(/^\$\{\{\s*(.*?)\s*\}\}$/); + if (fullMatch) return evalExpr(fullMatch[1].trim(), ctx); + return template.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_m, expr) => String(evalExpr(expr.trim(), ctx))); +} + +export function evalExpr(expr: string, ctx: RenderContext): any { + const args = ctx.args ?? {}; + const item = ctx.item ?? {}; + const data = ctx.data; + const index = ctx.index ?? 0; + + // Default filter: args.limit | default(20) + if (expr.includes('|') && expr.includes('default(')) { + const [mainExpr, rest] = expr.split('|', 2); + const defaultMatch = rest.match(/default\((.+?)\)/); + const defaultVal = defaultMatch ? defaultMatch[1] : null; + const result = resolvePath(mainExpr.trim(), { args, item, data, index }); + if (result === null || result === undefined) { + if (defaultVal !== null) { + const intVal = parseInt(defaultVal!, 10); + if (!isNaN(intVal) && String(intVal) === defaultVal!.trim()) return intVal; + return defaultVal!.replace(/^['"]|['"]$/g, ''); + } + } + return result; + } + + // Arithmetic: index + 1 + const arithMatch = expr.match(/^([\w][\w.]*)\s*([+\-*/])\s*(\d+)$/); + if (arithMatch) { + const [, varName, op, numStr] = arithMatch; + const val = resolvePath(varName, { args, item, data, index }); + if (val !== null && val !== undefined) { + const numVal = Number(val); const num = Number(numStr); + if (!isNaN(numVal)) { + switch (op) { + case '+': return numVal + num; case '-': return numVal - num; + case '*': return numVal * num; case '/': return num !== 0 ? numVal / num : 0; + } + } + } + } + + // JS-like fallback expression: item.tweetCount || 'N/A' + const orMatch = expr.match(/^(.+?)\s*\|\|\s*(.+)$/); + if (orMatch) { + const left = evalExpr(orMatch[1].trim(), ctx); + if (left) return left; + const right = orMatch[2].trim(); + return right.replace(/^['"]|['"]$/g, ''); + } + + return resolvePath(expr, { args, item, data, index }); +} + +export function resolvePath(pathStr: string, ctx: RenderContext): any { + const args = ctx.args ?? {}; + const item = ctx.item ?? {}; + const data = ctx.data; + const index = ctx.index ?? 0; + const parts = pathStr.split('.'); + const rootName = parts[0]; + let obj: any; let rest: string[]; + if (rootName === 'args') { obj = args; rest = parts.slice(1); } + else if (rootName === 'item') { obj = item; rest = parts.slice(1); } + else if (rootName === 'data') { obj = data; rest = parts.slice(1); } + else if (rootName === 'index') return index; + else { obj = item; rest = parts; } + for (const part of rest) { + if (obj && typeof obj === 'object' && !Array.isArray(obj)) obj = obj[part]; + else if (Array.isArray(obj) && /^\d+$/.test(part)) obj = obj[parseInt(part, 10)]; + else return null; + } + return obj; +} + +/** + * Normalize JavaScript source for browser evaluate() calls. + */ +export function normalizeEvaluateSource(source: string): string { + const stripped = source.trim(); + if (!stripped) return '() => undefined'; + if (stripped.startsWith('(') && stripped.endsWith(')()')) return `() => (${stripped})`; + if (/^(async\s+)?\([^)]*\)\s*=>/.test(stripped)) return stripped; + if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(stripped)) return stripped; + if (stripped.startsWith('function ') || stripped.startsWith('async function ')) return stripped; + return `() => (${stripped})`; +} diff --git a/src/pipeline/transform.test.ts b/src/pipeline/transform.test.ts new file mode 100644 index 0000000..b678001 --- /dev/null +++ b/src/pipeline/transform.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for pipeline transform steps: select, map, filter, sort, limit. + */ + +import { describe, it, expect } from 'vitest'; +import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js'; + +const SAMPLE_DATA = [ + { title: 'Alpha', score: 10, author: 'Alice' }, + { title: 'Beta', score: 30, author: 'Bob' }, + { title: 'Gamma', score: 20, author: 'Charlie' }, +]; + +describe('stepSelect', () => { + it('selects nested path', async () => { + const data = { result: { items: [1, 2, 3] } }; + const result = await stepSelect(null, 'result.items', data, {}); + expect(result).toEqual([1, 2, 3]); + }); + + it('selects array by index', async () => { + const data = { list: ['a', 'b', 'c'] }; + const result = await stepSelect(null, 'list.1', data, {}); + expect(result).toBe('b'); + }); + + it('returns null for missing path', async () => { + const result = await stepSelect(null, 'missing.path', { foo: 1 }, {}); + expect(result).toBeNull(); + }); + + it('returns data as-is for non-object', async () => { + const result = await stepSelect(null, 'foo', 'string-data', {}); + expect(result).toBe('string-data'); + }); +}); + +describe('stepMap', () => { + it('maps array items', async () => { + const result = await stepMap(null, { + name: '${{ item.title }}', + rank: '${{ index + 1 }}', + }, SAMPLE_DATA, {}); + expect(result).toEqual([ + { name: 'Alpha', rank: 1 }, + { name: 'Beta', rank: 2 }, + { name: 'Gamma', rank: 3 }, + ]); + }); + + it('handles single object', async () => { + const result = await stepMap(null, { + name: '${{ item.title }}', + }, { title: 'Solo' }, {}); + expect(result).toEqual([{ name: 'Solo' }]); + }); + + it('returns null/undefined as-is', async () => { + expect(await stepMap(null, { x: '${{ item.x }}' }, null, {})).toBeNull(); + }); +}); + +describe('stepFilter', () => { + it('filters by expression', async () => { + const result = await stepFilter(null, 'item.score', SAMPLE_DATA, {}); + expect(result).toHaveLength(3); // all truthy + }); + + it('returns non-array as-is', async () => { + const result = await stepFilter(null, 'item.x', 'not-array', {}); + expect(result).toBe('not-array'); + }); +}); + +describe('stepSort', () => { + it('sorts ascending by key', async () => { + const result = await stepSort(null, 'score', SAMPLE_DATA, {}); + expect(result.map((r: any) => r.title)).toEqual(['Alpha', 'Gamma', 'Beta']); + }); + + it('sorts descending', async () => { + const result = await stepSort(null, { by: 'score', order: 'desc' }, SAMPLE_DATA, {}); + expect(result.map((r: any) => r.title)).toEqual(['Beta', 'Gamma', 'Alpha']); + }); + + it('does not mutate original', async () => { + const original = [...SAMPLE_DATA]; + await stepSort(null, 'score', SAMPLE_DATA, {}); + expect(SAMPLE_DATA).toEqual(original); + }); +}); + +describe('stepLimit', () => { + it('limits array to N items', async () => { + const result = await stepLimit(null, '2', SAMPLE_DATA, {}); + expect(result).toHaveLength(2); + }); + + it('limits using template expression', async () => { + const result = await stepLimit(null, '${{ args.limit }}', SAMPLE_DATA, { limit: 1 }); + expect(result).toHaveLength(1); + }); + + it('returns non-array as-is', async () => { + expect(await stepLimit(null, '5', 'string', {})).toBe('string'); + }); +}); diff --git a/src/promote.ts b/src/promote.ts deleted file mode 100644 index fc56060..0000000 --- a/src/promote.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** Promote verified drafts. */ -import { registerCandidates } from './register.js'; -export function promoteCandidate(opts: any): any { return registerCandidates(opts); } diff --git a/src/register.ts b/src/register.ts deleted file mode 100644 index 65b464e..0000000 --- a/src/register.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Register candidates, promote, scaffold, generate โ€” stubs with exports. */ -export function registerCandidates(opts: any): any { return { ok: true, count: 0 }; } diff --git a/src/registry.ts b/src/registry.ts index 5e573c3..fa6d824 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -2,6 +2,8 @@ * Core registry: Strategy enum, Arg/CliCommand interfaces, cli() registration. */ +import type { IPage } from './types.js'; + export enum Strategy { PUBLIC = 'public', COOKIE = 'cookie', @@ -28,7 +30,7 @@ export interface CliCommand { browser?: boolean; args: Arg[]; columns?: string[]; - func?: (page: any, kwargs: Record<string, any>, debug?: boolean) => Promise<any>; + func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>; pipeline?: any[]; timeoutSeconds?: number; source?: string; @@ -43,7 +45,7 @@ export interface CliOptions { browser?: boolean; args?: Arg[]; columns?: string[]; - func?: (page: any, kwargs: Record<string, any>, debug?: boolean) => Promise<any>; + func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>; pipeline?: any[]; timeoutSeconds?: number; } diff --git a/src/runtime.ts b/src/runtime.ts index 46b1a82..ff6626f 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -2,6 +2,8 @@ * Runtime utilities: timeouts and browser session management. */ +import type { IPage } from './types.js'; + export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10); export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_COMMAND_TIMEOUT ?? '45', 10); export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_EXPLORE_TIMEOUT ?? '120', 10); @@ -24,7 +26,7 @@ export async function runWithTimeout<T>( export async function browserSession<T>( BrowserFactory: new () => any, - fn: (page: any) => Promise<T>, + fn: (page: IPage) => Promise<T>, ): Promise<T> { const mcp = new BrowserFactory(); try { diff --git a/src/scaffold.ts b/src/scaffold.ts deleted file mode 100644 index 623d3c4..0000000 --- a/src/scaffold.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Scaffold a draft CLI from endpoint evidence. */ -export function scaffoldCli(opts: any): any { return { ok: true }; } diff --git a/src/smoke.ts b/src/smoke.ts deleted file mode 100644 index 212c6ec..0000000 --- a/src/smoke.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Smoke testing, verification, register, promote, explore, synthesize, scaffold, generate โ€” stubs. */ -export async function runSmoke(cmd: any, page: any, args?: any): Promise<any> { return { ok: true, result: null }; } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fe57806 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,23 @@ +/** + * Page interface: type-safe abstraction over Playwright MCP browser page. + * + * All pipeline steps and CLI adapters should use this interface + * instead of `any` for browser interactions. + */ + +export interface IPage { + goto(url: string): Promise<void>; + evaluate(js: string): Promise<any>; + snapshot(opts?: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean }): Promise<any>; + click(ref: string): Promise<void>; + typeText(ref: string, text: string): Promise<void>; + pressKey(key: string): Promise<void>; + wait(seconds: number): Promise<void>; + tabs(): Promise<any>; + closeTab(index?: number): Promise<void>; + newTab(): Promise<void>; + selectTab(index: number): Promise<void>; + networkRequests(includeStatic?: boolean): Promise<any>; + consoleMessages(level?: string): Promise<any>; + scroll(direction?: string, amount?: number): Promise<void>; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +});