Compare commits

..

28 Commits

Author SHA1 Message Date
jackwener 90971d4c1a fix: remove duplicate and unused imports in chatgpt adapters 2026-03-24 21:46:33 +08:00
jackwener 4945f10ac8 refactor: use CliError subclasses in chatgpt adapters
Replace raw Error throws with appropriate CliError subclasses:
- chatgpt/status.ts: ConfigError for platform limitation
- chatgpt/ask.ts: ConfigError for platform limitation
- chatgpt/new.ts: ConfigError for platform limitation
- chatgpt/read.ts: ConfigError for platform limitation, CommandExecutionError for read failures

This enables better error handling and user-facing error messages.
2026-03-24 21:45:53 +08:00
jackwener 454964010d refactor: use CliError subclasses in youtube, bilibili, and boss adapters
Replace raw Error throws with appropriate CliError subclasses:
- youtube/transcript.ts: CommandExecutionError, EmptyResultError
- youtube/video.ts: CommandExecutionError
- bilibili/utils.ts: EmptyResultError
- boss/send.ts: EmptyResultError, SelectorError
- boss/mark.ts: ArgumentError, EmptyResultError

This enables better error handling and user-facing error messages.
2026-03-24 21:45:46 +08:00
jakevin 94c3ef9af1 refactor: simplify codebase with type dedup, shared analysis module, and consistent naming (#373)
- Remove unused re-exports from registry.ts (serializeArg, serializeCommand, etc.)
- Unify FormatOptions into SnapshotOptions from types.ts; rename dom-snapshot's
  SnapshotOptions to DomSnapshotOptions to avoid name collision
- Extract shared analysis.ts module from explore.ts and record.ts, eliminating
  ~200 lines of duplicated logic (urlToPattern, findArrayPath, inferCapabilityName,
  inferStrategy, detectAuth*, classifyQueryParams)
- Merge snapshotFormatter from 7-pass to 4-pass pipeline by combining parse+filter
  with ad/boilerplate subtree skipping, and merging three dedup passes into one
- Rename all CLI adapter shared files to consistent utils.ts naming
  (boss/common.ts, douban/shared.ts, doubao*/common.ts, jike/shared.ts,
  medium/shared.ts, sinablog/shared.ts, substack/shared.ts)
- Merge douban/shared.ts into douban/utils.ts
2026-03-24 21:20:08 +08:00
jakevin 86b59d91a6 refactor: adopt CliError in desktop UI adapters (#372) 2026-03-24 21:12:40 +08:00
jakevin e916c164b6 refactor: use CliError subclasses in remaining adapters (#367)
* refactor: use CliError subclasses in linkedin adapters

Replace raw Error throws with appropriate CliError subclasses:
- linkedin/timeline.ts: AuthRequiredError, EmptyResultError
- linkedin/search.ts: ArgumentError, CommandExecutionError

This enables better error handling and user-facing error messages.

* refactor: use CliError subclasses in twitter adapters

Replace raw Error throws with appropriate CliError subclasses:
- twitter/trending.ts: AuthRequiredError, EmptyResultError
- twitter/bookmarks.ts: AuthRequiredError, CommandExecutionError

This enables better error handling and user-facing error messages.

* refactor: use CliError subclasses in reddit adapters

Replace raw Error throws with appropriate CliError subclasses:
- reddit/read.ts: CommandExecutionError for API-related errors

This enables better error handling and user-facing error messages.

* refactor: use CliError subclasses in adapters for better error handling

- linkedin/timeline: AuthRequiredError, EmptyResultError
- linkedin/search: ArgumentError, CommandExecutionError
- bilibili/subtitle: AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError
- bilibili/following: CommandExecutionError
- medium/shared: CommandExecutionError
- twitter/delete, twitter/unfollow: CommandExecutionError

This allows the top-level error handler to render consistent,
helpful output with emoji-coded severity and actionable hints.

* refactor: use CliError subclasses in youtube, bilibili, and boss adapters

Replace raw Error throws with appropriate CliError subclasses:
- youtube/transcript.ts: CommandExecutionError, EmptyResultError
- youtube/video.ts: CommandExecutionError
- bilibili/utils.ts: EmptyResultError
- boss/send.ts: EmptyResultError, SelectorError
- boss/mark.ts: ArgumentError, EmptyResultError

This enables better error handling and user-facing error messages.
2026-03-24 21:00:51 +08:00
jakevin 1af0f48023 docs: remove kubectl references from documentation and external CLI list (#363)
Remove kubectl from:
- README.md highlights and external CLI examples table
- README.zh-CN.md highlights and external CLI examples table
- src/external-clis.yaml external CLI registry

kubectl is not relevant to the opencli project scope and should not be showcased as a primary example.
2026-03-24 20:25:15 +08:00
jakevin 2fb7ed131b fix(e2e): remove duplicate closing bracket causing vite:oxc parse error (#361)
The dictionary adapters commit (3d39574) introduced a duplicate
`}, 30_000);` at line 524 of public-commands.test.ts, causing
the vite:oxc transformer to fail with [PARSE_ERROR] Unexpected token
in the E2E Headed Chrome CI workflow.
2026-03-24 20:21:11 +08:00
jakevin 14672ddf9b refactor: simplify codebase by removing dead code, deduplicating types, and extracting shared desktop adapter commands (#360)
- Remove dead code: unused `promises` array in discovery.ts, unused `DEFAULT_BROWSER_SMOKE_TIMEOUT`, unused `checkFfmpeg()`, deprecated `PlaywrightMCP` alias
- Extract shared `YamlArgDefinition`/`YamlCliDefinition` into `yaml-schema.ts` (was duplicated in discovery.ts and build-manifest.ts)
- Unify `BrowserCookie` type: remove duplicate from download/index.ts, re-export from types.ts
- Create `_shared/desktop-commands.ts` with factory functions (makeScreenshotCommand, makeStatusCommand, makeNewCommand, makeDumpCommand), simplifying 11 adapter files from ~20-30 lines each to 3 lines
- Fix unnecessary dynamic imports in utils.ts

Net: +30 / -361 lines
2026-03-24 20:20:32 +08:00
jakevin 77814553cf Revert "feat(browser): human-like delay system for anti-detection (#297)" (#359)
This reverts commit 376c63c7db.
2026-03-24 19:49:53 +08:00
jakevin 9018713749 fix: add fallback guards to dictionary search for better error handling (#358) 2026-03-24 19:43:26 +08:00
VK 3d39574501 feat(dictionary): add dictionary search, synonyms, and examples adapters (#241)
* feat(dictionary): add dictionary search, synonyms, and examples adapters

* feat: Improve dictionary commands with positional word arguments, URL encoding, enhanced phonetic parsing, and new E2E tests.
2026-03-24 19:26:00 +08:00
dependabot[bot] b1067b64ee chore(ci): bump peter-evans/repository-dispatch from 3 to 4 (#323)
Bumps [peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) from 3 to 4.
- [Release notes](https://github.com/peter-evans/repository-dispatch/releases)
- [Commits](https://github.com/peter-evans/repository-dispatch/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/repository-dispatch
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 19:23:59 +08:00
工具人研究所 376c63c7db feat(browser): human-like delay system for anti-detection (#297)
* feat(browser): human-like delay system for anti-detection

Adds a framework-level delay/jitter system using log-normal distribution
to simulate natural browsing patterns, addressing issue #59 (P0).

- New `HumanDelay` class with configurable profiles (none/fast/moderate/cautious/stealth)
- Log-normal distribution for realistic delay variance (not uniform)
- Periodic "breaks" that simulate reading/thinking pauses
- Auto-injected between page.goto() navigations
- Configurable via OPENCLI_DELAY_PROFILE env var
- Boss search adapter migrated from hardcoded jitter to framework delay
- 10 unit tests covering all profiles and edge cases

Real-world validation against a major job board (cookie-authenticated,
aggressive bot detection):

| Scenario              | Without jitter     | With jitter        |
|-----------------------|--------------------|--------------------|
| 50 detail pages       |  OK              |  OK              |
| 200 detail pages      |  Banned (code 32) |  OK              |
| 850 requests over 5h  | N/A (banned early) |  Zero detection   |
| 4-day sustained crawl | N/A                |  1800+ records    |

Closes #59

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable human delay in CI environment to prevent E2E timeouts

In CI environments (CI=true), resolveProfile() now defaults to the
'none' profile instead of 'moderate'. This prevents the 1-8s per-
navigation delay from causing E2E test timeouts (30s limit).

Users can override this by setting OPENCLI_DELAY_PROFILE explicitly.

---------

Co-authored-by: toolmanlab <toolmanlab@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 19:22:13 +08:00
sline ccfc0ed0de fix: remove stale SQLite file, add chatgpt platform guard, fix daemon exit code (#308)
- Add *.db to .gitignore to prevent accidental database commits
- Add macOS platform check before osascript calls in chatgpt commands
- Change daemon EADDRINUSE exit code from 0 to 1
2026-03-24 19:20:42 +08:00
AstroHan 3a7a5e135b fix(grok): preserve conversation across repeated ask calls (#332)
* fix(grok): preserve conversation across repeated ask calls (#330)

The adapter unconditionally navigated to grok.com/ on every invocation,
destroying the existing conversation URL even when --new was not passed.
Since the browser daemon already reuses the same Chrome tab, skipping
navigation lets the tab stay on the current chat thread.

- Only navigate to grok.com/ when --new is true or tab is not on grok.com
- Add tryStartFreshChat to the default path's --new branch (was dead code)
- Add isOnGrok helper with hostname-based domain matching
- Add unit tests for isOnGrok

* test(grok): add adapter to vitest project config

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 19:19:40 +08:00
jakevin 618dae9148 fix(stealth): harden anti-detection against advanced fingerprinting (#357)
- navigator.webdriver returns false instead of undefined (matches real Chrome)
- Stealth guard uses non-enumerable prototype property instead of discoverable window prop
- Interceptor globals are non-enumerable via Object.defineProperty
- Monkey-patched fetch/XHR disguised with native toString() signatures
- XHR instance properties use non-enumerable descriptors
- Remove overly broad chrome-extension:// stack filter
- Remove __opencli from stack patterns to avoid self-exposure
- Update download User-Agent from Chrome/120 to Chrome/134
- Clean up dead STEALTH_GUARD export

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:19:27 +08:00
AstroHan 11df7181b3 fix(twitter): retry transient search spa navigation (#355)
* fix(twitter): retry transient search spa navigation

* test(twitter): cover search navigation failure path
2026-03-24 19:18:23 +08:00
MatrixA 2b24f517fe fix(arxiv): use correct query arg instead of keyword in search (#356)
The search command defined its argument as `query` but referenced
`args.keyword`, causing the search term to be undefined.

Closes #334

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:16:16 +08:00
AstroHan d44a0ab256 docs: add "Why opencli?" section and comparison guide (#331)
* docs: add "Why opencli?" section and comparison guide (#238)

- Add "Why opencli?" section to README.md and README.zh-CN.md
  (between Highlights and Prerequisites)
- Add docs/comparison.md with 5-scenario honest evaluation
- Add Comparison entry to VitePress sidebar

* docs: refine positioning — use approximate numbers, emphasize broad coverage

- Replace specific counts (300+, 55, 20+) with approximate descriptions
- Emphasize broad global + Chinese platform coverage instead of singling out Chinese sites
- Fix Firecrawl description to mention self-hosted option
- Replace "sub-second" / "milliseconds" with accurate "seconds" / "fast deterministic"
- Add testing and AI workflow to Further Reading links
- Add "easy to extend" point to strengths

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 19:13:49 +08:00
zhanghui67 9f2aa3b711 fix(bilibili): use actual user UID instead of 0 for favorite command (#333)
The favorite command was using up_mid: 0 which returns empty results. Now it correctly fetches the current user UID using getSelfUid().

Co-authored-by: 章晖 <zhanghui@MacBook-Pro.local>
2026-03-24 16:23:41 +08:00
jakevin 505c86bae9 docs: add missing adapter docs for jd and web (#349)
Add documentation for jd (item) and web (read) adapters to fix
doc-coverage CI check (55/57 → 57/57).
2026-03-24 16:03:18 +08:00
dependabot[bot] b8b4fa011b chore(deps): bump ws from 8.19.0 to 8.20.0 (#326)
Bumps [ws](https://github.com/websockets/ws) from 8.19.0 to 8.20.0.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.19.0...8.20.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 15:57:50 +08:00
Piotr Yordanov 8869d3b457 feat(linkedin): add timeline feed command (#342)
* feat(linkedin): add timeline feed command

* test(linkedin): add timeline adapter unit tests

Add shape tests and utility function tests for the new timeline command.
Include linkedin in the vitest adapter project config.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 15:54:51 +08:00
jakevin 7c9caa81f8 refactor: extract shared utilities and simplify codebase (#348)
- R1: Extract isRecord() type guard to shared src/utils.ts (8 files)
- R2: Merge duplicate mapConcurrent() to shared utils (fetch.ts + download.ts)
- R3: Extract saveBase64ToFile() helper (cdp.ts + page.ts)
- R4: Merge _tabOpt() + _workspaceOpt() into _cmdOpts()/_wsOpt() (page.ts)
- R5: Extract normalizeRows() + resolveColumns() in output.ts
- R6: Remove dead register stub from generate.ts
2026-03-24 15:47:21 +08:00
Xeron 7c808fd339 feat(jd): add JD.com product details adapter (#344)
* feat(jd): add item adapter for JD.com product details

Support fetching:
- Product title, price, shop
- Product specifications (品牌, 型号, 规格参数 etc.)
- Main product images
- Detail images from product page

Usage: opencli jd item <sku>

* fix: use images arg instead of hardcoded limit, add command shape test

- Wire the `images` arg to control mainImages/detailImages slice count
  (was hardcoded to 10, ignoring the arg entirely)
- Add item.test.ts verifying command registration shape

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 15:37:22 +08:00
jakevin fb562fa1e9 fix: allow browser:false commands to run without page after lazy-load (#347)
The C2 fix in PR #337 added a null-page guard after lazy-loading TS
modules, but it threw unconditionally — breaking all browser:false
commands (bloomberg, apple-podcasts, google, yollomi, etc.) that
use func() with a null page. Guard now checks updated.browser !== false.

Also fixes apple-podcasts top E2E flake: when the command times out on
CI, stderr is empty and the guard didn't catch it.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:08:08 +08:00
haijun huang f6466db39a feat: add generic web read command for any URL → Markdown (#343)
* feat: add generic `web read` command for any URL → Markdown

Adds a new `opencli web read --url <any-url>` command that fetches any
web page and exports it as clean Markdown with optional image download.

Uses browser-side DOM heuristics for content extraction:
  1. <article> element
  2. [role="main"] element
  3. <main> element
  4. Largest text-dense block fallback

Pipes through the existing article-download pipeline (Turndown + image
localization), so it inherits code block handling, frontmatter generation,
and concurrent image downloading for free.

Tested on: Anthropic blog, OpenAI blog, general news sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: improve web read dedup for sites with duplicated DOM paragraphs

Anthropic's blog renders each paragraph twice (a normal version + a
line-broken animation version). The previous substring-based dedup
missed these because whitespace differences changed string lengths.

Fix: compare texts after stripping ALL whitespace, and keep the
version with more proper spacing (more spaces = better formatted).

Result on Anthropic blog: 98.4KB → 53.7KB (45% reduction).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Harrison <harrison@HarrisondeMacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:57:34 +08:00
143 changed files with 2275 additions and 1257 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger opencli-website rebuild
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
repository: jackwener/opencli-website
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger website rebuild
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
repository: jackwener/opencli-website
+3
View File
@@ -19,3 +19,6 @@ docs/.vitepress/cache
.windsurf
.claude
.cortex
# Database files
*.db
+22 -3
View File
@@ -23,11 +23,31 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, etc). Zero setup.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
## Why opencli?
There are many great browser automation tools. Here's when opencli is the right choice:
| Your need | Best tool | Why |
|-----------|-----------|-----|
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
**What makes opencli different:**
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
- **Broad coverage** — 50+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
## Prerequisites
- **Node.js**: >= 20.0.0
@@ -117,7 +137,6 @@ Run `opencli list` for the live registry.
| **apple-podcasts** | `search` `episodes` `top` | Public |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
| **zhihu** | `hot` `search` `question` `download` | Browser |
| **web** | `read` | Browser |
| **weixin** | `download` | Browser |
| **youtube** | `search` `video` `transcript` | Browser |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
@@ -126,6 +145,7 @@ Run `opencli list` for the live registry.
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
| **ctrip** | `search` | Browser |
| **devto** | `top` `tag` `user` | Public |
| **dictionary** | `search` `synonyms` `examples` | Public |
| **arxiv** | `search` `paper` | Public |
| **wikipedia** | `search` `summary` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
@@ -166,7 +186,6 @@ OpenCLI acts as a universal hub for your existing command-line tools. It provide
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker command-line interface | `opencli docker ps` |
| **kubectl** | Kubernetes command-line tool | `opencli kubectl get pods` |
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
+22 -3
View File
@@ -25,11 +25,31 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker``kubectl` 等本地 CLI
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker` 等本地 CLI
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
- **AI 原生** — `explore` 自动发现 API`synthesize` 生成适配器,`cascade` 探测认证策略
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
## 为什么选 opencli
浏览器自动化工具很多,opencli 适合什么场景?
| 你的需求 | 最佳工具 | 原因 |
|----------|----------|------|
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
**opencli 的核心差异:**
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
## 前置要求
- **Node.js**: >= 20.0.0
@@ -119,7 +139,6 @@ npm install -g @jackwener/opencli@latest
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
| **web** | `read` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
@@ -128,6 +147,7 @@ npm install -g @jackwener/opencli@latest
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
| **ctrip** | `search` | 浏览器 |
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
@@ -168,7 +188,6 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **kubectl** | Kubernetes CLI | `opencli kubectl get pods` |
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
+2
View File
@@ -29,6 +29,7 @@ export default defineConfig({
items: [
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Installation', link: '/guide/installation' },
{ text: 'Comparison', link: '/comparison' },
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
{ text: 'Plugins', link: '/guide/plugins' },
@@ -80,6 +81,7 @@ export default defineConfig({
items: [
{ text: 'HackerNews', link: '/adapters/browser/hackernews' },
{ text: 'Dev.to', link: '/adapters/browser/devto' },
{ text: 'Dictionary', link: '/adapters/browser/dictionary' },
{ text: 'BBC', link: '/adapters/browser/bbc' },
{ text: 'Apple Podcasts', link: '/adapters/browser/apple-podcasts' },
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
+27
View File
@@ -0,0 +1,27 @@
# Dictionary
**Mode**: 🌐 Public · **Domain**: `api.dictionaryapi.dev`
Search the open dictionary to quickly fetch native definitions, part of speech contexts, and phonetic pronunciations directly in your IDE terminal.
## Commands
| Command | Description |
|---------|-------------|
| `opencli dictionary search` | Fetch the exact definition of a word |
| `opencli dictionary synonyms` | Find related synonyms for a word |
| `opencli dictionary examples` | Read real-world sentence usage examples |
## Usage Examples
```bash
# Look up a complex term
opencli dictionary search serendipity
# Discover phonetics
opencli dictionary search ephemeral
```
## Prerequisites
- No browser required — utilizes the fast, open JSON definitions API.
+27
View File
@@ -0,0 +1,27 @@
# JD.com
**Mode**: 🔐 Browser · **Domain**: `item.jd.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
## Usage Examples
```bash
# Get product details by SKU
opencli jd item 100291143898
# Limit detail images
opencli jd item 100291143898 --images 5
# JSON output
opencli jd item 100291143898 -f json
```
## Prerequisites
- Chrome running and **logged into** jd.com
- [Browser Bridge extension](/guide/browser-bridge) installed
+6
View File
@@ -7,6 +7,7 @@
| Command | Description |
|---------|-------------|
| `opencli linkedin search` | |
| `opencli linkedin timeline` | Read posts from your LinkedIn home feed |
## Usage Examples
@@ -14,9 +15,14 @@
# Quick start
opencli linkedin search --limit 5
# Read your home timeline
opencli linkedin timeline --limit 5
# JSON output
opencli linkedin search -f json
opencli linkedin timeline -f json
# Verbose mode
opencli linkedin search -v
```
+16 -22
View File
@@ -1,36 +1,30 @@
# web
# Web
Generic browser-backed article reader for arbitrary URLs.
**Mode**: 🔐 Browser · **Domain**: any URL
## Commands
| Command | Description |
|---------|-------------|
| `opencli web read <url>` | Fetch a web page and export the main content as Markdown |
| `opencli web read <url>` | Fetch any web page and export as Markdown |
## Examples
## Usage Examples
```bash
# Save an arbitrary article as Markdown
opencli web read "https://www.anthropic.com/research/..." --output ./articles
# Read a web page and save as Markdown
opencli web read https://example.com/article
# Skip local image download
opencli web read "https://openai.com/index/..." --download-images false
# Custom output directory
opencli web read https://example.com/article --output ./my-articles
# Wait a bit longer for JS-rendered pages
opencli web read "https://example.com/post" --wait 5
# Skip image download
opencli web read https://example.com/article --download-images false
# JSON output
opencli web read https://example.com/article -f json
```
## Options
## Prerequisites
| Option | Default | Description |
|--------|---------|-------------|
| `<url>` | required | Target page URL |
| `--output` | `./web-articles` | Output directory |
| `--download-images` | `true` | Download article images locally |
| `--wait` | `3` | Seconds to wait after navigation |
## Notes
- `web read` is intentionally generic: it uses DOM heuristics instead of a site-specific parser.
- For sites that already have a dedicated adapter such as `weixin download` or `zhihu download`, prefer the site-specific command when you need stronger extraction quality.
- Chrome running
- [Browser Bridge extension](/guide/browser-bridge) installed
+2 -2
View File
@@ -10,14 +10,13 @@ Run `opencli list` for the live registry.
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
| **[web](/adapters/browser/web)** | `read` | 🔐 Browser |
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 🔐 Browser |
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
| **[weibo](/adapters/browser/weibo)** | `hot` `search` | 🔐 Browser |
| **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser |
| **[linkedin](/adapters/browser/linkedin)** | `search` `timeline` | 🔐 Browser |
| **[coupang](/adapters/browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
| **[boss](/adapters/browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
| **[ctrip](/adapters/browser/ctrip)** | `search` | 🔐 Browser |
@@ -46,6 +45,7 @@ Run `opencli list` for the live registry.
| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
| **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public |
| **[dictionary](/adapters/browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
+125
View File
@@ -0,0 +1,125 @@
# Comparison Guide
OpenCLI occupies a specific niche in the browser automation ecosystem. This guide honestly evaluates where opencli excels, where it's a viable option, and where other tools are a better fit.
## At a Glance
| Tool | Approach | Best for |
|------|----------|----------|
| **opencli** | Pre-built adapters (YAML/TS) | Deterministic site commands, broad platform coverage, desktop apps |
| **Browser-Use** | LLM-driven browser control | General-purpose AI browser automation |
| **Crawl4AI** | Async web crawler | Large-scale data crawling |
| **Firecrawl** | Scraping API / self-hosted | Clean markdown extraction, managed or self-hosted infrastructure |
| **agent-browser** | Browser primitive CLI | Token-efficient AI agent browsing |
| **Stagehand** | AI browser framework | Developer-friendly browser automation |
| **Skyvern** | Visual AI automation | Cross-site generalized workflows |
## Scenario Comparison
### 1. Scheduled Batch Data Extraction
> "I want to pull trending posts from Bilibili/Reddit/HackerNews every hour into my pipeline."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | One command, structured JSON output, zero runtime cost. Runs in cron/CI without tokens or API keys. |
| Crawl4AI | Good | Strong for large-scale crawling, but requires writing extraction logic per site. |
| Firecrawl | Viable | Managed service with clean output, but costs scale with volume. |
| Browser-Use / Stagehand | Poor | LLM inference on every run is slow, expensive, and non-deterministic for repeated tasks. |
**Why opencli wins here:** A command like `opencli bilibili hot -f json` returns the same structured schema every time, costs nothing to run, and finishes in seconds. For recurring data extraction from known sites, pre-built adapters beat LLM-driven approaches on cost, speed, and reliability.
### 2. AI Agent Site Operations
> "My AI agent needs to search Twitter, read Reddit threads, or post to Xiaohongshu."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | Structured JSON output, fast deterministic execution, hundreds of commands ready to use. |
| agent-browser | Good | Token-efficient browser primitives, but requires LLM reasoning for every step. |
| Browser-Use | Viable | General-purpose, but each operation costs tokens and takes 10-60s. |
| Stagehand | Viable | Good DX, but same LLM-per-action cost model. |
**Why opencli wins here:** When your agent needs `twitter search "AI news" -f json`, a deterministic command that returns in seconds is strictly better than an LLM clicking through a webpage. The agent saves tokens for reasoning, not navigation.
### 3. Authenticated Operations (Login-Required Sites)
> "I need to access my bookmarks, post content, or interact with sites that require login."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | Reuses your Chrome login session via Browser Bridge. No credentials stored or transmitted. |
| Browser-Use | Viable | Can use browser profiles, but credential management is manual. |
| Firecrawl | Poor | Cloud service cannot access your authenticated sessions. |
| Crawl4AI | Poor | Requires manual cookie/session injection. |
**Why opencli wins here:** The Browser Bridge extension reuses your existing Chrome login state in real-time. You log in once in Chrome, and opencli commands work immediately. No OAuth setup, no API keys, no credential files.
### 4. General Web Browsing & Exploration
> "I need to explore an unknown website, fill forms, or navigate complex multi-step flows."
| Tool | Fit | Notes |
|------|-----|-------|
| Browser-Use | Best | LLM-driven, handles arbitrary websites and flows. |
| Stagehand | Best | Clean API for `act()`, `extract()`, `observe()` on any page. |
| agent-browser | Good | Token-efficient primitives for AI agents. |
| Skyvern | Good | Visual AI that generalizes across sites. |
| **opencli** | Poor | Only works with sites that have pre-built adapters. Cannot handle arbitrary websites. |
**opencli is not the right tool here.** If you need to explore unknown websites or handle one-off tasks on sites without adapters, use an LLM-driven browser tool. opencli trades generality for determinism and cost.
### 5. Desktop App Control
> "I want to script Cursor, ChatGPT, Notion, or other Electron apps from the terminal."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | 8 desktop adapters via CDP + AppleScript. The only CLI tool with this capability. |
| All others | N/A | Browser automation tools cannot control desktop applications. |
**This is unique to opencli.** No other tool in this comparison can send a prompt to ChatGPT desktop, extract code from Cursor, or write to Notion pages via CLI.
## Key Trade-offs
### opencli's Strengths
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times for free.
- **Deterministic output** — Same command always returns the same schema. Pipeable, scriptable, CI-friendly.
- **Speed** — Adapter commands return in seconds, not minutes.
- **Broad platform coverage** — 50+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
- **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more.
- **Easy to extend** — Drop a `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
### opencli's Limitations
- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a YAML or TypeScript adapter.
- **Adapter maintenance** — When a website updates its DOM or API, the corresponding adapter may need updating. The community maintains these, but breakage is possible.
- **Not general-purpose** — Cannot handle arbitrary websites. For unknown sites, pair opencli with a general browser tool as a fallback.
## Complementary Usage
opencli works best alongside general-purpose browser tools, not as a replacement:
```
Has adapter? ──yes──▶ opencli (fast, free, deterministic)
no
One-off task? ──yes──▶ Browser-Use / Stagehand (LLM-driven)
no
Recurring? ──yes──▶ Write an opencli adapter, then use opencli
```
## Further Reading
- [Architecture Overview](./developer/architecture.md)
- [Writing a YAML Adapter](./developer/yaml-adapter.md)
- [Writing a TypeScript Adapter](./developer/ts-adapter.md)
- [Testing Guide](./developer/testing.md)
- [AI Workflow](./developer/ai-workflow.md)
- [Contributing Guide](./developer/contributing.md)
+3 -3
View File
@@ -4370,9 +4370,9 @@
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+2
View File
@@ -28,6 +28,8 @@ total=0
for adapter_dir in "$SRC_DIR"/*/; do
adapter_name="$(basename "$adapter_dir")"
# Skip internal directories (e.g., _shared)
[[ "$adapter_name" == _* ]] && continue
total=$((total + 1))
# Check if doc exists in browser/ or desktop/ subdirectories
+169
View File
@@ -0,0 +1,169 @@
/**
* Shared API analysis helpers used by both explore.ts and record.ts.
*
* Extracts common logic for:
* - URL pattern normalization
* - Array path discovery in JSON responses
* - Field role detection
* - Auth indicator inference
* - Capability name inference
* - Strategy inference
*/
import {
VOLATILE_PARAMS,
SEARCH_PARAMS,
PAGINATION_PARAMS,
FIELD_ROLES,
} from './constants.js';
// ── URL pattern normalization ───────────────────────────────────────────────
/** Normalize a full URL into a pattern (replace IDs, strip volatile params). */
export function urlToPattern(url: string): string {
try {
const p = new URL(url);
const pathNorm = p.pathname
.replace(/\/\d+/g, '/{id}')
.replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}')
.replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
const params: string[] = [];
p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); });
return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
} catch { return url; }
}
// ── Array discovery in JSON responses ───────────────────────────────────────
export interface ArrayDiscovery {
path: string;
items: unknown[];
}
/** Find the best (largest) array of objects in a JSON response body. */
export function findArrayPath(obj: unknown, depth = 0): ArrayDiscovery | null {
if (depth > 5 || !obj || typeof obj !== 'object') return null;
if (Array.isArray(obj)) {
if (obj.length >= 2 && obj.some(i => i && typeof i === 'object' && !Array.isArray(i))) {
return { path: '', items: obj };
}
return null;
}
let best: ArrayDiscovery | null = null;
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
const found = findArrayPath(val, depth + 1);
if (found) {
const fullPath = found.path ? `${key}.${found.path}` : key;
const candidate = { path: fullPath, items: found.items };
if (!best || candidate.items.length > best.items.length) best = candidate;
}
}
return best;
}
// ── Field flattening & role detection ───────────────────────────────────────
/** Flatten nested object keys up to maxDepth. */
export function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[] {
if (maxDepth <= 0 || !obj || typeof obj !== 'object') return [];
const names: string[] = [];
const record = obj as Record<string, unknown>;
for (const key of Object.keys(record)) {
const full = prefix ? `${prefix}.${key}` : key;
names.push(full);
const val = record[key];
if (val && typeof val === 'object' && !Array.isArray(val)) names.push(...flattenFields(val, full, maxDepth - 1));
}
return names;
}
/** Detect semantic field roles (title, url, author, etc.) from sample fields. */
export function detectFieldRoles(sampleFields: string[]): Record<string, string> {
const detectedFields: Record<string, string> = {};
for (const [role, aliases] of Object.entries(FIELD_ROLES)) {
for (const f of sampleFields) {
if (aliases.includes(f.split('.').pop()?.toLowerCase() ?? '')) {
detectedFields[role] = f;
break;
}
}
}
return detectedFields;
}
// ── Capability name inference ───────────────────────────────────────────────
/** Infer a CLI capability name from a URL. */
export function inferCapabilityName(url: string, goal?: string): string {
if (goal) return goal;
const u = url.toLowerCase();
if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending')) return 'hot';
if (u.includes('search')) return 'search';
if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic')) return 'feed';
if (u.includes('comment') || u.includes('reply')) return 'comments';
if (u.includes('history')) return 'history';
if (u.includes('profile') || u.includes('userinfo') || u.includes('/me')) return 'me';
if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark')) return 'favorite';
try {
const segs = new URL(url).pathname
.split('/')
.filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i) && !s.match(/^v\d+$/));
if (segs.length) return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
} catch {}
return 'data';
}
// ── Strategy inference ──────────────────────────────────────────────────────
/** Infer auth strategy from detected indicators. */
export function inferStrategy(authIndicators: string[]): string {
if (authIndicators.includes('signature')) return 'intercept';
if (authIndicators.includes('bearer') || authIndicators.includes('csrf')) return 'header';
return 'cookie';
}
// ── Auth indicator detection ────────────────────────────────────────────────
/** Detect auth indicators from HTTP headers. */
export function detectAuthFromHeaders(headers?: Record<string, string>): string[] {
if (!headers) return [];
const indicators: string[] = [];
const keys = Object.keys(headers).map(k => k.toLowerCase());
if (keys.some(k => k === 'authorization')) indicators.push('bearer');
if (keys.some(k => k.startsWith('x-csrf') || k.startsWith('x-xsrf'))) indicators.push('csrf');
if (keys.some(k => k.startsWith('x-s') || k === 'x-t' || k === 'x-s-common')) indicators.push('signature');
return indicators;
}
/** Detect auth indicators from URL and response body (heuristic). */
export function detectAuthFromContent(url: string, body: unknown): string[] {
const indicators: string[] = [];
if (body && typeof body === 'object') {
const keys = Object.keys(body as object).map(k => k.toLowerCase());
if (keys.some(k => k.includes('sign') || k === 'w_rid' || k.includes('token'))) {
indicators.push('signature');
}
}
if (url.includes('/wbi/') || url.includes('w_rid=')) indicators.push('signature');
if (url.includes('bearer') || url.includes('access_token')) indicators.push('bearer');
return indicators;
}
// ── Query param classification ──────────────────────────────────────────────
/** Extract non-volatile query params and classify them. */
export function classifyQueryParams(url: string): {
params: string[];
hasSearch: boolean;
hasPagination: boolean;
hasLimit: boolean;
} {
const params: string[] = [];
try { new URL(url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); }); } catch {}
return {
params,
hasSearch: params.some(p => SEARCH_PARAMS.has(p)),
hasPagination: params.some(p => PAGINATION_PARAMS.has(p)),
hasLimit: params.some(p => SEARCH_PARAMS.has(p)),
};
}
+2 -2
View File
@@ -1,6 +1,5 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
import { STEALTH_GUARD } from './browser/stealth.js';
import * as daemonClient from './browser/daemon-client.js';
describe('browser helpers', () => {
@@ -170,7 +169,8 @@ describe('stealth anti-detection', () => {
it('includes guard flag to prevent double-injection', () => {
const js = generateStealthJs();
expect(js).toContain(STEALTH_GUARD);
// Guard uses a non-enumerable property on a built-in prototype
expect(js).toContain("EventTarget.prototype");
// Guard should check early and return 'skipped'
expect(js).toContain("return 'skipped'");
// Normal path returns 'applied'
+2 -8
View File
@@ -292,11 +292,7 @@ class CDPPage implements IPage {
});
const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : '';
if (options.path) {
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.dirname(options.path);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
await saveBase64ToFile(base64, options.path);
}
return base64;
}
@@ -341,9 +337,7 @@ class CDPPage implements IPage {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
import { isRecord, saveBase64ToFile } from '../utils.js';
function isCookie(value: unknown): value is BrowserCookie {
return isRecord(value)
+2 -2
View File
@@ -26,7 +26,7 @@
// ─── Types ───────────────────────────────────────────────────────────
export interface SnapshotOptions {
export interface DomSnapshotOptions {
/** Extra pixels beyond viewport to include (default 800) */
viewportExpand?: number;
/** Maximum DOM depth to traverse (default 50) */
@@ -175,7 +175,7 @@ export function getFormStateJs(): string {
* - `|iframe|` — iframe content
* - `|table|` — markdown table rendering
*/
export function generateSnapshotJs(opts: SnapshotOptions = {}): string {
export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
const viewportExpand = opts.viewportExpand ?? 800;
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200));
const interactiveOnly = opts.interactiveOnly ?? false;
+2 -2
View File
@@ -6,12 +6,12 @@
*/
export { Page } from './page.js';
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
export { BrowserBridge } from './mcp.js';
export { CDPBridge } from './cdp.js';
export { isDaemonRunning } from './daemon-client.js';
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
export { generateStealthJs } from './stealth.js';
export type { SnapshotOptions } from './dom-snapshot.js';
export type { DomSnapshotOptions } from './dom-snapshot.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { __test__ as cdpTest } from './cdp.js';
-3
View File
@@ -117,6 +117,3 @@ export class BrowserBridge {
);
}
}
/** @deprecated Use BrowserBridge instead */
export const PlaywrightMCP = BrowserBridge;
+33 -36
View File
@@ -14,6 +14,7 @@ import { formatSnapshot } from '../snapshotFormatter.js';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { sendCommand } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { saveBase64ToFile } from '../utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import { generateStealthJs } from './stealth.js';
import {
@@ -36,20 +37,23 @@ export class Page implements IPage {
/** Active tab ID, set after navigate and used in all subsequent commands */
private _tabId: number | undefined;
/** Helper: spread tabId into command params if we have one */
private _tabOpt(): { tabId: number } | Record<string, never> {
return this._tabId !== undefined ? { tabId: this._tabId } : {};
/** Helper: spread workspace into command params */
private _wsOpt(): { workspace: string } {
return { workspace: this.workspace };
}
private _workspaceOpt(): { workspace: string } {
return { workspace: this.workspace };
/** Helper: spread workspace + tabId into command params */
private _cmdOpts(): Record<string, unknown> {
return {
workspace: this.workspace,
...(this._tabId !== undefined && { tabId: this._tabId }),
};
}
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
const result = await sendCommand('navigate', {
url,
...this._workspaceOpt(),
...this._tabOpt(),
...this._cmdOpts(),
}) as { tabId?: number };
// Remember the tabId for subsequent exec calls
if (result?.tabId) {
@@ -59,8 +63,7 @@ export class Page implements IPage {
try {
await sendCommand('exec', {
code: generateStealthJs(),
...this._workspaceOpt(),
...this._tabOpt(),
...this._cmdOpts(),
});
} catch {
// Non-fatal: stealth is best-effort
@@ -71,8 +74,7 @@ export class Page implements IPage {
const maxMs = options?.settleMs ?? 1000;
await sendCommand('exec', {
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
...this._workspaceOpt(),
...this._tabOpt(),
...this._cmdOpts(),
});
}
}
@@ -80,7 +82,7 @@ export class Page implements IPage {
/** Close the automation window in the extension */
async closeWindow(): Promise<void> {
try {
await sendCommand('close-window', { ...this._workspaceOpt() });
await sendCommand('close-window', { ...this._wsOpt() });
} catch {
// Window may already be closed or daemon may be down
}
@@ -88,11 +90,11 @@ export class Page implements IPage {
async evaluate(js: string): Promise<unknown> {
const code = wrapForEval(js);
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
return sendCommand('exec', { code, ...this._cmdOpts() });
}
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts });
const result = await sendCommand('cookies', { ...this._wsOpt(), ...opts });
return Array.isArray(result) ? result : [];
}
@@ -108,7 +110,7 @@ export class Page implements IPage {
});
try {
const result = await sendCommand('exec', { code: snapshotJs, ...this._workspaceOpt(), ...this._tabOpt() });
const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
// The advanced engine already produces a clean, pruned, LLM-friendly output.
// Do NOT pass through formatSnapshot — its format is incompatible.
return result;
@@ -148,7 +150,7 @@ export class Page implements IPage {
return buildTree(document.body, 0);
})()
`;
const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
@@ -156,27 +158,27 @@ export class Page implements IPage {
async click(ref: string): Promise<void> {
const code = clickJs(ref);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async typeText(ref: string, text: string): Promise<void> {
const code = typeTextJs(ref, text);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async pressKey(key: string): Promise<void> {
const code = pressKeyJs(key);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async scrollTo(ref: string): Promise<unknown> {
const code = scrollToRefJs(ref);
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
return sendCommand('exec', { code, ...this._cmdOpts() });
}
async getFormState(): Promise<Record<string, unknown>> {
const code = getFormStateJs();
return (await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() })) as Record<string, unknown>;
return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
}
async wait(options: number | WaitOptions): Promise<void> {
@@ -191,35 +193,35 @@ export class Page implements IPage {
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
const code = waitForTextJs(options.text, timeout);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
}
async tabs(): Promise<unknown[]> {
const result = await sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
return Array.isArray(result) ? result : [];
}
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
// Invalidate cached tabId — the closed tab might have been our active one.
// We can't know for sure (close-by-index doesn't return tabId), so reset.
this._tabId = undefined;
}
async newTab(): Promise<void> {
const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number };
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
if (result?.tabId) this._tabId = result.tabId;
}
async selectTab(index: number): Promise<void> {
const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) as { selected?: number };
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
if (result?.selected) this._tabId = result.selected;
}
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
const code = networkRequestsJs(includeStatic);
const result = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
const result = await sendCommand('exec', { code, ...this._cmdOpts() });
return Array.isArray(result) ? result : [];
}
@@ -241,19 +243,14 @@ export class Page implements IPage {
*/
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
const base64 = await sendCommand('screenshot', {
...this._workspaceOpt(),
...this._cmdOpts(),
format: options.format,
quality: options.quality,
fullPage: options.fullPage,
...this._tabOpt(),
}) as string;
if (options.path) {
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.dirname(options.path);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
await saveBase64ToFile(base64, options.path);
}
return base64;
@@ -261,14 +258,14 @@ export class Page implements IPage {
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
const code = scrollJs(direction, amount);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
const code = autoScrollJs(times, delayMs);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async installInterceptor(pattern: string): Promise<void> {
+24 -10
View File
@@ -9,9 +9,6 @@
* Inspired by puppeteer-extra-plugin-stealth.
*/
/** Guard flag set on `window` to prevent double-injection. */
export const STEALTH_GUARD = '__opencli_stealth_applied';
/**
* Return a self-contained JS string that, when evaluated in a page context,
* applies all stealth patches. Safe to call multiple times — the guard flag
@@ -20,16 +17,25 @@ export const STEALTH_GUARD = '__opencli_stealth_applied';
export function generateStealthJs(): string {
return `
(() => {
// Guard: skip if already applied
if (window.${STEALTH_GUARD}) return 'skipped';
// Use defineProperty so the guard flag is non-enumerable (not a detection vector).
Object.defineProperty(window, '${STEALTH_GUARD}', { value: true, configurable: true });
// Guard: prevent double-injection across separate CDP evaluations.
// We cannot use a closure variable (each eval is a fresh scope), and
// window properties / Symbols are discoverable by anti-bot scripts.
// Instead, stash the flag in a non-enumerable getter on a built-in
// prototype that fingerprinters are unlikely to scan.
const _gProto = EventTarget.prototype;
const _gKey = '__lsn'; // looks like an internal listener cache
if (_gProto[_gKey]) return 'skipped';
try {
Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true });
} catch {}
// 1. navigator.webdriver → undefined
// 1. navigator.webdriver → false
// Most common check; Playwright/Puppeteer/CDP set this to true.
// Real Chrome returns false (not undefined) — returning undefined is
// itself a detection signal for advanced fingerprinters.
try {
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
get: () => false,
configurable: true,
});
} catch {}
@@ -119,9 +125,17 @@ export function generateStealthJs(): string {
// We override the stack property getter on Error.prototype to filter them.
// Note: Error.prepareStackTrace is V8/Node-only and not available in
// browser page context, so we use a property descriptor approach instead.
// We use generic protocol patterns instead of product-specific names to
// also catch our own injected code frames without leaking identifiers.
try {
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
const _cdpPatterns = ['puppeteer_evaluation_script', 'pptr:', 'debugger://', '__opencli'];
const _cdpPatterns = [
'puppeteer_evaluation_script',
'pptr:',
'debugger://',
'__playwright',
'__puppeteer',
];
if (_origDescriptor && _origDescriptor.get) {
Object.defineProperty(Error.prototype, 'stack', {
get: function () {
+2 -26
View File
@@ -46,33 +46,9 @@ export interface ManifestEntry {
navigateBefore?: boolean | string;
}
interface YamlArgDefinition {
type?: string;
default?: unknown;
required?: boolean;
positional?: boolean;
description?: string;
help?: string;
choices?: string[];
}
import type { YamlCliDefinition } from './yaml-schema.js';
interface YamlCliDefinition {
site?: string;
name?: string;
description?: string;
domain?: string;
strategy?: string;
browser?: boolean;
args?: Record<string, YamlArgDefinition>;
columns?: string[];
pipeline?: Record<string, unknown>[];
timeout?: number;
navigateBefore?: boolean | string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
import { isRecord } from './utils.js';
function extractBalancedBlock(
-2
View File
@@ -173,8 +173,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const r = await generateCliFromUrl({
url,
BrowserFactory: getBrowserFactory(),
builtinClis: BUILTIN_CLIS,
userClis: USER_CLIS,
goal: opts.goal,
site: opts.site,
workspace,
+117
View File
@@ -0,0 +1,117 @@
/**
* Shared command factories for Electron/desktop app adapters.
* Eliminates duplicate screenshot/status/new/dump implementations
* across cursor, codex, chatwise, etc.
*/
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
/**
* Factory: capture DOM HTML + accessibility snapshot.
*/
export function makeScreenshotCommand(site: string, displayName?: string) {
const label = displayName ?? site;
return cli({
site,
name: 'screenshot',
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
}
/**
* Factory: check CDP connection status.
*/
export function makeStatusCommand(site: string, displayName?: string) {
const label = displayName ?? site;
return cli({
site,
name: 'status',
description: `Check active CDP connection to ${label}`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status', 'Url', 'Title'],
func: async (page: IPage) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [{ Status: 'Connected', Url: url, Title: title }];
},
});
}
/**
* Factory: start a new session via Cmd/Ctrl+N.
*/
export function makeNewCommand(site: string, displayName?: string) {
const label = displayName ?? site;
return cli({
site,
name: 'new',
description: `Start a new ${label} session`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status'],
func: async (page: IPage) => {
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
}
/**
* Factory: dump DOM + snapshot for reverse-engineering.
*/
export function makeDumpCommand(site: string) {
return cli({
site,
name: 'dump',
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page: IPage) => {
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
},
];
},
});
}
+1 -1
View File
@@ -15,7 +15,7 @@ cli({
columns: ['id', 'title', 'authors', 'published'],
func: async (_page, args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 25));
const query = encodeURIComponent(`all:${args.keyword}`);
const query = encodeURIComponent(`all:${args.query}`);
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
const entries = parseEntries(xml);
if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
+5 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { apiGet, payloadData } from './utils.js';
import { apiGet, payloadData, getSelfUid } from './utils.js';
cli({
site: 'bilibili',
@@ -15,9 +15,12 @@ cli({
func: async (page, kwargs) => {
const { limit = 20, page: pageNum = 1 } = kwargs;
// Get current user's UID
const uid = await getSelfUid(page);
// Get default favorite folder ID
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
params: { up_mid: 0 },
params: { up_mid: uid },
signed: true,
});
const folders = payloadData(foldersPayload)?.list ?? [];
+3 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
@@ -14,7 +15,7 @@ cli({
],
columns: ['mid', 'name', 'sign', 'following', 'fans'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
// 1. Resolve UID (default to self)
const uid = kwargs.uid
@@ -30,7 +31,7 @@ cli({
);
if (payload.code !== 0) {
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
}
const list = payload.data?.list || [];
+8 -7
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { apiGet } from './utils.js';
@@ -13,7 +14,7 @@ cli({
],
columns: ['index', 'from', 'to', 'content'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
@@ -24,7 +25,7 @@ cli({
})()`);
if (!cid) {
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
@@ -35,12 +36,12 @@ cli({
});
if (payload.code !== 0) {
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
}
const subtitles = payload.data?.subtitle?.subtitles || [];
if (subtitles.length === 0) {
throw new Error('此视频没有发现外挂或智能字幕。');
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
}
// 4. 选择目标字幕语言
@@ -50,7 +51,7 @@ cli({
const targetSubUrl = target.subtitle_url;
if (!targetSubUrl || targetSubUrl === '') {
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
}
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
@@ -81,12 +82,12 @@ cli({
const items = await page.evaluate(fetchJs);
if (items?.error) {
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
}
const finalItems = items?.data || [];
if (!Array.isArray(finalItems)) {
throw new Error('解析到的字幕列表对象不符合数组格式');
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
}
// 6. 数据映射
+2 -2
View File
@@ -3,7 +3,7 @@
*/
import type { IPage } from '../../types.js';
import { AuthRequiredError } from '../../errors.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.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,
@@ -112,5 +112,5 @@ export async function resolveUid(page: IPage, input: string): Promise<string> {
});
const results = payload?.data?.result ?? [];
if (results.length > 0) return String(results[0].mid);
throw new Error(`Cannot resolve UID for: ${input}`);
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
import {
requirePage, navigateToChat, fetchRecommendList,
clickCandidateInList, typeAndSendMessage, verbose,
} from './common.js';
} from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, fetchFriendList } from './common.js';
import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './common.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateTo, bossFetch, verbose } from './common.js';
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 exchange — request phone/wechat exchange with a candidate.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage, verbose,
} from './common.js';
} from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 invite — send interview invitation to a candidate.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job list — list my published jobs via boss API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, verbose } from './common.js';
import { requirePage, navigateToChat, bossFetch, verbose } from './utils.js';
cli({
site: 'boss',
+3 -2
View File
@@ -7,6 +7,7 @@
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
import { ArgumentError, EmptyResultError } from '../../errors.js';
const LABEL_MAP: Record<string, number> = {
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
@@ -44,7 +45,7 @@ cli({
if (entry) {
labelId = entry[1];
} else {
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
}
}
@@ -53,7 +54,7 @@ cli({
await navigateToChat(page);
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
if (!friend) throw new Error('未找到该候选人');
if (!friend) throw new EmptyResultError('boss candidate search');
const friendName = friend.name || '候选人';
const action = remove ? 'deleteMark' : 'addMark';
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 recommend — view recommended candidates (新招呼/greet sort list).
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './common.js';
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -10,7 +10,7 @@
* .position-content → job being discussed + expectation
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './common.js';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './utils.js';
cli({
site: 'boss',
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 job search — browser cookie API.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './common.js';
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './utils.js';
/** City name → BOSS Zhipin city code mapping */
const CITY_CODES: Record<string, string> = {
+4 -3
View File
@@ -9,6 +9,7 @@ import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage,
} from './common.js';
import { EmptyResultError, SelectorError } from '../../errors.js';
cli({
site: 'boss',
@@ -29,21 +30,21 @@ cli({
await navigateToChat(page, 3);
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const clicked = await clickCandidateInList(page, numericUid);
if (!clicked) {
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
}
await page.wait({ time: 2 });
const sent = await typeAndSendMessage(page, kwargs.text);
if (!sent) {
throw new Error('找不到消息输入框');
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
}
await page.wait({ time: 1 });
+1 -1
View File
@@ -2,7 +2,7 @@
* BOSS直聘 stats job statistics overview.
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './common.js';
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './utils.js';
cli({
site: 'boss',
+5
View File
@@ -1,5 +1,6 @@
import { execSync, spawnSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { getVisibleChatMessages } from './ax.js';
@@ -16,6 +17,10 @@ export const askCommand = cli({
],
columns: ['Role', 'Text'],
func: async (page: IPage | null, kwargs: any) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
const text = kwargs.text as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 30;
+5
View File
@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const newCommand = cli({
@@ -12,6 +13,10 @@ export const newCommand = cli({
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.5'");
+6 -1
View File
@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { getVisibleChatMessages } from './ax.js';
@@ -13,6 +14,10 @@ export const readCommand = cli({
args: [],
columns: ['Role', 'Text'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.3'");
@@ -24,7 +29,7 @@ export const readCommand = cli({
return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
} catch (err: any) {
throw new Error("Failed to read from ChatGPT: " + err.message);
throw new CommandExecutionError("Failed to read from ChatGPT: " + err.message);
}
},
});
+6 -1
View File
@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, ConfigError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const statusCommand = cli({
@@ -12,11 +13,15 @@ export const statusCommand = cli({
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
try {
const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim();
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
} catch {
return [{ Status: 'Error querying application state' }];
throw new CommandExecutionError('Error querying ChatGPT application state');
}
},
});
+5 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -26,14 +27,14 @@ export const askCommand = cli({
`);
// Send message
await page.evaluate(`
const injected = await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) throw new Error('Could not find input');
if (!composer) return false;
composer.focus();
if (composer.tagName === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
@@ -42,8 +43,10 @@ export const askCommand = cli({
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
+5 -3
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const modelCommand = cli({
@@ -44,7 +45,7 @@ export const modelCommand = cli({
return [{ Status: 'Active', Model: currentModel }];
} else {
// Try to switch model
await page.evaluate(`
const opened = await page.evaluate(`
(function(target) {
const selectors = [
'[class*="model"]',
@@ -54,11 +55,12 @@ export const modelCommand = cli({
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
if (el) { el.click(); return true; }
}
throw new Error('Could not find model selector');
return false;
})(${JSON.stringify(desiredModel)})
`);
if (!opened) throw new SelectorError('ChatWise model selector');
await page.wait(0.5);
+2 -20
View File
@@ -1,21 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { makeNewCommand } from '../_shared/desktop-commands.js';
export const newCommand = cli({
site: 'chatwise',
name: 'new',
description: 'Start a new conversation in ChatWise',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status'],
func: async (page: IPage) => {
// ChatWise uses standard Electron shortcuts
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation');
+2 -32
View File
@@ -1,33 +1,3 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
export const screenshotCommand = cli({
site: 'chatwise',
name: 'screenshot',
description: 'Capture a snapshot of the current ChatWise window (DOM + Accessibility tree)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: 'Output file path (default: /tmp/chatwise-snapshot)' },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const basePath = (kwargs.output as string) || '/tmp/chatwise-snapshot';
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = basePath + '-dom.html';
const snapPath = basePath + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise');
+5 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -13,7 +14,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
await page.evaluate(`
const injected = await page.evaluate(`
(function(text) {
// ChatWise input can be textarea or contenteditable
let composer = document.querySelector('textarea');
@@ -22,7 +23,7 @@ export const sendCommand = cli({
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) throw new Error('Could not find ChatWise input element');
if (!composer) return false;
composer.focus();
@@ -34,8 +35,10 @@ export const sendCommand = cli({
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
+2 -24
View File
@@ -1,25 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { makeStatusCommand } from '../_shared/desktop-commands.js';
export const statusCommand = cli({
site: 'chatwise',
name: 'status',
description: 'Check active CDP connection to ChatWise Desktop',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status', 'Url', 'Title'],
func: async (page: IPage) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [
{
Status: 'Connected',
Url: url,
Title: title,
},
];
},
});
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');
+5 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -26,15 +27,17 @@ export const askCommand = cli({
`);
// Inject and send
await page.evaluate(`
const injected = await page.evaluate(`
(function(text) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea');
if (!composer) throw new Error('Could not find Codex input');
if (!composer) return false;
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(text)})
`);
if (!injected) throw new SelectorError('Codex input element');
await page.wait(0.5);
await page.pressKey('Enter');
+2 -27
View File
@@ -1,28 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
import { makeDumpCommand } from '../_shared/desktop-commands.js';
export const dumpCommand = cli({
site: 'codex',
name: 'dump',
description: 'Dump the DOM and Accessibility tree of Codex for reverse-engineering',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page) => {
// Extract full HTML
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/codex-dom.html', dom);
// Get accessibility snapshot
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync('/tmp/codex-snapshot.json', JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: '/tmp/codex-dom.html, /tmp/codex-snapshot.json',
},
];
},
});
export const dumpCommand = makeDumpCommand('codex');
+2 -28
View File
@@ -1,29 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import { makeNewCommand } from '../_shared/desktop-commands.js';
export const newCommand = cli({
site: 'codex',
name: 'new',
description: 'Start a new Codex conversation thread / isolated workspace',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status', 'Action'],
func: async (page) => {
// According to research, Cmd+N / Ctrl+N spins up a new thread
const isMac = process.platform === 'darwin';
const newThreadKey = isMac ? 'Meta+N' : 'Control+N';
// Simulate keyboard shortcut
await page.pressKey(newThreadKey);
// Wait a brief moment for UI animation
await page.wait(1);
return [
{
Status: 'Success',
Action: `Pressed ${newThreadKey} to trigger New Thread`,
},
];
},
});
export const newCommand = makeNewCommand('codex', 'Codex conversation');
+2 -32
View File
@@ -1,33 +1,3 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
export const screenshotCommand = cli({
site: 'codex',
name: 'screenshot',
description: 'Capture a snapshot of the current Codex window (DOM + Accessibility tree)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: 'Output file path (default: /tmp/codex-snapshot.txt)' },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || '/tmp/codex-snapshot.txt';
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
export const screenshotCommand = makeScreenshotCommand('codex', 'Codex');
+5 -4
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -13,7 +14,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const textToInsert = kwargs.text as string;
await page.evaluate(`
const injected = await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea, [contenteditable="true"]');
@@ -22,14 +23,14 @@ export const sendCommand = cli({
composer = editables[editables.length - 1];
}
if (!composer) {
throw new Error('Could not find Composer input element in Codex UI');
}
if (!composer) return false;
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(textToInsert)})
`);
if (!injected) throw new SelectorError('Codex Composer input element');
// Wait for the UI to register the input
await page.wait(0.5);
+2 -24
View File
@@ -1,25 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { makeStatusCommand } from '../_shared/desktop-commands.js';
export const statusCommand = cli({
site: 'codex',
name: 'status',
description: 'Check active CDP connection to OpenAI Codex App',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status', 'Url', 'Title'],
func: async (page: IPage) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [
{
Status: 'Connected',
Url: url,
Title: title,
},
];
},
});
export const statusCommand = makeStatusCommand('codex', 'OpenAI Codex App');
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
@@ -33,7 +34,7 @@ export const askCommand = cli({
})(${JSON.stringify(text)})`
);
if (!injected) throw new Error('Could not find input element.');
if (!injected) throw new SelectorError('Cursor input element');
await page.wait(0.5);
await page.pressKey('Enter');
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const composerCommand = cli({
@@ -33,7 +34,7 @@ export const composerCommand = cli({
);
if (!typed) {
throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
}
await page.wait(0.5);
+2 -27
View File
@@ -1,28 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
import { makeDumpCommand } from '../_shared/desktop-commands.js';
export const dumpCommand = cli({
site: 'cursor',
name: 'dump',
description: 'Dump the DOM and Accessibility tree of Cursor for reverse-engineering',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page) => {
// Extract full HTML
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/cursor-dom.html', dom);
// Get accessibility snapshot
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync('/tmp/cursor-snapshot.json', JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: '/tmp/cursor-dom.html, /tmp/cursor-snapshot.json',
},
];
},
});
export const dumpCommand = makeDumpCommand('cursor');
+2 -20
View File
@@ -1,21 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { makeNewCommand } from '../_shared/desktop-commands.js';
export const newCommand = cli({
site: 'cursor',
name: 'new',
description: 'Start a new Cursor chat or Composer session',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status'],
func: async (page: IPage) => {
// Use keyboard shortcut — most robust approach, avoids brittle DOM selectors
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
export const newCommand = makeNewCommand('cursor', 'Cursor chat or Composer');
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { EmptyResultError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const readCommand = cli({
@@ -39,7 +40,7 @@ export const readCommand = cli({
`);
if (!history || history.length === 0) {
throw new Error('No conversation history found in Cursor.');
throw new EmptyResultError('cursor read', 'No conversation history found in Cursor.');
}
return history;
+1 -36
View File
@@ -1,38 +1,3 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
function makeScreenshotCommand(site: string) {
return cli({
site,
name: 'screenshot',
description: `Capture a snapshot of the current ${site} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
// Get both the accessibility snapshot and the raw DOM HTML
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
}
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
export const screenshotCursor = makeScreenshotCommand('cursor');
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
@@ -29,7 +30,7 @@ export const sendCommand = cli({
);
if (!injected) {
throw new Error('Could not find Cursor Composer input element.');
throw new SelectorError('Cursor Composer input element');
}
// Submit the command. In Cursor, Enter usually submits the chat.
+2 -22
View File
@@ -1,23 +1,3 @@
import { cli, Strategy } from '../../registry.js';
import { makeStatusCommand } from '../_shared/desktop-commands.js';
export const statusCommand = cli({
site: 'cursor',
name: 'status',
description: 'Check active CDP connection to Cursor AI Editor',
domain: 'localhost',
strategy: Strategy.UI, // Interactive UI manipulation
browser: true,
columns: ['Status', 'Url', 'Title'],
func: async (page) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [
{
Status: 'Connected',
Url: url,
Title: title,
},
];
},
});
export const statusCommand = makeStatusCommand('cursor', 'Cursor AI Editor');
+25
View File
@@ -0,0 +1,25 @@
site: dictionary
name: examples
description: Read real-world example sentences utilizing the word
domain: api.dictionaryapi.dev
strategy: public
browser: false
args:
word:
type: string
required: true
positional: true
description: Word to get example sentences for
pipeline:
- fetch:
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
- map:
word: "${{ item.word }}"
example: "${{ (() => { if (item.meanings) { for (const m of item.meanings) { if (m.definitions) { for (const d of m.definitions) { if (d.example) return d.example; } } } } return 'No example found in API.'; })() }}"
- limit: 1
columns: [word, example]
+27
View File
@@ -0,0 +1,27 @@
site: dictionary
name: search
description: Search the Free Dictionary API for definitions, parts of speech, and pronunciations.
domain: api.dictionaryapi.dev
strategy: public
browser: false
args:
word:
type: string
required: true
positional: true
description: Word to define (e.g., serendipity)
pipeline:
- fetch:
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
- map:
word: "${{ item.word }}"
phonetic: "${{ (() => { if (item.phonetic) return item.phonetic; if (item.phonetics) { for (const p of item.phonetics) { if (p.text) return p.text; } } return ''; })() }}"
type: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].partOfSpeech) return item.meanings[0].partOfSpeech; return 'N/A'; })() }}"
definition: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].definitions && item.meanings[0].definitions[0] && item.meanings[0].definitions[0].definition) return item.meanings[0].definitions[0].definition; return 'No definition found in API.'; })() }}"
- limit: 1
columns: [word, phonetic, type, definition]
+25
View File
@@ -0,0 +1,25 @@
site: dictionary
name: synonyms
description: Find synonyms for a specific word
domain: api.dictionaryapi.dev
strategy: public
browser: false
args:
word:
type: string
required: true
positional: true
description: Word to find synonyms for (e.g., serendipity)
pipeline:
- fetch:
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
- map:
word: "${{ item.word }}"
synonyms: "${{ (() => { const s = new Set(); if (item.meanings) { for (const m of item.meanings) { if (m.synonyms) { for (const syn of m.synonyms) s.add(syn); } if (m.definitions) { for (const d of m.definitions) { if (d.synonyms) { for (const syn of d.synonyms) s.add(syn); } } } } } const arr = Array.from(s); return arr.length > 0 ? arr.slice(0, 5).join(', ') : 'No synonyms found in API.'; })() }}"
- limit: 1
columns: [word, synonyms]
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadDoubanBookHot } from './shared.js';
import { loadDoubanBookHot } from './utils.js';
cli({
site: 'douban',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { loadDoubanMovieHot } from './shared.js';
import { loadDoubanMovieHot } from './utils.js';
cli({
site: 'douban',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { searchDouban } from './shared.js';
import { searchDouban } from './utils.js';
cli({
site: 'douban',
-165
View File
@@ -1,165 +0,0 @@
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}
async function ensureDoubanReady(page: IPage): Promise<void> {
const state = await page.evaluate(`
(() => {
const title = (document.title || '').trim();
const href = (location.href || '').trim();
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
return { blocked, title, href };
})()
`);
if (state?.blocked) {
throw new CliError(
'AUTH_REQUIRED',
'Douban requires a logged-in browser session before these commands can load data.',
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
);
}
}
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://book.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const books = [];
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
try {
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;
const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(' / ');
books.push({
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
title,
rating: parseFloat(ratingText) || 0,
quote,
author: infoParts[0] || '',
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
} catch {}
}
return books.slice(0, ${safeLimit});
})()
`);
return Array.isArray(data) ? data : [];
}
export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://movie.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const results = [];
for (const el of Array.from(document.querySelectorAll('.item'))) {
const titleEl = el.querySelector('.pl2 a');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;
const info = normalize(el.querySelector('.pl2 p')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const releaseIndex = (() => {
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
}
return -1;
})();
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
results.push({
rank: results.length + 1,
title,
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
quote: normalize(el.querySelector('.inq')?.textContent),
director: directorPart.replace(/^导演:\\s*/, ''),
year: yearMatch?.[0] || '',
region: regionPart,
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
await page.wait(2);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(async () => {
const type = ${JSON.stringify(type)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const seen = new Set();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 20; i += 1) {
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
await sleep(300);
}
const items = Array.from(document.querySelectorAll('.item-root'));
const results = [];
for (const el of items) {
const titleEl = el.querySelector('.title-text, .title a, a[title]');
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
if (!url.includes('/subject/') || seen.has(url)) continue;
seen.add(url);
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
const abstract = normalize(
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
);
results.push({
rank: results.length + 1,
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
type,
title,
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
+165 -1
View File
@@ -1,9 +1,173 @@
/**
* Douban movie adapter utilities.
* Douban adapter utilities.
*/
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}
async function ensureDoubanReady(page: IPage): Promise<void> {
const state = await page.evaluate(`
(() => {
const title = (document.title || '').trim();
const href = (location.href || '').trim();
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
return { blocked, title, href };
})()
`);
if (state?.blocked) {
throw new CliError(
'AUTH_REQUIRED',
'Douban requires a logged-in browser session before these commands can load data.',
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
);
}
}
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://book.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const books = [];
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
try {
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;
const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(' / ');
books.push({
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
title,
rating: parseFloat(ratingText) || 0,
quote,
author: infoParts[0] || '',
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
} catch {}
}
return books.slice(0, ${safeLimit});
})()
`);
return Array.isArray(data) ? data : [];
}
export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://movie.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const results = [];
for (const el of Array.from(document.querySelectorAll('.item'))) {
const titleEl = el.querySelector('.pl2 a');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;
const info = normalize(el.querySelector('.pl2 p')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const releaseIndex = (() => {
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
}
return -1;
})();
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
results.push({
rank: results.length + 1,
title,
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
quote: normalize(el.querySelector('.inq')?.textContent),
director: directorPart.replace(/^导演:\\s*/, ''),
year: yearMatch?.[0] || '',
region: regionPart,
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
await page.wait(2);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(async () => {
const type = ${JSON.stringify(type)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const seen = new Set();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 20; i += 1) {
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
await sleep(300);
}
const items = Array.from(document.querySelectorAll('.item-root'));
const results = [];
for (const el of items) {
const titleEl = el.querySelector('.title-text, .title a, a[title]');
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
if (!url.includes('/subject/') || seen.has(url)) continue;
seen.add(url);
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
const abstract = normalize(
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
);
results.push({
rank: results.length + 1,
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
type,
title,
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
/**
* Get current user's Douban ID from movie.douban.com/mine page
*/
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './common.js';
import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './utils.js';
export const askCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { clickNewChatScript } from './common.js';
import { clickNewChatScript } from './utils.js';
export const newCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { readMessagesScript } from './common.js';
import { readMessagesScript } from './utils.js';
export const readCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { injectTextScript, clickSendScript } from './common.js';
import { injectTextScript, clickSendScript } from './utils.js';
export const sendCommand = cli({
site: 'doubao-app',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './common.js';
import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './utils.js';
export const askCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, startNewDoubaoChat } from './common.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, startNewDoubaoChat } from './utils.js';
export const newCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './common.js';
import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './utils.js';
export const readCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, sendDoubaoMessage } from './common.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, sendDoubaoMessage } from './utils.js';
export const sendCommand = cli({
site: 'doubao',
+1 -1
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, getDoubaoPageState } from './common.js';
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, getDoubaoPageState } from './utils.js';
export const statusCommand = cli({
site: 'doubao',
+25
View File
@@ -1,7 +1,32 @@
import { describe, expect, it } from 'vitest';
import type { IPage } from '../../types.js';
import { __test__ } from './ask.js';
describe('grok ask helpers', () => {
describe('isOnGrok', () => {
const fakePage = (url: string | Error): IPage =>
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
it('returns true for grok.com URLs', async () => {
expect(await __test__.isOnGrok(fakePage('https://grok.com/'))).toBe(true);
expect(await __test__.isOnGrok(fakePage('https://grok.com/chat/abc123'))).toBe(true);
});
it('returns true for grok.com subdomains', async () => {
expect(await __test__.isOnGrok(fakePage('https://api.grok.com/v1'))).toBe(true);
});
it('returns false for non-grok domains', async () => {
expect(await __test__.isOnGrok(fakePage('https://fakegrok.com/'))).toBe(false);
expect(await __test__.isOnGrok(fakePage('https://example.com/?next=grok.com'))).toBe(false);
expect(await __test__.isOnGrok(fakePage('about:blank'))).toBe(false);
});
it('returns false when evaluate throws (detached tab)', async () => {
expect(await __test__.isOnGrok(fakePage(new Error('detached')))).toBe(false);
});
});
it('normalizes boolean flags for explicit web routing', () => {
expect(__test__.normalizeBooleanFlag(true)).toBe(true);
expect(__test__.normalizeBooleanFlag('true')).toBe(true);
+25 -12
View File
@@ -53,6 +53,19 @@ function updateStableState(previousText: string, stableCount: number, nextText:
return { previousText: nextText, stableCount: 0 };
}
/** Check whether the tab is already on grok.com (any path). */
async function isOnGrok(page: IPage): Promise<boolean> {
// catch handles blank tabs (about:blank) or detached pages
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string' || !url) return false;
try {
const hostname = new URL(url).hostname;
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
} catch {
return false;
}
}
async function runDefaultAsk(
page: IPage,
prompt: string,
@@ -60,21 +73,17 @@ async function runDefaultAsk(
newChat: boolean,
) {
if (newChat) {
// Explicitly start a fresh conversation via the homepage
await page.goto(GROK_URL);
await page.wait(2);
await page.evaluate(`(() => {
const btn = [...document.querySelectorAll('a, button')].find(b => {
const t = (b.textContent || '').trim().toLowerCase();
return t.includes('new') || b.getAttribute('href') === '/';
});
if (btn) btn.click();
})()`);
await tryStartFreshChat(page);
await page.wait(2);
} else if (!(await isOnGrok(page))) {
// First invocation or tab was recycled — navigate to Grok
await page.goto(GROK_URL);
await page.wait(3);
}
await page.goto(GROK_URL);
await page.wait(3);
const promptJson = JSON.stringify(prompt);
const sendResult = await page.evaluate(`(async () => {
try {
@@ -249,11 +258,14 @@ async function runExplicitWebAsk(
timeoutMs: number,
newChat: boolean,
) {
await page.goto(GROK_URL, { settleMs: 2000 });
if (newChat) {
// Navigate to homepage and start a fresh conversation
await page.goto(GROK_URL, { settleMs: 2000 });
await tryStartFreshChat(page);
await page.wait(2);
} else if (!(await isOnGrok(page))) {
// First invocation or tab was recycled — navigate to Grok
await page.goto(GROK_URL, { settleMs: 2000 });
}
const baselineBubbles = await getBubbleTexts(page);
@@ -318,4 +330,5 @@ export const __test__ = {
updateStableState,
normalizeBooleanFlag,
normalizeBubbleText,
isOnGrok,
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '../../registry.js';
import './item.js';
describe('jd item adapter', () => {
const command = getRegistry().get('jd/item');
it('registers the command with correct shape', () => {
expect(command).toBeDefined();
expect(command!.site).toBe('jd');
expect(command!.name).toBe('item');
expect(command!.domain).toBe('item.jd.com');
expect(command!.strategy).toBe('cookie');
expect(typeof command!.func).toBe('function');
});
it('has sku as a required positional arg', () => {
const skuArg = command!.args.find((a) => a.name === 'sku');
expect(skuArg).toBeDefined();
expect(skuArg!.required).toBe(true);
expect(skuArg!.positional).toBe(true);
});
it('has images arg with default 10', () => {
const imagesArg = command!.args.find((a) => a.name === 'images');
expect(imagesArg).toBeDefined();
expect(imagesArg!.default).toBe(10);
});
it('includes expected columns', () => {
expect(command!.columns).toEqual(
expect.arrayContaining(['title', 'price', 'shop', 'specs', 'mainImages', 'detailImages']),
);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* browser cookie, DOM scraping + evaluate.
*
* 依赖: 需要在 Chrome
* 用法: opencli jd item 100291143898
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'jd',
name: 'item',
description: '京东商品详情(价格、主图、详情图、规格参数)',
domain: 'item.jd.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'sku',
required: true,
positional: true,
help: '商品 SKU ID(如 100291143898',
},
{
name: 'images',
type: 'int',
default: 10,
help: '详情图数量(默认10',
},
],
columns: ['title', 'price', 'shop', 'specs', 'mainImages', 'detailImages'],
func: async (page, kwargs) => {
const sku = kwargs.sku;
const maxImages = kwargs.images as number;
const url = `https://item.jd.com/${sku}.html`;
await page.goto(url, { waitUntil: 'load' });
await page.wait(2);
// 滚动加载详情图
for (let i = 0; i < 6; i++) {
await page.evaluate(`window.scrollTo(0, ${i * 2500})`);
await page.wait(1);
}
await page.evaluate(`window.scrollTo(0, document.body.scrollHeight)`);
await page.wait(2);
const data = await page.evaluate(`
(() => {
const maxImg = ${maxImages};
// 尝试多种价格选择器
const skuMatch = location.pathname.match(/(\\d+)\\.html/);
const sku = skuMatch ? skuMatch[1] : '';
const priceEl = document.querySelector('.J-p-' + sku) ||
document.querySelector('[class*="price"] [class*="num"]') ||
document.querySelector('.p-price strong') ||
document.querySelector('.price.jd-price');
const price = priceEl?.textContent?.trim() || 'not found';
// 标题
const title = document.querySelector('.product-title')?.textContent?.trim() ||
document.title.split('-')[0].trim();
// 店铺
const shop = document.querySelector('.J-shop-name')?.textContent?.trim() || '京东自营';
// 所有图片
const allImgs = Array.from(document.querySelectorAll('img[src*="360buyimg.com"]'));
const srcs = allImgs.map(img => img.src).filter(Boolean);
const unique = [...new Set(srcs)];
// 主图
const mainImgs = unique
.filter(u => u.includes('/n1/') || u.includes('/n3/') || u.includes('/n4/') || u.includes('/img/'))
.slice(0, maxImg);
// 详情图
const detailImgs = unique
.filter(u => u.includes('/babel/') || u.includes('/popshop/'))
.slice(0, maxImg);
// 规格参数:从页面文本提取
const text = document.body.innerText;
const specMatch = text.match(/商品编号[\\s\\S]*?(?=包装清单|\\n\\n|$)/);
let specs = {};
if (specMatch) {
const lines = specMatch[0].split('\\n').filter(l => l.trim());
for (let i = 0; i < lines.length - 1; i += 2) {
const key = lines[i].trim();
const val = lines[i + 1]?.trim() || '';
if (key && val && key !== '商品编号') {
specs[key] = val;
}
}
}
return { title, price, shop, specs, mainImages: mainImgs, detailImages: detailImgs, totalImages: unique.length };
})()
`);
return [data];
},
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { JikePost, getPostDataJs } from './shared.js';
import { JikePost, getPostDataJs } from './utils.js';
/**
*
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { JikePost, getPostDataJs } from './shared.js';
import { JikePost, getPostDataJs } from './utils.js';
/**
*
+5 -4
View File
@@ -1,5 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { ArgumentError, CommandExecutionError } from '../../errors.js';
// ── Filter value mappings ──────────────────────────────────────────────
@@ -64,7 +65,7 @@ function mapFilterValues(input: unknown, mapping: Record<string, string>, label:
const resolved = values.map(value => {
const key = value.toLowerCase();
const mapped = mapping[key];
if (!mapped) throw new Error(`Unsupported ${label}: ${value}`);
if (!mapped) throw new ArgumentError(`Unsupported ${label}: ${value}`);
return mapped;
});
return [...new Set(resolved)];
@@ -214,7 +215,7 @@ async function resolveCompanyIds(page: IPage, input: unknown): Promise<string[]>
}
if (unresolved.length) {
throw new Error(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
throw new ArgumentError(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
}
return [...ids];
@@ -252,7 +253,7 @@ async function fetchJobCards(
})()`);
if (!batch || batch.error) {
throw new Error(batch?.error || 'LinkedIn search returned an unexpected response');
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
}
const elements: any[] = Array.isArray(batch?.elements) ? batch.elements : [];
@@ -387,7 +388,7 @@ cli({
const location = (kwargs.location ?? '').trim();
const keywords = String(kwargs.query ?? '').trim();
if (!keywords) throw new Error('query is required');
if (!keywords) throw new ArgumentError('query is required');
const searchParams = new URLSearchParams({ keywords });
if (location) searchParams.set('location', location);
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '../../registry.js';
import './timeline.js';
const { parseMetric, buildPostId, mergeTimelinePosts } = await import('./timeline.js').then(
(m) => (m as any).__test__,
);
describe('linkedin timeline adapter', () => {
const command = getRegistry().get('linkedin/timeline');
it('registers the command with correct shape', () => {
expect(command).toBeDefined();
expect(command!.site).toBe('linkedin');
expect(command!.name).toBe('timeline');
expect(command!.domain).toBe('www.linkedin.com');
expect(command!.strategy).toBe('cookie');
expect(command!.browser).toBe(true);
expect(typeof command!.func).toBe('function');
});
it('has limit arg with default 20', () => {
const limitArg = command!.args.find((a) => a.name === 'limit');
expect(limitArg).toBeDefined();
expect(limitArg!.default).toBe(20);
});
it('includes expected columns', () => {
expect(command!.columns).toEqual(
expect.arrayContaining(['author', 'text', 'reactions', 'comments', 'url']),
);
});
});
describe('parseMetric', () => {
it('parses plain numbers', () => {
expect(parseMetric('42')).toBe(42);
expect(parseMetric('1,234')).toBe(1234);
});
it('handles k/m suffixes', () => {
expect(parseMetric('2.5k')).toBe(2500);
expect(parseMetric('1.2M')).toBe(1200000);
});
it('returns 0 for empty/undefined', () => {
expect(parseMetric('')).toBe(0);
expect(parseMetric(undefined)).toBe(0);
expect(parseMetric(null)).toBe(0);
});
});
describe('buildPostId', () => {
it('uses url when present', () => {
expect(buildPostId({ url: 'https://linkedin.com/post/123' })).toBe(
'https://linkedin.com/post/123',
);
});
it('falls back to composite key', () => {
const id = buildPostId({ author: 'Alice', posted_at: '2h', text: 'Hello world' });
expect(id).toBe('Alice::2h::Hello world');
});
});
describe('mergeTimelinePosts', () => {
it('deduplicates by url', () => {
const url = 'https://linkedin.com/post/1';
const a = {
id: url,
author: 'Alice',
author_url: '',
headline: '',
text: 'Hello',
posted_at: '1h',
reactions: 5,
comments: 1,
url,
};
const result = mergeTimelinePosts([a], [a]);
expect(result).toHaveLength(1);
});
it('skips posts without author or text', () => {
const empty = {
id: '2',
author: '',
author_url: '',
headline: '',
text: 'some text',
posted_at: '',
reactions: 0,
comments: 0,
url: '',
};
const result = mergeTimelinePosts([], [empty]);
expect(result).toHaveLength(0);
});
});
+532
View File
@@ -0,0 +1,532 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
interface TimelinePost {
rank?: number;
id: string;
author: string;
author_url: string;
headline: string;
text: string;
posted_at: string;
reactions: number;
comments: number;
url: string;
}
interface ExtractedBatch {
loginRequired?: boolean;
posts?: TimelinePost[];
}
function normalizeWhitespace(value: unknown): string {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function parseMetric(value: unknown): number {
const raw = normalizeWhitespace(value).toLowerCase();
if (!raw) return 0;
const compact = raw.replace(/,/g, '');
const match = compact.match(/(\d+(?:\.\d+)?)(k|m)?/i);
if (!match) return 0;
const base = Number(match[1]);
const suffix = (match[2] || '').toLowerCase();
if (suffix === 'k') return Math.round(base * 1000);
if (suffix === 'm') return Math.round(base * 1000000);
return Math.round(base);
}
function buildPostId(post: Partial<TimelinePost>): string {
const url = normalizeWhitespace(post.url);
if (url) return url;
const author = normalizeWhitespace(post.author);
const text = normalizeWhitespace(post.text);
const postedAt = normalizeWhitespace(post.posted_at);
return `${author}::${postedAt}::${text.slice(0, 120)}`;
}
function mergeTimelinePosts(existing: TimelinePost[], batch: TimelinePost[]): TimelinePost[] {
const seen = new Set(existing.map(post => post.id));
const merged = [...existing];
for (const rawPost of batch) {
const post: TimelinePost = {
id: buildPostId(rawPost),
author: normalizeWhitespace(rawPost.author),
author_url: normalizeWhitespace(rawPost.author_url),
headline: normalizeWhitespace(rawPost.headline),
text: normalizeWhitespace(rawPost.text),
posted_at: normalizeWhitespace(rawPost.posted_at),
reactions: Number(rawPost.reactions) || 0,
comments: Number(rawPost.comments) || 0,
url: normalizeWhitespace(rawPost.url),
};
if (!post.author || !post.text) continue;
if (seen.has(post.id)) continue;
seen.add(post.id);
merged.push(post);
}
return merged;
}
async function extractVisiblePosts(page: IPage): Promise<ExtractedBatch> {
return page.evaluate(`(function () {
function normalize(value) {
return String(value || '').replace(/\\s+/g, ' ').trim();
}
function textOf(root, selector) {
var el = root.querySelector(selector);
return el ? el.textContent : '';
}
function hrefOf(root, selector) {
var el = root.querySelector(selector);
return el && el.href ? el.href : '';
}
function attrOf(root, selector, attr) {
var el = root.querySelector(selector);
return el ? el.getAttribute(attr) : '';
}
function cleanTimestamp(value) {
return normalize(String(value || '').replace(/[•.]/g, ' '));
}
function parseMetric(value) {
var raw = normalize(value).toLowerCase();
var match;
var base;
var suffix;
if (!raw) return 0;
match = raw.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)(k|m)?/i);
if (!match) return 0;
base = Number(match[1]);
suffix = (match[2] || '').toLowerCase();
if (suffix === 'k') return Math.round(base * 1000);
if (suffix === 'm') return Math.round(base * 1000000);
return Math.round(base);
}
function splitBlocks(text) {
var lines = String(text || '').split('\\n');
var blocks = [];
var current = [];
var i;
var line;
for (i = 0; i < lines.length; i += 1) {
line = normalize(lines[i]);
if (!line) {
if (current.length) {
blocks.push(normalize(current.join(' ')));
current = [];
}
continue;
}
current.push(line);
}
if (current.length) blocks.push(normalize(current.join(' ')));
return blocks;
}
function looksLikeTimestamp(value) {
var lower = String(value || '').toLowerCase();
return /^\\d+\\s*(s|m|h|d|w|mo|yr|min)(\\s*[•.])?$/i.test(lower);
}
function looksLikeBadge(value) {
var lower = String(value || '').toLowerCase();
return String(value || '').indexOf('•') === 0
|| lower === '1st'
|| lower === '2nd'
|| lower === '3rd'
|| lower === 'degree connection';
}
function looksLikeAction(value) {
return /^(follow|send message|connect|visit my website|view my newsletter|subscribe)$/i.test((value || '').toLowerCase());
}
function looksLikeCta(value) {
return /^(book an appointment|view my services|visit my website|view my newsletter|subscribe|learn more|contact us)$/i.test((value || '').toLowerCase());
}
function looksLikeEngagement(value) {
return /(reactions?|comments?|reposts?)/i.test(String(value || ''));
}
function looksLikeFooterAction(value) {
return /^(like|comment|repost|send|reply|load more comments)$/i.test((value || '').toLowerCase());
}
function findActivityUrn(root) {
var elements = [root].concat(Array.from(root.querySelectorAll('*')));
var i;
var j;
var attrs;
var value;
var match;
for (i = 0; i < elements.length; i += 1) {
attrs = Array.from(elements[i].attributes || []);
for (j = 0; j < attrs.length; j += 1) {
value = String(attrs[j].value || '');
match = value.match(/urn:li:activity:\\d+/);
if (match) return match[0];
}
}
return '';
}
function parseReactionCount(root, blocks) {
var direct = textOf(root, '.social-details-social-counts__reactions-count');
var rootText = String(root.innerText || '');
var i;
var value;
value = rootText.match(/and\\s+(\\d[\\d,]*)\\s+others\\s+reacted/i);
if (value) return parseMetric(value[1]) + 1;
value = rootText.match(/and\\s+(\\d[\\d,]*)\\s+others(?!\\s+comments?)(?!\\s+reposts?)/i);
if (value) return parseMetric(value[1]) + 1;
value = rootText.match(/(\\d[\\d,]*)\\s+reactions?/i);
if (value) return parseMetric(value[0]);
if (direct) return parseMetric(direct);
for (i = 0; i < blocks.length; i += 1) {
value = blocks[i];
if (/and\\s+\\d[\\d,]*\\s+others(?!\\s+comments?)(?!\\s+reposts?)/i.test(value)) {
return parseMetric(value) + 1;
}
if (/reactions?/i.test(value)) return parseMetric(value);
if (/and\\s+\\d+[\\d,]*\\s+others\\s+reacted/i.test(value)) return parseMetric(value) + 1;
}
return 0;
}
function parseCommentCount(blocks) {
var i;
var text = blocks.join(' ');
var match = text.match(/(\\d[\\d,]*)\\s+comments?/i);
if (match) return parseMetric(match[0]);
for (i = 0; i < blocks.length; i += 1) {
if (/comments?/i.test(blocks[i])) return parseMetric(blocks[i]);
}
return 0;
}
function selectProfileLink(root, author) {
var links = Array.from(root.querySelectorAll('a[href*="/in/"], a[href*="/company/"]'));
var normalizedAuthor = normalize(author).toLowerCase();
var i;
var label;
for (i = 0; i < links.length; i += 1) {
label = normalize(links[i].textContent || links[i].getAttribute('aria-label')).toLowerCase();
if (!links[i].href) continue;
if (normalizedAuthor && label.indexOf(normalizedAuthor) >= 0) return links[i];
}
return links[0] || null;
}
function selectProfileUrl(root, author) {
var link = selectProfileLink(root, author);
return link && link.href ? link.href : '';
}
function parseActorLinkMeta(root, author) {
var link = selectProfileLink(root, author);
var text = normalize(link ? link.textContent : '');
var normalizedAuthor = normalize(author);
var match;
var rest;
var headline = '';
var postedAt = '';
if (!text || !normalizedAuthor) return { headline: '', postedAt: '' };
if (text.indexOf(normalizedAuthor) === 0) {
rest = normalize(text.slice(normalizedAuthor.length));
} else {
rest = text;
}
rest = normalize(rest.replace(/^[•·]\\s*(1st|2nd|3rd\\+?|3rd|degree connection)/i, ''));
match = rest.match(/(\\d+\\s*(?:s|m|h|d|w|mo|yr|min))\\s*[•·]?$/i);
if (match) {
postedAt = cleanTimestamp(match[1]);
headline = normalize(rest.slice(0, rest.length - match[0].length));
} else {
headline = rest;
}
headline = normalize(headline.replace(/^(book an appointment|view my services|visit my website|view my newsletter)\\s*/i, ''));
return { headline: headline, postedAt: postedAt };
}
function stripBodyTail(value) {
return normalize(String(value || '')
.replace(/\\s+\\d[\\d,]*\\s+reactions?[\\s\\S]*$/i, '')
.replace(/\\s+\\d[\\d,]*\\s+comments?[\\s\\S]*$/i, '')
.replace(/\\s+[A-Z][^\\n]+\\s+and\\s+\\d[\\d,]*\\s+others\\s+reacted[\\s\\S]*$/i, '')
.replace(/\\s+Like\\s+Comment\\s+Repost\\s+Send[\\s\\S]*$/i, '')
.replace(/\\s+Reaction button state:[\\s\\S]*$/i, '')
.replace(/^\\d+\\s*(?:s|m|h|d|w|mo|yr|min)\\s*[•.]?\\s*Follow\\s+/i, '')
);
}
function parseActorMeta(root) {
var actorLink = root.querySelector('a[href*="/in/"], a[href*="/company/"]');
var actorText = normalize(actorLink ? actorLink.textContent : '');
var author = '';
var headline = '';
var postedAt = '';
var match;
if (actorText) {
match = actorText.match(/^(.+?)\\s+[•·]\\s+(1st|2nd|3rd\\+?|3rd|degree connection)(.*)$/i);
if (match) {
author = normalize(match[1]);
actorText = normalize(match[3]);
}
}
match = actorText.match(/(.+?)\\s+(\\d+\\s*(?:s|m|h|d|w|mo|yr|min))\\s*[•·]?$/i);
if (match) {
headline = normalize(match[1]);
postedAt = cleanTimestamp(match[2]);
} else if (actorText) {
headline = actorText;
}
return {
author: author,
headline: headline,
postedAt: postedAt,
authorUrl: actorLink && actorLink.href ? actorLink.href : '',
};
}
function extractFromListItem(root) {
var blocks = splitBlocks(root.innerText || '');
var filtered = [];
var i;
var value;
var author = '';
var authorUrl = '';
var headline = '';
var postedAt = '';
var text = '';
var bodyStart = -1;
var permalink;
var url;
var reactions;
var comments;
var endIndex = -1;
var urn;
if (blocks.length < 5) return null;
if (blocks[0] !== 'Feed post') return null;
for (i = 1; i < blocks.length; i += 1) {
value = blocks[i];
if (!value) continue;
if (/commented on this|reposted this|liked this|suggested/i.test(value)) continue;
filtered.push(value);
}
if (filtered.length < 4) return null;
for (i = 0; i < filtered.length; i += 1) {
value = filtered[i];
if (!author && !looksLikeBadge(value) && !looksLikeAction(value) && !looksLikeTimestamp(value)) {
author = value;
continue;
}
if (author && !headline && !looksLikeBadge(value) && !looksLikeAction(value) && !looksLikeTimestamp(value) && !looksLikeCta(value)) {
headline = value;
continue;
}
if (!postedAt && looksLikeTimestamp(value)) {
postedAt = cleanTimestamp(value);
continue;
}
}
if (!author) return null;
authorUrl = selectProfileUrl(root, author);
if (!headline || !postedAt) {
var actorMeta = parseActorLinkMeta(root, author);
if (!headline && actorMeta.headline) headline = actorMeta.headline;
if (!postedAt && actorMeta.postedAt) postedAt = actorMeta.postedAt;
}
for (i = 0; i < filtered.length; i += 1) {
value = filtered[i];
if (looksLikeAction(value)) {
bodyStart = i + 1;
break;
}
}
if (bodyStart < 0 && postedAt) {
bodyStart = filtered.indexOf(postedAt) + 1;
}
if (bodyStart < 0) bodyStart = Math.min(filtered.length, headline ? 2 : 1);
for (i = bodyStart; i < filtered.length; i += 1) {
value = filtered[i];
if (looksLikeEngagement(value) || looksLikeFooterAction(value)) {
endIndex = i;
break;
}
}
if (endIndex < 0) endIndex = filtered.length;
text = stripBodyTail(filtered.slice(bodyStart, endIndex).join('\\n\\n'));
if (!text) return null;
permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
url = permalink ? permalink.href : '';
urn = findActivityUrn(root);
if (!url && urn) url = 'https://www.linkedin.com/feed/update/' + urn + '/';
reactions = parseReactionCount(root, filtered);
comments = parseCommentCount(filtered);
return {
id: url || (author + '::' + postedAt + '::' + text.slice(0, 120)),
author: author,
author_url: authorUrl,
headline: headline,
text: text,
posted_at: postedAt,
reactions: reactions,
comments: comments,
url: url,
};
}
function commentMetric(root) {
var links = Array.from(root.querySelectorAll('button, a'));
var i;
var label;
for (i = 0; i < links.length; i += 1) {
label = normalize(links[i].textContent || links[i].getAttribute('aria-label'));
if (/comment/i.test(label)) return parseMetric(label);
}
return 0;
}
var currentUrl = window.location.href;
var path = String(window.location.pathname || '');
var loginRequired = path.indexOf('/login') >= 0
|| path.indexOf('/checkpoint/') >= 0
|| Boolean(document.querySelector('input[name="session_key"], form.login__form'));
var moreButtons = Array.from(document.querySelectorAll('button, a[role="button"]'))
.filter(function (el) {
return /see more|more/i.test(normalize(el.textContent))
|| /see more|more/i.test(normalize(el.getAttribute('aria-label')));
})
.slice(0, 8);
var cards = Array.from(document.querySelectorAll('article, .feed-shared-update-v2, .occludable-update, [role="listitem"]'));
var seen = new Set();
var posts = [];
var i;
var card;
var root;
var author;
var headline;
var text;
var postedAt;
var permalink;
var url;
var reactions;
var comments;
for (i = 0; i < moreButtons.length; i += 1) {
try { moreButtons[i].click(); } catch (err) {}
}
for (i = 0; i < cards.length; i += 1) {
card = cards[i];
root = card.closest('article, .feed-shared-update-v2, .occludable-update, [role="listitem"]') || card;
if (!root || seen.has(root)) continue;
seen.add(root);
if (String(root.getAttribute('role') || '') === 'listitem') {
var extracted = extractFromListItem(root);
if (extracted) posts.push(extracted);
continue;
}
author = normalize(
textOf(root, '.update-components-actor__title span[dir="ltr"]')
|| textOf(root, '.update-components-actor__title')
|| textOf(root, '[data-control-name="actor"] span[dir="ltr"]')
|| textOf(root, '[data-control-name="actor"]')
);
headline = normalize(
textOf(root, '.update-components-actor__description')
|| textOf(root, '.update-components-actor__sub-description')
);
text = normalize(
textOf(root, '.update-components-text span[dir="ltr"]')
|| textOf(root, '.update-components-text')
|| textOf(root, '.feed-shared-inline-show-more-text span[dir="ltr"]')
|| textOf(root, '.feed-shared-inline-show-more-text')
|| textOf(root, '[data-test-id="main-feed-activity-card"] .break-words')
);
postedAt = normalize(
textOf(root, '.update-components-actor__sub-description a')
|| textOf(root, '.update-components-actor__sub-description span[aria-hidden="true"]')
|| textOf(root, 'time')
);
permalink = root.querySelector('a[href*="/feed/update/"], a[href*="/posts/"], a[href*="/pulse/"]');
url = permalink ? permalink.href : '';
if (url && url.indexOf('/') === 0) url = new URL(url, currentUrl).toString();
reactions = parseMetric(
textOf(root, '.social-details-social-counts__reactions-count')
|| attrOf(root, '[aria-label*="reaction"]', 'aria-label')
|| attrOf(root, '[aria-label*="like"]', 'aria-label')
);
comments = commentMetric(root);
if (!author || !text) continue;
posts.push({
id: url || (author + '::' + postedAt + '::' + text.slice(0, 120)),
author: author,
author_url: hrefOf(root, 'a[href*="/in/"], a[href*="/company/"]'),
headline: headline,
text: text,
posted_at: postedAt,
reactions: reactions,
comments: comments,
url: url,
});
}
return { loginRequired: loginRequired, posts: posts };
})()`);
}
cli({
site: 'linkedin',
name: 'timeline',
description: 'Read LinkedIn home timeline posts',
domain: 'www.linkedin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts to return (max 100)' },
],
columns: ['rank', 'author', 'author_url', 'headline', 'text', 'posted_at', 'reactions', 'comments', 'url'],
func: async (page, kwargs) => {
const limit = Math.max(1, Math.min(kwargs.limit ?? 20, 100));
await page.goto('https://www.linkedin.com/feed/');
await page.wait(4);
let posts: TimelinePost[] = [];
let sawLoginWall = false;
for (let i = 0; i < 6 && posts.length < limit; i++) {
const batch = await extractVisiblePosts(page);
if (batch?.loginRequired) sawLoginWall = true;
posts = mergeTimelinePosts(posts, Array.isArray(batch?.posts) ? batch.posts : []);
if (posts.length >= limit) break;
await page.autoScroll({ times: 1, delayMs: 1200 });
await page.wait(1);
}
if (sawLoginWall && posts.length === 0) {
throw new AuthRequiredError('linkedin.com', 'LinkedIn timeline requires an active signed-in browser session');
}
if (posts.length === 0) {
throw new EmptyResultError('linkedin timeline', 'Make sure your LinkedIn home feed is visible in the browser.');
}
return posts.slice(0, limit).map((post, index) => ({
rank: index + 1,
...post,
}));
},
});
export const __test__ = {
parseMetric,
buildPostId,
mergeTimelinePosts,
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { buildMediumTagUrl, loadMediumPosts } from './shared.js';
import { buildMediumTagUrl, loadMediumPosts } from './utils.js';
cli({
site: 'medium',
+1 -1
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { buildMediumSearchUrl, loadMediumPosts } from './shared.js';
import { buildMediumSearchUrl, loadMediumPosts } from './utils.js';
cli({
site: 'medium',

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