Compare commits

..

48 Commits

Author SHA1 Message Date
jackwener f8d6319f87 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 20:59:09 +08:00
jackwener bf6207a4e2 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.
2026-03-24 20:44:31 +08:00
jackwener 4e669629fd 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.
2026-03-24 20:44:10 +08:00
jackwener 6cf14e0800 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.
2026-03-24 20:43:44 +08:00
jackwener 45370b50e1 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.
2026-03-24 20:43:20 +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
dependabot[bot] 37c7ea41b8 chore(deps): bump typescript from 5.9.3 to 6.0.2 (#327)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  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 14:51:15 +08:00
jakevin d58e1a74cd fix: resolve 11 important bugs from deep code review (#340)
- I1: Log pre-navigation failures in debug mode instead of silently swallowing
- I2: Validate env var timeout values, fallback on NaN/negative
- I4: Guard against indexOf returning -1 for unknown strategies in cascade
- I5: Fix shouldReplaceManifestEntry returning true for same-type entries
- I6: Prevent infinite loop in parseTsArgsBlock cursor advancement
- I7: Skip redundant Page.enable calls in CDP goto
- I8: Fix wait({time:0}) being treated as falsy
- I10: Warn when cookiesFile path doesn't exist before fallback
- I11: Sanitize tab/newline chars in cookie name/value for Netscape format
- I12: Use DEFAULT_DAEMON_PORT constant instead of hardcoded port in error
- I15: Log npm install failures in plugin lifecycle instead of swallowing

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:10:13 +08:00
jakevin 1b3f74cd6a test: focus adapter coverage on four priority sites (#339) 2026-03-24 12:03:26 +08:00
jakevin bdcffd147f fix: resolve 6 critical bugs from deep code review (#337)
1. execution.ts: Guard lazy-loaded func commands against null page — if a
   lazy module incorrectly requires browser context, throw a clear error
   instead of a cryptic TypeError on page.goto().

2. daemon.ts: Fix readBody race condition — add aborted flag to prevent
   req.destroy() from triggering both reject (via error) and resolve
   (via end event) on the same Promise, which could process truncated data.

3. browser/cdp.ts: Prevent CDPBridge.connect() reentry — throw if already
   connected instead of silently leaking the previous WebSocket and its
   message handlers.

4. interceptor.ts: Store intercept pattern in a separate global variable
   so subsequent installInterceptor calls with different patterns update
   the match condition without being blocked by the patchGuard.

5. record.ts: Always call cleanupEnter() after Promise.race — previously
   only called in the timeout path, leaving readline open when user pressed
   Enter, potentially blocking process exit. Also removed unused enterRace.

6. generate.ts: Fix undefined entering String.includes() — when c.name is
   undefined, toLowerCase() returns undefined which gets coerced to the
   string "undefined" by includes(), causing false positive matches.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:37:03 +08:00
jakevin 53699eb807 fix: harden security-sensitive execution paths (#335)
* fix(security): harden against command injection and sandbox escape

1. cli.ts: Remove auto-discover of arbitrary system binaries via denylist.
   Unknown commands now require explicit registration via `opencli register`.
   The previous denylist approach was trivially bypassable (bash, curl, etc.).

2. template.ts: Protect evalJsExpr against prototype chain escape.
   Block expressions containing constructor/prototype/__proto__/process/etc.
   Deep-copy context objects to sever prototype chains before passing to
   new Function().

3. external.ts: Expand shell operator detection in parseCommand to cover
   $(), $, #, \n, \r — preventing command substitution and comment injection.

4. fetch.ts: Use JSON.stringify for HTTP method in browser evaluate() instead
   of raw string interpolation, preventing JS injection via crafted method values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden security-sensitive execution paths

* chore: tighten template sandbox guard

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:28:22 +08:00
jakevin 56d4326646 refactor(extension): reuse async detach() in registerListeners (#328)
Replace inline sync chrome.debugger.detach() in onUpdated listener
with the shared async detach() function for consistent cleanup behavior
across all detach paths.
2026-03-24 02:18:34 +08:00
QSam2023 4c9a2b1fde fix: detach debugger before navigation in browser bridge (#322)
* fix: detach debugger before navigation in browser bridge

* refactor: make detach() async, await all detach calls

- cdp.ts: detach() now async, awaits chrome.debugger.detach()
- background.ts: await detach() in handleNavigate and handleTabs close
- Eliminates theoretical race between detach and subsequent tab operations

Co-authored-by: jackwener <jackwener@gmail.com>

---------

Co-authored-by: bluey_heeler <fragwang231@gmail.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
Co-authored-by: jackwener <jackwener@gmail.com>
2026-03-24 02:14:31 +08:00
dependabot[bot] 57fcc50c1b chore(deps): bump vitest from 4.1.0 to 4.1.1 (#325)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.1/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 02:13:04 +08:00
dependabot[bot] 689cd1ebb3 chore(ci): bump actions/upload-artifact from 4 to 7 (#324)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  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 02:12:48 +08:00
jakevin 167c7c784a chore: release v1.3.3 (#321)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-24 01:45:21 +08:00
jakevin f852da3af4 fix(stealth): review fixes — guard plugins, rewrite stack trace cleanup (#320)
- Only override navigator.plugins when empty (don't replace real user
  browser plugins with fakes)
- Replace Error.prepareStackTrace (V8/Node-only) with
  Error.prototype.stack getter override that works in browser context
- Fix \\n escaping in template literal for stack trace split/join
- Dynamic cdc_ variable scan via getOwnPropertyNames instead of
  hardcoded names
- Update tests to cover 7 patches
2026-03-24 01:41:22 +08:00
jakevin 15d9ef814f feat(browser): add stealth anti-detection for CDP and daemon modes (#319)
Add stealth.ts module that patches browser globals to hide automation
fingerprints when opencli controls a browser via CDP or daemon extension.

Patches applied:
- navigator.webdriver → undefined (CDP sets it to true)
- window.chrome stub (only if missing)
- navigator.plugins fake list (only if empty)
- navigator.languages guarantee (only if empty)
- Permissions.query normalization for notifications
- Cleanup __playwright/__puppeteer/cdc_* artifacts

CDP mode: stealth registered via Page.addScriptToEvaluateOnNewDocument
(runs before any page JS on every navigation).

Daemon mode: stealth injected via exec after navigation, with guard
flag to prevent double-injection.
2026-03-24 01:32:06 +08:00
jakevin 89a20e11bc docs: sync command references with current registry (#318) 2026-03-24 01:13:55 +08:00
jakevin 760b91e7b3 chore: release v1.3.2 (#317) 2026-03-24 01:05:47 +08:00
calm b6a02f82ed refactor: extract getErrorMessage and DEFAULT_DAEMON_PORT to shared modules (#313)
- Add getErrorMessage() to errors.ts (used in 5 files)
- Add DEFAULT_DAEMON_PORT to constants.ts (used in 5 files)
- Reduces code duplication and improves maintainability
2026-03-24 00:56:15 +08:00
jakevin a170873ad6 fix(e2e): broaden xiaoyuzhou skip logic for overseas CI runners (#316)
* fix: remove duplicate getErrorMessage import in discovery.ts

Squash merge left a duplicate import line causing TS2300 and oxc parse
errors in CI. Also clean up stale blank lines in discovery.ts and
execution.ts.

* fix(e2e): broaden xiaoyuzhou skip logic for overseas CI runners

The isExpectedChineseSiteRestriction function only matched FETCH_ERROR
with specific HTTP status codes. On overseas CI runners, xiaoyuzhou may
also return PARSE_ERROR (mangled HTML) or NOT_FOUND (geo-redirected
pages), causing false test failures. Now matches all CliError codes
from the adapter.
2026-03-24 00:47:07 +08:00
jakevin 75f42371ca fix: remove duplicate getErrorMessage import in discovery.ts (#315)
Squash merge left a duplicate import line causing TS2300 and oxc parse
errors in CI. Also clean up stale blank lines in discovery.ts and
execution.ts.
2026-03-24 00:42:29 +08:00
sline 41aedf68cd fix(external): replace execSync with execFileSync to prevent command injection (#309)
* fix(external): replace execSync with execFileSync to prevent command injection

* fix(review): preserve Windows external installs and restore docs build

* fix(review): preserve Windows external installs after rebase

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 00:40:17 +08:00
jakevin 8bf750c4ea docs(SKILL.md): sync command reference — add missing sites and desktop adapters (#314)
- Remove hardcoded '150+ commands across 30+ sites' from description
- Fix verify → validate command name
- Add 15+ missing site command references: douban, facebook, instagram,
  tiktok, medium, substack, sinablog, lobsters, google, devto, steam, wikipedia
- Add Desktop Adapter Commands section with 7 adapters: cursor, codex,
  chatgpt, chatwise, notion, discord-app, doubao-app
2026-03-24 00:37:42 +08:00
jakevin c9b3568594 chore: pre-release cleanup — fix dependencies, sync docs, reduce code duplication (#311)
* chore: pre-release cleanup — fix dependencies, sync docs, reduce code duplication

- fix: move @types/turndown from dependencies to devDependencies
- docs: backfill CHANGELOG for v1.2.0 through v1.3.1
- docs: remove internal release reminder from READMEs
- docs: update SKILL.md version to 1.3.1
- refactor: extract getErrorMessage() to errors.ts (was duplicated 5x)
- refactor: introduce CommandArgs type alias in registry.ts
- refactor: eliminate as-any in runtime.ts and cli.ts
- refactor: parallelize site directory scanning in discovery.ts
- refactor: add declare global for registry globalThis access
- fix: strengthen isBooleanRecord type guard in explore.ts
- fix: replace empty catch with log.debug in explore.ts
- fix: daemon now accepts timeout from request body
- chore: remove unused REGISTRY_KEY constant
- chore: update generate.ts TODO to stub annotation

* docs: add doubao-app desktop adapter doc (fixes docs-build dead link)
2026-03-24 00:33:40 +08:00
jakevin b4d64cad6e feat: refine error handling with semantic error types (#312)
- Add 5 new CliError subclasses: AuthRequiredError, TimeoutError,
  ArgumentError, EmptyResultError, SelectorError
- Centralize getErrorMessage() and ERROR_ICONS in errors.ts
- Data-driven error rendering in commanderAdapter.ts (replaces 5 if/else)
- withTimeoutMs accepts factory function for backward compatibility
- browser/errors.ts returns BrowserConnectError instead of bare Error
- Migrate 5 benchmark adapters to AuthRequiredError
- 318 unit tests passing, 0 regressions
2026-03-24 00:20:38 +08:00
117 changed files with 3842 additions and 1460 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
zip -r ../opencli-extension.zip .
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: opencli-extension-build
path: |
+18 -1
View File
@@ -54,7 +54,24 @@ jobs:
run: npm ci
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
adapter-test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run focused adapter tests
run: npm run test:adapter -- --reporter=verbose
# ── Smoke tests (scheduled / manual only) ──
smoke-test:
+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
+128
View File
@@ -1,5 +1,133 @@
# Changelog
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
### Features
* **error-handling:** refine error handling with semantic error types and emoji-coded output ([#312](https://github.com/jackwener/opencli/issues/312)) ([b4d64ca](https://github.com/jackwener/opencli/commit/b4d64ca))
### Bug Fixes
* **security:** replace execSync with execFileSync to prevent command injection ([#309](https://github.com/jackwener/opencli/issues/309)) ([41aedf6](https://github.com/jackwener/opencli/commit/41aedf6))
* remove duplicate getErrorMessage import in discovery.ts ([#315](https://github.com/jackwener/opencli/issues/315)) ([75f4237](https://github.com/jackwener/opencli/commit/75f4237))
* **e2e:** broaden xiaoyuzhou skip logic for overseas CI runners ([#316](https://github.com/jackwener/opencli/issues/316)) ([a170873](https://github.com/jackwener/opencli/commit/a170873))
### Documentation
* **SKILL.md:** sync command reference — add missing sites and desktop adapters ([#314](https://github.com/jackwener/opencli/issues/314)) ([8bf750c](https://github.com/jackwener/opencli/commit/8bf750c))
### Chores
* pre-release cleanup — fix dependencies, sync docs, reduce code duplication ([#311](https://github.com/jackwener/opencli/issues/311)) ([c9b3568](https://github.com/jackwener/opencli/commit/c9b3568))
## [1.3.1](https://github.com/jackwener/opencli/compare/v1.3.0...v1.3.1) (2026-03-22)
### Features
* **plugin:** add update command, hot reload after install, README section ([#307](https://github.com/jackwener/opencli/issues/307)) ([966f6e5](https://github.com/jackwener/opencli/commit/966f6e5))
* **yollomi:** add new commands and update documentation ([#235](https://github.com/jackwener/opencli/issues/235)) ([ea83242](https://github.com/jackwener/opencli/commit/ea83242))
* **record:** add live recording command for API capture ([#300](https://github.com/jackwener/opencli/issues/300)) ([dff0fe5](https://github.com/jackwener/opencli/commit/dff0fe5))
* **weibo:** add weibo search command ([#299](https://github.com/jackwener/opencli/issues/299)) ([c7895ea](https://github.com/jackwener/opencli/commit/c7895ea))
* **v2ex:** add node, user, member, replies, nodes commands ([#282](https://github.com/jackwener/opencli/issues/282)) ([a83027d](https://github.com/jackwener/opencli/commit/a83027d))
* **hackernews:** add new, best, ask, show, jobs, search, user commands ([#290](https://github.com/jackwener/opencli/issues/290)) ([127a974](https://github.com/jackwener/opencli/commit/127a974))
* **doubao-app:** add Doubao AI desktop app CLI adapter ([#289](https://github.com/jackwener/opencli/issues/289)) ([66c4b84](https://github.com/jackwener/opencli/commit/66c4b84))
* **doubao:** add doubao browser adapter ([#277](https://github.com/jackwener/opencli/issues/277)) ([9cdc127](https://github.com/jackwener/opencli/commit/9cdc127))
* **xiaohongshu:** add publish command for 图文 note automation ([#276](https://github.com/jackwener/opencli/issues/276)) ([a6d993f](https://github.com/jackwener/opencli/commit/a6d993f))
* **weixin:** add weixin article download adapter & abstract download helpers ([#280](https://github.com/jackwener/opencli/issues/280)) ([b7c6c02](https://github.com/jackwener/opencli/commit/b7c6c02))
### Bug Fixes
* **tests:** use positional arg syntax in browser search tests ([#302](https://github.com/jackwener/opencli/issues/302)) ([4343ec0](https://github.com/jackwener/opencli/commit/4343ec0))
* **xiaohongshu:** improve search login-wall handling and detail output ([#298](https://github.com/jackwener/opencli/issues/298)) ([f8bf663](https://github.com/jackwener/opencli/commit/f8bf663))
* ensure standard PATH is available for external CLIs ([#285](https://github.com/jackwener/opencli/issues/285)) ([22f5c7a](https://github.com/jackwener/opencli/commit/22f5c7a))
* **xiaohongshu:** scope image selector to avoid downloading avatars ([#293](https://github.com/jackwener/opencli/issues/293)) ([3a21be6](https://github.com/jackwener/opencli/commit/3a21be6))
* add turndown dependency to package.json ([#288](https://github.com/jackwener/opencli/issues/288)) ([2a52906](https://github.com/jackwener/opencli/commit/2a52906))
## [1.3.0](https://github.com/jackwener/opencli/compare/v1.2.3...v1.3.0) (2026-03-21)
### Features
* **daemon:** harden security against browser CSRF attacks ([#268](https://github.com/jackwener/opencli/issues/268)) ([40bd11d](https://github.com/jackwener/opencli/commit/40bd11d))
### Performance
* smart page settle via DOM stability detection ([#271](https://github.com/jackwener/opencli/issues/271)) ([4b976da](https://github.com/jackwener/opencli/commit/4b976da))
### Refactoring
* doctor defaults to live mode, remove setup command entirely ([#263](https://github.com/jackwener/opencli/issues/263)) ([b4a8089](https://github.com/jackwener/opencli/commit/b4a8089))
## [1.2.3](https://github.com/jackwener/opencli/compare/v1.2.2...v1.2.3) (2026-03-21)
### Bug Fixes
* replace all about:blank with data: URI to prevent New Tab Override interception ([#257](https://github.com/jackwener/opencli/issues/257)) ([3e91876](https://github.com/jackwener/opencli/commit/3e91876))
* harden resolveTabId against New Tab Override extension interception ([#255](https://github.com/jackwener/opencli/issues/255)) ([112fdef](https://github.com/jackwener/opencli/commit/112fdef))
## [1.2.2](https://github.com/jackwener/opencli/compare/v1.2.1...v1.2.2) (2026-03-21)
### Bug Fixes
* harden browser automation pipeline (resolves [#249](https://github.com/jackwener/opencli/issues/249)) ([#251](https://github.com/jackwener/opencli/issues/251)) ([71b2c39](https://github.com/jackwener/opencli/commit/71b2c39))
## [1.2.1](https://github.com/jackwener/opencli/compare/v1.2.0...v1.2.1) (2026-03-21)
### Bug Fixes
* **twitter:** harden timeline review findings ([#236](https://github.com/jackwener/opencli/issues/236)) ([4cd0409](https://github.com/jackwener/opencli/commit/4cd0409))
* **wikipedia:** fix search arg name + add random and trending commands ([#231](https://github.com/jackwener/opencli/issues/231)) ([1d56dd7](https://github.com/jackwener/opencli/commit/1d56dd7))
* resolve inconsistent doctor --live report (fix [#121](https://github.com/jackwener/opencli/issues/121)) ([#224](https://github.com/jackwener/opencli/issues/224)) ([387aa0d](https://github.com/jackwener/opencli/commit/387aa0d))
## [1.2.0](https://github.com/jackwener/opencli/compare/v1.1.0...v1.2.0) (2026-03-21)
### Features
* **douban:** add movie adapter with search, top250, subject, marks, reviews commands ([#239](https://github.com/jackwener/opencli/issues/239)) ([70651d3](https://github.com/jackwener/opencli/commit/70651d3))
* **devto:** add devto adapter ([#234](https://github.com/jackwener/opencli/issues/234)) ([ea113a6](https://github.com/jackwener/opencli/commit/ea113a6))
* **twitter:** add --type flag to timeline command ([#83](https://github.com/jackwener/opencli/issues/83)) ([e98cf75](https://github.com/jackwener/opencli/commit/e98cf75))
* **google:** add search, suggest, news, and trends adapters ([#184](https://github.com/jackwener/opencli/issues/184)) ([4e32599](https://github.com/jackwener/opencli/commit/4e32599))
* add douban, sinablog, substack adapters; upgrade medium to TS ([#185](https://github.com/jackwener/opencli/issues/185)) ([bdf5967](https://github.com/jackwener/opencli/commit/bdf5967))
* **xueqiu:** add earnings-date command ([#211](https://github.com/jackwener/opencli/issues/211)) ([fae1dce](https://github.com/jackwener/opencli/commit/fae1dce))
* **browser:** advanced DOM snapshot engine with 13-layer pruning pipeline ([#210](https://github.com/jackwener/opencli/issues/210)) ([d831b04](https://github.com/jackwener/opencli/commit/d831b04))
* **instagram,facebook:** add write actions and extended commands ([#201](https://github.com/jackwener/opencli/issues/201)) ([eb0ccaf](https://github.com/jackwener/opencli/commit/eb0ccaf))
* **grok:** add opt-in --web flow for grok ask ([#193](https://github.com/jackwener/opencli/issues/193)) ([fcff2e4](https://github.com/jackwener/opencli/commit/fcff2e4))
* **tiktok:** add TikTok adapter with 15 commands ([#202](https://github.com/jackwener/opencli/issues/202)) ([4391ccf](https://github.com/jackwener/opencli/commit/4391ccf))
* add Lobste.rs, Instagram, and Facebook adapters ([#199](https://github.com/jackwener/opencli/issues/199)) ([ce484c2](https://github.com/jackwener/opencli/commit/ce484c2))
* **medium:** add medium adapter ([#190](https://github.com/jackwener/opencli/issues/190)) ([06c902a](https://github.com/jackwener/opencli/commit/06c902a))
* plugin system (Stage 0-2) ([1d39295](https://github.com/jackwener/opencli/commit/1d39295))
* make primary args positional across all CLIs ([#242](https://github.com/jackwener/opencli/issues/242)) ([9696db9](https://github.com/jackwener/opencli/commit/9696db9))
* **xueqiu:** make primary args positional ([#213](https://github.com/jackwener/opencli/issues/213)) ([fb2a145](https://github.com/jackwener/opencli/commit/fb2a145))
### Refactoring
* replace hardcoded skipPreNav with declarative navigateBefore field ([#208](https://github.com/jackwener/opencli/issues/208)) ([a228758](https://github.com/jackwener/opencli/commit/a228758))
* **boss:** extract common.ts utilities, fix missing login detection ([#200](https://github.com/jackwener/opencli/issues/200)) ([ae30763](https://github.com/jackwener/opencli/commit/ae30763))
* type discovery core ([#219](https://github.com/jackwener/opencli/issues/219)) ([bd274ce](https://github.com/jackwener/opencli/commit/bd274ce))
* type browser core ([#218](https://github.com/jackwener/opencli/issues/218)) ([28c393e](https://github.com/jackwener/opencli/commit/28c393e))
* type pipeline core ([#217](https://github.com/jackwener/opencli/issues/217)) ([8a4ea41](https://github.com/jackwener/opencli/commit/8a4ea41))
* reduce core any usage ([#216](https://github.com/jackwener/opencli/issues/216)) ([45cee57](https://github.com/jackwener/opencli/commit/45cee57))
* fail fast on invalid pipeline steps ([#237](https://github.com/jackwener/opencli/issues/237)) ([c76f86c](https://github.com/jackwener/opencli/commit/c76f86c))
## [1.1.0](https://github.com/jackwener/opencli/compare/v1.0.6...v1.1.0) (2026-03-20)
+6 -3
View File
@@ -17,7 +17,8 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npx vitest run src/
npm test
npm run test:adapter
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -161,7 +162,8 @@ args: [
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
```bash
npx vitest run src/ # Unit tests
npm test # Core unit tests (non-adapter)
npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili
npx vitest run tests/e2e/ # E2E tests
npx vitest run # All tests
```
@@ -194,7 +196,8 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel
3. Run the checks that apply:
```bash
npx tsc --noEmit # Type check
npx vitest run src/ # Unit tests
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if you touched adapter logic)
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
+30 -10
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
@@ -99,7 +119,7 @@ Run `opencli list` for the live registry.
| Site | Commands | Mode |
|------|----------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | Browser |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | Browser |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
@@ -111,7 +131,7 @@ Run `opencli list` for the live registry.
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | Desktop |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
| **apple-podcasts** | `search` `episodes` `top` | Public |
@@ -125,8 +145,9 @@ 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` | Public |
| **wikipedia** | `search` `summary` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
| **linkedin** | `search` | Browser |
| **reuters** | `search` | Browser |
@@ -145,14 +166,14 @@ Run `opencli list` for the live registry.
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
| **steam** | `top-sellers` | Public |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
| **douban** | `search` `top250` `subject` `marks` `reviews` | Browser |
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
| **google** | `news` `search` `suggest` `trends` | Public |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
| **lobsters** | `hot` `newest` `active` `tag` | Public |
| **medium** | `feed` `search` `user` `shared` | Browser |
| **sinablog** | `hot` `search` `article` `user` `shared` | Browser |
| **substack** | `feed` `search` `publication` `shared` | Browser |
| **medium** | `feed` `search` `user` | Browser |
| **sinablog** | `hot` `search` `article` `user` | Browser |
| **substack** | `feed` `search` `publication` | Browser |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
@@ -165,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` |
@@ -328,7 +348,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
After publishing the new version, remember to update the browser extension in the Chrome Web Store as well, so the extension release stays in sync with the CLI release.
## License
+30 -10
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
@@ -101,7 +121,7 @@ npm install -g @jackwener/opencli@latest
| 站点 | 命令 | 模式 |
|------|------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
@@ -113,7 +133,7 @@ npm install -g @jackwener/opencli@latest
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
@@ -127,8 +147,9 @@ 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` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **linkedin** | `search` | 浏览器 |
| **reuters** | `search` | 浏览器 |
@@ -147,14 +168,14 @@ npm install -g @jackwener/opencli@latest
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
| **douban** | `search` `top250` `subject` `marks` `reviews` | 浏览器 |
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
| **medium** | `feed` `search` `user` `shared` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` `shared` | 浏览器 |
| **substack** | `feed` `search` `publication` `shared` | 浏览器 |
| **medium** | `feed` `search` `user` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
| **substack** | `feed` `search` `publication` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
@@ -167,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` |
@@ -326,7 +346,7 @@ opencli cascade https://api.example.com/data
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
发版完成后,记得也要去 Chrome Web Store 更新浏览器插件,保持插件版本和 CLI 版本同步。
## License
+173 -5
View File
@@ -1,7 +1,7 @@
---
name: opencli
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 150+ commands across 30+ sites."
version: 1.1.0
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 1.3.1
author: jackwener
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
---
@@ -182,7 +182,6 @@ opencli antigravity dump # 导出 DOM 和快照调试信息
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
opencli antigravity model claude # 切换底层模型
opencli antigravity watch # 流式监听增量消息
opencli antigravity serve --port 8082 # 启动 Anthropic 兼容代理
# Barchart (browser)
opencli barchart quote --symbol AAPL # 股票行情
@@ -240,6 +239,175 @@ opencli hf top --limit 10 # 热门模型
# 超星学习通 (browser)
opencli chaoxing assignments # 作业列表
opencli chaoxing exams # 考试列表
# Douban 豆瓣 (browser)
opencli douban search "三体" # 搜索 (query positional)
opencli douban top250 # 豆瓣 Top 250
opencli douban subject 1234567 # 条目详情 (id positional)
opencli douban marks --limit 10 # 我的标记
opencli douban reviews --limit 10 # 短评
# Facebook (browser)
opencli facebook feed --limit 10 # 动态流
opencli facebook profile username # 用户资料 (id positional)
opencli facebook search "AI" # 搜索 (query positional)
opencli facebook friends # 好友列表
opencli facebook groups # 群组
opencli facebook events # 活动
opencli facebook notifications # 通知
opencli facebook memories # 回忆
opencli facebook add-friend username # 添加好友 (id positional)
opencli facebook join-group groupid # 加入群组 (id positional)
# Instagram (browser)
opencli instagram explore # 探索
opencli instagram profile username # 用户资料 (id positional)
opencli instagram search "AI" # 搜索 (query positional)
opencli instagram user username # 用户详情 (id positional)
opencli instagram followers username # 粉丝 (id positional)
opencli instagram following username # 关注 (id positional)
opencli instagram follow username # 关注用户 (id positional)
opencli instagram unfollow username # 取消关注 (id positional)
opencli instagram like postid # 点赞 (id positional)
opencli instagram unlike postid # 取消点赞 (id positional)
opencli instagram comment postid "评论" # 评论 (id + text positional)
opencli instagram save postid # 收藏 (id positional)
opencli instagram unsave postid # 取消收藏 (id positional)
opencli instagram saved # 已收藏列表
# TikTok (browser)
opencli tiktok explore # 探索
opencli tiktok search "AI" # 搜索 (query positional)
opencli tiktok profile username # 用户资料 (id positional)
opencli tiktok user username # 用户详情 (id positional)
opencli tiktok following username # 关注列表 (id positional)
opencli tiktok follow username # 关注 (id positional)
opencli tiktok unfollow username # 取消关注 (id positional)
opencli tiktok like videoid # 点赞 (id positional)
opencli tiktok unlike videoid # 取消点赞 (id positional)
opencli tiktok comment videoid "评论" # 评论 (id + text positional)
opencli tiktok save videoid # 收藏 (id positional)
opencli tiktok unsave videoid # 取消收藏 (id positional)
opencli tiktok live # 直播
opencli tiktok notifications # 通知
opencli tiktok friends # 朋友
# Medium (browser)
opencli medium feed --limit 10 # 动态流
opencli medium search "AI" # 搜索 (query positional)
opencli medium user username # 用户主页 (id positional)
# Substack (browser)
opencli substack feed --limit 10 # 订阅动态
opencli substack search "AI" # 搜索 (query positional)
opencli substack publication name # 出版物详情 (id positional)
# Sinablog 新浪博客 (browser)
opencli sinablog hot --limit 10 # 热门
opencli sinablog search "AI" # 搜索 (query positional)
opencli sinablog article url # 文章详情
opencli sinablog user username # 用户主页 (id positional)
# Lobsters (public)
opencli lobsters hot --limit 10 # 热门
opencli lobsters newest --limit 10 # 最新
opencli lobsters active --limit 10 # 活跃
opencli lobsters tag rust # 按标签筛选 (tag positional)
# Google (public)
opencli google news --limit 10 # 新闻
opencli google search "AI" # 搜索 (query positional)
opencli google suggest "AI" # 搜索建议 (query positional)
opencli google trends # 趋势
# DEV.to (public)
opencli devto top --limit 10 # 热门文章
opencli devto tag javascript --limit 10 # 按标签 (tag positional)
opencli devto user username # 用户文章 (username positional)
# Steam (public)
opencli steam top-sellers --limit 10 # 热销游戏
# Wikipedia (public)
opencli wikipedia search "AI" # 搜索 (query positional)
opencli wikipedia summary "Python" # 摘要 (title positional)
```
### Desktop Adapter Commands
```bash
# Cursor (desktop — CDP via Electron)
opencli cursor status # 检查连接
opencli cursor send "message" # 发送消息
opencli cursor read # 读取回复
opencli cursor new # 新建对话
opencli cursor dump # 导出 DOM 调试信息
opencli cursor composer # Composer 模式
opencli cursor model claude # 切换模型
opencli cursor extract-code # 提取代码块
opencli cursor ask "question" # 一键提问并等回复
opencli cursor screenshot # 截图
opencli cursor history # 对话历史
opencli cursor export # 导出对话
# Codex (desktop — headless CLI agent)
opencli codex status # 检查连接
opencli codex send "message" # 发送消息
opencli codex read # 读取回复
opencli codex new # 新建对话
opencli codex dump # 导出调试信息
opencli codex extract-diff # 提取 diff
opencli codex model gpt-4 # 切换模型
opencli codex ask "question" # 一键提问并等回复
opencli codex screenshot # 截图
opencli codex history # 对话历史
opencli codex export # 导出对话
# ChatGPT (desktop — macOS AppleScript/CDP)
opencli chatgpt status # 检查应用状态
opencli chatgpt new # 新建对话
opencli chatgpt send "message" # 发送消息
opencli chatgpt read # 读取回复
opencli chatgpt ask "question" # 一键提问并等回复
# ChatWise (desktop — multi-LLM client)
opencli chatwise status # 检查连接
opencli chatwise new # 新建对话
opencli chatwise send "message" # 发送消息
opencli chatwise read # 读取回复
opencli chatwise ask "question" # 一键提问并等回复
opencli chatwise model claude # 切换模型
opencli chatwise history # 对话历史
opencli chatwise export # 导出对话
opencli chatwise screenshot # 截图
# Notion (desktop — CDP via Electron)
opencli notion status # 检查连接
opencli notion search "keyword" # 搜索页面
opencli notion read # 读取当前页面
opencli notion new # 新建页面
opencli notion write "content" # 写入内容
opencli notion sidebar # 侧边栏导航
opencli notion favorites # 收藏列表
opencli notion export # 导出
# Discord App (desktop — CDP via Electron)
opencli discord-app status # 检查连接
opencli discord-app send "message" # 发送消息
opencli discord-app read # 读取消息
opencli discord-app channels # 频道列表
opencli discord-app servers # 服务器列表
opencli discord-app search "keyword" # 搜索
opencli discord-app members # 成员列表
# Doubao App 豆包桌面版 (desktop — CDP via Electron)
opencli doubao-app status # 检查连接
opencli doubao-app new # 新建对话
opencli doubao-app send "message" # 发送消息
opencli doubao-app read # 读取回复
opencli doubao-app ask "question" # 一键提问并等回复
opencli doubao-app screenshot # 截图
opencli doubao-app dump # 导出 DOM 调试信息
```
### Management Commands
@@ -285,8 +453,8 @@ opencli cascade <api-url>
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
opencli explore <url> --auto --click "字幕,CC,评论"
# Verify: validate adapter definitions
opencli verify
# Validate: validate adapter definitions
opencli validate
```
## Output Formats
+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.
+18 -8
View File
@@ -6,19 +6,17 @@
| Command | Description |
|---------|-------------|
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
| `opencli douban top250` | 豆瓣电影 Top 250 |
| `opencli douban subject` | 条目详情 |
| `opencli douban marks` | 我的标记 |
| `opencli douban reviews` | 我的短评 |
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
| `opencli douban book-hot` | 豆瓣图书热门榜单 |
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
## Usage Examples
```bash
# 电影热门
opencli douban movie-hot --limit 10
# 图书热门
opencli douban book-hot --limit 10
# 搜索电影
opencli douban search "流浪地球"
@@ -28,8 +26,20 @@ opencli douban search --type book "三体"
# 搜索音乐
opencli douban search --type music "周杰伦"
# 电影 Top 250
opencli douban top250 --limit 10
# 条目详情
opencli douban subject 1292052
# 电影热门
opencli douban movie-hot --limit 10
# 图书热门
opencli douban book-hot --limit 10
# JSON output
opencli douban movie-hot -f json
opencli douban top250 -f json
```
## Prerequisites
+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
```
+30
View File
@@ -0,0 +1,30 @@
# Web
**Mode**: 🔐 Browser · **Domain**: any URL
## Commands
| Command | Description |
|---------|-------------|
| `opencli web read <url>` | Fetch any web page and export as Markdown |
## Usage Examples
```bash
# Read a web page and save as Markdown
opencli web read https://example.com/article
# Custom output directory
opencli web read https://example.com/article --output ./my-articles
# Skip image download
opencli web read https://example.com/article --download-images false
# JSON output
opencli web read https://example.com/article -f json
```
## Prerequisites
- Chrome running
- [Browser Bridge extension](/guide/browser-bridge) installed
-9
View File
@@ -8,8 +8,6 @@
|---------|-------------|
| `opencli wikipedia search` | Search Wikipedia articles |
| `opencli wikipedia summary` | Get Wikipedia article summary |
| `opencli wikipedia random` | Get a random Wikipedia article |
| `opencli wikipedia trending` | Most-read articles (yesterday) |
## Usage Examples
@@ -20,15 +18,8 @@ opencli wikipedia search "quantum computing" --limit 10
# Get article summary
opencli wikipedia summary "Artificial intelligence"
# Get a random article
opencli wikipedia random
# Most-read articles (yesterday)
opencli wikipedia trending --limit 5
# Use with other languages
opencli wikipedia search "人工智能" --lang zh
opencli wikipedia random --lang ja
# JSON output
opencli wikipedia search "Rust" -f json
-3
View File
@@ -47,6 +47,3 @@ Quickly target and switch the active LLM engine. Example: `opencli antigravity m
### `opencli antigravity watch`
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
### `opencli antigravity serve --port 8082`
Start an Anthropic-compatible `/v1/messages` proxy backed by the local Antigravity app. Useful when you want external tools to talk to Antigravity through an API-shaped interface.
+17 -23
View File
@@ -1,41 +1,35 @@
# Doubao App
# Doubao App (豆包桌面版)
Drive the **Doubao (豆包) AI desktop app** via Chrome DevTools Protocol. This adapter controls the Electron-based Doubao client directly.
Control the **Doubao AI Desktop App** via Chrome DevTools Protocol (CDP).
## Prerequisites
1. Install the Doubao desktop app from [doubao.com](https://www.doubao.com/).
2. Launch with remote debugging enabled:
```bash
/Applications/Doubao.app/Contents/MacOS/Doubao \
--remote-debugging-port=9226
```
3. Set the CDP endpoint:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
```
1. Launch Doubao Desktop with remote debugging enabled:
```bash
/Applications/Doubao.app/Contents/MacOS/Doubao --remote-debugging-port=9225
```
2. Set the CDP endpoint:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9225"
```
## Commands
| Command | Description |
|---------|-------------|
| `opencli doubao-app status` | Check if the Doubao app is running and reachable |
| `opencli doubao-app status` | Check CDP connection status |
| `opencli doubao-app new` | Start a new conversation |
| `opencli doubao-app send "message"` | Send a message to the active chat |
| `opencli doubao-app read` | Read all messages in the current conversation |
| `opencli doubao-app send "message"` | Send a message to the current chat |
| `opencli doubao-app read` | Read the latest assistant reply |
| `opencli doubao-app ask "message"` | Send a prompt and wait for the reply |
| `opencli doubao-app screenshot` | Capture a screenshot of the app window |
| `opencli doubao-app dump` | Dump the current page DOM snapshot |
| `opencli doubao-app dump` | Export DOM and snapshot debug info |
## How It Works
The adapter connects to Doubao's Electron renderer via CDP and uses `data-testid` selectors to interact with the chat UI. Text injection uses React's internal value setter for reliable textarea updates.
Connects to the Doubao Electron app via CDP, injecting JavaScript into the renderer process to control the chat UI — sending messages, reading replies, and capturing screenshots.
## Limitations
- macOS only (Electron app path)
- Requires Doubao to be launched with `--remote-debugging-port`
- `read` returns messages visible in the current conversation only
- Requires Doubao Desktop to be launched with `--remote-debugging-port`
- macOS / Linux / Windows (Electron-based, platform independent)
+10 -9
View File
@@ -6,17 +6,17 @@ Run `opencli list` for the live registry.
| Site | Commands | Mode |
|------|----------|------|
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 🔐 Browser |
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `me` `user` `download` `publish` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 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 |
@@ -30,12 +30,12 @@ Run `opencli list` for the live registry.
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` | 🔐 Browser |
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` `shared` | 🔐 Browser |
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` `shared` | 🔐 Browser |
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` `shared` | 🔐 Browser |
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
## Public API Adapters
@@ -45,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 |
@@ -53,7 +54,7 @@ Run `opencli list` for the live registry.
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` | 🌐 Public |
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
## Desktop Adapters
+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)
+4 -2
View File
@@ -17,7 +17,8 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npx vitest run src/
npm test
npm run test:adapter
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -129,7 +130,8 @@ chore: bump vitest to v4
3. Run the checks:
```bash
npx tsc --noEmit # Type check
npx vitest run src/ # Unit tests
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if adapter logic changed)
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
+14 -8
View File
@@ -30,12 +30,14 @@ tests/
├── smoke/
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
src/
── **/*.test.ts # 单元测试(当前 31 个文件
── **/*.test.ts # 核心单元测试(默认 `unit` project
└── clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts # 聚焦 adapter tests
```
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|---|---|---:|---|---|
| 单元测试 | `src/**/*.test.ts` | 31 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 |
| 单元测试 | `src/**/*.test.ts`(排除 `src/clis/**` | - | `npm test` | 内部模块、pipeline、runtime |
| Adapter 测试 | `src/clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts` | - | `npm run test:adapter` | 保留 4 个重点站点的 adapter 覆盖 |
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
@@ -43,13 +45,13 @@ src/
## 当前覆盖范围
### 单元测试31 个文件)
### 单元测试与 Adapter 测试
| 领域 | 文件 |
|---|---|
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` |
| 聚焦 adapter 逻辑 | `src/clis/zhihu/download.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/reddit/read.test.ts`, `src/clis/bilibili/dynamic.test.ts` |
这些测试覆盖的重点包括:
@@ -99,8 +101,11 @@ npm run build # 编译(E2E / smoke 测试需要 dist/main.js
### 运行命令
```bash
# 全部单元测试
npx vitest run src/
# 默认核心单元测试(不含大多数 adapter tests
npm test
# 聚焦 adapter tests(只保留 4 个重点站点)
npm run test:adapter
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
npx vitest run tests/e2e/
@@ -192,7 +197,8 @@ it('producthunt me fails gracefully without login', async () => {
| Job | 触发条件 | 内容 |
|---|---|---|
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
| `unit-test` | push/PR 到 `main`,`dev` | Node `20``22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 |
| `unit-test` | push/PR 到 `main`,`dev` | Node `20``22` 双版本运行核心 `unit` tests,按 `2` shard 并行 |
| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 运行聚焦的 `zhihu/twitter/reddit/bilibili` adapter tests |
| `smoke-test` | `schedule``workflow_dispatch` | 安装真实 Chrome`xvfb-run` 执行 `tests/smoke/` |
### `e2e-headed.yml`
@@ -214,7 +220,7 @@ strategy:
node-version: ['20', '22']
shard: [1, 2]
steps:
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
- run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
```
:::
+519 -444
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -321,6 +321,14 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
const beforeUrl = beforeTab.url ?? '';
const targetUrl = cmd.url;
// Detach any existing debugger before top-level navigation.
// Some sites (observed on creator.xiaohongshu.com flows) can invalidate the
// current inspected target during navigation, which leaves a stale CDP attach
// state and causes the next Runtime.evaluate to fail with
// "Inspected target navigated or closed". Resetting here forces a clean
// re-attach after navigation.
await executor.detach(tabId);
await chrome.tabs.update(tabId, { url: targetUrl });
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
@@ -398,12 +406,12 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.remove(target.id);
executor.detach(target.id);
await executor.detach(target.id);
return { id: cmd.id, ok: true, data: { closed: target.id } };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.remove(tabId);
executor.detach(tabId);
await executor.detach(tabId);
return { id: cmd.id, ok: true, data: { closed: tabId } };
}
case 'select': {
+4 -7
View File
@@ -144,10 +144,10 @@ export async function screenshot(
}
}
export function detach(tabId: number): void {
export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
@@ -158,12 +158,9 @@ export function registerListeners(): void {
if (source.tabId) attached.delete(source.tabId);
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener((tabId, info) => {
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.url && !isDebuggableUrl(info.url)) {
if (attached.has(tabId)) {
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
await detach(tabId);
}
});
}
+138 -158
View File
@@ -1,16 +1,15 @@
{
"name": "@jackwener/opencli",
"version": "1.3.1",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.3.1",
"version": "1.3.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@types/turndown": "^5.0.6",
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
@@ -24,9 +23,10 @@
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.5.13",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"typescript": "^6.0.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.0"
},
@@ -195,7 +195,6 @@
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.49.2",
"@algolia/requester-browser-xhr": "5.49.2",
@@ -405,9 +404,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -417,9 +416,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -927,20 +926,10 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -948,9 +937,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
"integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
"cpu": [
"arm64"
],
@@ -965,9 +954,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
"integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
"cpu": [
"arm64"
],
@@ -982,9 +971,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
"integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
"cpu": [
"x64"
],
@@ -999,9 +988,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
"integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
"cpu": [
"x64"
],
@@ -1016,9 +1005,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
"integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
"cpu": [
"arm"
],
@@ -1033,9 +1022,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
"cpu": [
"arm64"
],
@@ -1050,9 +1039,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
"integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
"cpu": [
"arm64"
],
@@ -1067,9 +1056,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
"cpu": [
"ppc64"
],
@@ -1084,9 +1073,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
"cpu": [
"s390x"
],
@@ -1101,9 +1090,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
"cpu": [
"x64"
],
@@ -1118,9 +1107,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
"integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
"cpu": [
"x64"
],
@@ -1135,9 +1124,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
"integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
"cpu": [
"arm64"
],
@@ -1152,9 +1141,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
"integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
"cpu": [
"wasm32"
],
@@ -1169,9 +1158,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
"integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
"cpu": [
"arm64"
],
@@ -1186,9 +1175,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
"integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
"cpu": [
"x64"
],
@@ -1203,9 +1192,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
"integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
"dev": true,
"license": "MIT"
},
@@ -1755,6 +1744,7 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@@ -1789,16 +1779,16 @@
"license": "ISC"
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
@@ -1807,13 +1797,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.0",
"@vitest/spy": "4.1.1",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1822,7 +1812,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -1834,9 +1824,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1847,13 +1837,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.0",
"@vitest/utils": "4.1.1",
"pathe": "^2.0.3"
},
"funding": {
@@ -1861,14 +1851,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"@vitest/utils": "4.1.0",
"@vitest/pretty-format": "4.1.1",
"@vitest/utils": "4.1.1",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1877,9 +1867,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1887,13 +1877,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"@vitest/pretty-format": "4.1.1",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
@@ -2172,7 +2162,6 @@
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.15.2",
"@algolia/client-abtesting": "5.49.2",
@@ -2501,7 +2490,6 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tabbable": "^6.4.0"
}
@@ -2630,7 +2618,6 @@
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -3102,7 +3089,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3206,14 +3192,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.11"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3222,21 +3208,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
"@rolldown/binding-android-arm64": "1.0.0-rc.11",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.11",
"@rolldown/binding-darwin-x64": "1.0.0-rc.11",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.11",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.11",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.11",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.11",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11"
}
},
"node_modules/rollup": {
@@ -3491,7 +3477,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3516,12 +3501,11 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3641,18 +3625,16 @@
}
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"rolldown": "1.0.0-rc.11",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -3669,7 +3651,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -4212,7 +4194,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -4268,19 +4249,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.0",
"@vitest/mocker": "4.1.0",
"@vitest/pretty-format": "4.1.0",
"@vitest/runner": "4.1.0",
"@vitest/snapshot": "4.1.0",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"@vitest/expect": "4.1.1",
"@vitest/mocker": "4.1.1",
"@vitest/pretty-format": "4.1.1",
"@vitest/runner": "4.1.1",
"@vitest/snapshot": "4.1.1",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -4292,7 +4273,7 @@
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -4308,13 +4289,13 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-preview": "4.1.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/ui": "4.1.0",
"@vitest/browser-playwright": "4.1.1",
"@vitest/browser-preview": "4.1.1",
"@vitest/browser-webdriverio": "4.1.1",
"@vitest/ui": "4.1.1",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -4355,7 +4336,6 @@
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
@@ -4390,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"
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.3.1",
"version": "1.3.3",
"publishConfig": {
"access": "public"
},
@@ -30,6 +30,7 @@
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest run --project unit",
"test:adapter": "vitest run --project adapter",
"test:all": "vitest run",
"test:e2e": "vitest run --project e2e",
"docs:dev": "vitepress dev docs",
@@ -49,7 +50,6 @@
"url": "git+https://github.com/jackwener/opencli.git"
},
"dependencies": {
"@types/turndown": "^5.0.6",
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
@@ -60,9 +60,10 @@
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.5.13",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"typescript": "^6.0.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.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
+51 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, __test__ } from './browser/index.js';
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
import * as daemonClient from './browser/daemon-client.js';
describe('browser helpers', () => {
@@ -133,3 +133,53 @@ describe('BrowserBridge state', () => {
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
});
});
describe('stealth anti-detection', () => {
it('generates non-empty JS string', () => {
const js = generateStealthJs();
expect(typeof js).toBe('string');
expect(js.length).toBeGreaterThan(100);
});
it('contains all 7 anti-detection patches', () => {
const js = generateStealthJs();
// 1. webdriver
expect(js).toContain('navigator');
expect(js).toContain('webdriver');
// 2. chrome stub
expect(js).toContain('window.chrome');
// 3. plugins
expect(js).toContain('plugins');
expect(js).toContain('PDF Viewer');
// 4. languages
expect(js).toContain('languages');
// 5. permissions
expect(js).toContain('Permissions');
expect(js).toContain('notifications');
// 6. automation artifacts (dynamic cdc_ scan)
expect(js).toContain('__playwright');
expect(js).toContain('__puppeteer');
expect(js).toContain('getOwnPropertyNames');
expect(js).toContain('cdc_');
// 7. CDP stack trace cleanup
expect(js).toContain('Error.prototype');
expect(js).toContain('puppeteer_evaluation_script');
expect(js).toContain('getOwnPropertyDescriptor');
});
it('includes guard flag to prevent double-injection', () => {
const js = generateStealthJs();
// 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'
expect(js).toContain("return 'applied'");
});
it('generates syntactically valid JS', () => {
const js = generateStealthJs();
// Should not throw when parsed
expect(() => new Function(js)).not.toThrow();
});
});
+19 -11
View File
@@ -12,6 +12,7 @@ import { WebSocket, type RawData } from 'ws';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import { generateStealthJs } from './stealth.js';
import {
clickJs,
typeTextJs,
@@ -50,6 +51,8 @@ export class CDPBridge {
private _eventListeners = new Map<string, Set<(params: unknown) => void>>();
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
@@ -71,9 +74,16 @@ export class CDPBridge {
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
ws.on('open', () => {
ws.on('open', async () => {
clearTimeout(timeout);
this._ws = ws;
// Register stealth script to run before any page JS on every navigation.
try {
await this.send('Page.enable');
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
} catch {
// Non-fatal: stealth is best-effort
}
resolve(new CDPPage(this));
});
@@ -169,13 +179,17 @@ export class CDPBridge {
}
class CDPPage implements IPage {
private _pageEnabled = false;
constructor(private bridge: CDPBridge) {}
/** Navigate with proper load event waiting (P1 fix #3) */
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
await this.bridge.send('Page.enable');
if (!this._pageEnabled) {
await this.bridge.send('Page.enable');
this._pageEnabled = true;
}
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
.catch(() => {}); // Don't fail if event times out
.catch(() => {}); // Don't fail if load event times out — page may be an SPA
await this.bridge.send('Page.navigate', { url });
await loadPromise;
// Smart settle: use DOM stability detection instead of fixed sleep.
@@ -278,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;
}
@@ -327,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)
+4 -3
View File
@@ -4,11 +4,12 @@
* Provides a typed send() function that posts a Command and returns a Result.
*/
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import type { BrowserSessionInfo } from '../types.js';
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
let _idCounter = 0;
function generateId(): string {
+2 -1
View File
@@ -5,6 +5,7 @@
* scanning for @playwright/mcp locations.
*/
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { isDaemonRunning } from './daemon-client.js';
export { isDaemonRunning };
@@ -17,7 +18,7 @@ export async function checkDaemonStatus(): Promise<{
extensionConnected: boolean;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
});
+2 -1
View File
@@ -6,6 +6,7 @@
*/
import { BrowserConnectError } from '../errors.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
@@ -17,7 +18,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str
(detail ? `\n\n${detail}` : ''),
'The daemon should start automatically. If it doesn\'t, try:\n' +
' node dist/daemon.js\n' +
'Make sure port 19825 is available.',
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
);
case 'extension-not-connected':
return new BrowserConnectError(
+2 -1
View File
@@ -6,10 +6,11 @@
*/
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';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
+2 -4
View File
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import { Page } from './page.js';
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
@@ -112,10 +113,7 @@ export class BrowserBridge {
throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
'Make sure port 19825 is available.',
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
);
}
}
/** @deprecated Use BrowserBridge instead */
export const PlaywrightMCP = BrowserBridge;
+43 -35
View File
@@ -14,7 +14,9 @@ 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 {
clickJs,
typeTextJs,
@@ -35,33 +37,44 @@ 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) {
this._tabId = result.tabId;
}
// Inject stealth anti-detection patches (guard flag prevents double-injection).
try {
await sendCommand('exec', {
code: generateStealthJs(),
...this._cmdOpts(),
});
} catch {
// Non-fatal: stealth is best-effort
}
// Smart settle: use DOM stability detection instead of fixed sleep.
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
if (options?.waitUntil !== 'none') {
const maxMs = options?.settleMs ?? 1000;
await sendCommand('exec', {
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
...this._workspaceOpt(),
...this._tabOpt(),
...this._cmdOpts(),
});
}
}
@@ -69,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
}
@@ -77,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 : [];
}
@@ -97,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;
@@ -137,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;
@@ -145,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> {
@@ -173,42 +186,42 @@ export class Page implements IPage {
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (options.time) {
if (typeof options.time === 'number') {
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
return;
}
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 : [];
}
@@ -230,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;
@@ -250,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> {
+156
View File
@@ -0,0 +1,156 @@
/**
* Stealth anti-detection module.
*
* Generates JS code that patches browser globals to hide automation
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
* plugin list). Injected before page scripts run so that websites cannot
* detect CDP / extension-based control.
*
* Inspired by puppeteer-extra-plugin-stealth.
*/
/**
* 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
* ensures patches are applied only once.
*/
export function generateStealthJs(): string {
return `
(() => {
// 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 → 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: () => false,
configurable: true,
});
} catch {}
// 2. window.chrome stub
// Real Chrome exposes window.chrome with runtime, loadTimes, csi.
// Headless/automated Chrome may not have it.
try {
if (!window.chrome) {
window.chrome = {
runtime: {
onConnect: { addListener: () => {}, removeListener: () => {} },
onMessage: { addListener: () => {}, removeListener: () => {} },
},
loadTimes: () => ({}),
csi: () => ({}),
};
}
} catch {}
// 3. navigator.plugins — fake population only if empty
// Real user browser already has plugins; only patch in automated/headless
// contexts where the list is empty (overwriting real plugins with fakes
// would be counterproductive and detectable).
try {
if (!navigator.plugins || navigator.plugins.length === 0) {
const fakePlugins = [
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
];
fakePlugins.item = (i) => fakePlugins[i] || null;
fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null;
fakePlugins.refresh = () => {};
Object.defineProperty(navigator, 'plugins', {
get: () => fakePlugins,
configurable: true,
});
}
} catch {}
// 4. navigator.languages — guarantee non-empty
// Some automated contexts return undefined or empty array.
try {
if (!navigator.languages || navigator.languages.length === 0) {
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
configurable: true,
});
}
} catch {}
// 5. Permissions.query — normalize notification permission
// Headless Chrome throws on Permissions.query({ name: 'notifications' }).
try {
const origQuery = window.Permissions?.prototype?.query;
if (origQuery) {
window.Permissions.prototype.query = function (parameters) {
if (parameters?.name === 'notifications') {
return Promise.resolve({ state: Notification.permission, onchange: null });
}
return origQuery.call(this, parameters);
};
}
} catch {}
// 6. Clean automation artifacts
// Remove properties left by Playwright, Puppeteer, or CDP injection.
try {
delete window.__playwright;
delete window.__puppeteer;
// ChromeDriver injects cdc_ prefixed globals; the suffix varies by version,
// so scan window for any matching property rather than hardcoding names.
for (const prop of Object.getOwnPropertyNames(window)) {
if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) {
try { delete window[prop]; } catch {}
}
}
} catch {}
// 7. CDP stack trace cleanup
// Runtime.evaluate injects scripts whose source URLs appear in Error
// stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://).
// Websites detect automation by doing: new Error().stack and inspecting it.
// 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://',
'__playwright',
'__puppeteer',
];
if (_origDescriptor && _origDescriptor.get) {
Object.defineProperty(Error.prototype, 'stack', {
get: function () {
const raw = _origDescriptor.get.call(this);
if (typeof raw !== 'string') return raw;
return raw.split('\\n').filter(line =>
!_cdpPatterns.some(p => line.includes(p))
).join('\\n');
},
configurable: true,
});
}
} catch {}
return 'applied';
})()
`;
}
+5 -29
View File
@@ -46,34 +46,9 @@ export interface ManifestEntry {
navigateBefore?: boolean | string;
}
interface YamlArgDefinition {
type?: string;
default?: unknown;
required?: boolean;
positional?: boolean;
description?: string;
help?: string;
choices?: string[];
}
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 type { YamlCliDefinition } from './yaml-schema.js';
import { isRecord } from './utils.js';
function extractBalancedBlock(
@@ -180,7 +155,8 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
choices: parseInlineChoices(body),
});
cursor = objectStart + body.length + 2;
cursor = objectStart + body.length;
if (cursor <= objectStart) break; // safety: prevent infinite loop
}
return args;
@@ -301,7 +277,7 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
* prefer the TS version (it self-registers and typically has richer logic).
*/
export function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean {
if (current.type === next.type) return true;
if (current.type === next.type) return false;
return current.type === 'yaml' && next.type === 'ts';
}
+2 -1
View File
@@ -145,9 +145,10 @@ export async function cascadeProbe(
url: string,
opts: { maxStrategy?: Strategy; timeout?: number } = {},
): Promise<CascadeResult> {
const maxIdx = opts.maxStrategy
const rawIdx = opts.maxStrategy
? CASCADE_ORDER.indexOf(opts.maxStrategy)
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
const maxIdx = rawIdx === -1 ? CASCADE_ORDER.indexOf(Strategy.HEADER) : rawIdx;
const probes: ProbeResult[] = [];
+7 -21
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,
@@ -196,7 +194,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
.action(async (url, opts) => {
const { recordSession, renderRecordSummary } = await import('./record.js');
const result = await recordSession({
BrowserFactory: getBrowserFactory() as any,
BrowserFactory: getBrowserFactory(),
url,
site: opts.site,
outDir: opts.out,
@@ -408,29 +406,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
registerAllCommands(program, siteGroups);
// ── Unknown command fallback ──────────────────────────────────────────────
const DENY_LIST = new Set([
'rm', 'sudo', 'dd', 'mkfs', 'fdisk', 'shutdown', 'reboot',
'kill', 'killall', 'chmod', 'chown', 'passwd', 'su', 'mount',
'umount', 'format', 'diskutil',
]);
// Security: do NOT auto-discover and register arbitrary system binaries.
// Only explicitly registered external CLIs (via `opencli register`) are allowed.
program.on('command:*', (operands: string[]) => {
const binary = operands[0];
if (DENY_LIST.has(binary)) {
console.error(chalk.red(`Refusing to register system command '${binary}'.`));
process.exitCode = 1;
return;
}
console.error(chalk.red(`error: unknown command '${binary}'`));
if (isBinaryInstalled(binary)) {
console.log(chalk.cyan(`🔹 Auto-discovered local CLI '${binary}'. Registering...`));
registerExternalCli(binary);
passthroughExternal(binary);
} else {
console.error(chalk.red(`error: unknown command '${binary}'`));
program.outputHelp();
process.exitCode = 1;
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
}
program.outputHelp();
process.exitCode = 1;
});
program.parse();
+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');
+79
View File
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
vi.mock('./utils.js', () => ({
apiGet: mockApiGet,
}));
import { getRegistry } from '../../registry.js';
import './dynamic.js';
describe('bilibili dynamic adapter', () => {
const command = getRegistry().get('bilibili/dynamic');
beforeEach(() => {
mockApiGet.mockReset();
});
it('maps desc text rows from the dynamic feed payload', async () => {
mockApiGet.mockResolvedValue({
data: {
items: [
{
id_str: '123',
modules: {
module_author: { name: 'Alice' },
module_dynamic: { desc: { text: 'hello world' } },
module_stat: { like: { count: 9 } },
},
},
],
},
});
const result = await command!.func!({} as any, { limit: 5 });
expect(mockApiGet).toHaveBeenCalledWith({}, '/x/polymer/web-dynamic/v1/feed/all', { params: {}, signed: false });
expect(result).toEqual([
{
id: '123',
author: 'Alice',
text: 'hello world',
likes: 9,
url: 'https://t.bilibili.com/123',
},
]);
});
it('falls back to archive title when desc text is absent', async () => {
mockApiGet.mockResolvedValue({
data: {
items: [
{
id_str: '456',
modules: {
module_author: { name: 'Bob' },
module_dynamic: { major: { archive: { title: 'Video title' } } },
module_stat: { like: { count: 3 } },
},
},
],
},
});
const result = await command!.func!({} as any, { limit: 5 });
expect(result).toEqual([
{
id: '456',
author: 'Bob',
text: 'Video title',
likes: 3,
url: 'https://t.bilibili.com/456',
},
]);
});
});
+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.');
}
+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';
+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 });
+4
View File
@@ -16,6 +16,10 @@ export const askCommand = cli({
],
columns: ['Role', 'Text'],
func: async (page: IPage | null, kwargs: any) => {
if (process.platform !== 'darwin') {
throw new Error('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;
+4
View File
@@ -12,6 +12,10 @@ export const newCommand = cli({
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new Error('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'");
+4
View File
@@ -13,6 +13,10 @@ export const readCommand = cli({
args: [],
columns: ['Role', 'Text'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new Error('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'");
+4
View File
@@ -12,6 +12,10 @@ export const statusCommand = cli({
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
if (process.platform !== 'darwin') {
throw new Error('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' }];
+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');
+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');
+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');
+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 -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');
+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 -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]
+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];
},
});
+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,
};
+2 -1
View File
@@ -1,3 +1,4 @@
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
export function buildMediumTagUrl(topic?: string): string {
@@ -13,7 +14,7 @@ export function buildMediumUserUrl(username: string): string {
}
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
if (!page) throw new Error('Requires browser session');
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
await page.goto(url);
await page.wait(5);
const data = await page.evaluate(`
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '../../registry.js';
import './read.js';
describe('reddit read adapter', () => {
const command = getRegistry().get('reddit/read');
it('returns threaded rows from the browser-evaluated payload', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue([
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
]),
} as any;
const result = await command!.func!(page, { 'post-id': 'abc123', limit: 5 });
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
expect(result).toEqual([
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
]);
});
it('surfaces adapter-level API errors clearly', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ error: 'Reddit API returned HTTP 403' }),
} as any;
await expect(command!.func!(page, { 'post-id': 'abc123' })).rejects.toThrow('Reddit API returned HTTP 403');
});
});
+4 -3
View File
@@ -7,6 +7,7 @@
* - Indented output showing conversation threads
*/
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
cli({
site: 'reddit',
@@ -176,9 +177,9 @@ cli({
})()
`);
if (!data || typeof data !== 'object') throw new Error('Failed to fetch post data');
if (!Array.isArray(data) && data.error) throw new Error(data.error);
if (!Array.isArray(data)) throw new Error('Unexpected response');
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch post data');
if (!Array.isArray(data) && data.error) throw new CommandExecutionError(data.error);
if (!Array.isArray(data)) throw new CommandExecutionError('Unexpected response');
return data;
},
+3 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
@@ -137,7 +138,7 @@ cli({
const ct0 = await page.evaluate(`() => {
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
}`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
const queryId = await page.evaluate(`async () => {
try {
@@ -185,7 +186,7 @@ cli({
}`);
if (data?.error) {
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
if (allTweets.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
break;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
@@ -13,7 +14,7 @@ cli({
],
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '../../registry.js';
import './search.js';
describe('twitter search command', () => {
it('retries transient SPA navigation failures before giving up', async () => {
const command = getRegistry().get('twitter/search');
expect(command?.func).toBeTypeOf('function');
const evaluate = vi.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce('/explore')
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce('/search');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
evaluate,
autoScroll: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([
{
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: 'tweet-1',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'hello world',
favorite_count: 7,
},
core: {
user_results: {
result: {
core: {
screen_name: 'alice',
},
},
},
},
views: {
count: '12',
},
},
},
},
},
},
],
},
],
},
},
},
},
},
]),
};
const result = await command!.func!(page as any, { query: 'from:alice', limit: 5 });
expect(result).toEqual([
{
id: '1',
author: 'alice',
text: 'hello world',
likes: 7,
views: '12',
url: 'https://x.com/i/status/1',
},
]);
expect(page.installInterceptor).toHaveBeenCalledWith('SearchTimeline');
expect(evaluate).toHaveBeenCalledTimes(4);
});
it('throws with the final path after both attempts fail', async () => {
const command = getRegistry().get('twitter/search');
expect(command?.func).toBeTypeOf('function');
const evaluate = vi.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce('/explore')
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce('/login');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
evaluate,
autoScroll: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn(),
};
await expect(command!.func!(page as any, { query: 'from:alice', limit: 5 }))
.rejects
.toThrow('Final path: /login');
expect(page.autoScroll).not.toHaveBeenCalled();
expect(page.getInterceptedRequests).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(4);
});
});
+37 -14
View File
@@ -1,4 +1,40 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
/**
* Trigger Twitter search SPA navigation and retry once on transient failures.
*
* Twitter/X sometimes keeps the page on /explore for a short period even after
* pushState + popstate. A second attempt is enough for the intermittent cases
* reported in issue #353 while keeping the flow narrowly scoped.
*/
async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: string): Promise<void> {
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=top`);
let lastPath = '';
for (let attempt = 1; attempt <= 2; attempt++) {
await page.evaluate(`
(() => {
window.history.pushState({}, '', ${searchUrl});
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
})()
`);
await page.wait(5);
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
if (lastPath.startsWith('/search')) {
return;
}
if (attempt < 2) {
await page.wait(1);
}
}
throw new Error(
`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`,
);
}
cli({
site: 'twitter',
@@ -29,20 +65,7 @@ cli({
// a full page reload, so the interceptor stays alive.
// Note: the previous approach (nativeSetter + Enter keydown on the
// search input) does not reliably trigger Twitter's form submission.
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=top`);
await page.evaluate(`
(() => {
window.history.pushState({}, '', ${searchUrl});
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
})()
`);
await page.wait(5);
// Verify SPA navigation succeeded
const currentPath = await page.evaluate('() => window.location.pathname');
if (!currentPath?.startsWith('/search')) {
throw new Error('SPA navigation to /search failed. Twitter may have changed its routing.');
}
await navigateToSearch(page, query);
// 4. Scroll to trigger additional pagination
await page.autoScroll({ times: 3, delayMs: 2000 });
+3 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
// ── Twitter GraphQL constants ──────────────────────────────────────────
@@ -37,7 +38,7 @@ cli({
const ct0 = await page.evaluate(`(() => {
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
})()`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
// Try legacy guide.json API first (faster than DOM scraping)
let trends: TrendItem[] = [];
@@ -105,7 +106,7 @@ cli({
}
if (trends.length === 0) {
throw new Error('No trending data found. API may have changed or login may be required.');
throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
}
return trends.slice(0, limit);
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
@@ -13,7 +14,7 @@ cli({
],
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for twitter unfollow');
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
+210
View File
@@ -0,0 +1,210 @@
/**
* Generic web page reader — fetch any URL and export as Markdown.
*
* Uses browser-side DOM heuristics to extract the main content:
* 1. <article> element
* 2. [role="main"] element
* 3. <main> element
* 4. Largest text-dense block as fallback
*
* Pipes through the shared article-download pipeline (Turndown + image download).
*
* Usage:
* opencli web read --url "https://www.anthropic.com/research/..." --output ./articles
* opencli web read --url "https://..." --download-images false
*/
import { cli, Strategy } from '../../registry.js';
import { downloadArticle } from '../../download/article-download.js';
cli({
site: 'web',
name: 'read',
description: 'Fetch any web page and export as Markdown',
strategy: Strategy.COOKIE,
navigateBefore: false, // we handle navigation ourselves
args: [
{ name: 'url', required: true, help: 'Any web page URL' },
{ name: 'output', default: './web-articles', help: 'Output directory' },
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
{ name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' },
],
columns: ['title', 'author', 'publish_time', 'status', 'size'],
func: async (page, kwargs) => {
const url = kwargs.url;
const waitSeconds = kwargs.wait ?? 3;
// Navigate to the target URL
await page.goto(url);
await page.wait(waitSeconds);
// Extract article content using browser-side heuristics
const data = await page.evaluate(`
(() => {
const result = {
title: '',
author: '',
publishTime: '',
contentHtml: '',
imageUrls: []
};
// --- Title extraction ---
// Priority: og:title > <title> > first <h1>
const ogTitle = document.querySelector('meta[property="og:title"]');
if (ogTitle) {
result.title = ogTitle.getAttribute('content')?.trim() || '';
}
if (!result.title) {
result.title = document.title?.trim() || '';
}
if (!result.title) {
const h1 = document.querySelector('h1');
result.title = h1?.textContent?.trim() || 'untitled';
}
// Strip site suffix (e.g. " | Anthropic", " - Blog")
result.title = result.title.replace(/\\s*[|\\-–—]\\s*[^|\\-–—]{1,30}$/, '').trim();
// --- Author extraction ---
const authorMeta = document.querySelector(
'meta[name="author"], meta[property="article:author"], meta[name="twitter:creator"]'
);
result.author = authorMeta?.getAttribute('content')?.trim() || '';
// --- Publish time extraction ---
const timeMeta = document.querySelector(
'meta[property="article:published_time"], meta[name="date"], meta[name="publishdate"], time[datetime]'
);
if (timeMeta) {
result.publishTime = timeMeta.getAttribute('content')
|| timeMeta.getAttribute('datetime')
|| timeMeta.textContent?.trim()
|| '';
}
// --- Content extraction ---
// Strategy: try semantic elements first, then fall back to largest text block
let contentEl = null;
// 1. <article>
const articles = document.querySelectorAll('article');
if (articles.length === 1) {
contentEl = articles[0];
} else if (articles.length > 1) {
// Pick the largest article by text length
let maxLen = 0;
articles.forEach(a => {
const len = a.textContent?.length || 0;
if (len > maxLen) { maxLen = len; contentEl = a; }
});
}
// 2. [role="main"]
if (!contentEl) {
contentEl = document.querySelector('[role="main"]');
}
// 3. <main>
if (!contentEl) {
contentEl = document.querySelector('main');
}
// 4. Largest text-dense block fallback
if (!contentEl) {
const candidates = document.querySelectorAll(
'div[class*="content"], div[class*="article"], div[class*="post"], ' +
'div[class*="entry"], div[class*="body"], div[id*="content"], ' +
'div[id*="article"], div[id*="post"], section'
);
let maxLen = 0;
candidates.forEach(c => {
const len = c.textContent?.length || 0;
if (len > maxLen) { maxLen = len; contentEl = c; }
});
}
// 5. Last resort: document.body
if (!contentEl || (contentEl.textContent?.length || 0) < 200) {
contentEl = document.body;
}
// Clean up noise elements before extraction
const clone = contentEl.cloneNode(true);
const noise = 'nav, header, footer, aside, .sidebar, .nav, .menu, .footer, ' +
'.header, .comments, .comment, .ad, .ads, .advertisement, .social-share, ' +
'.related-posts, .newsletter, .cookie-banner, script, style, noscript, iframe';
clone.querySelectorAll(noise).forEach(el => el.remove());
// Deduplicate: some sites (e.g. Anthropic) render each paragraph twice
// (a visible version + a line-broken animation version with missing spaces).
// Compare by stripping ALL whitespace so "Hello world" matches "Helloworld".
const stripWS = (s) => (s || '').replace(/\\s+/g, '');
const dedup = (parent) => {
const children = Array.from(parent.children || []);
for (let i = children.length - 1; i >= 1; i--) {
const curRaw = children[i].textContent || '';
const prevRaw = children[i - 1].textContent || '';
const cur = stripWS(curRaw);
const prev = stripWS(prevRaw);
if (cur.length < 20 || prev.length < 20) continue;
// Exact match after whitespace strip, or >90% overlap
if (cur === prev) {
// Keep the one with more proper spacing (more spaces = better formatted)
const curSpaces = (curRaw.match(/ /g) || []).length;
const prevSpaces = (prevRaw.match(/ /g) || []).length;
if (curSpaces >= prevSpaces) children[i - 1].remove();
else children[i].remove();
} else if (prev.includes(cur) && cur.length / prev.length > 0.8) {
children[i].remove();
} else if (cur.includes(prev) && prev.length / cur.length > 0.8) {
children[i - 1].remove();
}
}
};
dedup(clone);
clone.querySelectorAll('section, div').forEach(el => {
if (el.children && el.children.length > 2) dedup(el);
});
result.contentHtml = clone.innerHTML;
// --- Image extraction ---
const seen = new Set();
clone.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('data-src')
|| img.getAttribute('data-original')
|| img.getAttribute('src');
if (src && !src.startsWith('data:') && !seen.has(src)) {
seen.add(src);
result.imageUrls.push(src);
}
});
return result;
})()
`);
// Determine Referer from URL for image downloads
let referer = '';
try {
const parsed = new URL(url);
referer = parsed.origin + '/';
} catch { /* ignore */ }
return downloadArticle(
{
title: data?.title || 'untitled',
author: data?.author,
publishTime: data?.publishTime,
sourceUrl: url,
contentHtml: data?.contentHtml || '',
imageUrls: data?.imageUrls,
},
{
output: kwargs.output,
downloadImages: kwargs['download-images'],
imageHeaders: referer ? { Referer: referer } : undefined,
},
);
},
});
+5 -4
View File
@@ -17,6 +17,7 @@ import {
type RawSegment,
type Chapter,
} from './transcript-group.js';
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
cli({
site: 'youtube',
@@ -91,10 +92,10 @@ cli({
`);
if (!captionData || typeof captionData === 'string') {
throw new Error(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
throw new CommandExecutionError(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
}
if (captionData.error) {
throw new Error(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
throw new CommandExecutionError(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
}
// Warn if --lang was specified but not matched
@@ -176,10 +177,10 @@ cli({
`);
if (!Array.isArray(segments)) {
throw new Error((segments as any)?.error || 'Failed to parse caption segments');
throw new CommandExecutionError((segments as any)?.error || 'Failed to parse caption segments');
}
if (segments.length === 0) {
throw new Error('No caption segments found');
throw new EmptyResultError('youtube transcript');
}
// Step 3: Fetch chapters (for grouped mode)
+3 -2
View File
@@ -3,6 +3,7 @@
*/
import { cli, Strategy } from '../../registry.js';
import { parseVideoId } from './utils.js';
import { CommandExecutionError } from '../../errors.js';
cli({
site: 'youtube',
@@ -104,8 +105,8 @@ cli({
})()
`);
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
if (data.error) throw new Error(data.error);
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to extract video metadata from page');
if (data.error) throw new CommandExecutionError(data.error);
// Return as field/value pairs for table display
return Object.entries(data).map(([field, value]) => ({
+3
View File
@@ -2,6 +2,9 @@
* Shared constants used across explore, synthesize, and pipeline modules.
*/
/** Default daemon port for HTTP/WebSocket communication with browser extension */
export const DEFAULT_DAEMON_PORT = 19825;
/** URL query params that are volatile/ephemeral and should be stripped from patterns */
export const VOLATILE_PARAMS = new Set([
'w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign',
+12 -7
View File
@@ -21,8 +21,9 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket, type RawData } from 'ws';
import { DEFAULT_DAEMON_PORT } from './constants.js';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// ─── State ───────────────────────────────────────────────────────────
@@ -63,13 +64,14 @@ function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
let aborted = false;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return; }
if (size > MAX_BODY) { aborted = true; req.destroy(); reject(new Error('Body too large')); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
req.on('end', () => { if (!aborted) resolve(Buffer.concat(chunks).toString('utf-8')); });
req.on('error', (err) => { if (!aborted) reject(err); });
});
}
@@ -150,11 +152,14 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
return;
}
const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
? body.timeout * 1000
: 120000;
const result = await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(body.id);
reject(new Error('Command timeout (120s)'));
}, 120000);
reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
}, timeoutMs);
pending.set(body.id, { resolve, reject, timer });
extensionWs!.send(JSON.stringify(body));
});
@@ -267,7 +272,7 @@ httpServer.listen(PORT, '127.0.0.1', () => {
httpServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
process.exit(0);
process.exit(1);
}
console.error('[daemon] Server error:', err.message);
process.exit(1);
+35 -57
View File
@@ -14,39 +14,15 @@ import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
import { log } from './logger.js';
import { getErrorMessage } from './errors.js';
import { log } from './logger.js';
import type { ManifestEntry } from './build-manifest.js';
/** Plugins directory: ~/.opencli/plugins/ */
export const PLUGINS_DIR = path.join(os.homedir(), '.opencli', 'plugins');
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
interface YamlArgDefinition {
type?: string;
default?: unknown;
required?: boolean;
positional?: boolean;
description?: string;
help?: string;
choices?: string[];
}
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;
}
import type { YamlCliDefinition } from './yaml-schema.js';
function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy {
if (!rawStrategy) return fallback;
@@ -54,9 +30,7 @@ function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Str
return Strategy[key] ?? fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
import { isRecord } from './utils.js';
/**
* Discover and register CLI commands.
@@ -68,12 +42,12 @@ export async function discoverClis(...dirs: string[]): Promise<void> {
const manifestPath = path.resolve(dir, '..', 'cli-manifest.json');
try {
await fs.promises.access(manifestPath);
await loadFromManifest(manifestPath, dir);
continue; // Skip filesystem scan for this directory
const loaded = await loadFromManifest(manifestPath, dir);
if (loaded) continue; // Skip filesystem scan only when manifest is usable
} catch {
// Fallback: runtime filesystem scan (development)
await discoverClisFromFs(dir);
// Fall through to filesystem scan
}
await discoverClisFromFs(dir);
}
}
@@ -82,7 +56,7 @@ export async function discoverClis(...dirs: string[]): Promise<void> {
* YAML pipelines are inlined — zero YAML parsing at runtime.
* TS modules are deferred — loaded lazily on first execution.
*/
async function loadFromManifest(manifestPath: string, clisDir: string): Promise<void> {
async function loadFromManifest(manifestPath: string, clisDir: string): Promise<boolean> {
try {
const raw = await fs.promises.readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(raw) as ManifestEntry[];
@@ -128,8 +102,10 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
registerCommand(cmd);
}
}
return true;
} catch (err) {
log.warn(`Failed to load manifest ${manifestPath}: ${getErrorMessage(err)}`);
return false;
}
}
@@ -138,32 +114,34 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
*/
async function discoverClisFromFs(dir: string): Promise<void> {
try { await fs.promises.access(dir); } catch { return; }
const promises: Promise<unknown>[] = [];
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const site = entry.name;
const siteDir = path.join(dir, site);
const files = await fs.promises.readdir(siteDir);
for (const file of files) {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
promises.push(registerYamlCli(filePath, site));
} else if (
(file.endsWith('.js') && !file.endsWith('.d.js')) ||
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
) {
if (!(await isCliModule(filePath))) continue;
promises.push(
import(pathToFileURL(filePath).href).catch((err) => {
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
})
);
const sitePromises = entries
.filter(entry => entry.isDirectory())
.map(async (entry) => {
const site = entry.name;
const siteDir = path.join(dir, site);
const files = await fs.promises.readdir(siteDir);
const filePromises: Promise<unknown>[] = [];
for (const file of files) {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
filePromises.push(registerYamlCli(filePath, site));
} else if (
(file.endsWith('.js') && !file.endsWith('.d.js')) ||
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
) {
if (!(await isCliModule(filePath))) continue;
filePromises.push(
import(pathToFileURL(filePath).href).catch((err) => {
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
})
);
}
}
}
}
await Promise.all(promises);
await Promise.all(filePromises);
});
await Promise.all(sitePromises);
}
async function registerYamlCli(filePath: string, defaultSite: string): Promise<void> {
+2 -1
View File
@@ -6,6 +6,7 @@
*/
import chalk from 'chalk';
import { DEFAULT_DAEMON_PORT } from './constants.js';
import { checkDaemonStatus } from './browser/discover.js';
import { BrowserBridge } from './browser/index.js';
import { listSessions } from './browser/daemon-client.js';
@@ -107,7 +108,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
// Daemon status
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running'}`);
// Extension status
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
+45 -2
View File
@@ -1,5 +1,29 @@
import { describe, expect, it } from 'vitest';
import { formatCookieHeader, resolveRedirectUrl } from './index.js';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
const servers: http.Server[] = [];
afterEach(async () => {
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
})));
servers.length = 0;
});
async function startServer(handler: http.RequestListener): Promise<string> {
const server = http.createServer(handler);
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to start test server');
}
return `http://127.0.0.1:${address.port}`;
}
describe('download helpers', () => {
it('resolves relative redirects against the original URL', () => {
@@ -13,4 +37,23 @@ describe('download helpers', () => {
{ name: 'ct0', value: 'def', domain: 'example.com' },
])).toBe('sid=abc; ct0=def');
});
it('fails after exceeding the redirect limit', async () => {
const baseUrl = await startServer((_req, res) => {
res.statusCode = 302;
res.setHeader('Location', '/loop');
res.end();
});
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-download-'));
const destPath = path.join(tempDir, 'file.txt');
const result = await httpDownload(`${baseUrl}/loop`, destPath, { maxRedirects: 2 });
expect(result).toEqual({
success: false,
size: 0,
error: 'Too many redirects (> 2)',
});
expect(fs.existsSync(destPath)).toBe(false);
});
});
+27 -21
View File
@@ -11,12 +11,16 @@ import * as os from 'node:os';
import { URL } from 'node:url';
import type { ProgressBar } from './progress.js';
import { isBinaryInstalled } from '../external.js';
import type { BrowserCookie } from '../types.js';
export type { BrowserCookie } from '../types.js';
export interface DownloadOptions {
cookies?: string;
headers?: Record<string, string>;
timeout?: number;
onProgress?: (received: number, total: number) => void;
maxRedirects?: number;
}
export interface YtdlpOptions {
@@ -27,26 +31,11 @@ export interface YtdlpOptions {
onProgress?: (percent: number) => void;
}
export interface BrowserCookie {
name: string;
value: string;
domain: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
expirationDate?: number;
}
/** Check if yt-dlp is available in PATH. */
export function checkYtdlp(): boolean {
return isBinaryInstalled('yt-dlp');
}
/** Check if ffmpeg is available in PATH. */
export function checkFfmpeg(): boolean {
return isBinaryInstalled('ffmpeg');
}
/** Domains that host video content and can be downloaded via yt-dlp. */
const VIDEO_PLATFORM_DOMAINS = [
'youtube.com', 'youtu.be', 'bilibili.com', 'twitter.com',
@@ -92,15 +81,16 @@ export async function httpDownload(
url: string,
destPath: string,
options: DownloadOptions = {},
redirectCount = 0,
): Promise<{ success: boolean; size: number; error?: string }> {
const { cookies, headers = {}, timeout = 30000, onProgress } = options;
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
return new Promise((resolve) => {
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const requestHeaders: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
...headers,
};
@@ -120,7 +110,16 @@ export async function httpDownload(
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
file.close();
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
httpDownload(resolveRedirectUrl(url, response.headers.location), destPath, options).then(resolve);
if (redirectCount >= maxRedirects) {
resolve({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
return;
}
httpDownload(
resolveRedirectUrl(url, response.headers.location),
destPath,
options,
redirectCount + 1,
).then(resolve);
return;
}
@@ -188,7 +187,9 @@ export function exportCookiesToNetscape(
const cookiePath = cookie.path || '/';
const secure = cookie.secure ? 'TRUE' : 'FALSE';
const expiry = Math.floor(Date.now() / 1000) + 86400 * 365; // 1 year from now
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${cookie.name}\t${cookie.value}`);
const safeName = cookie.name.replace(/[\t\n\r]/g, '');
const safeValue = cookie.value.replace(/[\t\n\r]/g, '');
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${safeName}\t${safeValue}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -226,8 +227,13 @@ export async function ytdlpDownload(
'--progress',
];
if (cookiesFile && fs.existsSync(cookiesFile)) {
args.push('--cookies', cookiesFile);
if (cookiesFile) {
if (fs.existsSync(cookiesFile)) {
args.push('--cookies', cookiesFile);
} else {
console.error(`[download] Cookies file not found: ${cookiesFile}, falling back to browser cookies`);
args.push('--cookies-from-browser', 'chrome');
}
} else {
// Try to use browser cookies
args.push('--cookies-from-browser', 'chrome');
+30
View File
@@ -45,6 +45,36 @@ cli({
await fs.promises.rm(tempRoot, { recursive: true, force: true });
}
});
it('falls back to filesystem discovery when the manifest is invalid', async () => {
const tempBuildRoot = await fs.promises.mkdtemp(path.join('/tmp', 'opencli-manifest-fallback-'));
const distDir = path.join(tempBuildRoot, 'dist');
const siteDir = path.join(distDir, 'fallback-site');
const commandPath = path.join(siteDir, 'hello.ts');
const manifestPath = path.join(tempBuildRoot, 'cli-manifest.json');
try {
await fs.promises.mkdir(siteDir, { recursive: true });
await fs.promises.writeFile(manifestPath, '{ invalid json');
await fs.promises.writeFile(commandPath, `
import { cli, Strategy } from '${path.join(process.cwd(), 'src', 'registry.ts')}';
cli({
site: 'fallback-site',
name: 'hello',
description: 'hello command',
strategy: Strategy.PUBLIC,
browser: false,
func: async () => [{ ok: true }],
});
`);
await discoverClis(distDir);
expect(getRegistry().get('fallback-site/hello')).toBeDefined();
} finally {
await fs.promises.rm(tempBuildRoot, { recursive: true, force: true });
}
});
});
describe('discoverPlugins', () => {
+10 -4
View File
@@ -22,7 +22,6 @@ const _loadedModules = new Set<string>();
type CommandArgs = Record<string, unknown>;
/**
* Validates and coerces arguments based on the command's Arg definitions.
*/
@@ -99,11 +98,16 @@ async function runCommand(
}
// After loading, the module's cli() call will have updated the registry.
const updated = getRegistry().get(fullName(cmd));
if (updated?.func) return updated.func(page!, kwargs, debug);
if (updated?.func) {
if (!page && updated.browser !== false) {
throw new CommandExecutionError(`Command ${fullName(cmd)} requires a browser session but none was provided`);
}
return updated.func(page as IPage, kwargs, debug);
}
if (updated?.pipeline) return executePipeline(page, updated.pipeline, { args: kwargs, debug });
}
if (cmd.func) return cmd.func(page!, kwargs, debug);
if (cmd.func) return cmd.func(page as IPage, kwargs, debug);
if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
throw new CommandExecutionError(
`Command ${fullName(cmd)} has no func or pipeline`,
@@ -155,7 +159,9 @@ export async function executeCommand(
// Each adapter controls this via `navigateBefore` (see CliCommand docs).
const preNavUrl = resolvePreNav(cmd);
if (preNavUrl) {
try { await page.goto(preNavUrl); await page.wait(2); } catch {}
try { await page.goto(preNavUrl); await page.wait(2); } catch (err) {
if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
}
}
return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT,
+4 -2
View File
@@ -15,6 +15,7 @@ import { detectFramework } from './scripts/framework.js';
import { discoverStores } from './scripts/store.js';
import { interactFuzz } from './scripts/interact.js';
import type { IPage } from './types.js';
import { log } from './logger.js';
// ── Site name detection ────────────────────────────────────────────────────
@@ -224,7 +225,8 @@ function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[]
}
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
return typeof value === 'object' && value !== null && !Array.isArray(value)
&& Object.values(value as Record<string, unknown>).every(v => typeof v === 'boolean');
}
function scoreEndpoint(ep: { contentType: string; responseAnalysis: AnalyzedEndpoint['responseAnalysis']; pattern: string; status: number | null; hasSearchParam: boolean; hasPaginationParam: boolean; hasLimitParam: boolean }): number {
@@ -453,7 +455,7 @@ export async function exploreUrl(
const clicks = await page.evaluate(INTERACT_FUZZ_JS);
await page.wait(2); // wait for XHRs to settle
} catch (e) {
// fuzzing is best-effort, don't fail the whole explore
log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
}
}
-8
View File
@@ -22,14 +22,6 @@
install:
default: "npm install -g @readwiseio/readwise-cli"
- name: kubectl
binary: kubectl
description: "Kubernetes command-line tool"
homepage: "https://kubernetes.io/docs/reference/kubectl/"
tags: [kubernetes, k8s, devops]
install:
mac: "brew install kubectl"
- name: docker
binary: docker
description: "Docker command-line interface"
+97
View File
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
mockExecFileSync: vi.fn(),
mockPlatform: vi.fn(() => 'darwin'),
}));
vi.mock('node:child_process', () => ({
spawnSync: vi.fn(),
execFileSync: mockExecFileSync,
}));
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os');
return {
...actual,
platform: mockPlatform,
};
});
import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
describe('parseCommand', () => {
it('splits binaries and quoted arguments without invoking a shell', () => {
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
binary: 'npm',
args: ['install', '-g', '@scope/tool name'],
});
});
it('rejects shell operators', () => {
expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
'Install command contains unsafe shell operators',
);
});
it('rejects command substitution and multiline input', () => {
expect(() => parseCommand('brew install $(whoami)')).toThrow(
'Install command contains unsafe shell operators',
);
expect(() => parseCommand('brew install gh\nrm -rf /')).toThrow(
'Install command contains unsafe shell operators',
);
});
});
describe('installExternalCli', () => {
const cli: ExternalCliConfig = {
name: 'readwise',
binary: 'readwise',
install: {
default: 'npm install -g @readwiseio/readwise-cli',
},
};
beforeEach(() => {
mockExecFileSync.mockReset();
mockPlatform.mockReturnValue('darwin');
});
it('retries with .cmd on Windows when the bare binary is unavailable', () => {
mockPlatform.mockReturnValue('win32');
mockExecFileSync
.mockImplementationOnce(() => {
const err = new Error('not found') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
})
.mockReturnValueOnce(Buffer.from(''));
expect(installExternalCli(cli)).toBe(true);
expect(mockExecFileSync).toHaveBeenNthCalledWith(
1,
'npm',
['install', '-g', '@readwiseio/readwise-cli'],
{ stdio: 'inherit' },
);
expect(mockExecFileSync).toHaveBeenNthCalledWith(
2,
'npm.cmd',
['install', '-g', '@readwiseio/readwise-cli'],
{ stdio: 'inherit' },
);
});
it('does not mask non-ENOENT failures', () => {
mockPlatform.mockReturnValue('win32');
mockExecFileSync.mockImplementationOnce(() => {
const err = new Error('permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
expect(installExternalCli(cli)).toBe(false);
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
});
});
+56 -2
View File
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync, execSync, execFileSync } from 'node:child_process';
import { spawnSync, execFileSync } from 'node:child_process';
import yaml from 'js-yaml';
import chalk from 'chalk';
import { log } from './logger.js';
@@ -82,6 +82,60 @@ export function getInstallCmd(installConfig?: ExternalCliInstall): string | null
return null;
}
/**
* Safely parses a command string into a binary and argument list.
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
* cannot be safely expressed as execFileSync arguments.
*
* Args:
* cmd: Raw command string from YAML config (e.g. "brew install gh")
*
* Returns:
* Object with `binary` and `args` fields, or throws on unsafe input.
*/
export function parseCommand(cmd: string): { binary: string; args: string[] } {
const shellOperators = /&&|\|\|?|;|[><`$#\n\r]|\$\(/;
if (shellOperators.test(cmd)) {
throw new Error(
`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
`Please install the tool manually.`
);
}
// Tokenise respecting single- and double-quoted segments (no variable expansion).
const tokens: string[] = [];
const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(cmd)) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3]);
}
if (tokens.length === 0) {
throw new Error(`Install command is empty.`);
}
const [binary, ...args] = tokens;
return { binary, args };
}
function shouldRetryWithCmdShim(binary: string, err: NodeJS.ErrnoException): boolean {
return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
}
function runInstallCommand(cmd: string): void {
const { binary, args } = parseCommand(cmd);
try {
execFileSync(binary, args, { stdio: 'inherit' });
} catch (err: any) {
if (shouldRetryWithCmdShim(binary, err)) {
execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
return;
}
throw err;
}
}
export function installExternalCli(cli: ExternalCliConfig): boolean {
if (!cli.install) {
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
@@ -99,7 +153,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
console.log(chalk.dim(`$ ${cmd}`));
try {
execSync(cmd, { stdio: 'inherit' });
runInstallCommand(cmd);
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
return true;
} catch (err: any) {
+4 -40
View File
@@ -12,29 +12,13 @@ import { exploreUrl } from './explore.js';
import type { IBrowserFactory } from './runtime.js';
import { synthesizeFromExplore, type SynthesizeCandidateSummary, type SynthesizeResult } from './synthesize.js';
// TODO: implement real CLI registration (copy candidate YAML to user clis dir)
interface RegisterCandidatesOptions {
target: string;
builtinClis?: string;
userClis?: string;
name?: string;
}
interface RegisterCandidatesResult {
ok: boolean;
count: number;
}
export interface GenerateCliOptions {
url: string;
BrowserFactory: new () => IBrowserFactory;
builtinClis?: string;
userClis?: string;
goal?: string | null;
site?: string;
waitSeconds?: number;
top?: number;
register?: boolean;
workspace?: string;
}
@@ -56,11 +40,6 @@ export interface GenerateCliResult {
candidate_count: number;
candidates: Array<Pick<SynthesizeCandidateSummary, 'name' | 'strategy' | 'confidence'>>;
};
register: RegisterCandidatesResult | null;
}
function registerCandidates(_opts: RegisterCandidatesOptions): RegisterCandidatesResult {
return { ok: true, count: 0 };
}
const CAPABILITY_ALIASES: Record<string, string[]> = {
@@ -101,9 +80,10 @@ function selectCandidate(candidates: SynthesizeResult['candidates'], goal?: stri
}
const lower = (goal ?? '').trim().toLowerCase();
const partial = candidates.find(c =>
c.name?.toLowerCase().includes(lower) || lower.includes(c.name?.toLowerCase())
);
const partial = candidates.find(c => {
const cName = c.name?.toLowerCase() ?? '';
return cName.includes(lower) || lower.includes(cName);
});
return partial ?? candidates[0];
}
@@ -126,19 +106,6 @@ export async function generateCliFromUrl(opts: GenerateCliOptions): Promise<Gene
const selected = selectCandidate(synthesizeResult.candidates ?? [], opts.goal);
const selectedSite = synthesizeResult.site ?? exploreResult.site;
// Step 4: Register (if requested)
let registerResult: RegisterCandidatesResult | null = null;
if (opts.register !== false && synthesizeResult.candidate_count > 0) {
try {
registerResult = registerCandidates({
target: synthesizeResult.out_dir,
builtinClis: opts.builtinClis,
userClis: opts.userClis,
name: selected?.name,
});
} catch {}
}
const ok = exploreResult.endpoint_count > 0 && synthesizeResult.candidate_count > 0;
return {
@@ -163,7 +130,6 @@ export async function generateCliFromUrl(opts: GenerateCliOptions): Promise<Gene
confidence: c.confidence,
})),
},
register: registerResult,
};
}
@@ -187,8 +153,6 @@ export function renderGenerateSummary(r: GenerateCliResult): string {
lines.push(`${c.name} (${c.strategy}, ${((c.confidence ?? 0) * 100).toFixed(0)}%)`);
}
if (r.register) lines.push(`\nRegistered: ${r.register.count ?? 0}`);
const fw = r.explore?.framework ?? {};
const fwNames = Object.entries(fw).filter(([, v]) => v).map(([k]) => k);
if (fwNames.length) lines.push(`Framework: ${fwNames.join(', ')}`);

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