Compare commits

..

33 Commits

Author SHA1 Message Date
jakevin 3076c12d6c chore: bump version to 1.7.4 (#1045)
Release / release (push) Has been cancelled
2026-04-15 15:50:30 +08:00
Howard 44147e54c1 feat(youtube): add feed, history, watch-later, subscriptions, playlist, like, unlike, subscribe, unsubscribe (#1029)
* feat(youtube): add feed, history, watch-later, subscriptions, playlist, like, unlike, subscribe, unsubscribe

* fix(youtube): normalize subscriptions channel fields

* docs(skills): add youtube command coverage

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-15 12:43:27 +08:00
槑囿脑袋 677e37b7a4 feat(xiaoyuzhou): add episode download and transcript support (#1031)
* feat(xiaoyuzhou): add episode audio download

* feat(xiaoyuzhou): add transcript download support

* docs(xiaoyuzhou): clarify credential file requirement

* fix(xiaoyuzhou): remove env credential fallback
2026-04-15 12:35:27 +08:00
Harvey Yue d48c71b993 feat(binance): depth shows both bids and asks (#1019)
* feat(binance): depth shows both bids and asks

* test(pipeline): cover root data access after inline select

* fix(binance): preserve map select context and register manifest entries

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-15 12:30:31 +08:00
DavidDuang 6fbeda951e feat: add hot stock ranking adapters for eastmoney, tdx, ths (#1025)
* feat: add hot stock ranking adapters for eastmoney, tdx, ths

Add three new site adapters for Chinese stock hot rankings:
- eastmoney/hot-rank: 东方财富热股榜
- tdx/hot-rank: 通达信热搜榜
- ths/hot-rank: 同花顺热股榜

All use Strategy.COOKIE browser mode with page.evaluate() DOM scraping.
Each includes co-located tests (13 tests total, all passing).

* fix(tdx,ths): add symbol validation and deduplication in evaluate()

Add seen Set for deduplication and skip entries with empty symbol/name,
matching the pattern already used in eastmoney/hot-rank.js.

* fix: refine hot-rank selectors based on browser inspection

- eastmoney: use table.rank_table tbody tr with td index-based extraction,
  fix name from a[title] to avoid post content contamination
- tdx: use div.top-cell[data-code] data attributes for reliable extraction,
  add tags column from div.tips-item.gnbk
- ths: use card-based layout selectors, remove price column (not in UI),
  extract tags from div.tag.PFSC-R

* fix(hot-rank): align tdx and ths columns with actual output

* fix: register hot stock ranking adapters

---------

Co-authored-by: dengjingren <dengjingren@cn.wilmar-intl.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-15 12:24:41 +08:00
jakevin 9bcdaaa0be fix(external): use safe npm install for dws (#1033) 2026-04-15 12:12:13 +08:00
zhengyu db70a3aaf3 fix(deamon&extension): preserve network capture and surface extension mismatch diagnostics (#1030)
* fix: preserve network capture and surface extension mismatch diagnostics

Older Browser Bridge installs can still connect to the daemon while
missing two capabilities we now rely on: the network-capture actions
and the extension version handshake. That created three user-facing
failure modes with real impact:

1. `opencli explore ...` crashed with `Unknown action: network-capture-start`
   against an old extension, so exploration stopped before any site
   analysis finished.
2. `opencli doctor` and `opencli daemon status` could show a healthy
   connection even when the extension never reported a version, which
   hid the compatibility problem and sent users toward the wrong fix.
3. After reloading a new extension, `explore` could still report
   `Endpoints: 0 total, 0 API` because `handleNavigate()` detached the
   debugger before top-level navigation and cleared the active network
   capture state right before the page load we needed to observe.

Fix this in two layers:

- Teach `Page` to treat unsupported `network-capture-*` actions as an
  old-extension compatibility case. It now warns once, memoizes the
  unsupported state, and returns empty capture data instead of throwing.
- Teach `doctor` and `daemon status` to treat "connected but version
  unknown" as a warning instead of a healthy state, so version-handshake
  failures are visible immediately.
- Preserve the debugger attachment while network capture is armed, so
  the initial navigation keeps the capture state alive and the extension
  can record requests from the first page load.

Before:

- `opencli explore ...` -> `Error: Unknown action: network-capture-start`
- `opencli doctor` -> `[OK] Extension: connected` / `Everything looks good!`
- `opencli daemon status` -> `Extension: connected` even when the
  extension version was missing
- `opencli explore ...` after reloading the extension -> `Endpoints: 0 total, 0 API`

After:

- `opencli explore ...` on an old extension -> warns once and continues
- `opencli doctor` -> `[WARN] Extension: connected (version unknown)`
- `opencli daemon status` -> `Extension: connected (version unknown)`
- `opencli explore ...` on the reloaded extension keeps network capture
  armed across navigation instead of clearing it before the page load

* fix: reset network capture flags on closeWindow()

Prevents stale _networkCaptureUnsupported flag from persisting across
sessions when the user reinstalls or reloads the extension mid-session.

* fix: startNetworkCapture returns boolean to prevent false-positive on old extensions

When the extension doesn't support network-capture-*, startNetworkCapture()
now returns false instead of silently resolving. This ensures browser open/
network correctly falls back to the JS interceptor on old extensions.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-15 12:07:31 +08:00
jakevin 0040081f2b fix: auto-restart stale daemon and improve connection error messages (#1028)
* fix: auto-restart stale daemon and improve connection error messages

When daemon is running but extension never connected (stale daemon started
before extension was installed), the CLI now auto-restarts the daemon to
give the extension a fresh WebSocket endpoint, instead of just waiting
and then telling the user to install the extension.

Also improves error messages across cli.ts, bridge.ts, and doctor.ts to
suggest "opencli daemon stop && opencli doctor" as the quick fix, since
that's what actually resolves the issue.

* fix: version-aware stale daemon detection and improved error messages

- Daemon /status now includes `daemonVersion` field
- bridge.ts: when daemon is running but extension not connected, checks
  daemonVersion vs CLI version. Only auto-restarts if version mismatch
  (stale daemon from older CLI). Same-version daemon shows improved error
  message with "opencli daemon stop && opencli doctor" hint.
- doctor.ts: explicitly identifies stale daemon (version mismatch) in
  diagnostics report, shows daemon version in status line
- cli.ts: error message changed to suggest "opencli daemon stop && opencli doctor"

* fix: treat missing daemonVersion as stale, verify shutdown before respawn

- Missing daemonVersion (pre-version daemon) is now treated as stale,
  covering the most common user scenario (old daemon without version field)
- After requestDaemonShutdown(), poll until daemon actually stops (port
  released) before spawning new one, with 3s timeout
- If shutdown request fails, log warning instead of silently proceeding
- doctor.ts also treats missing daemonVersion as stale with clear message

* fix: fail explicitly when stale daemon replacement fails

- If shutdown request fails or port isn't released within 3s, throw
  'Stale daemon could not be replaced' instead of blindly spawning on
  an occupied port
- Add tests for all three stale-daemon branches: same-version (no
  restart), missing daemonVersion (stale), mismatched version (stale)

* fix: use type-based error dispatch in browserAction instead of string matching

browserAction() now checks `instanceof BrowserConnectError` first and
renders both message and hint, instead of string-matching on message
content. This ensures stale daemon errors ("Stale daemon could not be
replaced") surface the actionable hint to the user.
2026-04-15 11:33:26 +08:00
AstroHan 16d597cfce fix(doubao): harden ask response parsing (#933)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-14 20:43:12 +08:00
flizzywine ba3a674d7b feat(grok): add image command for grok.com image generation (#906)
* feat(grok): add image command for grok.com image generation

Add `opencli grok image <prompt>` which submits a prompt via the existing
grok.com browser session and returns the generated image URLs from the
latest assistant bubble.

Because assets.grok.com URLs are gated by Cloudflare and cannot be
downloaded with a plain HTTP client, the --out flag triggers an in-page
fetch(credentials: 'include') so the browser session's cookies and
referer are attached, then writes the decoded blob to disk.

Flags:
- --new       start a fresh chat before sending
- --timeout   max seconds to wait for the image (default 240)
- --count     minimum number of images to wait for before returning
- --out       directory to save downloaded images

Ships with unit tests for the helpers (isOnGrok, normalizeBooleanFlag,
dedupeBySrc, imagesSignature, extFromContentType, buildFilename).

* fix(grok): harden image composer and bubble detection

* fix(grok): harden image flow and docs

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-14 20:42:43 +08:00
warkcod 0e38fd8c37 Feat/douban book subject (#993)
* chore: ignore local worktrees

* feat(douban): support book subject details
2026-04-14 20:41:14 +08:00
AstroHan cd48917a39 fix(xiaohongshu): require signed note URLs (#996)
* fix(xiaohongshu): require signed note urls

* chore: drop generated manifest from pr
2026-04-14 20:40:58 +08:00
CissiBot 45d6f5b09f feat(uiverse): add Uiverse code and preview adapters (#1000)
* feat(uiverse): add code and preview adapters

* fix(manifest): register uiverse commands

* docs(uiverse): add usage examples

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-14 20:38:38 +08:00
XavierCai 3ebc46f978 feat(bilibili): favorite command supports specifying fid (#1013)
* feat(bilibili): favorite command supports specifying fid

* fix(bilibili): sync favorite help and docs contract

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-14 20:37:54 +08:00
Benjamin Liu 6e29845dc3 fix(plugin): install monorepo sub-plugin dependencies when not hoisted (#1007)
Closes #722
2026-04-14 17:25:50 +08:00
mademing68092354-glitch 88bce1becf fix(chatgpt): support Chinese UI for model selector (#1006)
When ChatGPT macOS app is set to Chinese language, the "Options"
button label becomes "选项". This change checks for both English
and Chinese labels to find the button.

Co-authored-by: mad <mademing@maddeMac-mini.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-14 17:21:54 +08:00
jakevin ca68f3999b feat: Ref-Backed Locator for browser actions (#1016)
* feat: implement Ref-Backed Locator for browser actions

Introduces a unified target resolution system with fingerprint
verification and structured error diagnostics.

Snapshot phase:
- Each interactive element now gets a fingerprint (tag, role, text,
  ariaLabel, id, testId) stored in window.__opencli_ref_identity
- Zero overhead: metadata is already available during DOM walk

Resolution phase (new target-resolver.ts):
- Numeric input → ref path with fingerprint verification
- CSS-like input → querySelectorAll with uniqueness check
- No more silent first-match: ambiguous selectors are rejected

Error model (new target-errors.ts):
- stale_ref: element identity changed since snapshot
- ambiguous: CSS selector matched multiple elements (with candidates)
- not_found: element not in DOM or invalid input
- All errors include actionable hints for AI agents

base-page.ts:
- click() and typeText() now use two-phase resolve-then-act
- Existing CDP fallback for click preserved

* feat: migrate scrollTo to unified resolver pipeline

scrollTo now uses the same two-phase resolve-then-act pattern as
click and typeText, getting fingerprint verification and structured
error diagnostics (stale_ref/ambiguous/not_found) for free.

* fix: address review — stronger fingerprint verification & surface TargetError in CLI

1. Fingerprint verification now uses the full identity vector (tag, id,
   testId, ariaLabel, role, text) instead of just tag/role/text. Strong
   identifiers (id, testId) are decisive; remaining signals use majority
   voting. Fixes false negatives where same-tag elements swapped.

2. browserAction() now renders TargetError with code, hint, and
   candidates list instead of just the message string.

* fix: migrate get/select/type-autocomplete to unified resolver

- browser get text/value/attributes now resolve via resolveTargetJs
  instead of raw querySelector, getting fingerprint verification and
  structured errors for free
- browser select uses selectResolvedJs on __resolved element
- type command's autocomplete detection uses isAutocompleteResolvedJs
  on the already-resolved element
- Fix empty-string text prefix match: fp.text="Login" + text="" no
  longer falsely passes fingerprint check
2026-04-14 16:57:05 +08:00
jakevin 847c8317b6 fix(twitter): register lists command in manifest (#1011) 2026-04-14 10:37:34 +08:00
forvendettaw 741bcf9b6e Add bookmark_count field to bookmarks command (#1010)
* Add bookmark_count field to bookmarks command

Extract bookmark_count from legacy object in Twitter GraphQL
Bookmarks response. Add to returned tweet object and table columns.

* fix(manifest): sync twitter bookmarks columns

---------

Co-authored-by: Hermes Agent <hermes@lei.zong>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-14 10:27:20 +08:00
dependabot[bot] 44388d21fc chore(ci): bump softprops/action-gh-release from 2.6.1 to 3.0.0 (#1002)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.1 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2.6.1...v3.0.0)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  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-04-14 10:18:45 +08:00
dependabot[bot] 745ce459d1 chore(deps): bump undici from 8.0.2 to 8.1.0 (#1003)
Bumps [undici](https://github.com/nodejs/undici) from 8.0.2 to 8.1.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.0.2...v8.1.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.1.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-04-14 10:18:32 +08:00
dependabot[bot] a5cd0dc307 chore(deps): bump @types/node from 25.5.2 to 25.6.0 (#1004)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  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-04-14 10:18:24 +08:00
dependabot[bot] beabed4bad chore(deps): bump vitest from 4.1.2 to 4.1.4 (#1005)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.4
  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-04-14 10:18:15 +08:00
jakevin fa208ec761 docs: sync Highlights cleanup across all doc surfaces (#1009)
- docs/index.md: update feature cards to match README Highlights
- docs/zh/index.md: sync Chinese feature cards
- docs/guide/getting-started.md: align Highlights section
- README.zh-CN.md: rename "为什么是 OpenCLI" to "亮点", align with EN
2026-04-14 09:24:33 +08:00
jakevin 56a727cc04 docs: remove empty Why OpenCLI section and clean up Highlights (#1008)
- Remove the empty "Why OpenCLI" heading
- Rename "CLI All Electron" to "Desktop App Control" for clarity
- Remove "Anti-detection built-in" (exposes implementation details)
- Remove "Broad coverage" (duplicates intro and Built-in Commands table)
- Merge "Self-healing setup" and "Dynamic Loader" out (minor features)
- Rename "External CLI Hub" to "CLI Hub" for brevity
2026-04-14 09:19:08 +08:00
jakevin feedaf93b4 fix: remove duplicate extension zip from releases (#1001)
* fix: remove duplicate extension zip from releases

The release and build-extension workflows were creating both
opencli-extension.zip and opencli-extension-v{version}.zip (identical
content), causing both to be uploaded. Keep only the versioned filename.

* docs: update extension zip filename to versioned format

Update all references from opencli-extension.zip to
opencli-extension-v{version}.zip to match the workflow change.
2026-04-13 23:47:58 +08:00
jakevin 9ebb921c89 chore: prune legacy config switches (#998) 2026-04-13 23:28:30 +08:00
jakevin 9ac2e1d8ef chore: bump version to 1.7.3 (#997)
Release / release (push) Has been cancelled
2026-04-13 23:12:50 +08:00
SherlockSalvatore 2aee4caa10 feat(mubu): add Mubu adapter with 5 commands (#964)
* feat(mubu): add mubu (mubu.com) adapter with 5 commands

Commands: doc, docs, notes, recent, search.

- Uses COOKIE strategy; API calls via in-page XHR with Jwt-Token
  from localStorage (matches the web app's own mechanism).
- Renders node trees to Markdown (default) or plain text;
  supports tables, tasks, images, emoji, mentions, strikethrough,
  underline, and nested structures.
- notes supports flexible time ranges: single day, month, year,
  or custom --from/--to spans, plus a --list overview mode.
- search returns full-text matches with hit count and snippets
  for both folders and documents.

* fix(manifest): register mubu commands in runtime manifest

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-13 16:50:32 +08:00
jakevin 323fe8857c refactor: unify OPENCLI_VERBOSE and DEBUG=opencli (#991)
* refactor: unify OPENCLI_VERBOSE and DEBUG=opencli into one mechanism

Three debug output levels (verbose/debug/diagnostic) was redundant.
Merge DEBUG=opencli into OPENCLI_VERBOSE so `-v` flag controls all
verbose/debug output through a single mechanism.

- log.verbose() now checks both OPENCLI_VERBOSE and DEBUG=opencli
- log.debug() becomes an alias for log.verbose() (backward compat)
- boss/utils.js verbose helper simplified to check OPENCLI_VERBOSE only
- DEBUG=opencli still works as fallback (no breaking change)

* fix(boss): preserve debug fallback for verbose logs
2026-04-13 16:48:06 +08:00
jakevin ff6563d12a Fix automation window not closing on command failure (#992)
The error path in executeCommand did not call page.closeWindow(),
leaving the automation window open until the extension's idle timer
fires. On Windows, MV3 service worker suspension makes this timer
unreliable, causing windows to linger indefinitely.

Now closeWindow is called after diagnostic collection but before
rethrowing, ensuring the window is closed on both success and failure.
2026-04-13 16:47:47 +08:00
jakevin c42b040af4 Rename chatgpt adapters: desktop → chatgpt-app, web → chatgpt (#989)
* Rename chatgpt adapters: desktop → chatgpt-app, web → chatgpt

Aligns with existing `-app` suffix convention (discord-app, doubao-app):
- clis/chatgpt/ (desktop, AppleScript) → clis/chatgpt-app/
- clis/chatgptweb/ (browser, chatgpt.com) → clis/chatgpt/
- electron-apps.ts: chatgpt → chatgpt-app
- Updated all docs and README references

Closes #283

* Fix review findings: update cli-manifest.json and skill docs

- cli-manifest.json: update site/modulePath/sourceFile from chatgpt to chatgpt-app
- skills/opencli-usage/desktop.md: update commands from chatgpt to chatgpt-app
2026-04-13 14:33:32 +08:00
jakevin 79a15e8353 Remove unused OPENCLI_SKIP_FETCH env var (#987)
The adapter sync already has version caching (skips if same version)
and makes no network requests, so this opt-out flag adds no value.
2026-04-13 14:09:10 +08:00
138 changed files with 9134 additions and 1149 deletions
+4 -9
View File
@@ -46,25 +46,20 @@ jobs:
run: |
EXT_VERSION=$(node -p "require('./extension/package.json').version")
cd extension-package
zip -r ../opencli-extension.zip .
cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v7
with:
name: opencli-extension-build
path: |
opencli-extension.zip
opencli-extension-v*.zip
path: opencli-extension-v*.zip
retention-days: 7
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v3.0.0
with:
files: |
opencli-extension.zip
opencli-extension-v*.zip
files: opencli-extension-v*.zip
draft: false
prerelease: false
env:
-4
View File
@@ -136,12 +136,8 @@ jobs:
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
- name: Run smoke tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
timeout-minutes: 15
-4
View File
@@ -64,11 +64,7 @@ jobs:
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
- name: Run E2E tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
+2 -4
View File
@@ -42,15 +42,13 @@ jobs:
run: |
EXT_VERSION=$(node -p "require('./extension/package.json').version")
cd extension-package
zip -r ../opencli-extension.zip .
cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v3.0.0
with:
generate_release_notes: true
files: |
opencli-extension.zip
opencli-extension-v*.zip
- name: Publish to npm
+1
View File
@@ -3,6 +3,7 @@ dist/
!extension/dist/
*.tsbuildinfo
.opencli/
.worktrees/
.mcp.json
*.log
.DS_Store
+17 -17
View File
@@ -16,24 +16,16 @@ OpenCLI gives you one surface for three different kinds of automation:
It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion.
## Why OpenCLI
---
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Browser Automation** — `browser` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
- **Website → CLI** — Turn any website into a deterministic CLI: 87+ pre-built adapters, or crystallize your own with `opencli record`.
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
- **Browser Automation** — `browser` gives AI agents direct browser control: click, type, extract, screenshot — fully scriptable.
- **Website → CLI** — Turn any website into a deterministic CLI: 87+ pre-built adapters, or generate your own with `opencli generate`.
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `browser` controls the browser directly.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
- **Dynamic Loader** — Simply drop `.js` adapters into the `clis/` folder for auto-registration.
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, etc).
- **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** — 87+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
---
@@ -49,7 +41,7 @@ npm install -g @jackwener/opencli
OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extension plus a small local daemon. The daemon auto-starts when needed.
1. Download the latest `opencli-extension.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
1. Download the latest `opencli-extension-v{version}.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
2. Unzip it, open `chrome://extensions`, and enable **Developer mode**.
3. Click **Load unpacked** and select the unzipped folder.
@@ -154,8 +146,6 @@ OpenCLI is not only for websites. It can also:
| `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) |
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
| `OPENCLI_DIAGNOSTIC` | `false` | Set to `1` to capture structured diagnostic context on failures |
| `OUTPUT` | — | Override output format: `json`, `yaml`, or `table` |
| `DEBUG` | — | Set to `opencli` for internal debug logging |
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
## Update
@@ -214,9 +204,13 @@ To load the source Browser Bridge extension:
| **xianyu** | `search` `item` `chat` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
| **uiverse** | `code` `preview` |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` `download` `transcript*` |
87+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou transcript` requires local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## CLI Hub
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
@@ -246,7 +240,7 @@ Control Electron desktop apps directly from the terminal. Each adapter has its o
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
@@ -266,18 +260,24 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **douban** | Images | Poster / still image lists |
| **pixiv** | Images | Original-quality illustrations, multi-page |
| **1688** | Images, Videos | Downloads page-visible product media from item pages |
| **xiaoyuzhou** | Audio, Transcript | Downloads episode audio from public pages and transcript JSON/text with local credentials |
| **zhihu** | Articles (Markdown) | Exports with optional image download |
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
For video downloads, install `yt-dlp` first: `brew install yt-dlp`
```bash
opencli xiaohongshu download abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
opencli bilibili download BV1xxx --output ./bilibili
opencli twitter download elonmusk --limit 20 --output ./twitter
opencli 1688 download 841141931191 --output ./1688-downloads
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
```
`opencli xiaoyuzhou transcript` requires local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## Output Formats
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
+30 -17
View File
@@ -16,14 +16,16 @@ OpenCLI 可以用同一套 CLI 做三类事情:
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
## 为什么是 OpenCLI
## 亮点
- **同一个心智模型**:网站、浏览器自动化、Electron 应用、本地 CLI 都走同一个入口
- **复用真实会话**:浏览器命令直接使用你已经登录的 Chrome/Chromium,而不是重新造一套认证
- **输出稳定**:适配器命令返回固定结构,适合 shell、脚本、CI 和 AI Agent 工具调用
- **面向 AI Agent**`browser` 负责实时操作,`explore` 负责探索接口,`synthesize` 负责生成适配器,`cascade` 负责探测认证路径
- **运行成本低**:已有命令运行时不消耗模型 token
- **天然可扩展**:既能用内置能力,也能注册本地 CLI,或直接往 `clis/``.js` 适配器
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)
- **浏览器自动化** — `browser` 让 AI Agent 直接控制浏览器:点击、输入、提取、截图,完全可编程
- **网站 → CLI** — 把任何网站变成确定性 CLI:87+ 内置适配器,或用 `opencli generate` 生成新的
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器
- **面向 AI Agent** — `explore` 发现 API`synthesize` 生成适配器,`cascade` 探测认证策略,`browser` 直接控制浏览器
- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian 等)
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
## 快速开始
@@ -37,7 +39,7 @@ npm install -g @jackwener/opencli
OpenCLI 通过轻量 Browser Bridge 扩展和本地微型 daemon 与 Chrome/Chromium 通信。daemon 会按需自动启动。
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension-v{version}.zip`
2. 解压后打开 `chrome://extensions`,启用 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的目录。
@@ -142,8 +144,6 @@ OpenCLI 不只是网站 CLI,还可以:
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com` |
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
| `OUTPUT` | — | 覆盖输出格式:`json``yaml``table` |
| `DEBUG` | — | 设为 `opencli` 开启内部调试日志 |
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
## 更新
@@ -187,7 +187,7 @@ npm link
| 站点 | 命令 | 模式 |
|------|------|------|
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `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` | 浏览器 |
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
@@ -202,15 +202,16 @@ npm link
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
| **uiverse** | `code` `preview` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` `download` `transcript*` | 公开 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
| **bbc** | `news` | 公共 API |
@@ -232,7 +233,7 @@ npm link
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
| **chaoxing** | `assignments` `exams` | 浏览器 |
| **grok** | `ask` | 浏览器 |
| **grok** | `ask` `image` | 浏览器 |
| **hf** | `top` | 公开 |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
@@ -267,6 +268,8 @@ npm link
87+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
### 外部 CLI 枢纽
OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。
@@ -300,7 +303,7 @@ opencli register mycli
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
@@ -319,6 +322,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
| **1688** | 图片、视频 | 下载商品页中可见的商品素材 |
| **小宇宙** | 音频、转录 | 从公开单集数据下载音频,并使用本地凭证下载转录 JSON / 文本 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
@@ -338,7 +342,8 @@ brew install yt-dlp
```bash
# 下载小红书笔记中的图片/视频
opencli xiaohongshu download abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
# 下载B站视频(需要 yt-dlp
opencli bilibili download BV1xxx --output ./bilibili
@@ -356,6 +361,12 @@ opencli douban download 30382501 --output ./douban
# 下载 1688 商品页中的图片 / 视频素材
opencli 1688 download 841141931191 --output ./1688-downloads
# 下载小宇宙单集音频
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
# 下载小宇宙单集转录
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
# 导出知乎文章为 Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
@@ -366,6 +377,8 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
`opencli xiaoyuzhou transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
## 输出格式
+2 -7
View File
@@ -208,7 +208,7 @@ it('producthunt me fails gracefully without login', async () => {
|---|---|---|
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome。
### Sharding
@@ -233,12 +233,7 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
```yaml
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
```
CI 通过 `./.github/actions/setup-chrome` 准备真实 Chrome,再直接执行测试。
---
+2360 -337
View File
File diff suppressed because it is too large Load Diff
+18 -13
View File
@@ -3,27 +3,32 @@ import { apiGet, payloadData, getSelfUid } from './utils.js';
cli({
site: 'bilibili',
name: 'favorite',
description: '我的默认收藏夹',
description: '我的收藏夹',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'fid', type: 'int', required: false, help: 'Favorite folder ID (defaults to first folder)' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
],
columns: ['rank', 'title', 'author', 'plays', 'url'],
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: uid },
signed: true,
});
const folders = payloadData(foldersPayload)?.list ?? [];
if (!folders.length)
return [];
const fid = folders[0].id;
const { fid: favoriteId, limit = 20, page: pageNum = 1 } = kwargs;
let fid;
if (favoriteId) {
fid = Number(favoriteId);
} else {
// Fall back to the default (first) favorite folder
const uid = await getSelfUid(page);
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
params: { up_mid: uid },
signed: true,
});
const folders = payloadData(foldersPayload)?.list ?? [];
if (!folders.length)
return [];
fid = folders[0].id;
}
// Fetch favorite items
const payload = await apiGet(page, '/x/v3/fav/resource/list', {
params: { media_id: fid, pn: pageNum, ps: Math.min(Number(limit), 40) },
+3 -4
View File
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'depth',
description: 'Order book bid prices for a trading pair',
description: 'Order book bid and ask prices for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
browser: false,
@@ -11,11 +11,10 @@ cli({
{ name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
{ name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' },
],
columns: ['rank', 'bid_price', 'bid_qty'],
columns: ['rank', 'bid_price', 'bid_qty', 'ask_price', 'ask_qty'],
pipeline: [
{ fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
{ select: 'bids' },
{ map: { rank: '${{ index + 1 }}', bid_price: '${{ item.0 }}', bid_qty: '${{ item.1 }}' } },
{ map: { select: 'bids', rank: '${{ index + 1 }}', bid_price: '${{ item[0] }}', bid_qty: '${{ item[1] }}', ask_price: '${{ root.asks[index]?.[0] ?? "" }}', ask_qty: '${{ root.asks[index]?.[1] ?? "" }}' } },
{ limit: '${{ args.limit }}' },
],
});
+2 -2
View File
@@ -214,10 +214,10 @@ export async function typeAndSendMessage(page, text) {
return true;
}
/**
* Verbose log helper — prints when OPENCLI_VERBOSE or DEBUG=opencli is set.
* Verbose log helper — prints when OPENCLI_VERBOSE is set.
*/
export function verbose(msg) {
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] ${msg}`);
}
}
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { ConfigError } from '@jackwener/opencli/errors';
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
export const askCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'ask',
description: 'Send a prompt and wait for the AI response (send + wait + read)',
domain: 'localhost',
@@ -121,11 +121,14 @@ let args = CommandLine.arguments
let target = args.count > 1 ? args[1] : ""
let needsLegacy = args.count > 2 && args[2] == "legacy"
// Step 1: Click the "Options" button to open the popover
guard let optionsBtn = findByDesc(win, "Options") else {
// Step 1: Click the "Options" button to open the popover (support both English and Chinese UI)
var optionsBtn: AXUIElement? = nil
if let btn = findByDesc(win, "Options") { optionsBtn = btn }
else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
guard let options = optionsBtn else {
fputs("Could not find Options button\\n", stderr); exit(1)
}
press(optionsBtn)
press(options)
Thread.sleep(forTimeInterval: 0.8)
// Step 2: Find the popover that appeared, search ONLY within it
@@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { ConfigError } from '@jackwener/opencli/errors';
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
export const modelCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'model',
description: 'Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)',
domain: 'localhost',
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
export const newCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'new',
description: 'Open a new chat in ChatGPT Desktop App',
domain: 'localhost',
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
import { getVisibleChatMessages } from './ax.js';
export const readCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'read',
description: 'Read the last visible message from the focused ChatGPT Desktop window',
domain: 'localhost',
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { getErrorMessage } from '@jackwener/opencli/errors';
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
export const sendCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'send',
description: 'Send a message to the active ChatGPT Desktop App window',
domain: 'localhost',
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, ConfigError } from '@jackwener/opencli/errors';
export const statusCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'status',
description: 'Check if ChatGPT Desktop App is running natively on macOS',
domain: 'localhost',
@@ -30,7 +30,7 @@ async function currentChatGPTLink(page) {
}
export const imageCommand = cli({
site: 'chatgptweb',
site: 'chatgpt',
name: 'image',
description: 'Generate images with ChatGPT web and save them locally',
domain: CHATGPT_DOMAIN,
+1
View File
@@ -6,6 +6,7 @@ cli({
description: '搜索豆瓣电影、图书或音乐',
domain: 'search.douban.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{ name: 'type', default: 'movie', choices: ['movie', 'book', 'music'], help: '搜索类型(movie=电影, book=图书, music=音乐)' },
{ name: 'keyword', required: true, positional: true, help: '搜索关键词' },
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './search.js';
describe('douban search command', () => {
it('skips default pre-navigation because the adapter handles navigation itself', () => {
const command = getRegistry().get('douban/search');
expect(command).toBeDefined();
expect(command?.navigateBefore).toBe(false);
});
});
+20 -93
View File
@@ -1,18 +1,35 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { loadDoubanSubjectDetail } from './utils.js';
cli({
site: 'douban',
name: 'subject',
description: '获取电影详情',
description: '获取豆瓣条目详情',
domain: 'movie.douban.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'id', required: true, positional: true, help: '电影 ID' },
{ name: 'id', required: true, positional: true, help: '豆瓣条目 ID' },
{ name: 'type', default: 'movie', choices: ['movie', 'book'], help: '条目类型(movie=电影, book=图书)' },
],
columns: [
'id',
'type',
'title',
'subtitle',
'originalTitle',
'authors',
'translators',
'publisher',
'publishDate',
'publishYear',
'pageCount',
'binding',
'price',
'series',
'isbn10',
'isbn13',
'year',
'rating',
'ratingCount',
@@ -24,95 +41,5 @@ cli({
'summary',
'url',
],
pipeline: [
{ navigate: 'https://movie.douban.com/subject/${{ args.id }}' },
{ evaluate: `(async () => {
const id = '\${{ args.id }}';
// Wait for page to load
await new Promise(r => setTimeout(r, 2000));
// Extract title - v:itemreviewed contains "中文名 OriginalName"
const titleEl = document.querySelector('span[property="v:itemreviewed"]');
const fullTitle = titleEl?.textContent?.trim() || '';
// Split title and originalTitle
// Douban format: "中文名 OriginalName" - split by first space that separates CJK from non-CJK
let title = fullTitle;
let originalTitle = '';
const titleMatch = fullTitle.match(/^([\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]+(?:\\s*[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef·::!?]+)*)\\s+(.+)$/);
if (titleMatch) {
title = titleMatch[1].trim();
originalTitle = titleMatch[2].trim();
}
// Extract year
const yearEl = document.querySelector('.year');
const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || '';
// Extract rating
const ratingEl = document.querySelector('strong[property="v:average"]');
const rating = parseFloat(ratingEl?.textContent || '0');
// Extract rating count
const ratingCountEl = document.querySelector('span[property="v:votes"]');
const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10);
// Extract genres
const genreEls = document.querySelectorAll('span[property="v:genre"]');
const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');
// Extract directors
const directorEls = document.querySelectorAll('a[rel="v:directedBy"]');
const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');
// Extract casts
const castEls = document.querySelectorAll('a[rel="v:starring"]');
const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean);
// Extract info section for country and duration
const infoEl = document.querySelector('#info');
const infoText = infoEl?.textContent || '';
// Extract country/region from #info as list
let country = [];
const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);
if (countryMatch) {
country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);
}
// Extract duration from #info as pure number in min
const durationEl = document.querySelector('span[property="v:runtime"]');
let durationRaw = durationEl?.textContent?.trim() || '';
if (!durationRaw) {
const durationMatch = infoText.match(/片长:\\s*([^\\n]+)/);
if (durationMatch) {
durationRaw = durationMatch[1].trim();
}
}
const durationNumMatch = durationRaw.match(/(\\d+)/);
const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null;
// Extract summary
const summaryEl = document.querySelector('span[property="v:summary"]');
const summary = summaryEl?.textContent?.trim() || '';
return [{
id,
title,
originalTitle,
year,
rating,
ratingCount,
genres,
directors,
casts,
country,
duration,
summary: summary.substring(0, 200),
url: \`https://movie.douban.com/subject/\${id}\`
}];
})()
` },
],
func: async (page, args) => [await loadDoubanSubjectDetail(page, args.id, args.type)],
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './subject.js';
describe('douban subject command', () => {
it('skips default pre-navigation because the adapter handles subject navigation itself', () => {
const command = getRegistry().get('douban/subject');
expect(command).toBeDefined();
expect(command?.navigateBefore).toBe(false);
});
});
+250 -8
View File
@@ -7,6 +7,79 @@ const DOUBAN_PHOTO_PAGE_SIZE = 30;
const MAX_DOUBAN_PHOTOS = 500;
const clampLimit = (limit) => clamp(limit || 20, 1, 50);
const clampPhotoLimit = (limit) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
const DOUBAN_SEARCH_READY_SELECTOR = '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a';
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
function firstNonEmpty(values) {
for (const value of values) {
const normalized = normalizeText(value);
if (normalized)
return normalized;
}
return '';
}
function splitDoubanPeople(value) {
return normalizeText(value)
.split(/\s*\/\s*/)
.map((entry) => normalizeText(entry))
.filter(Boolean);
}
function parseDoubanBookInfoText(infoText) {
const lines = String(infoText || '')
.replace(/\r/g, '\n')
.split('\n')
.map((line) => normalizeText(line))
.filter(Boolean);
const map = {};
for (const line of lines) {
const match = line.match(/^([^:]+)\s*[:]\s*(.*)$/);
if (!match)
continue;
const label = normalizeText(match[1]);
const value = normalizeText(match[2]);
if (!label)
continue;
map[label] = value;
}
return map;
}
function parseDoubanRating(value) {
const normalized = normalizeText(value);
if (!normalized)
return 0;
const parsed = Number.parseFloat(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
function parseDoubanCount(value) {
const normalized = normalizeText(value).replace(/[^\d]/g, '');
if (!normalized)
return 0;
const parsed = Number.parseInt(normalized, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function parseDoubanPageCount(value) {
const match = normalizeText(value).match(/(\d+)/);
if (!match)
return null;
const parsed = Number.parseInt(match[1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
function extractDoubanPublishYear(value) {
const match = normalizeText(value).match(/\b(19|20)\d{2}\b/);
return match?.[0] || '';
}
function splitDoubanTitle(fullTitle) {
const normalized = normalizeText(fullTitle);
if (!normalized)
return { title: '', originalTitle: '' };
const match = normalized.match(/^([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+(?:\s*[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef·::!?]+)*)\s+(.+)$/);
if (!match) {
return { title: normalized, originalTitle: '' };
}
return {
title: normalizeText(match[1]),
originalTitle: normalizeText(match[2]),
};
}
async function ensureDoubanReady(page) {
const state = await page.evaluate(`
(() => {
@@ -20,6 +93,34 @@ async function ensureDoubanReady(page) {
throw new CliError('AUTH_REQUIRED', 'Douban requires a logged-in browser session before these commands can load data.', 'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.');
}
}
function isDetachedPageError(error) {
const message = error instanceof Error ? error.message : String(error || '');
return /Detached while handling command|Debugger is not attached to the tab|Target closed|No tab with id/i.test(message);
}
async function withDetachedRetry(task, options = {}) {
const attempts = Math.max(1, options.attempts || 2);
let lastError;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await task();
}
catch (error) {
lastError = error;
if (attempt >= attempts - 1 || !isDetachedPageError(error)) {
throw error;
}
}
}
throw lastError;
}
function buildDoubanSearchUrl(type, keyword) {
const url = new URL(`https://search.douban.com/${encodeURIComponent(type)}/subject_search`);
url.searchParams.set('search_text', String(keyword || ''));
if (String(type || '').trim() === 'book') {
url.searchParams.set('cat', '1001');
}
return url.toString();
}
export function normalizeDoubanSubjectId(subjectId) {
const normalized = String(subjectId || '').trim();
if (!/^\d+$/.test(normalized)) {
@@ -68,6 +169,144 @@ export function getDoubanPhotoExtension(url) {
return ext ? ext.replace(/[?#].*$/, '') : '.jpg';
}
}
export function normalizeDoubanBookSubject(raw) {
const info = parseDoubanBookInfoText(raw?.infoText);
const title = firstNonEmpty([raw?.title]);
const subtitle = firstNonEmpty([raw?.subtitle, info['副标题']]);
const originalTitle = firstNonEmpty([raw?.originalTitle, info['原作名']]);
const authors = splitDoubanPeople(firstNonEmpty([info['作者']]));
const translators = splitDoubanPeople(firstNonEmpty([info['译者']]));
const publisher = firstNonEmpty([info['出版社'], info['出品方']]);
const publishDate = firstNonEmpty([info['出版年']]);
const publishYear = extractDoubanPublishYear(publishDate);
const pageCount = parseDoubanPageCount(info['页数']);
const binding = firstNonEmpty([info['装帧']]);
const price = firstNonEmpty([info['定价']]);
const series = firstNonEmpty([info['丛书']]);
const isbnRaw = firstNonEmpty([info['ISBN']]).replace(/[^\dxX]/g, '');
const isbn10 = isbnRaw.length === 10 ? isbnRaw : '';
const isbn13 = isbnRaw.length === 13 ? isbnRaw : '';
return {
id: normalizeDoubanSubjectId(raw?.id),
type: 'book',
title,
subtitle,
originalTitle,
authors,
translators,
publisher,
publishDate,
publishYear,
pageCount,
binding,
price,
series,
isbn10,
isbn13,
rating: parseDoubanRating(raw?.rating),
ratingCount: parseDoubanCount(raw?.ratingCount),
summary: normalizeText(raw?.summary),
cover: firstNonEmpty([raw?.cover]),
url: firstNonEmpty([raw?.url]),
};
}
async function loadDoubanMovieSubject(page, subjectId) {
const normalizedId = normalizeDoubanSubjectId(subjectId);
const data = await withDetachedRetry(async () => {
await page.goto(`https://movie.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 });
await ensureDoubanReady(page);
await page.wait({ selector: 'span[property="v:itemreviewed"], #info', timeout: 8 }).catch(() => { });
return page.evaluate(`
(() => {
const id = ${JSON.stringify(normalizedId)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const { title, originalTitle } = (${splitDoubanTitle.toString()})(normalize(document.querySelector('span[property="v:itemreviewed"]')?.textContent || ''));
const year = normalize(document.querySelector('.year')?.textContent).replace(/[()()]/g, '');
const rating = parseFloat(normalize(document.querySelector('strong[property="v:average"]')?.textContent || '0')) || 0;
const ratingCount = parseInt(normalize(document.querySelector('span[property="v:votes"]')?.textContent || '0'), 10) || 0;
const genres = Array.from(document.querySelectorAll('span[property="v:genre"]'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(',');
const directors = Array.from(document.querySelectorAll('a[rel="v:directedBy"]'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(',');
const casts = Array.from(document.querySelectorAll('a[rel="v:starring"]'))
.slice(0, 5)
.map((node) => normalize(node.textContent))
.filter(Boolean);
const infoText = document.querySelector('#info')?.textContent || '';
let country = [];
const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);
if (countryMatch) {
country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);
}
const durationRaw = normalize(document.querySelector('span[property="v:runtime"]')?.textContent || '');
const durationMatch = durationRaw.match(/(\\d+)/);
const summary = normalize(document.querySelector('span[property="v:summary"]')?.textContent || '');
return {
id,
type: 'movie',
title,
originalTitle,
year,
rating,
ratingCount,
genres,
directors,
casts,
country,
duration: durationMatch ? parseInt(durationMatch[1], 10) : null,
summary: summary.slice(0, 200),
url: 'https://movie.douban.com/subject/' + id + '/',
};
})()
`);
});
return data;
}
async function loadDoubanBookSubject(page, subjectId) {
const normalizedId = normalizeDoubanSubjectId(subjectId);
const data = await withDetachedRetry(async () => {
await page.goto(`https://book.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 });
await ensureDoubanReady(page);
await page.wait({ selector: 'h1 span, #info', timeout: 8 }).catch(() => { });
return page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const pickSummary = () => {
const nodes = Array.from(document.querySelectorAll('#link-report .intro, .related_info .intro'));
for (let i = nodes.length - 1; i >= 0; i -= 1) {
const text = normalize(nodes[i]?.textContent);
if (text) return text;
}
return '';
};
return {
id: ${JSON.stringify(normalizedId)},
title: normalize(document.querySelector('h1 span')?.textContent || document.querySelector('h1')?.textContent || ''),
subtitle: '',
originalTitle: '',
infoText: document.querySelector('#info')?.innerText || document.querySelector('#info')?.textContent || '',
rating: normalize(document.querySelector('strong.rating_num, strong[property="v:average"]')?.textContent || ''),
ratingCount: normalize(document.querySelector('a.rating_people > span, span[property="v:votes"]')?.textContent || ''),
summary: pickSummary(),
cover: document.querySelector('#mainpic img')?.getAttribute('src') || '',
url: location.href,
};
})()
`);
});
return normalizeDoubanBookSubject(data);
}
export async function loadDoubanSubjectDetail(page, subjectId, subjectType = 'movie') {
const type = String(subjectType || 'movie').trim() === 'book' ? 'book' : 'movie';
if (type === 'book') {
return loadDoubanBookSubject(page, subjectId);
}
return loadDoubanMovieSubject(page, subjectId);
}
export async function loadDoubanSubjectPhotos(page, subjectId, options = {}) {
const normalizedId = normalizeDoubanSubjectId(subjectId);
const type = String(options.type || 'Rb').trim() || 'Rb';
@@ -312,11 +551,13 @@ export function inferDoubanSearchResultType(searchType, item = {}) {
}
export async function searchDouban(page, type, keyword, limit) {
const safeLimit = clampLimit(limit);
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
await page.wait(2);
await ensureDoubanReady(page);
const inferDoubanSearchResultTypeSource = inferDoubanSearchResultType.toString();
const data = await page.evaluate(`
const searchUrl = buildDoubanSearchUrl(type, keyword);
const data = await withDetachedRetry(async () => {
await page.goto(searchUrl, { waitUntil: 'load', settleMs: 1500 });
await ensureDoubanReady(page);
await page.wait({ selector: DOUBAN_SEARCH_READY_SELECTOR, timeout: 8 }).catch(() => { });
return page.evaluate(`
(async () => {
const type = ${JSON.stringify(type)};
const inferDoubanSearchResultType = ${inferDoubanSearchResultTypeSource};
@@ -335,13 +576,13 @@ export async function searchDouban(page, type, keyword, limit) {
await sleep(300);
}
const items = Array.from(document.querySelectorAll('.item-root'));
const items = Array.from(document.querySelectorAll('.item-root, .result-list .result-item'));
const results = [];
for (const el of items) {
const titleEl = el.querySelector('.title-text, .title a, a[title]');
const titleEl = el.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]');
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
let url = titleEl?.getAttribute('href') || '';
let url = titleEl?.getAttribute('href') || el.querySelector('a[href*="/subject/"]')?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
if (!url.includes('/subject/') || seen.has(url)) continue;
@@ -350,7 +591,7 @@ export async function searchDouban(page, type, keyword, limit) {
const rawItem = rawItemsById.get(id) || {};
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
const abstract = normalize(
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
el.querySelector('.meta.abstract, .meta, .abstract, .subject-abstract, p')?.textContent,
);
results.push({
rank: results.length + 1,
@@ -367,6 +608,7 @@ export async function searchDouban(page, type, keyword, limit) {
return results;
})()
`);
});
return Array.isArray(data) ? data : [];
}
/**
+179 -4
View File
@@ -1,6 +1,16 @@
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import { getDoubanPhotoExtension, inferDoubanSearchResultType, loadDoubanSubjectPhotos, normalizeDoubanSubjectId, promoteDoubanPhotoUrl, resolveDoubanPhotoAssetUrl, searchDouban, } from './utils.js';
import {
getDoubanPhotoExtension,
inferDoubanSearchResultType,
loadDoubanSubjectDetail,
loadDoubanSubjectPhotos,
normalizeDoubanBookSubject,
normalizeDoubanSubjectId,
promoteDoubanPhotoUrl,
resolveDoubanPhotoAssetUrl,
searchDouban,
} from './utils.js';
function createFakeNode(text = '', attrs = {}) {
return {
@@ -10,18 +20,22 @@ function createFakeNode(text = '', attrs = {}) {
},
};
}
function createFakeSearchItem({ title, url, rating, abstract, cover }) {
return {
querySelector(selector) {
if (selector === '.title-text, .title a, a[title]') {
if (selector === '.title-text, .title a, .title h3 a, h3 a, a[title]') {
return createFakeNode(title, { href: url, title });
}
if (selector === '.rating_nums') {
return createFakeNode(rating);
}
if (selector === '.meta.abstract, .meta, .abstract, p') {
if (selector === '.meta.abstract, .meta, .abstract, .subject-abstract, p') {
return createFakeNode(abstract);
}
if (selector === 'a[href*="/subject/"]') {
return createFakeNode('', { href: url });
}
if (selector === 'img') {
return createFakeNode('', { src: cover });
}
@@ -29,11 +43,15 @@ function createFakeSearchItem({ title, url, rating, abstract, cover }) {
},
};
}
async function runSearchEvaluate(script, rawItems, domItems) {
const document = {
querySelector(selector) {
if (selector === '.item-root .title-text, .item-root .title a') {
return domItems[0]?.querySelector('.title-text, .title a, a[title]') || null;
return domItems[0]?.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]') || null;
}
if (selector === '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a') {
return domItems[0]?.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]') || null;
}
return null;
},
@@ -41,9 +59,13 @@ async function runSearchEvaluate(script, rawItems, domItems) {
if (selector === '.item-root') {
return domItems;
}
if (selector === '.item-root, .result-list .result-item') {
return domItems;
}
return [];
},
};
return vm.runInNewContext(script, {
Map,
Promise,
@@ -59,20 +81,25 @@ async function runSearchEvaluate(script, rawItems, domItems) {
},
});
}
describe('douban utils', () => {
it('normalizes valid subject ids', () => {
expect(normalizeDoubanSubjectId(' 30382501 ')).toBe('30382501');
});
it('rejects invalid subject ids', () => {
expect(() => normalizeDoubanSubjectId('tt30382501')).toThrow('Invalid Douban subject ID');
});
it('promotes thumbnail urls to large photo urls', () => {
expect(promoteDoubanPhotoUrl('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp')).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
expect(promoteDoubanPhotoUrl('https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2578474613.jpg')).toBe('https://img9.doubanio.com/view/photo/l/public/p2578474613.jpg');
});
it('rejects non-http photo urls during promotion', () => {
expect(promoteDoubanPhotoUrl('data:image/gif;base64,abc')).toBe('');
});
it('prefers lazy-loaded photo urls over data placeholders', () => {
expect(resolveDoubanPhotoAssetUrl([
'',
@@ -80,9 +107,11 @@ describe('douban utils', () => {
'data:image/gif;base64,abc',
], 'https://movie.douban.com/subject/30382501/photos?type=Rb')).toBe('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp');
});
it('drops unsupported non-http photo urls when no real image url exists', () => {
expect(resolveDoubanPhotoAssetUrl(['data:image/gif;base64,abc', 'blob:https://movie.douban.com/example'], 'https://movie.douban.com/subject/30382501/photos?type=Rb')).toBe('');
});
it('removes the default photo cap when scanning for an exact photo id', async () => {
const evaluate = vi.fn()
.mockResolvedValueOnce({ blocked: false, title: 'Some Movie', href: 'https://movie.douban.com/subject/30382501/photos?type=Rb' })
@@ -116,10 +145,12 @@ describe('douban utils', () => {
expect(scanScript).toContain(`const limit = ${Number.MAX_SAFE_INTEGER};`);
expect(scanScript).toContain('for (let pageIndex = 0; photos.length < limit; pageIndex += 1)');
});
it('keeps image extensions when download urls contain query params', () => {
expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp?foo=1')).toBe('.webp');
expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.jpeg')).toBe('.jpeg');
});
it('maps tv series results to tvshow in searchDouban output', async () => {
const domItems = [
createFakeSearchItem({
@@ -161,7 +192,149 @@ describe('douban utils', () => {
{ id: '36289423', type: 'movie', title: '射雕英雄传:侠之大者‎ (2025)' },
]);
});
it('normalizes douban book subject raw data into structured fields', () => {
const normalized = normalizeDoubanBookSubject({
id: '2567698',
title: '小狗钱钱',
subtitle: '让孩子和家长共同成长的财商童话',
originalTitle: 'Ein Hund namens Money',
infoText: `
作者: [德] 博多·舍费尔
出版社: 南海出版公司
副标题: 让孩子和家长共同成长的财商童话
原作名: Ein Hund namens Money
译者: 王钟欣 / 余茜
出版年: 2014-1-1
页数: 208
定价: 26.00元
装帧: 平装
丛书: 新经典文库·爱心树童书
ISBN: 9787544270871
`,
rating: '8.9',
ratingCount: '12345',
summary: '理财启蒙故事',
cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg',
url: 'https://book.douban.com/subject/2567698/',
});
expect(normalized).toMatchObject({
id: '2567698',
type: 'book',
title: '小狗钱钱',
subtitle: '让孩子和家长共同成长的财商童话',
originalTitle: 'Ein Hund namens Money',
authors: ['[德] 博多·舍费尔'],
translators: ['王钟欣', '余茜'],
publisher: '南海出版公司',
publishDate: '2014-1-1',
publishYear: '2014',
pageCount: 208,
binding: '平装',
price: '26.00元',
series: '新经典文库·爱心树童书',
isbn13: '9787544270871',
rating: 8.9,
ratingCount: 12345,
summary: '理财启蒙故事',
cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg',
url: 'https://book.douban.com/subject/2567698/',
});
});
it('loads book subject details from book.douban.com when type=book', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ blocked: false, title: '小狗钱钱 (豆瓣)', href: 'https://book.douban.com/subject/2567698/' })
.mockResolvedValueOnce({
id: '2567698',
title: '小狗钱钱',
subtitle: '',
originalTitle: '',
infoText: `
作者: [德] 博多·舍费尔
出版社: 南海出版公司
出版年: 2014-1-1
ISBN: 9787544270871
`,
rating: '8.9',
ratingCount: '12345',
summary: '理财启蒙故事',
cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg',
url: 'https://book.douban.com/subject/2567698/',
}),
};
const detail = await loadDoubanSubjectDetail(page, '2567698', 'book');
expect(page.goto).toHaveBeenCalledWith('https://book.douban.com/subject/2567698/', {
waitUntil: 'load',
settleMs: 1500,
});
expect(page.wait).toHaveBeenCalledWith({ selector: 'h1 span, #info', timeout: 8 });
expect(detail).toMatchObject({
id: '2567698',
type: 'book',
title: '小狗钱钱',
authors: ['[德] 博多·舍费尔'],
publisher: '南海出版公司',
isbn13: '9787544270871',
rating: 8.9,
ratingCount: 12345,
url: 'https://book.douban.com/subject/2567698/',
});
});
it('retries transient detached navigation errors when loading douban search results', async () => {
const page = {
goto: vi.fn()
.mockRejectedValueOnce(new Error('Detached while handling command'))
.mockResolvedValueOnce(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({
blocked: false,
title: '经济学思维 - 豆瓣搜索',
href: 'https://search.douban.com/book/subject_search?search_text=%E7%BB%8F%E6%B5%8E%E5%AD%A6%E6%80%9D%E7%BB%B4&cat=1001',
})
.mockResolvedValueOnce([
{
rank: 1,
id: '26895402',
type: 'book',
title: '经济学思维',
rating: 7.9,
abstract: '李子畅 / 中信出版社 / 2016-7',
url: 'https://book.douban.com/subject/26895402/',
cover: 'https://img1.doubanio.com/view/subject/m/public/s29000000.jpg',
},
]),
};
const results = await searchDouban(page, 'book', '经济学思维', 3);
expect(page.goto).toHaveBeenNthCalledWith(1, 'https://search.douban.com/book/subject_search?search_text=%E7%BB%8F%E6%B5%8E%E5%AD%A6%E6%80%9D%E7%BB%B4&cat=1001', {
waitUntil: 'load',
settleMs: 1500,
});
expect(page.goto).toHaveBeenCalledTimes(2);
expect(page.wait).toHaveBeenCalledWith({
selector: '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a',
timeout: 8,
});
expect(results).toEqual([
{
rank: 1,
id: '26895402',
type: 'book',
title: '经济学思维',
rating: 7.9,
abstract: '李子畅 / 中信出版社 / 2016-7',
url: 'https://book.douban.com/subject/26895402/',
cover: 'https://img1.doubanio.com/view/subject/m/public/s29000000.jpg',
},
]);
});
});
describe('inferDoubanSearchResultType', () => {
it('returns tvshow for movie search results marked as TV', () => {
expect(inferDoubanSearchResultType('movie', {
@@ -169,12 +342,14 @@ describe('inferDoubanSearchResultType', () => {
labels: [{ text: '剧集' }],
})).toBe('tvshow');
});
it('returns movie when a movie search result has no TV signal', () => {
expect(inferDoubanSearchResultType('movie', {
moreUrl: "onclick=\"moreurl(this,{is_tv:'0'})\"",
labels: [{ text: '可播放' }],
})).toBe('movie');
});
it('preserves non-movie search types', () => {
expect(inferDoubanSearchResultType('book', {
moreUrl: '',
+334 -145
View File
@@ -1,6 +1,44 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
export const DOUBAO_DOMAIN = 'www.doubao.com';
export const DOUBAO_CHAT_URL = 'https://www.doubao.com/chat';
export const DOUBAO_NEW_CHAT_URL = 'https://www.doubao.com/chat/new-thread/create-by-msg';
const DOUBAO_COMPOSER_SELECTORS = [
'textarea[data-testid="chat_input_input"]',
'[data-testid="chat_input"] textarea',
'.chat-input textarea',
'.chat-input [contenteditable="true"]',
'.chat-editor textarea',
'.chat-editor [contenteditable="true"]',
'textarea[placeholder*="发消息"]',
'textarea[placeholder*="Message"]',
'[contenteditable="true"][placeholder*="发消息"]',
'[contenteditable="true"][placeholder*="Message"]',
'[contenteditable="true"][aria-label*="发消息"]',
'[contenteditable="true"][aria-label*="Message"]',
'textarea',
'[contenteditable="true"]',
];
function buildDoubaoComposerLocatorScript() {
return `
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const composerSelectors = ${JSON.stringify(DOUBAO_COMPOSER_SELECTORS)};
const findComposer = () => {
for (const selector of composerSelectors) {
const node = Array.from(document.querySelectorAll(selector)).find(isVisible);
if (node) return node;
}
return null;
};
`;
}
function getTranscriptLinesScript() {
return `
(() => {
@@ -205,41 +243,97 @@ function getTurnsScript() {
})()
`;
}
function prepareDoubaoComposerScript() {
return `
(() => {
${buildDoubaoComposerLocatorScript()}
const composer = findComposer();
if (
!(composer instanceof HTMLTextAreaElement)
&& !(composer instanceof HTMLInputElement)
&& !(composer instanceof HTMLElement)
) {
return { ok: false, reason: 'Could not find Doubao input element' };
}
try {
composer.focus();
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
const length = composer.value.length;
composer.setSelectionRange(0, length);
} else {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(composer);
selection?.removeAllRanges();
selection?.addRange(range);
}
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
return { ok: true };
})()
`;
}
function composerStateScript() {
return `
(() => {
${buildDoubaoComposerLocatorScript()}
const composer = findComposer();
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
return { hasText: !!composer.value.trim(), text: composer.value };
}
if (composer instanceof HTMLElement) {
const text = (composer.innerText || '').trim() || (composer.textContent || '').trim();
return {
hasText: !!text,
text,
};
}
return { hasText: false, text: '' };
})()
`;
}
function syncComposerAfterNativeTypeScript() {
return `
(() => {
${buildDoubaoComposerLocatorScript()}
const composer = findComposer();
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
const value = composer.value;
composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' }));
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
return { hasText: !!value.trim(), text: value };
}
if (composer instanceof HTMLElement) {
const text = (composer.innerText || '').trim() || (composer.textContent || '').trim();
composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: text, inputType: 'insertText' }));
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
return { hasText: !!text, text };
}
return { hasText: false, text: '' };
})()
`;
}
function fillComposerScript(text) {
return `
((inputText) => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const candidates = [
'textarea[data-testid="chat_input_input"]',
'.chat-input textarea',
'.chat-input [contenteditable="true"]',
'.chat-editor textarea',
'.chat-editor [contenteditable="true"]',
'textarea[placeholder*="发消息"]',
'textarea[placeholder*="Message"]',
'[contenteditable="true"][placeholder*="发消息"]',
'[contenteditable="true"][placeholder*="Message"]',
'[contenteditable="true"][aria-label*="发消息"]',
'[contenteditable="true"][aria-label*="Message"]',
'textarea',
'[contenteditable="true"]',
];
let composer = null;
for (const selector of candidates) {
const node = Array.from(document.querySelectorAll(selector)).find(isVisible);
if (node) {
composer = node;
break;
}
}
${buildDoubaoComposerLocatorScript()}
const composer = findComposer();
if (!composer) throw new Error('Could not find Doubao input element');
@@ -251,9 +345,10 @@ function fillComposerScript(text) {
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
setter?.call(composer, inputText);
composer.dispatchEvent(new Event('input', { bubbles: true }));
composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: inputText, inputType: 'insertText' }));
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
return 'text-input';
return { hasText: !!composer.value.trim(), mode: 'text-input', text: composer.value };
}
if (composer instanceof HTMLElement) {
@@ -265,71 +360,21 @@ function fillComposerScript(text) {
selection?.removeAllRanges();
selection?.addRange(range);
document.execCommand('insertText', false, inputText);
composer.dispatchEvent(new Event('input', { bubbles: true }));
composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: inputText, inputType: 'insertText' }));
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
return 'contenteditable';
return {
hasText: !!((composer.innerText || '').trim() || (composer.textContent || '').trim()),
mode: 'contenteditable',
text: (composer.innerText || '').trim() || (composer.textContent || '').trim(),
};
}
throw new Error('Unsupported Doubao input element');
})(${JSON.stringify(text)})
`;
}
function fillAndSubmitComposerScript(text) {
return `
((inputText) => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const candidates = [
'textarea[data-testid="chat_input_input"]',
'[data-testid="chat_input"] textarea',
'.chat-input textarea',
'textarea[placeholder*="发消息"]',
'textarea[placeholder*="Message"]',
'textarea',
];
let composer = null;
for (const selector of candidates) {
const node = Array.from(document.querySelectorAll(selector)).find(isVisible);
if (node) {
composer = node;
break;
}
}
if (!(composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement)) {
throw new Error('Could not find Doubao textarea input element');
}
composer.focus();
const proto = composer instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
setter?.call(composer, inputText);
composer.dispatchEvent(new Event('input', { bubbles: true }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
const root = document.querySelector('[data-testid="chat_input"], .chat-input') || document.body;
const buttons = Array.from(root.querySelectorAll('button, [role="button"]')).filter(isVisible);
const target = buttons[buttons.length - 1];
if (target) {
target.click();
return 'button';
}
return 'enter';
})(${JSON.stringify(text)})
`;
}
function clickSendButtonScript() {
function detectDoubaoVerificationScript() {
return `
(() => {
const isVisible = (el) => {
@@ -340,64 +385,115 @@ function clickSendButtonScript() {
return rect.width > 0 && rect.height > 0;
};
const labels = ['发送', 'Send', '发消息...', 'Message...'];
const root = document.querySelector('[data-testid="chat_input"], .chat-input') || document;
const buttons = Array.from(root.querySelectorAll(
'.chat-input-button button, .chat-input-button [role="button"], .chat-input button, button[type="submit"], [role="button"]'
));
const challengeSelectors = [
'iframe[src*="captcha"]',
'iframe[src*="verify"]',
'input[placeholder*="验证码"]',
'input[aria-label*="验证码"]',
];
const selectorMatch = challengeSelectors.find((selector) => {
return Array.from(document.querySelectorAll(selector)).some((node) => isVisible(node));
});
if (selectorMatch) {
return { detected: true, reason: selectorMatch };
}
const phrasePattern = /人机验证|完成安全验证|异常访问|滑动验证|拖动滑块/i;
const candidateRoots = Array.from(
document.querySelectorAll('[role="dialog"], [aria-modal="true"], .semi-modal, .modal')
);
const match = candidateRoots.find((node) => {
if (!(node instanceof HTMLElement)) return false;
if (!isVisible(node)) return false;
const text = (node.innerText || node.textContent || '').trim();
if (!text || text.length > 400) return false;
return phrasePattern.test(text);
});
return {
detected: !!match,
reason: match ? ((match.innerText || match.textContent || '').trim().slice(0, 80) || 'challenge-ui') : '',
};
})()
`;
}
function clickSendButtonScript() {
return `
(() => {
${buildDoubaoComposerLocatorScript()}
const composer = findComposer();
if (!(composer instanceof HTMLElement)) return false;
const composerRect = composer.getBoundingClientRect();
const rootCandidates = [
composer.closest('form'),
composer.closest('[role="form"]'),
composer.closest('[data-testid="chat_input"]'),
composer.closest('.chat-input'),
composer.parentElement,
composer.parentElement?.parentElement,
].filter(Boolean);
const seen = new Set();
const buttons = [];
for (const root of rootCandidates) {
root.querySelectorAll('button, [role="button"]').forEach((node) => {
if (!(node instanceof HTMLElement)) return;
if (seen.has(node)) return;
seen.add(node);
buttons.push(node);
});
}
const submitPattern = /send|发送|提交|发消息/i;
const excludedPattern = /新对话|new chat|快速|视频生成|深入研究|图像生成|帮我写作|音乐生成|更多|上传|upload|麦克风|microphone|模式|mode|工具|tools|设置|settings|云盘|history|历史/i;
let bestButton = null;
let bestScore = -Infinity;
for (const button of buttons) {
if (!isVisible(button)) continue;
const disabled = button.getAttribute('disabled') !== null
|| button.getAttribute('aria-disabled') === 'true';
if (disabled) continue;
const text = (button.innerText || button.textContent || '').trim();
const aria = (button.getAttribute('aria-label') || '').trim();
const title = (button.getAttribute('title') || '').trim();
const haystacks = [text, aria, title];
if (haystacks.some((value) => labels.some((label) => value.includes(label)))) {
button.click();
return true;
const className = String(button.className || '');
const haystack = [text, aria, title].join(' ').trim();
if (excludedPattern.test(haystack)) continue;
const rect = button.getBoundingClientRect();
const dx = rect.left - composerRect.right;
const dy = Math.abs((rect.top + rect.height / 2) - (composerRect.top + composerRect.height / 2));
const distancePenalty = Math.abs(dx) + dy;
const isSubmitLike = submitPattern.test(haystack)
|| button.getAttribute('type') === 'submit'
|| className.includes('bg-dbx-text-highlight')
|| className.includes('bg-dbx-fill-highlight')
|| className.includes('text-dbx-text-static-white-primary');
if (!isSubmitLike) continue;
if (dx < -80 || dx > 280) continue;
if (dy > 140) continue;
let score = -distancePenalty;
if (submitPattern.test(haystack)) score += 5000;
if (button.getAttribute('type') === 'submit') score += 1200;
if (button.closest('.chat-input-button')) score += 1200;
if (className.includes('bg-dbx-text-highlight')) score += 600;
if (className.includes('bg-dbx-fill-highlight')) score += 600;
if (className.includes('text-dbx-text-static-white-primary')) score += 400;
if (dx >= -40 && dx <= 240) score += 120;
if (rect.left >= composerRect.left - 40) score += 40;
if (score > bestScore) {
bestScore = score;
bestButton = button;
}
}
const styledCandidate = [...buttons].reverse().find((button) => {
if (!isVisible(button)) return false;
const disabled = button.getAttribute('disabled') !== null
|| button.getAttribute('aria-disabled') === 'true';
if (disabled) return false;
const className = button.className || '';
return className.includes('bg-dbx-text-highlight')
|| className.includes('bg-dbx-fill-highlight')
|| className.includes('text-dbx-text-static-white-primary');
});
if (styledCandidate) {
styledCandidate.click();
return true;
}
const inputButton = [...buttons].reverse().find((button) => {
if (!isVisible(button)) return false;
const disabled = button.getAttribute('disabled') !== null
|| button.getAttribute('aria-disabled') === 'true';
if (disabled) return false;
return !!button.closest('.chat-input-button');
});
if (inputButton) {
inputButton.click();
return true;
}
const lastEnabledButton = [...buttons].reverse().find((button) => {
if (!isVisible(button)) return false;
return button.getAttribute('disabled') === null
&& button.getAttribute('aria-disabled') !== 'true';
});
if (lastEnabledButton) {
lastEnabledButton.click();
if (bestButton && bestScore >= 200) {
bestButton.click();
return true;
}
@@ -513,15 +609,56 @@ export async function getDoubaoTranscriptLines(page) {
}
export async function sendDoubaoMessage(page, text) {
await ensureDoubaoChatPage(page);
const submittedBy = await page.evaluate(fillAndSubmitComposerScript(text));
if (submittedBy === 'enter') {
const normalizeComposerText = (value) => value.replace(/\r\n/g, '\n').trim();
const expectedText = normalizeComposerText(text);
const prepared = await page.evaluate(prepareDoubaoComposerScript());
if (!prepared?.ok) {
throw new CommandExecutionError(prepared?.reason || 'Could not find Doubao input element');
}
let hasText = false;
if (page.nativeType) {
try {
await page.nativeType(text);
await page.wait(0.2);
await page.evaluate(syncComposerAfterNativeTypeScript());
const nativeState = await page.evaluate(composerStateScript());
hasText = !!nativeState?.hasText && normalizeComposerText(nativeState?.text || '') === expectedText;
}
catch { }
}
if (!hasText) {
const fallbackState = await page.evaluate(fillComposerScript(text));
hasText = !!fallbackState?.hasText && normalizeComposerText(fallbackState?.text || '') === expectedText;
}
if (!hasText) {
throw new CommandExecutionError('Failed to insert text into Doubao composer');
}
let submittedBy = 'enter';
const clicked = await page.evaluate(clickSendButtonScript());
if (clicked) {
submittedBy = 'button';
}
else if (page.nativeKeyPress) {
try {
await page.nativeKeyPress('Enter');
}
catch {
await page.pressKey('Enter');
}
}
else {
await page.pressKey('Enter');
}
await page.wait(0.8);
const verification = await page.evaluate(detectDoubaoVerificationScript());
if (verification?.detected) {
throw new CommandExecutionError('Doubao blocked the request with a verification challenge', verification.reason
? `Detected challenge signal: ${verification.reason}`
: 'Please complete the challenge in the browser and try again.');
}
return submittedBy;
}
export async function waitForDoubaoResponse(page, beforeLines, beforeTurns, promptText, timeoutSeconds) {
const beforeSet = new Set(beforeLines);
const beforeTurnSet = new Set(beforeTurns
.filter((turn) => turn.Role === 'Assistant')
.map((turn) => `${turn.Role}::${turn.Text}`));
@@ -534,6 +671,12 @@ export async function waitForDoubaoResponse(page, beforeLines, beforeTurns, prom
.replace(/window\\._SSR_DATA.*$/g, '')
.trim();
const getCandidate = async () => {
const verification = await page.evaluate(detectDoubaoVerificationScript());
if (verification?.detected) {
throw new CommandExecutionError('Doubao blocked the request with a verification challenge', verification.reason
? `Detected challenge signal: ${verification.reason}`
: 'Please complete the challenge in the browser and try again.');
}
const turns = await getDoubaoVisibleTurns(page);
const assistantCandidate = [...turns]
.reverse()
@@ -542,10 +685,9 @@ export async function waitForDoubaoResponse(page, beforeLines, beforeTurns, prom
if (visibleCandidate)
return visibleCandidate;
const lines = await getDoubaoTranscriptLines(page);
const additions = lines
.filter((line) => !beforeSet.has(line))
.map((line) => sanitizeCandidate(line))
.filter((line) => line && line !== promptText);
const additions = collectDoubaoTranscriptAdditions(beforeLines, lines, promptText, sanitizeCandidate)
.split('\n')
.filter(Boolean);
const shortCandidate = additions.find((line) => line.length <= 120);
return shortCandidate || additions[additions.length - 1] || '';
};
@@ -571,6 +713,48 @@ export async function waitForDoubaoResponse(page, beforeLines, beforeTurns, prom
}
return lastCandidate;
}
export function isLikelyDoubaoUiNoise(value) {
const text = value.replace(/\s+/g, '');
if (!text)
return false;
const exactNoise = new Set([
'快速视频生成深入研究图像生成帮我写作音乐生成更多',
]);
return exactNoise.has(text);
}
function isAlwaysTranscriptUiNoise(value) {
const text = value.replace(/\s+/g, '');
if (!text)
return false;
const exactNoise = new Set([
'AI创作云盘更多历史对话',
]);
return exactNoise.has(text);
}
function isLikelyTranscriptUiNoise(rawValue, sanitizedValue, promptText) {
const normalizeWhitespace = (value) => value.replace(/\s+/g, ' ').trim();
const normalizedRaw = normalizeWhitespace(rawValue);
const normalizedPrompt = normalizeWhitespace(promptText);
if (!normalizedPrompt || !normalizedRaw.startsWith(normalizedPrompt))
return false;
const remainder = normalizedRaw.slice(normalizedPrompt.length).trim();
if (!remainder)
return true;
return isLikelyDoubaoUiNoise(remainder) || isLikelyDoubaoUiNoise(sanitizedValue);
}
export function collectDoubaoTranscriptAdditions(beforeLines, currentLines, promptText, sanitize = (value) => value.trim()) {
const normalizedBefore = new Set(beforeLines.map((line) => sanitize(line)).filter(Boolean));
return currentLines
.filter((line) => !beforeLines.includes(line))
.map((line) => ({ raw: line, sanitized: sanitize(line) }))
.filter(({ raw, sanitized }) => sanitized
&& sanitized !== promptText
&& !normalizedBefore.has(sanitized)
&& !isAlwaysTranscriptUiNoise(sanitized)
&& !isLikelyTranscriptUiNoise(raw, sanitized, promptText))
.map(({ sanitized }) => sanitized)
.join('\n');
}
function getConversationListScript() {
return `
(() => {
@@ -888,6 +1072,11 @@ export async function triggerTranscriptDownload(page) {
const btnResult = await page.evaluate(clickTranscriptDownloadBtnScript());
return !btnResult.error;
}
export const __test__ = {
clickSendButtonScript,
composerStateScript,
detectDoubaoVerificationScript,
};
export async function startNewDoubaoChat(page) {
await ensureDoubaoChatPage(page);
const clickedLabel = await page.evaluate(clickNewChatScript());
+241 -2
View File
@@ -1,5 +1,41 @@
import { describe, expect, it } from 'vitest';
import { mergeTranscriptSnapshots, parseDoubaoConversationId } from './utils.js';
import { describe, expect, it, vi } from 'vitest';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import {
__test__,
collectDoubaoTranscriptAdditions,
mergeTranscriptSnapshots,
parseDoubaoConversationId,
sendDoubaoMessage,
waitForDoubaoResponse,
} from './utils.js';
function createPageMock() {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
nativeType: vi.fn().mockResolvedValue(undefined),
nativeKeyPress: vi.fn().mockResolvedValue(undefined),
};
}
describe('parseDoubaoConversationId', () => {
it('extracts the numeric id from a full conversation URL', () => {
expect(parseDoubaoConversationId('https://www.doubao.com/chat/1234567890123')).toBe('1234567890123');
@@ -8,6 +44,209 @@ describe('parseDoubaoConversationId', () => {
expect(parseDoubaoConversationId('1234567890123')).toBe('1234567890123');
});
});
describe('doubao send strategy', () => {
it('prefers native CDP text insertion and button submission when a send button is available', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const nativeType = vi.mocked(page.nativeType);
const nativeKeyPress = vi.mocked(page.nativeKeyPress);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ detected: false });
const result = await sendDoubaoMessage(page, '你好');
expect(nativeType).toHaveBeenCalledWith('你好');
expect(nativeKeyPress).not.toHaveBeenCalled();
expect(result).toBe('button');
});
it('falls back to DOM insertion when native insertion does not update the composer', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const nativeType = vi.mocked(page.nativeType);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: false, text: '' })
.mockResolvedValueOnce({ hasText: false, text: '' })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ detected: false });
const result = await sendDoubaoMessage(page, '你好');
expect(nativeType).toHaveBeenCalledWith('你好');
expect(evaluate).toHaveBeenCalledTimes(7);
expect(result).toBe('button');
});
it('falls back to DOM insertion when native insertion text does not match the requested prompt', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true, text: '你' })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ detected: false });
const result = await sendDoubaoMessage(page, '你好');
expect(result).toBe('button');
});
it('falls back to native Enter when no clickable submit button is found', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const nativeKeyPress = vi.mocked(page.nativeKeyPress);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce(false)
.mockResolvedValueOnce({ detected: false });
const result = await sendDoubaoMessage(page, '你好');
expect(nativeKeyPress).toHaveBeenCalledWith('Enter');
expect(result).toBe('enter');
});
it('does not throw verification errors just because the prompt mentions verification terms', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true, text: '请解释 CAPTCHA verification 是什么' })
.mockResolvedValueOnce({ hasText: true, text: '请解释 CAPTCHA verification 是什么' })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ detected: false, reason: '' });
await expect(sendDoubaoMessage(page, '请解释 CAPTCHA verification 是什么')).resolves.toBe('button');
});
it('does not throw verification errors for ordinary chinese prompts mentioning security terms', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true, text: '请解释人机验证和完成安全验证的区别' })
.mockResolvedValueOnce({ hasText: true, text: '请解释人机验证和完成安全验证的区别' })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ detected: false, reason: '' });
await expect(sendDoubaoMessage(page, '请解释人机验证和完成安全验证的区别')).resolves.toBe('button');
});
it('throws a command error when Doubao shows a verification challenge after submit', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce({ hasText: true, text: '你好' })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ detected: true, reason: '请完成安全验证' });
await expect(sendDoubaoMessage(page, '你好')).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('collectDoubaoTranscriptAdditions', () => {
it('ignores landing-page capability chips that are not assistant content', () => {
const before = ['older'];
const current = [
'older',
'测试一下,只回复OK快速视频生成深入研究图像生成帮我写作音乐生成更多',
'测试一下,只回复OK',
];
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('');
});
it('filters prompt-contaminated chip lines for arbitrary prompts', () => {
const before = ['older'];
const current = [
'older',
'你好快速视频生成深入研究图像生成帮我写作音乐生成更多',
];
expect(collectDoubaoTranscriptAdditions(before, current, '你好')).toBe('');
});
it('filters whitespace-normalized multiline prompt echoes and prompt-plus-chip artifacts', () => {
const before = ['older'];
const prompt = '第一行\n第二行';
expect(collectDoubaoTranscriptAdditions(before, ['older', '第一行 第二行'], prompt)).toBe('');
expect(collectDoubaoTranscriptAdditions(before, ['older', '第一行 第二行快速视频生成深入研究图像生成帮我写作音乐生成更多'], prompt)).toBe('');
});
it('keeps legitimate replies that discuss Doubao features', () => {
const before = ['older'];
const current = [
'older',
'图像生成和音乐生成目前都支持,但适用场景不同。',
];
expect(collectDoubaoTranscriptAdditions(before, current, 'irrelevant prompt')).toBe('图像生成和音乐生成目前都支持,但适用场景不同。');
});
it('keeps an exact chip string when it is the assistant reply rather than prompt contamination', () => {
const before = ['older'];
const current = [
'older',
'快速视频生成深入研究图像生成帮我写作音乐生成更多',
];
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('快速视频生成深入研究图像生成帮我写作音乐生成更多');
});
it('filters combined sidebar chrome that appears as a new transcript line', () => {
const before = ['older'];
const current = [
'older',
'AI 创作云盘更多历史对话',
];
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('');
});
it('filters transcript lines that only differ because the prompt was appended to existing page chrome', () => {
const before = [
'有什么我能帮你的吗?资讯:韩国三大运营商允许超流量用基本数据服务快速视频生成深入研究图像生成帮我写作音乐生成更多',
];
const current = [
'有什么我能帮你的吗?资讯:韩国三大运营商允许超流量用基本数据服务快速视频生成深入研究图像生成帮我写作音乐生成更多测试一下,只回复OK',
];
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK', (value) => value.replace('测试一下,只回复OK', '').trim())).toBe('');
});
it('treats only the exact landing-page chip string as UI noise', () => {
expect(__test__.clickSendButtonScript()).not.toContain('document,');
expect(__test__.clickSendButtonScript()).toContain('bestScore >= 200');
expect(__test__.clickSendButtonScript()).not.toContain("|| !!button.closest('.chat-input-button')");
expect(__test__.clickSendButtonScript()).toContain("button.getAttribute('type') === 'submit') score += 1200");
expect(__test__.composerStateScript()).toContain("(composer.innerText || '').trim() || (composer.textContent || '').trim()");
expect(__test__.detectDoubaoVerificationScript()).not.toContain('document.body?.innerText');
expect(__test__.detectDoubaoVerificationScript()).not.toContain('[class*=\"verify\"]');
expect(__test__.detectDoubaoVerificationScript()).not.toContain('[class*=\"captcha\"]');
expect(__test__.detectDoubaoVerificationScript()).not.toContain('document.body?.children');
});
});
describe('waitForDoubaoResponse', () => {
it('allows transcript fallback on local chat urls when new transcript lines appear', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const wait = vi.mocked(page.wait);
evaluate
.mockResolvedValueOnce({ detected: false })
.mockResolvedValueOnce('https://www.doubao.com/chat/local_123')
.mockResolvedValueOnce([])
.mockResolvedValueOnce('https://www.doubao.com/chat/local_123')
.mockResolvedValueOnce(['older', '真正的回答']);
const result = await waitForDoubaoResponse(page, ['older'], [], '测试一下,只回复OK', 2);
expect(wait).toHaveBeenCalled();
expect(result).toBe('真正的回答');
});
it('does not suppress assistant turns that happen to match landing-page chip text', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate
.mockResolvedValueOnce({ detected: false })
.mockResolvedValueOnce('https://www.doubao.com/chat')
.mockResolvedValueOnce([
{ Role: 'Assistant', Text: '快速视频生成深入研究图像生成帮我写作音乐生成更多' },
]);
const result = await waitForDoubaoResponse(page, [], [], '测试一下,只回复OK', 2);
expect(result).toBe('快速视频生成深入研究图像生成帮我写作音乐生成更多');
});
it('raises a command error when a verification challenge appears during polling', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
evaluate.mockResolvedValueOnce({ detected: true, reason: '请完成安全验证' });
await expect(waitForDoubaoResponse(page, [], [], '你好', 2)).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('mergeTranscriptSnapshots', () => {
it('extends the transcript when the next snapshot overlaps with the tail', () => {
const merged = mergeTranscriptSnapshots('Alice 00:00\nHello team\nBob 00:05\nHi', 'Bob 00:05\nHi\nAlice 00:10\nNext topic');
+50
View File
@@ -0,0 +1,50 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'eastmoney',
name: 'hot-rank',
description: '东方财富热股榜',
domain: 'guba.eastmoney.com',
strategy: Strategy.COOKIE,
navigateBefore: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
],
columns: ['rank', 'symbol', 'name', 'price', 'changePercent', 'heat', 'url'],
func: async (page, kwargs) => {
await page.goto('https://guba.eastmoney.com/rank/');
await page.wait({ selector: '#rankCont', timeout: 15000 });
const data = await page.evaluate(`
(() => {
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
const rows = document.querySelectorAll('table.rank_table tbody tr');
const results = [];
const seen = new Set();
let rank = 0;
rows.forEach((row) => {
const codeEl = row.querySelector('a.stock_code');
const href = codeEl?.getAttribute('href') || '';
const symbolMatch = href.match(/(\\d{6})/);
if (!symbolMatch) return;
const symbol = symbolMatch[1];
if (seen.has(symbol)) return;
seen.add(symbol);
rank++;
const tds = row.querySelectorAll('td');
results.push({
rank,
symbol,
name: row.querySelector('td.nametd a[title]')?.getAttribute('title') || cleanText(row.querySelector('td.nametd')),
price: tds[6] ? cleanText(tds[6]) : '',
changePercent: tds[8] ? cleanText(tds[8]) : '',
heat: cleanText(row.querySelector('td.fans')),
url: 'https://guba.eastmoney.com/list,' + symbol + '.html',
});
});
return results;
})()
`);
if (!Array.isArray(data)) return [];
return data.slice(0, kwargs.limit);
},
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './hot-rank.js';
describe('eastmoney hot-rank command', () => {
it('registers the command with correct metadata', () => {
const command = getRegistry().get('eastmoney/hot-rank');
expect(command).toBeDefined();
expect(command).toMatchObject({
site: 'eastmoney',
name: 'hot-rank',
description: expect.stringContaining('东方财富'),
domain: 'guba.eastmoney.com',
navigateBefore: true,
});
});
it('returns hot stock data from the page', async () => {
const command = getRegistry().get('eastmoney/hot-rank');
const mockData = [
{ rank: 1, symbol: '600519', name: '贵州茅台', price: '1680.00', changePercent: '+2.35%', heat: '28.5万', url: 'https://guba.eastmoney.com/list,600519.html' },
{ rank: 2, symbol: '000001', name: '平安银行', price: '12.50', changePercent: '-0.80%', heat: '15.2万', url: 'https://guba.eastmoney.com/list,000001.html' },
];
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 20 });
expect(result).toHaveLength(2);
expect(result[0]).toEqual(mockData[0]);
expect(page.goto).toHaveBeenCalledWith('https://guba.eastmoney.com/rank/');
});
it('respects the limit parameter', async () => {
const command = getRegistry().get('eastmoney/hot-rank');
const mockData = Array.from({ length: 30 }, (_, i) => ({
rank: i + 1, symbol: `${i}`, name: `stock${i}`, price: '0', changePercent: '0%', heat: '0', url: '',
}));
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 10 });
expect(result).toHaveLength(10);
});
it('returns empty array when evaluate returns non-array', async () => {
const command = getRegistry().get('eastmoney/hot-rank');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(null),
};
const result = await command.func(page, { limit: 20 });
expect(result).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import type { IPage } from '@jackwener/opencli/types';
import { __test__ } from './image.js';
describe('grok image 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://assets.grok.com/foo'))).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('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', () => {
expect(__test__.normalizeBooleanFlag(true)).toBe(true);
expect(__test__.normalizeBooleanFlag('true')).toBe(true);
expect(__test__.normalizeBooleanFlag('1')).toBe(true);
expect(__test__.normalizeBooleanFlag('yes')).toBe(true);
expect(__test__.normalizeBooleanFlag('on')).toBe(true);
expect(__test__.normalizeBooleanFlag(false)).toBe(false);
expect(__test__.normalizeBooleanFlag('false')).toBe(false);
expect(__test__.normalizeBooleanFlag(undefined)).toBe(false);
});
it('dedupes images by src', () => {
const deduped = __test__.dedupeBySrc([
{ src: 'https://a.example/1.jpg', w: 500, h: 500 },
{ src: 'https://a.example/1.jpg', w: 500, h: 500 },
{ src: 'https://a.example/2.jpg', w: 500, h: 500 },
{ src: '', w: 500, h: 500 },
]);
expect(deduped.map(i => i.src)).toEqual([
'https://a.example/1.jpg',
'https://a.example/2.jpg',
]);
});
it('builds a deterministic-ish signature order-independent by src', () => {
const sigA = __test__.imagesSignature([
{ src: 'https://a.example/1.jpg', w: 1, h: 1 },
{ src: 'https://a.example/2.jpg', w: 1, h: 1 },
]);
const sigB = __test__.imagesSignature([
{ src: 'https://a.example/2.jpg', w: 1, h: 1 },
{ src: 'https://a.example/1.jpg', w: 1, h: 1 },
]);
expect(sigA).toBe(sigB);
});
it('maps content-type to sensible image extensions', () => {
expect(__test__.extFromContentType('image/png')).toBe('png');
expect(__test__.extFromContentType('image/webp')).toBe('webp');
expect(__test__.extFromContentType('image/gif')).toBe('gif');
expect(__test__.extFromContentType('image/jpeg')).toBe('jpg');
expect(__test__.extFromContentType(undefined)).toBe('jpg');
expect(__test__.extFromContentType('')).toBe('jpg');
});
it('builds filenames with a stable sha1 slice tied to the src', () => {
const a1 = __test__.buildFilename('https://a.example/1.jpg', 'image/jpeg');
const a2 = __test__.buildFilename('https://a.example/1.jpg', 'image/jpeg');
const b1 = __test__.buildFilename('https://a.example/2.jpg', 'image/png');
// Same URL → same 12-char hash slice (timestamps may differ).
expect(a1.split('-')[2].split('.')[0]).toBe(a2.split('-')[2].split('.')[0]);
expect(a1.split('-')[2].split('.')[0]).not.toBe(b1.split('-')[2].split('.')[0]);
expect(a1.endsWith('.jpg')).toBe(true);
expect(b1.endsWith('.png')).toBe(true);
});
it('only accepts image bubbles that appeared after the baseline', () => {
const candidate = __test__.pickLatestImageCandidate([
[{ src: 'https://a.example/stale.jpg', w: 512, h: 512 }],
[],
[{ src: 'https://a.example/fresh.jpg', w: 1024, h: 1024 }],
], 1);
expect(candidate).toEqual([
{ src: 'https://a.example/fresh.jpg', w: 1024, h: 1024 },
]);
});
it('does not reuse stale images when no new image bubble appears after baseline', () => {
const candidate = __test__.pickLatestImageCandidate([
[{ src: 'https://a.example/stale.jpg', w: 512, h: 512 }],
[],
[],
], 1);
expect(candidate).toEqual([]);
});
});
+356
View File
@@ -0,0 +1,356 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
const GROK_URL = 'https://grok.com/';
const NO_IMAGE_PREFIX = '[NO IMAGE]';
const BLOCKED_PREFIX = '[BLOCKED]';
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
type SendResult = {
ok?: boolean;
msg?: string;
reason?: string;
detail?: string;
};
type BubbleImage = {
src: string;
w: number;
h: number;
};
type BubbleImageSet = BubbleImage[];
type FetchResult = {
ok: boolean;
base64?: string;
contentType?: string;
error?: string;
};
function normalizeBooleanFlag(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
function dedupeBySrc(images: BubbleImage[]): BubbleImage[] {
const seen = new Set<string>();
const out: BubbleImage[] = [];
for (const img of images) {
if (!img.src || seen.has(img.src)) continue;
seen.add(img.src);
out.push(img);
}
return out;
}
function imagesSignature(images: BubbleImage[]): string {
return images.map(i => i.src).sort().join('|');
}
function extFromContentType(ct?: string): string {
if (!ct) return 'jpg';
if (ct.includes('png')) return 'png';
if (ct.includes('webp')) return 'webp';
if (ct.includes('gif')) return 'gif';
return 'jpg';
}
function buildFilename(src: string, ct?: string): string {
const ext = extFromContentType(ct);
const hash = crypto.createHash('sha1').update(src).digest('hex').slice(0, 12);
return `grok-${Date.now()}-${hash}.${ext}`;
}
/** Check whether the tab is already on grok.com (any path). */
async function isOnGrok(page: IPage): Promise<boolean> {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string' || !url) return false;
try {
const hostname = new URL(url).hostname;
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
} catch {
return false;
}
}
async function tryStartFreshChat(page: IPage): Promise<void> {
await page.evaluate(`(() => {
const isVisible = (node) => {
if (!(node instanceof HTMLElement)) return false;
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
};
const candidates = Array.from(document.querySelectorAll('a, button')).filter(node => {
if (!isVisible(node)) return false;
const text = (node.textContent || '').trim().toLowerCase();
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
const href = node.getAttribute('href') || '';
return text.includes('new chat')
|| text.includes('new conversation')
|| aria.includes('new chat')
|| aria.includes('new conversation')
|| href === '/';
});
const target = candidates[0];
if (target instanceof HTMLElement) target.click();
})()`);
}
async function sendPrompt(page: IPage, prompt: string): Promise<SendResult> {
const promptJson = JSON.stringify(prompt);
return page.evaluate(`(async () => {
try {
const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const composerSelector = '.ProseMirror[contenteditable="true"]';
const isVisibleEnabledSubmit = (node) => {
if (!(node instanceof HTMLButtonElement)) return false;
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return !node.disabled
&& rect.width > 0
&& rect.height > 0
&& style.visibility !== 'hidden'
&& style.display !== 'none';
};
let pm = null;
let box = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
const composer = document.querySelector(composerSelector);
if (composer instanceof HTMLElement) {
pm = composer;
break;
}
const textarea = document.querySelector('textarea');
if (textarea instanceof HTMLTextAreaElement) {
box = textarea;
break;
}
await waitFor(1000);
}
// Prefer the ProseMirror composer when present (current grok.com UI).
if (pm && pm.editor && pm.editor.commands) {
try {
if (pm.editor.commands.clearContent) pm.editor.commands.clearContent();
pm.editor.commands.focus();
pm.editor.commands.insertContent(${promptJson});
for (let attempt = 0; attempt < 6; attempt += 1) {
const sbtn = Array.from(document.querySelectorAll('button[aria-label="Submit"], button[aria-label="\\u63d0\\u4ea4"]'))
.find(isVisibleEnabledSubmit);
if (sbtn) {
sbtn.click();
return { ok: true, msg: 'pm-submit' };
}
await waitFor(500);
}
} catch (e) { /* fall through to textarea */ }
}
// Fallback: legacy textarea composer.
if (!box) return { ok: false, msg: 'no composer (neither ProseMirror nor textarea)' };
box.focus(); box.value = '';
document.execCommand('selectAll');
document.execCommand('insertText', false, ${promptJson});
for (let attempt = 0; attempt < 6; attempt += 1) {
const btn = Array.from(document.querySelectorAll('button[aria-label="\\u63d0\\u4ea4"], button[aria-label="Submit"]'))
.find(isVisibleEnabledSubmit);
if (btn) {
btn.click();
return { ok: true, msg: 'clicked' };
}
const sub = Array.from(document.querySelectorAll('button[type="submit"]'))
.find(isVisibleEnabledSubmit);
if (sub) {
sub.click();
return { ok: true, msg: 'clicked-submit' };
}
await waitFor(500);
}
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
return { ok: true, msg: 'enter' };
} catch (e) { return { ok: false, msg: e && e.toString ? e.toString() : String(e) }; }
})()`) as Promise<SendResult>;
}
/** Read <img> elements from all message bubbles so callers can filter by baseline. */
async function getBubbleImageSets(page: IPage): Promise<BubbleImageSet[]> {
const result = await page.evaluate(`(() => {
const bubbles = document.querySelectorAll('div.message-bubble, [data-testid="message-bubble"]');
return Array.from(bubbles).map(bubble => Array.from(bubble.querySelectorAll('img'))
.map(img => ({
src: img.currentSrc || img.src || '',
w: img.naturalWidth || img.width || 0,
h: img.naturalHeight || img.height || 0,
}))
.filter(i => i.src && /^https?:/.test(i.src))
// Ignore tiny UI/avatar images that may live in the bubble chrome.
.filter(i => (i.w === 0 || i.w >= 128) && (i.h === 0 || i.h >= 128)));
})()`) as BubbleImageSet[] | undefined;
const raw = Array.isArray(result) ? result : [];
return raw.map(dedupeBySrc);
}
function pickLatestImageCandidate(
bubbleImageSets: BubbleImageSet[],
baselineCount: number,
): BubbleImage[] {
const freshSets = bubbleImageSets.slice(Math.max(0, baselineCount));
for (let i = freshSets.length - 1; i >= 0; i -= 1) {
if (freshSets[i].length) return freshSets[i];
}
return [];
}
// Download through the browser's fetch so grok.com cookies and referer are
// attached automatically — assets.grok.com is gated by Cloudflare and will
// refuse direct curl/node downloads.
async function fetchImageAsBase64(page: IPage, url: string): Promise<FetchResult> {
const urlJson = JSON.stringify(url);
return page.evaluate(`(async () => {
try {
const res = await fetch(${urlJson}, { credentials: 'include', referrer: 'https://grok.com/' });
if (!res.ok) return { ok: false, error: 'HTTP ' + res.status };
const blob = await res.blob();
const buf = await blob.arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return { ok: true, base64: btoa(binary), contentType: blob.type || 'image/jpeg' };
} catch (e) { return { ok: false, error: e && e.message || String(e) }; }
})()`) as Promise<FetchResult>;
}
async function saveImages(
page: IPage,
images: BubbleImage[],
outDir: string,
): Promise<Array<BubbleImage & { path: string }>> {
fs.mkdirSync(outDir, { recursive: true });
const results: Array<BubbleImage & { path: string }> = [];
for (const img of images) {
const fetched = await fetchImageAsBase64(page, img.src);
if (!fetched || !fetched.ok) {
results.push({ ...img, path: `[DOWNLOAD FAILED] ${fetched?.error || 'unknown'}` });
continue;
}
const filepath = path.join(outDir, buildFilename(img.src, fetched.contentType));
fs.writeFileSync(filepath, Buffer.from(fetched.base64 || '', 'base64'));
results.push({ ...img, path: filepath });
}
return results;
}
function toRow(img: BubbleImage, savedPath = '') {
return { url: img.src, width: img.w, height: img.h, path: savedPath };
}
export const imageCommand = cli({
site: 'grok',
name: 'image',
description: 'Generate images on grok.com and return image URLs',
domain: 'grok.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'prompt', positional: true, type: 'string', required: true, help: 'Image generation prompt' },
{ name: 'timeout', type: 'int', default: 240, help: 'Max seconds to wait for the image (default: 240)' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending (default: false)' },
{ name: 'count', type: 'int', default: 1, help: 'Minimum images to wait for before returning (default: 1)' },
{ name: 'out', type: 'string', default: '', help: 'Directory to save downloaded images (uses browser session to bypass auth)' },
],
columns: ['url', 'width', 'height', 'path'],
func: async (page: IPage, kwargs: Record<string, any>) => {
const prompt = kwargs.prompt as string;
const timeoutMs = ((kwargs.timeout as number) || 240) * 1000;
const newChat = normalizeBooleanFlag(kwargs.new);
const minCount = Math.max(1, Number(kwargs.count || 1));
const outDir = (kwargs.out || '').toString().trim();
if (newChat) {
await page.goto(GROK_URL);
await page.wait(2);
await tryStartFreshChat(page);
await page.wait(2);
} else if (!(await isOnGrok(page))) {
await page.goto(GROK_URL);
await page.wait(3);
}
const baselineBubbleCount = (await getBubbleImageSets(page)).length;
const sendResult = await sendPrompt(page, prompt);
if (!sendResult || !sendResult.ok) {
return [{
url: `${BLOCKED_PREFIX} send failed: ${JSON.stringify(sendResult)}. ${SESSION_HINT}`,
width: 0,
height: 0,
path: '',
}];
}
const startTime = Date.now();
let lastSignature = '';
let stableCount = 0;
let lastImages: BubbleImage[] = [];
while (Date.now() - startTime < timeoutMs) {
await page.wait(3);
const bubbleImageSets = await getBubbleImageSets(page);
const images = pickLatestImageCandidate(bubbleImageSets, baselineBubbleCount);
if (images.length >= minCount) {
const signature = imagesSignature(images);
if (signature === lastSignature) {
stableCount += 1;
// Require two consecutive stable reads (~6s) before declaring done.
if (stableCount >= 2) {
if (outDir) {
const saved = await saveImages(page, images, outDir);
return saved.map(s => toRow(s, s.path));
}
return images.map(i => toRow(i));
}
} else {
stableCount = 0;
lastSignature = signature;
lastImages = images;
}
}
}
if (lastImages.length) {
if (outDir) {
const saved = await saveImages(page, lastImages, outDir);
return saved.map(s => toRow(s, s.path));
}
return lastImages.map(i => toRow(i));
}
return [{
url: `${NO_IMAGE_PREFIX} No image appeared within ${Math.round(timeoutMs / 1000)}s.`,
width: 0,
height: 0,
path: '',
}];
},
});
export const __test__ = {
normalizeBooleanFlag,
isOnGrok,
dedupeBySrc,
imagesSignature,
extFromContentType,
buildFilename,
pickLatestImageCandidate,
};
+40
View File
@@ -0,0 +1,40 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { mubuPost, nodesToMarkdown, nodesToText } from './utils.js';
cli({
site: 'mubu',
name: 'doc',
description: '读取幕布文档内容(默认输出 Markdown,可用 --output text 输出纯文本)',
domain: 'mubu.com',
strategy: Strategy.COOKIE,
defaultFormat: 'plain',
args: [
{ name: 'id', positional: true, required: true, help: '文档 ID' },
{ name: 'output', default: 'md', help: '输出格式:md(默认,缩进列表 Markdown,适合导入 Obsidian)或 text(纯文本,适合终端阅读)' },
],
columns: ['content'],
func: async (page, kwargs) => {
const docId = kwargs.id;
const format = kwargs.output;
if (format !== 'md' && format !== 'text') {
throw new ArgumentError(`--output 只接受 md 或 text,收到:${format}`);
}
await page.goto('https://mubu.com/app');
const data = await mubuPost(page, '/document/edit/get', { docId });
let nodes = [];
try {
const def = JSON.parse(data.definition);
nodes = def.nodes ?? [];
} catch {
return [{ content: data.name }];
}
const output = format === 'md' ? nodesToMarkdown(nodes) : nodesToText(nodes);
return [{ content: output }];
},
});
+43
View File
@@ -0,0 +1,43 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { formatDate, mubuPost } from './utils.js';
cli({
site: 'mubu',
name: 'docs',
description: '列出幕布文档(默认根目录,--starred 查看快速访问列表)',
domain: 'mubu.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'folder', default: '0', help: '文件夹 ID(默认根目录 0' },
{ name: 'starred', type: 'bool', default: false, help: '只显示快速访问的文档和文件夹' },
{ name: 'limit', type: 'int', default: 50, help: '最多显示条数' },
],
columns: ['type', 'id', 'name', 'updated', 'stared'],
func: async (page, kwargs) => {
const folderId = kwargs.folder;
const starred = kwargs.starred;
const limit = kwargs.limit;
await page.goto('https://mubu.com/app');
const body = starred ? { source: 'star' } : { folderId };
const data = await mubuPost(page, '/list/get', body);
const folders = (data.folders ?? []).map((f) => ({
type: '📁',
id: f.id,
name: f.name,
updated: formatDate(f.updateTime),
stared: f.stared ? '★' : '',
}));
const docs = (data.documents ?? []).map((doc) => ({
type: '📄',
id: doc.id,
name: doc.name,
updated: formatDate(doc.updateTime),
stared: doc.stared ? '★' : '',
}));
return [...folders, ...docs].slice(0, limit);
},
});
+244
View File
@@ -0,0 +1,244 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { mubuPost, nodesToMarkdown, nodesToText, htmlToText } from './utils.js';
// ── 日期工具 ──────────────────────────────────────────────
function localToday() {
const d = new Date();
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
function lastDayOfMonth(year, month) {
return new Date(year, month, 0).getDate();
}
function validateYear(year, label = '年份') {
if (!Number.isInteger(year) || year < 1) {
throw new ArgumentError(`${label} 非法:${year},应为正整数`);
}
}
function validateMonth(month) {
if (!Number.isInteger(month) || month < 1 || month > 12) {
throw new ArgumentError(`月份非法:${month},应为 1-12`);
}
}
function validateDay(year, month, day) {
const maxDay = lastDayOfMonth(year, month);
if (!Number.isInteger(day) || day < 1 || day > maxDay) {
throw new ArgumentError(`日期非法:${year}-${month}-${day}${year}${month} 月共 ${maxDay} 天)`);
}
}
function parseDate(s) {
const parts = s.split('-').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) {
throw new ArgumentError(`日期格式错误:${s},应为 YYYY-MM-DD`);
}
const [year, month, day] = parts;
validateYear(year);
validateMonth(month);
validateDay(year, month, day);
return { year, month, day };
}
function parseMonth(s) {
const parts = s.split('-').map(Number);
if (parts.length !== 2 || parts.some(isNaN)) {
throw new ArgumentError(`月份格式错误:${s},应为 YYYY-MM`);
}
const [year, month] = parts;
validateYear(year);
validateMonth(month);
return { year, month };
}
function dateToKey(d) {
return `${d.year}-${String(d.month).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`;
}
/** 将各种时间参数统一解析为 {start, end} */
function resolveRange(kwargs) {
const dateStr = kwargs.date;
const monthStr = kwargs.month;
const yearArg = kwargs.year;
const fromStr = kwargs.from;
const toStr = kwargs.to;
// --from / --to 优先级最高
if (fromStr || toStr) {
if (!fromStr) throw new ArgumentError('使用 --to 时必须同时指定 --from');
const start = parseDate(fromStr);
const end = toStr ? parseDate(toStr) : localToday();
if (dateToKey(start) > dateToKey(end)) throw new ArgumentError('--from 不能晚于 --to');
return { start, end };
}
if (yearArg !== undefined && yearArg !== null) {
validateYear(yearArg, '--year');
return {
start: { year: yearArg, month: 1, day: 1 },
end: { year: yearArg, month: 12, day: 31 },
};
}
if (monthStr) {
const { year, month } = parseMonth(monthStr);
return {
start: { year, month, day: 1 },
end: { year, month, day: lastDayOfMonth(year, month) },
};
}
if (dateStr) {
const d = parseDate(dateStr);
return { start: d, end: d };
}
// 默认:今天
const today = localToday();
return { start: today, end: today };
}
// ── API 工具 ──────────────────────────────────────────────
async function getYearDocId(page, year) {
const raw = await page.evaluate(`localStorage.getItem('daily_notes_doc_list')`);
if (!raw) return null;
const list = JSON.parse(raw);
return list.find((d) => d.name === `${year}`)?.id ?? null;
}
async function getYearNodes(page, docId) {
const data = await mubuPost(page, '/document/edit/get', { docId });
const def = JSON.parse(data.definition);
return def.nodes ?? [];
}
/** 加载某年的所有 day 节点,返回带 dateKey 的列表 */
async function loadYearEntries(page, year) {
const docId = await getYearDocId(page, year);
if (!docId) return [];
const yearNodes = await getYearNodes(page, docId);
const entries = [];
for (const monthNode of yearNodes) {
const monthNum = parseInt(htmlToText(monthNode.text), 10);
if (!monthNode.children?.length) continue;
for (const dayNode of monthNode.children) {
const plain = htmlToText(dayNode.text).replace(/\s+/g, ' ').trim();
const compact = plain.replace(/\s/g, '');
const match = compact.match(/^(\d+)月(\d+)日/);
if (!match) continue;
const m = parseInt(match[1], 10);
const d = parseInt(match[2], 10);
if (m !== monthNum) continue;
const dateKey = dateToKey({ year, month: m, day: d });
entries.push({ dateKey, label: plain, node: dayNode });
}
}
return entries;
}
/** 收集 [start, end] 范围内涉及的所有年份 */
function yearsInRange(start, end) {
const years = [];
for (let y = start.year; y <= end.year; y++) years.push(y);
return years;
}
// ── 命令 ──────────────────────────────────────────────────
cli({
site: 'mubu',
name: 'notes',
description: '读取幕布速记(默认今天)。支持 --date/--month/--year/--from/--to 指定时间范围,--list 为概览模式(日期+条数)。',
domain: 'mubu.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'list',
type: 'bool',
default: false,
help: '概览模式:只输出日期和条数,不含速记内容。可与任意时间范围参数组合。',
},
{
name: 'date',
help: '单日,格式 YYYY-MM-DD。不指定时间范围则默认今天(系统本地时间)。',
},
{
name: 'month',
help: '整月,格式 YYYY-MM。',
},
{
name: 'year',
type: 'int',
help: '整年,格式 YYYY(整数)。',
},
{
name: 'from',
help: '范围起始日,格式 YYYY-MM-DD。须与 --to 同时使用。',
},
{
name: 'to',
help: '范围截止日,格式 YYYY-MM-DD。须与 --from 同时使用。',
},
{
name: 'output',
default: 'md',
help: '输出格式:md(默认,Markdown)或 text(纯文本)',
},
],
columns: ['date', 'content'],
func: async (page, kwargs) => {
const isList = kwargs.list;
const format = kwargs.output;
if (format !== 'md' && format !== 'text') {
throw new ArgumentError(`--output 只接受 md 或 text,收到:${format}`);
}
await page.goto('https://mubu.com/app');
const { start, end } = resolveRange(kwargs);
const startKey = dateToKey(start);
const endKey = dateToKey(end);
// 并行加载所有涉及年份的 day 节点,按范围过滤
const yearResults = await Promise.all(
yearsInRange(start, end).map((year) => loadYearEntries(page, year)),
);
const allEntries = yearResults
.flat()
.filter((e) => e.dateKey >= startKey && e.dateKey <= endKey);
if (allEntries.length === 0) {
const label = startKey === endKey ? startKey : `${startKey} ~ ${endKey}`;
return [{ date: label, content: '该时间段暂无速记' }];
}
// 概览模式
if (isList) {
return allEntries.map((e) => ({
date: e.label,
content: `${e.node.children?.length ?? 0} 条记录`,
}));
}
// 内容模式
const render = (children) =>
format === 'text' ? nodesToText(children) : nodesToMarkdown(children);
return allEntries
.filter((e) => e.node.children?.length)
.map((e) => ({
date: e.label,
content: render(e.node.children ?? []) || '(空)',
}));
},
});
+27
View File
@@ -0,0 +1,27 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { formatDate, mubuPost } from './utils.js';
cli({
site: 'mubu',
name: 'recent',
description: '最近编辑的幕布文档',
domain: 'mubu.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 20, help: '最多显示条数' },
],
columns: ['id', 'name', 'updated'],
func: async (page, kwargs) => {
const limit = kwargs.limit;
await page.goto('https://mubu.com/app');
const data = await mubuPost(page, '/list/get', { folderId: 'recent' });
return (data.documents ?? []).slice(0, limit).map((doc) => ({
id: doc.id,
name: doc.name,
updated: formatDate(doc.updateTime),
}));
},
});
+62
View File
@@ -0,0 +1,62 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { mubuPost, htmlToText } from './utils.js';
cli({
site: 'mubu',
name: 'search',
description: '全局搜索幕布文档和文件夹(标题+内容,服务端全量匹配)。结果含 type/id/name/path/hits/snippet 字段。',
domain: 'mubu.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
{ name: 'limit', type: 'int', default: 100, help: '最多显示条数(默认 100,结果被截断时用 --limit N 调大)' },
],
columns: ['type', 'id', 'name', 'path', 'hits', 'snippet'],
func: async (page, kwargs) => {
const query = kwargs.query;
const limit = kwargs.limit ?? 100;
await page.goto('https://mubu.com/app');
const data = await mubuPost(page, '/list/search', { keywords: query });
const formatPath = (paths) => paths.map((p) => p.name).join(' > ');
const folders = (data.folders ?? []).map((f) => ({
type: 'folder',
id: f.id,
name: f.name,
path: formatPath(f.paths),
hits: '',
snippet: '',
}));
const docs = (data.documents ?? []).map((d) => ({
type: 'doc',
id: d.id,
name: d.name,
path: formatPath(d.paths),
hits: d.total > 0 ? String(d.total) : '',
snippet: d.nodes
.map((n) => htmlToText(n.text))
.filter(Boolean)
.join(' | '),
}));
const all = [...folders, ...docs];
const result = all.slice(0, limit);
if (all.length > limit) {
result.push({
type: '...',
id: '',
name: `还有 ${all.length - limit} 条未显示,用 --limit ${all.length} 查看全部`,
path: '',
hits: '',
snippet: '',
});
}
return result;
},
});
+304
View File
@@ -0,0 +1,304 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
export const API_BASE = 'https://api2.mubu.com/v3/api';
const MUBU_DOMAIN = 'mubu.com';
const AUTH_HINT = 'Mubu requires a logged-in browser session at mubu.com';
function isAuthFailure(code, message) {
if (code === 401 || code === 403) return true;
if (!message) return false;
return /not logged in|login required|unauthorized|未登录|请先登录|需要登录|login expired/i.test(message);
}
/**
* 在浏览器页面上下文里用 XHR 发 POST 请求(参考 zsxq 适配器模式)。
* mubu app 自身也是这个机制:从 localStorage 读 Jwt-Token,通过同名 header 发到 api2.mubu.com。
* 不经过 Node.js 进程发网络请求,避免 CORS 问题和 extension fetch 拦截。
*/
export async function mubuPost(page, path, body) {
const url = `${API_BASE}${path}`;
const result = await page.evaluate(`
(async () => {
const token = localStorage.getItem('Jwt-Token');
if (!token) return { ok: false, status: 0, data: null, error: 'no token' };
return await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', ${JSON.stringify(url)}, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Jwt-Token', token);
xhr.onload = () => {
let data = null;
try { data = JSON.parse(xhr.responseText); } catch {}
resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, data });
};
xhr.onerror = () => resolve({ ok: false, status: 0, data: null, error: 'network error' });
xhr.send(${JSON.stringify(JSON.stringify(body))});
});
})()
`);
if (!result || result.error === 'no token') {
throw new AuthRequiredError(MUBU_DOMAIN, AUTH_HINT);
}
if (!result.ok || !result.data) {
throw new CommandExecutionError(`mubu: ${path}: HTTP ${result.status} ${result.error ?? ''}`);
}
const { data } = result;
if (data.code !== 0) {
if (isAuthFailure(data.code, data.message)) {
throw new AuthRequiredError(MUBU_DOMAIN, AUTH_HINT);
}
throw new CommandExecutionError(`mubu: ${path}: code=${data.code} ${data.message ?? ''}`);
}
return data.data;
}
export function formatDate(ts) {
if (!ts) return '';
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
function decodeHtmlEntities(s) {
return s
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
.replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, n) => NAMED_ENTITIES[n]);
}
/** 解析幕布 HTML 表格为行列二维数组(保留内部 HTML) */
function parseTableRows(tableHtml) {
const rows = [];
const rowMatches = tableHtml.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) ?? [];
for (const row of rowMatches) {
const cells = [];
const cellMatches = row.match(/<(?:td|th)[^>]*>[\s\S]*?<\/(?:td|th)>/gi) ?? [];
for (const cell of cellMatches) {
// 只剥离最外层的 <td> 或 <th>,保留内部的加粗、链接和 <br>
let innerHtml = cell.replace(/^<(?:td|th)[^>]*>|<\/(?:td|th)>$/gi, '');
cells.push(innerHtml.trim());
}
rows.push(cells);
}
return rows;
}
/** 将幕布 HTML text 转为纯文本 */
export function htmlToText(html) {
let text = html;
// 表格 → 纯文本(tab 分隔);用 </table>\s*</div> 作结束锚,跳过 th/td 内部嵌套 div
text = text.replace(/<div class="table-container">[\s\S]*?<\/table>\s*<\/div>/g, (m) => {
return parseTableRows(m).map((r) => r.map(c => {
// 纯文本环境:<br> 换空格,清空所有标签
let plainCell = c.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '');
return decodeHtmlEntities(plainCell).trim();
}).join('\t')).join('\n');
});
text = text
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '');
return decodeHtmlEntities(text).trim();
}
/** 将幕布 HTML 表格转为 Markdown 表格 */
function tableToMarkdown(tableHtml) {
const rows = parseTableRows(tableHtml);
if (rows.length === 0) return '';
const processRow = (row) => row.map(cellHtml => {
// 把表格内的 <br> 换成占位符,防止稍后被全局替换为 \n 导致表格断裂
return cellHtml.replace(/<br\s*\/?>/gi, '[[BR]]');
}).join(' | ');
const lines = [`| ${processRow(rows[0])} |`, `| ${rows[0].map(() => '---').join(' | ')} |`];
for (let i = 1; i < rows.length; i++) {
lines.push(`| ${processRow(rows[i])} |`);
}
return lines.join('\n');
}
/** 将幕布 HTML text 转为 Markdown inline 标记 */
export function htmlToMarkdown(html) {
let md = html;
// 1. 表格 → Markdown 表格
md = md.replace(/<div class="table-container">[\s\S]*?<\/table>\s*<\/div>/g, (m) => tableToMarkdown(m));
// 2. <br> → 换行
md = md.replace(/<br\s*\/?>/gi, '\n');
// 3. 统一处理样式标签,支持多 class 组合
md = md.replace(/<span class="([^"]+)"[^>]*>([\s\S]*?)<\/span>/gi, (match, classes, inner) => {
// 允许正常处理超链接内部的 bold 和 italic 样式
if (classes.includes('node-mention')) {
return match;
}
let res = inner;
if (/\bbold\b/.test(classes)) res = `**${res}**`;
if (/\bitalic\b/.test(classes)) res = `*${res}*`;
if (/\bstrikethrough\b/.test(classes)) res = `~~${res}~~`;
if (/\bunderline\b/.test(classes)) res = `\uFFFEU_OPEN\uFFFE${res}\uFFFEU_CLOSE\uFFFE`;
return res;
});
// 4. node-mention(主题链接 → Markdown 链接,支持组合 class 并继承自身样式)
md = md.replace(
/<span([^>]*\bclass="[^"]*\bnode-mention\b[^"]*"[^>]*)>([\s\S]*?)<\/span>/gi,
(match, attrs, inner) => {
const docMatch = attrs.match(/\bdata-doc="([^"]+)"/i);
const docId = docMatch ? docMatch[1] : '';
if (!docId) return match;
const classMatch = attrs.match(/\bclass="([^"]+)"/i);
const classes = classMatch ? classMatch[1] : '';
let res = inner.replace(/<[^>]+>/g, '').trim();
// 继承标签自身的样式
if (/\bbold\b/.test(classes)) res = `**${res}**`;
if (/\bitalic\b/.test(classes)) res = `*${res}*`;
if (/\bstrikethrough\b/.test(classes)) res = `~~${res}~~`;
if (/\bunderline\b/.test(classes)) res = `\uFFFEU_OPEN\uFFFE${res}\uFFFEU_CLOSE\uFFFE`;
return `[${res}](https://mubu.com/app/edit/${docId})`;
}
);
// 5. links (外部链接 → Markdown 链接,继承 a 标签自身样式)
md = md.replace(/<a([^>]*)>([\s\S]*?)<\/a>/gi, (match, attrs, inner) => {
const hrefMatch = attrs.match(/\bhref="([^"]+)"/i);
if (!hrefMatch) return match;
const href = hrefMatch[1];
const classMatch = attrs.match(/\bclass="([^"]+)"/i);
const classes = classMatch ? classMatch[1] : '';
let res = inner;
// 继承 a 标签自身的样式
if (/\bbold\b/.test(classes)) res = `**${res}**`;
if (/\bitalic\b/.test(classes)) res = `*${res}*`;
if (/\bstrikethrough\b/.test(classes)) res = `~~${res}~~`;
if (/\bunderline\b/.test(classes)) res = `\uFFFEU_OPEN\uFFFE${res}\uFFFEU_CLOSE\uFFFE`;
return `[${res}](${href})`;
});
// 6. 普通 span
md = md.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1');
// 7. 清理其余标签
md = md.replace(/<[^>]+>/g, '');
// 8. HTML 实体解码
md = decodeHtmlEntities(md);
// 9. 还原 underline 占位符
md = md.replace(/\uFFFEU_OPEN\uFFFE/g, '<u>').replace(/\uFFFEU_CLOSE\uFFFE/g, '</u>');
// 10. 还原表格内的换行符
md = md.replace(/\[\[BR\]\]/g, '<br>');
return md.trim();
}
const IMAGE_BASE = 'https://api2.mubu.com/v3';
function imageUrl(uri) {
return uri.startsWith('http') ? uri : `${IMAGE_BASE}/${uri}`;
}
function taskPrefix(node) {
if (!node.taskStatus) return '';
return node.taskStatus === 2 ? '[x] ' : '[ ] ';
}
function taskMeta(node) {
const parts = [];
if (node.deadline) {
const ts = formatDate(node.deadline * 1000);
parts.push(`📅 ${node.deadlineType === 'date' ? ts.slice(0, 10) : ts}`);
}
if (node.remindAt) parts.push(`${formatDate(node.remindAt * 1000)}`);
return parts.length ? ' ' + parts.join(' ') : '';
}
/** 递归将节点树渲染为缩进纯文本 */
export function nodesToText(nodes, depth = 0) {
const lines = [];
for (const node of nodes) {
const indent = ' '.repeat(depth);
const emoji = node.emoji ? node.emoji + ' ' : '';
const text = htmlToText(node.text);
const prefix = taskPrefix(node);
const meta = taskMeta(node);
if (text || emoji || prefix) {
if (text.includes('\n')) {
const [first, ...rest] = text.split('\n');
lines.push(indent + prefix + emoji + first);
for (const line of rest) lines.push(indent + ' ' + line);
if (meta) lines.push(indent + ' ' + meta.trim());
} else {
lines.push(indent + prefix + emoji + text + meta);
}
}
if (node.note) {
const noteText = htmlToText(node.note);
for (const line of noteText.split('\n')) lines.push(indent + ' ' + line);
}
if (node.images?.length) {
for (const img of node.images) {
lines.push(indent + `[图片: ${imageUrl(img.uri)}]`);
}
}
if (node.children?.length) {
lines.push(nodesToText(node.children, depth + 1));
}
}
return lines.filter(Boolean).join('\n');
}
/** 递归将节点树渲染为 Markdown(大纲 = 缩进列表,不映射为标题) */
export function nodesToMarkdown(nodes, depth = 0) {
const lines = [];
for (const node of nodes) {
const text = htmlToMarkdown(node.text);
if (!text && !node.images?.length && !node.note && !node.emoji) continue;
const indent = ' '.repeat(depth);
const emoji = node.emoji ? node.emoji + ' ' : '';
const prefix = taskPrefix(node);
const meta = taskMeta(node);
if (text || emoji || prefix) {
if (text.includes('\n')) {
const [first, ...rest] = text.split('\n');
lines.push(indent + '- ' + prefix + emoji + first);
const continuation = indent + ' ';
for (const line of rest) lines.push(continuation + line);
if (meta) lines.push(continuation + meta.trim());
} else {
lines.push(indent + '- ' + prefix + emoji + text + meta);
}
}
if (node.note) {
const noteLines = htmlToMarkdown(node.note).split('\n');
for (const line of noteLines) lines.push(indent + ' > ' + line);
}
if (node.images?.length) {
for (const img of node.images) {
lines.push(indent + ` ![image](${imageUrl(img.uri)})`);
}
}
if (node.children?.length) {
lines.push(nodesToMarkdown(node.children, depth + 1));
}
}
return lines.filter(Boolean).join('\n');
}
+47
View File
@@ -0,0 +1,47 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
const TDX_HOT_URL = 'https://pul.tdx.com.cn/site/app/gzhbd/tdx-topsearch/page-main.html?pageName=page_topsearch&tabClickIndex=0&subtabIndex=0';
cli({
site: 'tdx',
name: 'hot-rank',
description: '通达信热搜榜',
domain: 'pul.tdx.com.cn',
strategy: Strategy.COOKIE,
navigateBefore: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
],
columns: ['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags'],
func: async (page, kwargs) => {
await page.goto(TDX_HOT_URL);
await page.wait({ timeout: 15000 });
const data = await page.evaluate(`
(() => {
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
const cells = document.querySelectorAll('div.top-cell[data-code]');
const results = [];
const seen = new Set();
cells.forEach((cell, idx) => {
const symbol = cell.getAttribute('data-code') || '';
const name = cell.getAttribute('data-name') || '';
if (!symbol || !name || seen.has(symbol)) return;
seen.add(symbol);
const tagEls = cell.querySelectorAll('div.tips-item.gnbk');
const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
results.push({
rank: idx + 1,
symbol,
name,
changePercent: cleanText(cell.querySelector('div.top-zf')),
heat: cleanText(cell.querySelector('div.hotN')),
tags,
});
});
return results;
})()
`);
if (!Array.isArray(data)) return [];
return data.slice(0, kwargs.limit);
},
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './hot-rank.js';
describe('tdx hot-rank command', () => {
it('registers the command with correct metadata', () => {
const command = getRegistry().get('tdx/hot-rank');
expect(command).toBeDefined();
expect(command).toMatchObject({
site: 'tdx',
name: 'hot-rank',
description: expect.stringContaining('通达信'),
domain: 'pul.tdx.com.cn',
navigateBefore: true,
});
expect(command.columns).toEqual(['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags']);
});
it('returns hot stock data from the page', async () => {
const command = getRegistry().get('tdx/hot-rank');
const mockData = [
{ rank: 1, symbol: '600519', name: '贵州茅台', changePercent: '+2.35%', heat: '1285', tags: '白酒', },
{ rank: 2, symbol: '000001', name: '平安银行', changePercent: '-0.80%', heat: '856', tags: '银行', },
];
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 20 });
expect(result).toHaveLength(2);
expect(result[0]).toEqual(mockData[0]);
});
it('respects the limit parameter', async () => {
const command = getRegistry().get('tdx/hot-rank');
const mockData = Array.from({ length: 30 }, (_, i) => ({
rank: i + 1, symbol: `${i}`, name: `stock${i}`, changePercent: '0%', heat: '0', tags: '',
}));
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 10 });
expect(result).toHaveLength(10);
});
it('returns empty array when evaluate returns non-array', async () => {
const command = getRegistry().get('tdx/hot-rank');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(null),
};
const result = await command.func(page, { limit: 20 });
expect(result).toEqual([]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
const THS_HOT_URL = 'https://eq.10jqka.com.cn/webpage/ths-hot-list/index.html?showStatusBar=true';
cli({
site: 'ths',
name: 'hot-rank',
description: '同花顺热股榜',
domain: 'eq.10jqka.com.cn',
strategy: Strategy.COOKIE,
navigateBefore: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
],
columns: ['rank', 'name', 'changePercent', 'heat', 'tags'],
func: async (page, kwargs) => {
await page.goto(THS_HOT_URL);
await page.wait({ timeout: 15000 });
const data = await page.evaluate(`
(() => {
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
const cards = document.querySelectorAll('div.pt-22.pb-24.bgc-white.border');
const results = [];
const seen = new Set();
cards.forEach((card, idx) => {
const row = card.querySelector('div.flex.bgc-white');
if (!row) return;
const nameEl = row.querySelector('span.ellipsis');
const name = cleanText(nameEl);
if (!name || seen.has(name)) return;
seen.add(name);
const tagEls = card.querySelectorAll('div.tag.PFSC-R');
const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
const rankEl = row.querySelector('div.THSMF-M.bold');
results.push({
rank: cleanText(rankEl) || String(idx + 1),
name,
changePercent: cleanText(row.querySelector('div.range')),
heat: cleanText(row.querySelector('div.col4 > span')),
tags,
});
});
return results;
})()
`);
if (!Array.isArray(data)) return [];
return data.slice(0, kwargs.limit);
},
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './hot-rank.js';
describe('ths hot-rank command', () => {
it('registers the command with correct metadata', () => {
const command = getRegistry().get('ths/hot-rank');
expect(command).toBeDefined();
expect(command).toMatchObject({
site: 'ths',
name: 'hot-rank',
description: expect.stringContaining('同花顺'),
domain: 'eq.10jqka.com.cn',
navigateBefore: true,
});
expect(command.columns).toEqual(['rank', 'name', 'changePercent', 'heat', 'tags']);
});
it('includes tags column', () => {
const command = getRegistry().get('ths/hot-rank');
expect(command.columns).toContain('tags');
});
it('returns hot stock data with tags field', async () => {
const command = getRegistry().get('ths/hot-rank');
const mockData = [
{ rank: 1, name: '圣阳股份', changePercent: '+10.00%', heat: '28.5万', tags: '动力电池回收,钠离子电池' },
];
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 20 });
expect(result).toHaveLength(1);
expect(result[0].tags).toBe('动力电池回收,钠离子电池');
expect(result[0].name).toBe('圣阳股份');
});
it('respects the limit parameter', async () => {
const command = getRegistry().get('ths/hot-rank');
const mockData = Array.from({ length: 30 }, (_, i) => ({
rank: i + 1, name: `stock${i}`, changePercent: '0%', heat: '0', tags: '',
}));
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 10 });
expect(result).toHaveLength(10);
});
it('returns empty array when evaluate returns non-array', async () => {
const command = getRegistry().get('ths/hot-rank');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(null),
};
const result = await command.func(page, { limit: 20 });
expect(result).toEqual([]);
});
});
+2 -1
View File
@@ -60,6 +60,7 @@ function extractBookmarkTweet(result, seen) {
text: noteText || legacy.full_text || '',
likes: legacy.favorite_count || 0,
retweets: legacy.retweet_count || 0,
bookmarks: legacy.bookmark_count || 0,
created_at: legacy.created_at || '',
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
};
@@ -106,7 +107,7 @@ cli({
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['author', 'text', 'likes', 'url'],
columns: ['author', 'text', 'likes', 'retweets', 'bookmarks', 'url'],
func: async (page, kwargs) => {
const limit = kwargs.limit || 20;
await page.goto('https://x.com');
+368
View File
@@ -0,0 +1,368 @@
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
export const UIVERSE_BASE_URL = 'https://uiverse.io';
const ROUTE_DATA_KEY = 'routes/$username.$friendlyId';
const CODE_DATA_KEY = 'routes/resource.post.code.$id';
const EXPORT_TARGET_BUTTON_LABELS = ['React', 'Vue', 'Svelte', 'Lit'];
function trimPathSegment(value) {
return String(value || '').trim().replace(/^\/+|\/+$/g, '');
}
export function parseComponentInput(input) {
const raw = String(input || '').trim();
if (!raw) {
throw new Error('Missing component input. Pass a full Uiverse URL or an author/slug identifier.');
}
let pathname = raw;
if (/^https?:\/\//i.test(raw)) {
const url = new URL(raw);
if (url.hostname !== 'uiverse.io' && url.hostname !== 'www.uiverse.io') {
throw new Error(`Unsupported non-Uiverse URL: ${raw}`);
}
pathname = url.pathname;
}
const cleaned = trimPathSegment(pathname);
const segments = cleaned.split('/').filter(Boolean);
if (segments.length !== 2) {
throw new Error(`Could not parse author/slug from input: ${raw}`);
}
const [username, slug] = segments;
if (!username || !slug) {
throw new Error(`Invalid component identifier: ${raw}. Expected author/slug.`);
}
return {
raw,
username,
slug,
url: `${UIVERSE_BASE_URL}/${username}/${slug}`,
};
}
async function fetchJsonInBrowser(page, url) {
const raw = await page.evaluate(`(async () => {
const url = ${JSON.stringify(url)};
const response = await fetch(url, {
credentials: 'include',
headers: {
accept: 'application/json, text/plain, */*',
},
});
const text = await response.text();
return JSON.stringify({
ok: response.ok,
status: response.status,
statusText: response.statusText,
text,
url,
});
})()`);
const result = JSON.parse(raw);
if (!result?.ok) {
throw new Error(`Request failed: ${result?.status} ${result?.statusText} (${result?.url || url})`);
}
try {
return JSON.parse(result.text);
} catch {
throw new Error(`Response was not valid JSON: ${url}`);
}
}
export async function getPostDetails(page, input) {
const normalized = parseComponentInput(input);
await page.goto(normalized.url);
const raw = await page.evaluate(`(async () => {
const key = ${JSON.stringify(ROUTE_DATA_KEY)};
const loaderData = window.__remixContext?.state?.loaderData || {};
const routeData = loaderData[key];
return JSON.stringify({ routeData: routeData || null, keys: Object.keys(loaderData) });
})()`);
const parsed = JSON.parse(raw);
let routeData = parsed?.routeData;
if (!routeData?.post?.id) {
const routeUrl = `${normalized.url}?_data=${encodeURIComponent(ROUTE_DATA_KEY)}`;
routeData = await fetchJsonInBrowser(page, routeUrl);
}
if (!routeData?.post?.id) {
throw new Error(`Could not resolve post.id from the component page: ${normalized.url}`);
}
return {
...normalized,
post: routeData.post,
routeData,
};
}
export async function getRawCode(page, postId) {
const codeUrl = `${UIVERSE_BASE_URL}/resource/post/code/${postId}?v=1&_data=${encodeURIComponent(CODE_DATA_KEY)}`;
const payload = await fetchJsonInBrowser(page, codeUrl);
if (typeof payload?.html !== 'string' || typeof payload?.css !== 'string') {
throw new Error(`Unexpected code payload shape: ${codeUrl}`);
}
return payload;
}
export function inferLanguage(target, post) {
if (target === 'react') return 'tsx';
if (target === 'vue') return 'vue';
if (target === 'html') return post?.isTailwind ? 'html+tailwind' : 'html';
if (target === 'css') return 'css';
return 'text';
}
export function getCodeLength(code) {
return String(code || '').length;
}
function normalizeExportTarget(target) {
return String(target || '').trim().toLowerCase() === 'vue' ? 'Vue' : 'React';
}
export async function extractExportCode(page, target = 'react') {
const targetLabel = normalizeExportTarget(target);
const raw = await page.evaluate(`(async () => {
const targetLabel = ${JSON.stringify(targetLabel)};
const exportButtonLabel = 'Export';
const exportTargetButtonLabels = ${JSON.stringify(EXPORT_TARGET_BUTTON_LABELS)};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const triggerClick = (element) => {
if (!element) return;
element.focus?.();
const pointer = { bubbles: true, cancelable: true, composed: true, view: window };
const mouse = { bubbles: true, cancelable: true, composed: true, view: window, button: 0, buttons: 1 };
element.dispatchEvent(new PointerEvent('pointerdown', pointer));
element.dispatchEvent(new MouseEvent('mousedown', mouse));
element.dispatchEvent(new PointerEvent('pointerup', pointer));
element.dispatchEvent(new MouseEvent('mouseup', mouse));
element.dispatchEvent(new MouseEvent('click', mouse));
};
const isCompleteExportCode = (code) => {
if (!code) return false;
if (targetLabel === 'Vue') {
return code.includes('<template>')
&& code.includes('</template>')
&& code.includes('<style')
&& code.includes('</style>');
}
return code.includes('export default')
&& (code.includes('styled-components') || code.includes('StyledWrapper') || code.includes('styled.'));
};
const readCode = () => {
const dialog = document.querySelector('[role="dialog"]');
if (!dialog) return null;
const heading = dialog.querySelector('h1,h2,h3,h4,h5,h6');
if (heading && (heading.textContent || '').trim() !== targetLabel) return null;
const textarea = dialog.querySelector('textarea');
if (textarea && textarea.value) return textarea.value;
return null;
};
const exportButton = [...document.querySelectorAll('button')].find((element) => (element.textContent || '').trim() === exportButtonLabel);
const currentTargetButton = [...document.querySelectorAll('button')].find((element) => {
const text = (element.textContent || '').trim();
return exportTargetButtonLabels.includes(text);
});
const existing = readCode();
if (!existing && (!exportButton || !currentTargetButton)) {
return JSON.stringify({ ok: false, error: 'Could not find the export controls on the page.' });
}
if (!existing) {
const currentLabel = (currentTargetButton.textContent || '').trim();
if (currentLabel === targetLabel) {
triggerClick(exportButton);
} else {
triggerClick(currentTargetButton);
let menuItem = null;
for (let index = 0; index < 20; index += 1) {
menuItem = [...document.querySelectorAll('[role="menuitem"]')].find((element) => (element.textContent || '').trim() === targetLabel);
if (menuItem) break;
await sleep(100);
}
if (!menuItem) {
return JSON.stringify({ ok: false, error: 'Could not find target in export menu: ' + targetLabel });
}
triggerClick(menuItem);
}
}
let longest = existing || '';
let longestLooksComplete = isCompleteExportCode(longest);
let stableCount = 0;
for (let index = 0; index < 40; index += 1) {
await sleep(200);
const code = readCode();
if (!code) continue;
if (code.length > longest.length) {
longest = code;
longestLooksComplete = isCompleteExportCode(code);
stableCount = 0;
continue;
}
if (code === longest) {
if (longestLooksComplete) {
stableCount += 1;
if (stableCount >= 2) {
return JSON.stringify({ ok: true, code: longest, length: longest.length });
}
} else {
stableCount = 0;
}
}
}
const dialog = document.querySelector('[role="dialog"]');
if (longest && longestLooksComplete) {
return JSON.stringify({ ok: true, code: longest, length: longest.length, fallback: true });
}
return JSON.stringify({
ok: false,
error: dialog
? (targetLabel + ' dialog appeared, but the exported code never reached a stable complete state.')
: (targetLabel + ' export dialog did not appear after clicking the export controls.'),
dialogFound: Boolean(dialog),
dialogText: dialog ? (dialog.innerText || '').slice(0, 200) : null,
longestLength: longest.length,
});
})()`);
const data = JSON.parse(raw);
if (!data?.ok || typeof data.code !== 'string') {
throw new Error(data?.error || `Failed to extract ${targetLabel} export code.`);
}
return data.code;
}
export function parseHtmlRootSignature(html) {
const source = String(html || '').trim();
const match = source.match(/^<([a-zA-Z0-9-]+)([^>]*)>/);
if (!match) {
return { tag: null, id: null, classes: [] };
}
const [, tag, attrs] = match;
const idMatch = attrs.match(/\sid=["']([^"']+)["']/i);
const classMatch = attrs.match(/\sclass=["']([^"']+)["']/i);
const classes = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
return {
tag: tag.toLowerCase(),
id: idMatch ? idMatch[1] : null,
classes,
};
}
export async function locatePreviewElement(page, html) {
const signature = parseHtmlRootSignature(html);
const raw = await page.evaluate(`(async () => {
const sig = ${JSON.stringify(signature)};
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const fallbackTags = sig.tag ? [sig.tag] : ['label', 'button', 'a', 'div'];
const isVisible = (element) => {
if (!element || !(element instanceof Element)) return false;
if (element.closest('[role="dialog"]')) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const scoreCandidate = (element, source) => {
const rect = element.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const area = rect.width * rect.height;
let score = 0;
if (sig.tag && element.tagName.toLowerCase() === sig.tag) score += 30;
if (sig.id && element.id === sig.id) score += 120;
if (sig.classes.length && sig.classes.every((className) => element.classList.contains(className))) score += 120;
if (centerX <= viewportWidth * 0.65) score += 40;
if (centerY <= viewportHeight * 0.6) score += 40;
if (area <= viewportWidth * viewportHeight * 0.2) score += 30;
if (area <= viewportWidth * viewportHeight * 0.05) score += 20;
return {
source,
tag: element.tagName.toLowerCase(),
className: element.className || '',
id: element.id || '',
score,
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
};
};
const candidates = [];
const seen = new Set();
const collect = (element, source) => {
if (!isVisible(element)) return;
if (seen.has(element)) return;
seen.add(element);
candidates.push(scoreCandidate(element, source));
};
if (sig.id) collect(document.getElementById(sig.id), 'id');
if (sig.classes.length) collect(document.querySelector('.' + sig.classes.join('.')), 'classes');
for (const tagName of fallbackTags) {
const tagNodes = Array.from(document.querySelectorAll(tagName));
for (const node of tagNodes.slice(0, 200)) {
collect(node, 'tag:' + tagName);
}
}
candidates.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
if (left.rect.y !== right.rect.y) return left.rect.y - right.rect.y;
if (left.rect.x !== right.rect.x) return left.rect.x - right.rect.x;
return (left.rect.width * left.rect.height) - (right.rect.width * right.rect.height);
});
return JSON.stringify({ signature: sig, best: candidates[0] || null, candidates: candidates.slice(0, 5) });
})()`);
const result = JSON.parse(raw);
if (!result?.best?.rect?.width || !result?.best?.rect?.height) {
throw new Error(`Could not locate a Uiverse preview element. Candidate data: ${JSON.stringify(result)}`);
}
return result;
}
export function getDefaultOutputPath({ username, slug, suffix, extension }) {
const safeUsername = trimPathSegment(username).replace(/[^a-zA-Z0-9-_]/g, '-');
const safeSlug = trimPathSegment(slug).replace(/[^a-zA-Z0-9-_]/g, '-');
return path.join(os.tmpdir(), `opencli-uiverse-${safeUsername}-${safeSlug}-${suffix}.${extension}`);
}
export async function saveBase64File(base64, outputPath) {
const resolved = path.resolve(outputPath);
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, Buffer.from(base64, 'base64'));
return resolved;
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
UIVERSE_BASE_URL,
parseComponentInput,
parseHtmlRootSignature,
inferLanguage,
getCodeLength,
} from './_shared.js';
describe('uiverse shared helpers', () => {
it('parses full URLs and author/slug identifiers', () => {
expect(parseComponentInput('Galahhad/strong-squid-82')).toEqual({
raw: 'Galahhad/strong-squid-82',
username: 'Galahhad',
slug: 'strong-squid-82',
url: `${UIVERSE_BASE_URL}/Galahhad/strong-squid-82`,
});
expect(parseComponentInput('https://uiverse.io/Galahhad/strong-squid-82')).toEqual({
raw: 'https://uiverse.io/Galahhad/strong-squid-82',
username: 'Galahhad',
slug: 'strong-squid-82',
url: `${UIVERSE_BASE_URL}/Galahhad/strong-squid-82`,
});
});
it('rejects unsupported hosts and malformed identifiers', () => {
expect(() => parseComponentInput('https://example.com/foo/bar')).toThrow('Unsupported non-Uiverse URL');
expect(() => parseComponentInput('only-author')).toThrow('Could not parse author/slug');
expect(() => parseComponentInput('a/b/c')).toThrow('Could not parse author/slug');
});
it('parses the HTML root signature', () => {
expect(parseHtmlRootSignature('<label id="x" class="theme-switch primary"></label>')).toEqual({
tag: 'label',
id: 'x',
classes: ['theme-switch', 'primary'],
});
expect(parseHtmlRootSignature('')).toEqual({ tag: null, id: null, classes: [] });
});
it('infers the language from target and metadata', () => {
expect(inferLanguage('react', {})).toBe('tsx');
expect(inferLanguage('vue', {})).toBe('vue');
expect(inferLanguage('html', { isTailwind: true })).toBe('html+tailwind');
expect(inferLanguage('css', {})).toBe('css');
expect(inferLanguage('unknown', {})).toBe('text');
});
it('returns the code length safely', () => {
expect(getCodeLength('abc')).toBe(3);
expect(getCodeLength('')).toBe(0);
expect(getCodeLength(null)).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
getPostDetails,
getRawCode,
extractExportCode,
inferLanguage,
getCodeLength,
} from './_shared.js';
cli({
site: 'uiverse',
name: 'code',
description: 'Export Uiverse component code (HTML, CSS, React, or Vue)',
domain: 'uiverse.io',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'input', type: 'str', required: true, positional: true, help: 'Uiverse URL or author/slug identifier' },
{ name: 'target', type: 'str', required: true, choices: ['html', 'css', 'react', 'vue'], help: 'Code target to export' },
],
columns: ['target', 'username', 'slug', 'language', 'length'],
func: async (page, kwargs) => {
const detail = await getPostDetails(page, kwargs.input);
const target = String(kwargs.target).toLowerCase();
let code = '';
if (target === 'react' || target === 'vue') {
code = await extractExportCode(page, target);
} else {
const payload = await getRawCode(page, detail.post.id);
code = target === 'html' ? payload.html : payload.css;
}
return {
target,
username: detail.username,
slug: detail.slug,
url: detail.url,
language: inferLanguage(target, detail.post),
length: getCodeLength(code),
code,
postId: detail.post.id,
type: detail.post.type,
isTailwind: Boolean(detail.post.isTailwind),
};
},
});
+71
View File
@@ -0,0 +1,71 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
getPostDetails,
getRawCode,
locatePreviewElement,
getDefaultOutputPath,
saveBase64File,
} from './_shared.js';
cli({
site: 'uiverse',
name: 'preview',
description: 'Capture a screenshot of the Uiverse preview element',
domain: 'uiverse.io',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'input', type: 'str', required: true, positional: true, help: 'Uiverse URL or author/slug identifier' },
{ name: 'output', type: 'str', required: false, help: 'Output image path (defaults to a temp file)' },
{ name: 'padding', type: 'int', required: false, default: 8, help: 'Extra padding around the captured preview in pixels' },
],
columns: ['username', 'slug', 'width', 'height', 'output'],
func: async (page, kwargs) => {
const detail = await getPostDetails(page, kwargs.input);
const payload = await getRawCode(page, detail.post.id);
const located = await locatePreviewElement(page, payload.html);
const rect = located.best.rect;
const padding = Math.max(0, Number(kwargs.padding ?? 8));
const clip = {
x: Math.max(0, rect.x - padding),
y: Math.max(0, rect.y - padding),
width: Math.max(1, rect.width + padding * 2),
height: Math.max(1, rect.height + padding * 2),
scale: 1,
};
const shot = await page.cdp('Page.captureScreenshot', {
format: 'png',
clip,
captureBeyondViewport: false,
});
const base64 = typeof shot === 'string' ? shot : shot?.data;
if (!base64) {
throw new Error('CDP screenshot failed: no image data was returned.');
}
const outputPath = kwargs.output || getDefaultOutputPath({
username: detail.username,
slug: detail.slug,
suffix: 'preview',
extension: 'png',
});
const savedPath = await saveBase64File(base64, outputPath);
return {
username: detail.username,
slug: detail.slug,
url: detail.url,
output: savedPath,
width: Math.round(clip.width),
height: Math.round(clip.height),
x: Math.round(clip.x),
y: Math.round(clip.y),
selectorSource: located.best.source,
matchedTag: located.best.tag,
matchedClassName: located.best.className,
postId: detail.post.id,
};
},
});
+2 -2
View File
@@ -22,7 +22,7 @@ cli({
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
{ name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
],
@@ -32,7 +32,7 @@ cli({
const withReplies = Boolean(kwargs['with-replies']);
const raw = String(kwargs['note-id']);
const noteId = parseNoteId(raw);
await page.goto(buildNoteUrl(raw));
await page.goto(buildNoteUrl(raw, { commandName: 'xiaohongshu comments' }));
await page.wait({ time: 2 + Math.random() * 3 });
const data = await page.evaluate(`
(async () => {
+47 -26
View File
@@ -27,7 +27,7 @@ function createPageMock(evaluateResult) {
}
describe('xiaohongshu comments', () => {
const command = getRegistry().get('xiaohongshu/comments');
it('returns ranked comment rows', async () => {
it('returns ranked comment rows for signed full URLs', async () => {
const page = createPageMock({
loginWall: false,
results: [
@@ -35,22 +35,32 @@ describe('xiaohongshu comments', () => {
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
],
});
const result = (await command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 }));
expect(page.goto.mock.calls[0][0]).toContain('/search_result/69aadbcb000000002202f131');
const signedUrl = 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search';
const result = (await command.func(page, { 'note-id': signedUrl, limit: 5 }));
expect(page.goto.mock.calls[0][0]).toBe(signedUrl);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', text: 'Great note!', likes: 10 });
expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0 });
});
it('preserves full /explore/ URL as-is for navigation', async () => {
it('rejects bare note IDs before browser navigation', async () => {
const page = createPageMock({ loginWall: false, results: [] });
await expect(command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })).rejects.toMatchObject({
code: 'ARGUMENT',
message: expect.stringContaining('signed URL'),
hint: expect.stringContaining('xsec_token'),
});
expect(page.goto).not.toHaveBeenCalled();
});
it('preserves signed /explore/ URL as-is for navigation', async () => {
const page = createPageMock({
loginWall: false,
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
});
await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
limit: 5,
});
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131?xsec_token=abc');
});
it('preserves full search_result URL with xsec_token for navigation', async () => {
const page = createPageMock({
@@ -61,22 +71,21 @@ describe('xiaohongshu comments', () => {
await command.func(page, { 'note-id': fullUrl, limit: 5 });
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
});
it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
const page = createPageMock({
loginWall: false,
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
});
const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_user';
await command.func(page, { 'note-id': fullUrl, limit: 5 });
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
});
it('throws AuthRequiredError when login wall is detected', async () => {
const page = createPageMock({ loginWall: true, results: [] });
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
});
it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the comments page', async () => {
const page = createPageMock({
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
securityBlock: true,
loginWall: false,
results: [],
});
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toMatchObject({
code: 'SECURITY_BLOCK',
hint: expect.stringContaining('xsec_token'),
});
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
await expect(command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
limit: 5,
})).rejects.toThrow('Note comments require login');
});
it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
const page = createPageMock({
@@ -95,11 +104,17 @@ describe('xiaohongshu comments', () => {
});
it('returns empty array when no comments are found', async () => {
const page = createPageMock({ loginWall: false, results: [] });
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
await expect(command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
limit: 5,
})).resolves.toEqual([]);
});
it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
const page = createPageMock({ loginWall: false, results: [] });
await command.func(page, { 'note-id': 'abc123', limit: 5 });
await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
limit: 5,
});
const script = page.evaluate.mock.calls[0][0];
expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
@@ -115,7 +130,10 @@ describe('xiaohongshu comments', () => {
reply_to: '',
}));
const page = createPageMock({ loginWall: false, results: manyComments });
const result = (await command.func(page, { 'note-id': 'abc123', limit: 3 }));
const result = (await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
limit: 3,
}));
expect(result).toHaveLength(3);
expect(result[0].rank).toBe(1);
expect(result[2].rank).toBe(3);
@@ -128,7 +146,10 @@ describe('xiaohongshu comments', () => {
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
],
});
const result = (await command.func(page, { 'note-id': 'abc123', limit: -3 }));
const result = (await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
limit: -3,
}));
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
});
@@ -143,7 +164,7 @@ describe('xiaohongshu comments', () => {
],
});
const result = (await command.func(page, {
'note-id': 'abc123', limit: 50, 'with-replies': true,
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 50, 'with-replies': true,
}));
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
@@ -166,7 +187,7 @@ describe('xiaohongshu comments', () => {
});
// Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
const result = (await command.func(page, {
'note-id': 'abc123', limit: 2, 'with-replies': true,
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 2, 'with-replies': true,
}));
expect(result).toHaveLength(4);
expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
+6 -7
View File
@@ -2,10 +2,9 @@
* Xiaohongshu download — download images and videos from a note.
*
* Usage:
* opencli xiaohongshu download <note-id-or-url> --output ./xhs
* opencli xiaohongshu download <signed-note-url-or-shortlink> --output ./xhs
*
* Accepts a bare note ID, a full xiaohongshu.com URL (with xsec_token),
* or a short link (http://xhslink.com/...).
* Accepts a full xiaohongshu.com URL with xsec_token or an xhslink short link.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { formatCookieHeader } from '@jackwener/opencli/download';
@@ -20,7 +19,7 @@ cli({
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{ name: 'note-id', positional: true, required: true, help: 'Note ID, full URL, or short link' },
{ name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
{ name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
],
columns: ['index', 'type', 'status', 'size'],
@@ -28,7 +27,7 @@ cli({
const rawInput = String(kwargs['note-id']);
const output = kwargs.output;
const noteId = parseNoteId(rawInput);
await page.goto(buildNoteUrl(rawInput));
await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
await page.wait({ time: 1 + Math.random() * 2 });
// Extract note info and media URLs
const data = await page.evaluate(`
@@ -51,9 +50,9 @@ cli({
seenMedia.add(key);
result.media.push({ type, url });
};
const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)/i);
const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)|\\/user\\/profile\\/[^/?#]+\\/([a-f0-9]+)/i);
if (locationMatch) {
result.noteId = locationMatch[1];
result.noteId = locationMatch[1] || locationMatch[2];
}
// Get title
+17 -5
View File
@@ -70,19 +70,31 @@ describe('xiaohongshu download', () => {
filenamePrefix: '69bc166f000000001a02069a',
}));
});
it('throws SECURITY_BLOCK with bare-id guidance before starting downloads', async () => {
it('uses canonical note id for signed user profile note URLs', async () => {
const page = createPageMock({
noteId: '',
media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }],
});
const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_user';
await command.func(page, { 'note-id': fullUrl, output: './out' });
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], expect.objectContaining({
subdir: '69bc166f000000001a02069a',
filenamePrefix: '69bc166f000000001a02069a',
}));
});
it('rejects bare note IDs before browser navigation', async () => {
const page = createPageMock({
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
securityBlock: true,
noteId: '69bc166f000000001a02069a',
media: [],
});
await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({
code: 'SECURITY_BLOCK',
code: 'ARGUMENT',
message: expect.stringContaining('signed URL'),
hint: expect.stringContaining('xsec_token'),
});
expect(page.goto).not.toHaveBeenCalled();
expect(mockDownloadMedia).not.toHaveBeenCalled();
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
});
it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => {
const page = createPageMock({
+46 -12
View File
@@ -1,25 +1,59 @@
import { ArgumentError } from '@jackwener/opencli/errors';
/** Side-effect-free helpers shared by xiaohongshu note and comments commands. */
/** Extract a bare note ID from a full URL or raw ID string. */
export function parseNoteId(input) {
const trimmed = input.trim();
const match = trimmed.match(/\/(?:explore|note|search_result)\/([a-f0-9]+)/);
return match ? match[1] : trimmed;
const match = trimmed.match(/\/(?:explore|note|search_result|discovery\/item)\/([a-f0-9]+)|\/user\/profile\/[^/?#]+\/([a-f0-9]+)/i);
return match ? (match[1] || match[2]) : trimmed;
}
export const XHS_SIGNED_URL_HINT = 'Pass a full Xiaohongshu note URL with xsec_token from search results or user/profile context.';
function isShortLink(input) {
return /^https?:\/\/xhslink\.com\//i.test(input);
}
function isXiaohongshuHost(hostname) {
const normalized = hostname.toLowerCase();
return normalized === 'xiaohongshu.com' || normalized.endsWith('.xiaohongshu.com');
}
function isSupportedNotePath(pathname) {
return /^\/(?:explore|note|search_result|discovery\/item)\/[a-f0-9]+(?:[/?#]|$)/i.test(pathname)
|| /^\/user\/profile\/[^/?#]+\/[a-f0-9]+(?:[/?#]|$)/i.test(pathname);
}
/**
* Build the best navigation URL for a note.
*
* XHS blocks direct `/explore/<id>` access without a valid `xsec_token`.
* When the user passes a full URL (from search results), we preserve it
* so the browser navigates with the token intact. For bare IDs we now use
* `/search_result/<id>` which works without xsec_token when cookies are present.
* XHS note detail pages now require a valid signed URL for reliable access.
* Bare note IDs no longer resolve deterministically, so callers must provide
* a full note URL with xsec_token or, for downloads only, an xhslink short link.
*/
export function buildNoteUrl(input) {
export function buildNoteUrl(input, options = {}) {
const { allowShortLink = false, commandName = 'xiaohongshu note' } = options;
const trimmed = input.trim();
const message = `${commandName} now requires a full signed URL`;
const hint = allowShortLink
? `${XHS_SIGNED_URL_HINT} For downloads, xhslink short links are also supported.`
: XHS_SIGNED_URL_HINT;
if (/^https?:\/\//.test(trimmed)) {
// Full URL — navigate as-is; the browser will follow any redirects
return trimmed;
if (isShortLink(trimmed)) {
if (allowShortLink)
return trimmed;
throw new ArgumentError(message, hint);
}
try {
const url = new URL(trimmed);
const xsecToken = url.searchParams.get('xsec_token')?.trim();
if (isXiaohongshuHost(url.hostname) && isSupportedNotePath(url.pathname) && xsecToken) {
return trimmed;
}
}
catch { }
throw new ArgumentError(message, hint);
}
// Use /search_result/<id> instead of /explore/<id> — works without xsec_token
// when the user is logged in via cookies (which is always the case with opencli).
return `https://www.xiaohongshu.com/search_result/${trimmed}`;
throw new ArgumentError(message, hint);
}
+3 -5
View File
@@ -4,9 +4,7 @@
* Extracts title, author, description text, and engagement metrics
* (likes, collects, comment count) via DOM extraction.
*
* Supports both bare note IDs and full URLs (with xsec_token).
* Bare IDs now use /search_result/<id> which works without xsec_token
* when the user is logged in via cookies.
* Requires a full Xiaohongshu note URL with xsec_token.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
@@ -19,13 +17,13 @@ cli({
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
],
columns: ['field', 'value'],
func: async (page, kwargs) => {
const raw = String(kwargs['note-id']);
const noteId = parseNoteId(raw);
const url = buildNoteUrl(raw);
const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
await page.goto(url);
await page.wait({ time: 2 + Math.random() * 3 });
const data = await page.evaluate(`
+52 -25
View File
@@ -36,6 +36,9 @@ describe('parseNoteId', () => {
it('extracts ID from /note/ URL', () => {
expect(parseNoteId('https://www.xiaohongshu.com/note/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
});
it('extracts ID from signed /user/profile/<user>/<note> URL', () => {
expect(parseNoteId('https://www.xiaohongshu.com/user/profile/user123/69c131c9000000002800be4c?xsec_token=abc&xsec_source=pc_user')).toBe('69c131c9000000002800be4c');
});
it('returns raw string when no URL pattern matches', () => {
expect(parseNoteId('69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
});
@@ -48,8 +51,14 @@ describe('buildNoteUrl', () => {
const url = 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok';
expect(buildNoteUrl(url)).toBe(url);
});
it('constructs /search_result/ URL for bare note ID', () => {
expect(buildNoteUrl('abc123')).toBe('https://www.xiaohongshu.com/search_result/abc123');
it('rejects signed URLs from non-xiaohongshu hosts', () => {
expect(() => buildNoteUrl('https://example.com/?xsec_token=tok')).toThrow(/xiaohongshu/i);
});
it('rejects signed URLs with an empty xsec_token value', () => {
expect(() => buildNoteUrl('https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=')).toThrow(/xsec_token|signed url/i);
});
it('rejects bare note IDs because xiaohongshu now requires a signed URL', () => {
expect(() => buildNoteUrl('abc123')).toThrow(/xsec_token|signed url/i);
});
});
describe('xiaohongshu note', () => {
@@ -58,7 +67,7 @@ describe('xiaohongshu note', () => {
expect(command).toBeDefined();
expect(command.func).toBeTypeOf('function');
});
it('returns note content as field/value rows', async () => {
it('returns note content as field/value rows for signed full URLs', async () => {
const page = createPageMock({
loginWall: false,
notFound: false,
@@ -70,8 +79,9 @@ describe('xiaohongshu note', () => {
comments: '45',
tags: ['#尚界Z7', '#鸿蒙智行'],
});
const result = (await command.func(page, { 'note-id': '69c131c9000000002800be4c' }));
expect(page.goto.mock.calls[0][0]).toContain('/search_result/69c131c9000000002800be4c');
const signedUrl = 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc';
const result = (await command.func(page, { 'note-id': signedUrl }));
expect(page.goto.mock.calls[0][0]).toBe(signedUrl);
expect(result).toEqual([
{ field: 'title', value: '尚界Z7实车体验' },
{ field: 'author', value: '小红薯用户' },
@@ -82,6 +92,18 @@ describe('xiaohongshu note', () => {
{ field: 'tags', value: '#尚界Z7, #鸿蒙智行' },
]);
});
it('rejects bare note IDs before browser navigation', async () => {
const page = createPageMock({
loginWall: false, notFound: false,
title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
});
await expect(command.func(page, { 'note-id': '69c131c9000000002800be4c' })).rejects.toMatchObject({
code: 'ARGUMENT',
message: expect.stringContaining('signed URL'),
hint: expect.stringContaining('xsec_token'),
});
expect(page.goto).not.toHaveBeenCalled();
});
it('parses note ID from full /explore/ URL', async () => {
const page = createPageMock({
loginWall: false, notFound: false,
@@ -102,23 +124,20 @@ describe('xiaohongshu note', () => {
// Should navigate to the full URL as-is, not strip the token
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
});
it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
const page = createPageMock({
loginWall: false, notFound: false,
title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
});
const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69c131c9000000002800be4c?xsec_token=abc&xsec_source=pc_user';
await command.func(page, { 'note-id': fullUrl });
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
});
it('throws AuthRequiredError on login wall', async () => {
const page = createPageMock({ loginWall: true, notFound: false });
await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
});
it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the note page', async () => {
const page = createPageMock({
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
securityBlock: true,
loginWall: false,
notFound: false,
});
await expect(command.func(page, { 'note-id': '69c131c9000000002800be4c' })).rejects.toMatchObject({
code: 'SECURITY_BLOCK',
message: 'Xiaohongshu security block: the note detail page was blocked by risk control.',
hint: expect.stringContaining('xsec_token'),
});
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
await expect(command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
})).rejects.toThrow('Note content requires login');
});
it('throws SECURITY_BLOCK with retry guidance when a full URL is blocked', async () => {
const page = createPageMock({
@@ -136,7 +155,9 @@ describe('xiaohongshu note', () => {
});
it('throws EmptyResultError when note is not found', async () => {
const page = createPageMock({ loginWall: false, notFound: true });
await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
await expect(command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
})).rejects.toThrow('returned no data');
});
it('throws an empty-result error when the note page renders as an empty shell', async () => {
const page = createPageMock({
@@ -151,7 +172,9 @@ describe('xiaohongshu note', () => {
tags: [],
});
try {
await command.func(page, { 'note-id': '69ca3927000000001a020fd5' });
await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/69ca3927000000001a020fd5?xsec_token=abc',
});
throw new Error('expected xiaohongshu note to fail on an empty shell page');
}
catch (error) {
@@ -193,7 +216,9 @@ describe('xiaohongshu note', () => {
title: 'New note', desc: 'Just posted', author: 'Author',
likes: '赞', collects: '收藏', comments: '评论', tags: [],
});
const result = (await command.func(page, { 'note-id': 'abc123' }));
const result = (await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
}));
expect(result.find((r) => r.field === 'likes').value).toBe('0');
expect(result.find((r) => r.field === 'collects').value).toBe('0');
expect(result.find((r) => r.field === 'comments').value).toBe('0');
@@ -203,7 +228,7 @@ describe('xiaohongshu note', () => {
loginWall: false, notFound: false,
title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
});
await command.func(page, { 'note-id': 'abc123' });
await command.func(page, { 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok' });
const evaluateScript = page.evaluate.mock.calls[0][0];
expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
@@ -215,7 +240,9 @@ describe('xiaohongshu note', () => {
title: 'No tags', desc: 'Content', author: 'Author',
likes: '1', collects: '2', comments: '3', tags: [],
});
const result = (await command.func(page, { 'note-id': 'abc123' }));
const result = (await command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
}));
expect(result.find((r) => r.field === 'tags')).toBeUndefined();
expect(result).toHaveLength(6);
});
+303
View File
@@ -0,0 +1,303 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CliError, CommandExecutionError, ConfigError, EXIT_CODES, getErrorMessage } from '@jackwener/opencli/errors';
export const XIAOYUZHOU_API_BASE_URL = 'https://api.xiaoyuzhoufm.com';
export const XIAOYUZHOU_TOKEN_TTL_MS = 20 * 60 * 1000;
export const XIAOYUZHOU_REFRESH_SKEW_MS = 60 * 1000;
export const XIAOYUZHOU_DEFAULT_DEVICE_ID = '81ADBFD6-6921-482B-9AB9-A29E7CC7BB55';
export const XIAOYUZHOU_DEFAULT_DEVICE_PROPERTIES = '';
export const XIAOYUZHOU_DEFAULT_USER_AGENT = 'Xiaoyuzhou/2.98.0 (build:2908; iOS 26.2.1)';
function getNowMs() {
return Date.now();
}
export function getXiaoyuzhouCredentialFile() {
return path.join(os.homedir(), '.opencli', 'xiaoyuzhou.json');
}
function createXiaoyuzhouAuthError(message) {
return new CliError('AUTH_REQUIRED', message, `Update ${getXiaoyuzhouCredentialFile()} with fresh Xiaoyuzhou credentials before retrying.`, EXIT_CODES.NOPERM);
}
function coerceNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function normalizeXiaoyuzhouCredentials(raw = {}) {
const lastUpdatedTs = coerceNumber(raw.last_updated_ts ?? raw.lastUpdatedTs);
let expiresAt = coerceNumber(raw.expires_at ?? raw.expiresAt);
if (expiresAt > 0 && expiresAt < 10_000_000_000) {
expiresAt *= 1000;
}
if (!expiresAt && lastUpdatedTs > 0) {
expiresAt = lastUpdatedTs * 1000 + XIAOYUZHOU_TOKEN_TTL_MS;
}
return {
access_token: String(raw.access_token ?? raw.accessToken ?? '').trim(),
refresh_token: String(raw.refresh_token ?? raw.refreshToken ?? '').trim(),
expires_at: expiresAt,
device_id: String(raw.device_id ?? raw.deviceId ?? XIAOYUZHOU_DEFAULT_DEVICE_ID).trim() || XIAOYUZHOU_DEFAULT_DEVICE_ID,
device_properties: String(raw.device_properties ?? raw.deviceProperties ?? XIAOYUZHOU_DEFAULT_DEVICE_PROPERTIES),
};
}
export function loadXiaoyuzhouCredentials() {
const filePath = getXiaoyuzhouCredentialFile();
if (fs.existsSync(filePath)) {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const credentials = normalizeXiaoyuzhouCredentials(parsed);
if (!credentials.access_token || !credentials.refresh_token) {
throw new ConfigError(`Xiaoyuzhou credential file is missing access_token or refresh_token: ${filePath}`, 'Recreate the file with valid credentials.');
}
return credentials;
}
catch (error) {
if (error instanceof ConfigError) {
throw error;
}
throw new ConfigError(`Failed to parse Xiaoyuzhou credential file: ${filePath}`, `Ensure ${filePath} contains valid JSON. (${getErrorMessage(error)})`);
}
}
throw new ConfigError(`Missing Xiaoyuzhou credentials. Expected ${filePath}`, `Create ${filePath} with access_token and refresh_token.`);
}
export function saveXiaoyuzhouCredentials(credentials) {
const filePath = getXiaoyuzhouCredentialFile();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify({
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
expires_at: credentials.expires_at,
device_id: credentials.device_id,
device_properties: credentials.device_properties,
}, null, 2)}\n`, 'utf-8');
}
export function shouldRefreshXiaoyuzhouCredentials(credentials, now = getNowMs()) {
return Number.isFinite(credentials.expires_at)
&& credentials.expires_at > 0
&& now >= credentials.expires_at - XIAOYUZHOU_REFRESH_SKEW_MS;
}
export function buildXiaoyuzhouHeaders(credentials, options = {}) {
const {
contentType = 'application/json',
includeLocalTime = false,
includeRefreshToken = false,
} = options;
const headers = {
'Content-Type': contentType,
Host: 'api.xiaoyuzhoufm.com',
'User-Agent': XIAOYUZHOU_DEFAULT_USER_AGENT,
Market: 'AppStore',
'App-BuildNo': '2908',
OS: 'ios',
Manufacturer: 'Apple',
BundleID: 'app.podcast.cosmos',
Connection: 'keep-alive',
'abtest-info': '{"old_user_discovery_feed":"enable"}',
'Accept-Language': 'en-HK;q=1.0, zh-Hans-HK;q=0.9',
Model: 'iPhone18,1',
'app-permissions': '100000',
Accept: '*/*',
'App-Version': '2.98.0',
WifiConnected: 'true',
'OS-Version': '26.2.1',
'x-custom-xiaoyuzhou-app-dev': '',
'x-jike-device-id': credentials.device_id || XIAOYUZHOU_DEFAULT_DEVICE_ID,
'x-jike-device-properties': credentials.device_properties ?? XIAOYUZHOU_DEFAULT_DEVICE_PROPERTIES,
};
if (credentials.access_token) {
headers['x-jike-access-token'] = credentials.access_token;
}
if (includeRefreshToken && credentials.refresh_token) {
headers['x-jike-refresh-token'] = credentials.refresh_token;
}
if (includeLocalTime) {
headers['Local-Time'] = new Date().toISOString();
headers.Timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}
return headers;
}
export async function refreshXiaoyuzhouCredentials(credentials, fetchImpl = fetch) {
if (!credentials.refresh_token) {
throw createXiaoyuzhouAuthError('Xiaoyuzhou refresh token is missing');
}
let response;
try {
response = await fetchImpl(`${XIAOYUZHOU_API_BASE_URL}/app_auth_tokens.refresh`, {
method: 'POST',
headers: buildXiaoyuzhouHeaders(credentials, {
contentType: 'application/x-www-form-urlencoded; charset=utf-8',
includeLocalTime: true,
includeRefreshToken: true,
}),
signal: AbortSignal.timeout(20_000),
});
}
catch (error) {
throw new CommandExecutionError(`Failed to refresh Xiaoyuzhou credentials: ${getErrorMessage(error)}`);
}
const bodyText = await response.text();
if (!response.ok) {
throw createXiaoyuzhouAuthError(`Xiaoyuzhou token refresh failed with HTTP ${response.status}${bodyText ? `: ${bodyText}` : ''}`);
}
let parsed;
try {
parsed = JSON.parse(bodyText);
}
catch (error) {
throw new CommandExecutionError(`Xiaoyuzhou refresh returned invalid JSON: ${getErrorMessage(error)}`);
}
if (!parsed?.success) {
throw createXiaoyuzhouAuthError('Xiaoyuzhou refresh API returned success=false');
}
const nextCredentials = normalizeXiaoyuzhouCredentials({
...credentials,
access_token: parsed['x-jike-access-token'] || '',
refresh_token: parsed['x-jike-refresh-token'] || '',
expires_at: getNowMs() + XIAOYUZHOU_TOKEN_TTL_MS,
});
if (!nextCredentials.access_token || !nextCredentials.refresh_token) {
throw createXiaoyuzhouAuthError('Xiaoyuzhou refresh API returned empty access_token or refresh_token');
}
saveXiaoyuzhouCredentials(nextCredentials);
return nextCredentials;
}
function buildApiUrl(endpoint, query) {
const url = new URL(endpoint, XIAOYUZHOU_API_BASE_URL);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
async function performXiaoyuzhouJsonRequest(endpoint, options, credentials, fetchImpl) {
const {
method = 'GET',
query,
body,
} = options;
let response;
try {
response = await fetchImpl(buildApiUrl(endpoint, query), {
method,
headers: buildXiaoyuzhouHeaders(credentials, {
contentType: 'application/json',
includeLocalTime: true,
}),
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
}
catch (error) {
throw new CommandExecutionError(`Failed to reach Xiaoyuzhou API: ${getErrorMessage(error)}`);
}
return response;
}
export async function requestXiaoyuzhouJson(endpoint, options = {}, fetchImpl = fetch) {
let credentials = options.credentials ?? loadXiaoyuzhouCredentials();
if (shouldRefreshXiaoyuzhouCredentials(credentials)) {
credentials = await refreshXiaoyuzhouCredentials(credentials, fetchImpl);
}
let response = await performXiaoyuzhouJsonRequest(endpoint, options, credentials, fetchImpl);
if (response.status === 401) {
credentials = await refreshXiaoyuzhouCredentials(credentials, fetchImpl);
response = await performXiaoyuzhouJsonRequest(endpoint, options, credentials, fetchImpl);
}
const bodyText = await response.text();
if (!response.ok) {
throw new CommandExecutionError(`Xiaoyuzhou API request failed with HTTP ${response.status}${bodyText ? `: ${bodyText}` : ''}`);
}
let parsed;
try {
parsed = JSON.parse(bodyText);
}
catch (error) {
throw new CommandExecutionError(`Xiaoyuzhou API returned invalid JSON: ${getErrorMessage(error)}`);
}
if (parsed?.success === false) {
throw new CommandExecutionError(parsed?.message || parsed?.msg || 'Xiaoyuzhou API returned success=false');
}
return {
credentials,
raw: parsed,
data: parsed?.data,
};
}
export async function fetchXiaoyuzhouTranscriptBody(url, fetchImpl = fetch) {
let response;
try {
response = await fetchImpl(url, {
method: 'GET',
headers: {
'User-Agent': XIAOYUZHOU_DEFAULT_USER_AGENT,
Accept: '*/*',
Market: 'AppStore',
},
signal: AbortSignal.timeout(20_000),
});
}
catch (error) {
throw new CommandExecutionError(`Failed to fetch Xiaoyuzhou transcript content: ${getErrorMessage(error)}`);
}
const bodyText = await response.text();
if (!response.ok) {
throw new CommandExecutionError(`Xiaoyuzhou transcript download failed with HTTP ${response.status}${bodyText ? `: ${bodyText}` : ''}`);
}
return bodyText;
}
export function extractTranscriptText(transcriptBody) {
let parsed;
try {
parsed = JSON.parse(transcriptBody);
}
catch {
return { text: '', segmentCount: 0 };
}
let items = [];
if (Array.isArray(parsed)) {
items = parsed;
}
else if (parsed && typeof parsed === 'object') {
for (const key of ['segments', 'data', 'transcript', 'items']) {
if (Array.isArray(parsed[key])) {
items = parsed[key];
break;
}
}
if (items.length === 0) {
const directText = typeof parsed.text === 'string' ? parsed.text.trim() : '';
if (directText) {
return { text: directText, segmentCount: 1 };
}
}
}
const textItems = [];
for (const item of items) {
if (!item || typeof item !== 'object' || typeof item.text !== 'string') {
continue;
}
const cleaned = item.text.trim();
if (cleaned) {
textItems.push(cleaned);
}
}
return {
text: textItems.join('\n'),
segmentCount: textItems.length,
};
}
+124
View File
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockExistsSync, mockReadFileSync, mockMkdirSync, mockWriteFileSync, mockHomedir } = vi.hoisted(() => ({
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
mockMkdirSync: vi.fn(),
mockWriteFileSync: vi.fn(),
mockHomedir: vi.fn(() => '/Users/tester'),
}));
vi.mock('node:fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
mkdirSync: mockMkdirSync,
writeFileSync: mockWriteFileSync,
}));
vi.mock('node:os', () => ({
homedir: mockHomedir,
}));
const { extractTranscriptText, getXiaoyuzhouCredentialFile, loadXiaoyuzhouCredentials, normalizeXiaoyuzhouCredentials, refreshXiaoyuzhouCredentials, requestXiaoyuzhouJson, shouldRefreshXiaoyuzhouCredentials, XIAOYUZHOU_TOKEN_TTL_MS } = await import('./auth.js');
function createJsonResponse(status, payload) {
return {
ok: status >= 200 && status < 300,
status,
text: vi.fn().mockResolvedValue(JSON.stringify(payload)),
};
}
describe('xiaoyuzhou auth helpers', () => {
beforeEach(() => {
mockExistsSync.mockReset();
mockReadFileSync.mockReset();
mockMkdirSync.mockReset();
mockWriteFileSync.mockReset();
vi.useRealTimers();
});
it('loads credentials from the local credential file', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify({
access_token: 'file-access',
refresh_token: 'file-refresh',
expires_at: 123,
}));
const credentials = loadXiaoyuzhouCredentials();
expect(mockReadFileSync).toHaveBeenCalledWith(getXiaoyuzhouCredentialFile(), 'utf-8');
expect(credentials.access_token).toBe('file-access');
expect(credentials.refresh_token).toBe('file-refresh');
});
it('refreshes credentials and persists the updated token file', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-15T00:00:00Z'));
const fetchMock = vi.fn().mockResolvedValue(createJsonResponse(200, {
success: true,
'x-jike-access-token': 'new-access',
'x-jike-refresh-token': 'new-refresh',
}));
const refreshed = await refreshXiaoyuzhouCredentials(normalizeXiaoyuzhouCredentials({
access_token: 'old-access',
refresh_token: 'old-refresh',
device_id: 'device-1',
device_properties: 'props',
}), fetchMock);
expect(refreshed.access_token).toBe('new-access');
expect(refreshed.refresh_token).toBe('new-refresh');
expect(refreshed.expires_at).toBe(Date.now() + XIAOYUZHOU_TOKEN_TTL_MS);
expect(mockMkdirSync).toHaveBeenCalledWith('/Users/tester/.opencli', { recursive: true });
expect(mockWriteFileSync).toHaveBeenCalledWith('/Users/tester/.opencli/xiaoyuzhou.json', expect.stringContaining('"access_token": "new-access"'), 'utf-8');
});
it('retries once on 401 using refreshed credentials', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: false,
status: 401,
text: vi.fn().mockResolvedValue('unauthorized'),
})
.mockResolvedValueOnce(createJsonResponse(200, {
success: true,
'x-jike-access-token': 'refreshed-access',
'x-jike-refresh-token': 'refreshed-refresh',
}))
.mockResolvedValueOnce(createJsonResponse(200, {
success: true,
data: { title: 'Transcript Episode' },
}));
const result = await requestXiaoyuzhouJson('/v1/episode/get', {
query: { eid: 'ep123' },
credentials: normalizeXiaoyuzhouCredentials({
access_token: 'old-access',
refresh_token: 'old-refresh',
}),
}, fetchMock);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(result.data).toEqual({ title: 'Transcript Episode' });
expect(result.credentials.access_token).toBe('refreshed-access');
});
it('extracts transcript text from segment arrays and direct text payloads', () => {
expect(extractTranscriptText(JSON.stringify({
segments: [{ text: 'hello ' }, { text: ' world' }],
}))).toEqual({
text: 'hello\nworld',
segmentCount: 2,
});
expect(extractTranscriptText(JSON.stringify({ text: 'full transcript' }))).toEqual({
text: 'full transcript',
segmentCount: 1,
});
});
it('detects credentials that are close to expiry', () => {
expect(shouldRefreshXiaoyuzhouCredentials({
expires_at: Date.now() - 1,
})).toBe(true);
expect(shouldRefreshXiaoyuzhouCredentials({
expires_at: Date.now() + 10 * 60 * 1000,
})).toBe(false);
});
});
+49
View File
@@ -0,0 +1,49 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { httpDownload, sanitizeFilename } from '@jackwener/opencli/download';
import { formatBytes } from '@jackwener/opencli/download/progress';
import { fetchPageProps } from './utils.js';
cli({
site: 'xiaoyuzhou',
name: 'download',
description: 'Download Xiaoyuzhou episode audio',
domain: 'www.xiaoyuzhoufm.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' },
{ name: 'output', default: './xiaoyuzhou-downloads', help: 'Output directory' },
],
columns: ['title', 'podcast', 'status', 'size', 'file'],
func: async (_page, args) => {
const pageProps = await fetchPageProps(`/episode/${args.id}`);
const ep = pageProps.episode;
if (!ep) {
throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
}
const audioUrl = ep.media?.source?.url;
if (!audioUrl) {
throw new CliError('PARSE_ERROR', 'Audio URL not found in episode payload', 'Episode payload does not expose media.source.url');
}
const output = String(args.output || './xiaoyuzhou-downloads');
const ext = path.extname(new URL(audioUrl).pathname) || '.mp3';
const title = String(ep.title || 'episode');
const filename = `${args.id}_${sanitizeFilename(title, 80) || 'episode'}${ext}`;
const outputDir = path.join(output, String(args.id));
fs.mkdirSync(outputDir, { recursive: true });
const destPath = path.join(outputDir, filename);
const result = await httpDownload(audioUrl, destPath, {
timeout: 60000,
});
return [{
title,
podcast: ep.podcast?.title || '-',
status: result.success ? 'success' : 'failed',
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
file: result.success ? destPath : '-',
}];
},
});
+125
View File
@@ -0,0 +1,125 @@
import path from 'node:path';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
const { mockFetchPageProps, mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
mockFetchPageProps: vi.fn(),
mockHttpDownload: vi.fn(),
mockMkdirSync: vi.fn(),
}));
vi.mock('./utils.js', async () => {
const actual = await vi.importActual('./utils.js');
return {
...actual,
fetchPageProps: mockFetchPageProps,
};
});
vi.mock('@jackwener/opencli/download', () => ({
httpDownload: mockHttpDownload,
sanitizeFilename: vi.fn((value) => value.replace(/\s+/g, '_')),
}));
vi.mock('@jackwener/opencli/download/progress', () => ({
formatBytes: vi.fn((size) => `${size} B`),
}));
vi.mock('node:fs', () => ({
mkdirSync: mockMkdirSync,
}));
await import('./download.js');
let cmd;
function toPosixPath(value) {
return value.replaceAll(path.sep, '/');
}
beforeAll(() => {
cmd = getRegistry().get('xiaoyuzhou/download');
expect(cmd?.func).toBeTypeOf('function');
});
describe('xiaoyuzhou download', () => {
beforeEach(() => {
mockFetchPageProps.mockReset();
mockHttpDownload.mockReset();
mockMkdirSync.mockReset();
});
it('downloads audio from media.source.url into an episode subdirectory', async () => {
mockFetchPageProps.mockResolvedValue({
episode: {
title: 'Hello World',
podcast: { title: 'OpenCLI FM' },
media: {
source: {
url: 'https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc',
},
},
},
});
mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
const result = await cmd.func(null, {
id: 'ep123',
output: '/tmp/xiaoyuzhou-test',
});
expect(mockFetchPageProps).toHaveBeenCalledWith('/episode/ep123');
expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/xiaoyuzhou-test/ep123');
expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
expect(mockHttpDownload).toHaveBeenCalledWith('https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc', expect.stringContaining('/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3'), {
timeout: 60000,
});
expect(result).toEqual([{
title: 'Hello World',
podcast: 'OpenCLI FM',
status: 'success',
size: '1234 B',
file: '/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3',
}]);
});
it('preserves non-mp3 extensions from media.source.url', async () => {
mockFetchPageProps.mockResolvedValue({
episode: {
title: 'Lossless Episode',
podcast: { title: 'OpenCLI FM' },
media: {
source: {
url: 'https://media.xyzcdn.net/audio/lossless.m4a',
},
},
},
});
mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
const result = await cmd.func(null, {
id: 'ep456',
output: '/tmp/xiaoyuzhou-test',
});
expect(mockHttpDownload.mock.calls[0][1]).toContain('ep456_Lossless_Episode.m4a');
expect(result[0].file).toBe('/tmp/xiaoyuzhou-test/ep456/ep456_Lossless_Episode.m4a');
});
it('throws when media.source.url is missing', async () => {
mockFetchPageProps.mockResolvedValue({
episode: {
title: 'No Audio',
podcast: { title: 'OpenCLI FM' },
media: {},
},
});
await expect(cmd.func(null, { id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
code: 'PARSE_ERROR',
message: 'Audio URL not found in episode payload',
hint: 'Episode payload does not expose media.source.url',
});
expect(mockHttpDownload).not.toHaveBeenCalled();
});
});
+76
View File
@@ -0,0 +1,76 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CliError } from '@jackwener/opencli/errors';
import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson, fetchXiaoyuzhouTranscriptBody, extractTranscriptText } from './auth.js';
cli({
site: 'xiaoyuzhou',
name: 'transcript',
description: 'Download Xiaoyuzhou transcript as JSON and text (requires local credentials)',
domain: 'www.xiaoyuzhoufm.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' },
{ name: 'output', default: './xiaoyuzhou-transcripts', help: 'Output directory' },
{ name: 'json', type: 'boolean', default: true, help: 'Save transcript JSON file' },
{ name: 'text', type: 'boolean', default: true, help: 'Save extracted transcript text file' },
],
columns: ['title', 'podcast', 'status', 'segments', 'json_file', 'text_file'],
func: async (_page, kwargs) => {
if (kwargs.json === false && kwargs.text === false) {
throw new ArgumentError('At least one of --json or --text must be enabled', 'Example: opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --text true');
}
let credentials = loadXiaoyuzhouCredentials();
const episodeResponse = await requestXiaoyuzhouJson('/v1/episode/get', {
query: { eid: kwargs.id },
credentials,
});
credentials = episodeResponse.credentials;
const episode = episodeResponse.data;
if (!episode) {
throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the episode ID');
}
const mediaId = String(episode.transcript?.mediaId || episode.media?.id || episode.transcriptMediaId || '').trim();
if (!mediaId) {
throw new CliError('PARSE_ERROR', 'mediaId not found in episode payload', 'Transcript metadata requires episode.transcript.mediaId, episode.media.id, or episode.transcriptMediaId');
}
const transcriptResponse = await requestXiaoyuzhouJson('/v1/episode-transcript/get', {
method: 'POST',
body: {
eid: kwargs.id,
mediaId,
},
credentials,
});
const transcriptMeta = transcriptResponse.data;
const transcriptUrl = String(transcriptMeta?.transcriptUrl || transcriptMeta?.url || '').trim();
if (!transcriptUrl) {
throw new CliError('EMPTY_RESULT', 'Transcript URL not found', 'This episode may not have transcript data available');
}
const transcriptBody = await fetchXiaoyuzhouTranscriptBody(transcriptUrl);
const { text, segmentCount } = extractTranscriptText(transcriptBody);
if (kwargs.text !== false && transcriptBody.trim() && !text.trim()) {
throw new CliError('PARSE_ERROR', 'Failed to extract transcript text', 'Transcript payload format is unsupported. Re-run with --json true to inspect the raw payload.');
}
const outputDir = path.join(String(kwargs.output || './xiaoyuzhou-transcripts'), String(kwargs.id));
fs.mkdirSync(outputDir, { recursive: true });
const jsonPath = path.join(outputDir, 'transcript.json');
const textPath = path.join(outputDir, 'transcript.txt');
if (kwargs.json !== false) {
fs.writeFileSync(jsonPath, transcriptBody, 'utf-8');
}
if (kwargs.text !== false) {
fs.writeFileSync(textPath, text, 'utf-8');
}
return [{
title: episode.title || 'episode',
podcast: episode.podcast?.title || '-',
status: 'success',
segments: kwargs.text === false ? '-' : String(segmentCount),
json_file: kwargs.json === false ? '-' : jsonPath,
text_file: kwargs.text === false ? '-' : textPath,
}];
},
});
+195
View File
@@ -0,0 +1,195 @@
import path from 'node:path';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
const { mockLoadCredentials, mockRequestJson, mockFetchTranscriptBody, mockMkdirSync, mockWriteFileSync } = vi.hoisted(() => ({
mockLoadCredentials: vi.fn(),
mockRequestJson: vi.fn(),
mockFetchTranscriptBody: vi.fn(),
mockMkdirSync: vi.fn(),
mockWriteFileSync: vi.fn(),
}));
vi.mock('./auth.js', async () => {
const actual = await vi.importActual('./auth.js');
return {
...actual,
loadXiaoyuzhouCredentials: mockLoadCredentials,
requestXiaoyuzhouJson: mockRequestJson,
fetchXiaoyuzhouTranscriptBody: mockFetchTranscriptBody,
};
});
vi.mock('node:fs', () => ({
mkdirSync: mockMkdirSync,
writeFileSync: mockWriteFileSync,
}));
await import('./transcript.js');
let cmd;
function toPosixPath(value) {
return value.replaceAll(path.sep, '/');
}
beforeAll(() => {
cmd = getRegistry().get('xiaoyuzhou/transcript');
expect(cmd?.func).toBeTypeOf('function');
});
describe('xiaoyuzhou transcript', () => {
beforeEach(() => {
mockLoadCredentials.mockReset();
mockRequestJson.mockReset();
mockFetchTranscriptBody.mockReset();
mockMkdirSync.mockReset();
mockWriteFileSync.mockReset();
mockLoadCredentials.mockReturnValue({ access_token: 'access', refresh_token: 'refresh' });
});
it('downloads transcript json and extracted text files', async () => {
mockRequestJson
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
title: 'Transcript Episode',
podcast: { title: 'OpenCLI FM' },
transcript: { mediaId: 'media-123' },
},
})
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
transcriptUrl: 'https://cdn.example.com/transcript.json',
},
});
mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
segments: [{ text: 'hello' }, { text: 'world' }],
}));
const result = await cmd.func(null, {
id: 'ep123',
output: '/tmp/xiaoyuzhou-transcripts',
json: true,
text: true,
});
expect(mockRequestJson).toHaveBeenNthCalledWith(1, '/v1/episode/get', {
query: { eid: 'ep123' },
credentials: { access_token: 'access', refresh_token: 'refresh' },
});
expect(mockRequestJson).toHaveBeenNthCalledWith(2, '/v1/episode-transcript/get', {
method: 'POST',
body: { eid: 'ep123', mediaId: 'media-123' },
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
});
expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/xiaoyuzhou-transcripts/ep123', { recursive: true });
expect(mockWriteFileSync).toHaveBeenNthCalledWith(1, '/tmp/xiaoyuzhou-transcripts/ep123/transcript.json', expect.any(String), 'utf-8');
expect(mockWriteFileSync).toHaveBeenNthCalledWith(2, '/tmp/xiaoyuzhou-transcripts/ep123/transcript.txt', 'hello\nworld', 'utf-8');
expect(result).toEqual([{
title: 'Transcript Episode',
podcast: 'OpenCLI FM',
status: 'success',
segments: '2',
json_file: '/tmp/xiaoyuzhou-transcripts/ep123/transcript.json',
text_file: '/tmp/xiaoyuzhou-transcripts/ep123/transcript.txt',
}]);
});
it('derives mediaId from episode.media.id when transcript.mediaId is absent', async () => {
mockRequestJson
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
title: 'Transcript Episode',
podcast: { title: 'OpenCLI FM' },
media: { id: 'media-456' },
},
})
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
transcriptUrl: 'https://cdn.example.com/transcript.json',
},
});
mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({ text: 'hello' }));
await cmd.func(null, {
id: 'ep456',
output: '/tmp/xiaoyuzhou-transcripts',
json: false,
text: true,
});
expect(mockRequestJson.mock.calls[1][1].body.mediaId).toBe('media-456');
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/xiaoyuzhou-transcripts/ep456/transcript.txt', 'hello', 'utf-8');
});
it('throws when transcript url is missing', async () => {
mockRequestJson
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
title: 'Transcript Episode',
podcast: { title: 'OpenCLI FM' },
transcript: { mediaId: 'media-123' },
},
})
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {},
});
await expect(cmd.func(null, {
id: 'ep123',
output: '/tmp/xiaoyuzhou-transcripts',
json: true,
text: true,
})).rejects.toMatchObject({
code: 'EMPTY_RESULT',
message: 'Transcript URL not found',
});
expect(mockWriteFileSync).not.toHaveBeenCalled();
});
it('throws parse_error when transcript text extraction fails', async () => {
mockRequestJson
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
title: 'Transcript Episode',
podcast: { title: 'OpenCLI FM' },
transcript: { mediaId: 'media-123' },
},
})
.mockResolvedValueOnce({
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
data: {
transcriptUrl: 'https://cdn.example.com/transcript.json',
},
});
mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
segments: [{ startAt: 0, endAt: 1 }],
}));
await expect(cmd.func(null, {
id: 'ep123',
output: '/tmp/xiaoyuzhou-transcripts',
json: true,
text: true,
})).rejects.toMatchObject({
code: 'PARSE_ERROR',
message: 'Failed to extract transcript text',
});
expect(mockWriteFileSync).not.toHaveBeenCalled();
});
it('rejects disabling both json and text outputs', async () => {
await expect(cmd.func(null, {
id: 'ep123',
output: '/tmp/xiaoyuzhou-transcripts',
json: false,
text: false,
})).rejects.toMatchObject({
code: 'ARGUMENT',
message: 'At least one of --json or --text must be enabled',
});
expect(mockRequestJson).not.toHaveBeenCalled();
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* YouTube feed — homepage recommended videos.
* Reads ytInitialData from the homepage directly (personalized, no separate API call needed).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'feed',
description: 'Get YouTube homepage recommended videos',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Max videos to return (default 20, max 100)' },
],
columns: ['rank', 'title', 'channel', 'views', 'duration', 'published', 'url'],
func: async (page, kwargs) => {
const limit = Math.min(kwargs.limit || 20, 100);
await page.goto('https://www.youtube.com');
await page.wait(3);
const data = await page.evaluate(`
(async () => {
const d = window.ytInitialData;
if (!d) return { error: 'YouTube data not found — are you logged in?' };
const limit = ${limit};
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
function extractFromItem(item) {
// Modern lockupViewModel format
const lvm = item.richItemRenderer?.content?.lockupViewModel;
if (lvm && lvm.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO') {
const meta = lvm.metadata?.lockupMetadataViewModel;
const rows = meta?.metadata?.contentMetadataViewModel?.metadataRows || [];
const parts = rows.flatMap(r => (r.metadataParts || []).map(p => p.text?.content || '').filter(Boolean));
let duration = '';
for (const ov of (lvm.contentImage?.thumbnailViewModel?.overlays || [])) {
for (const b of (ov.thumbnailBottomOverlayViewModel?.badges || [])) {
if (b.thumbnailBadgeViewModel?.text) duration = b.thumbnailBadgeViewModel.text;
}
}
return {
title: meta?.title?.content || '',
channel: parts[0] || '',
views: parts[1] || '',
duration,
published: parts[2] || '',
videoId: lvm.contentId,
};
}
// Legacy videoRenderer format
const v = item.richItemRenderer?.content?.videoRenderer || item.videoRenderer;
if (v?.videoId) {
return {
title: v.title?.runs?.[0]?.text || '',
channel: v.ownerText?.runs?.[0]?.text || v.shortBylineText?.runs?.[0]?.text || '',
views: v.viewCountText?.simpleText || v.shortViewCountText?.simpleText || '',
duration: v.lengthText?.simpleText || '',
published: v.publishedTimeText?.simpleText || '',
videoId: v.videoId,
};
}
return null;
}
const tabs = d.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
const richContents = tabs[0]?.tabRenderer?.content?.richGridRenderer?.contents || [];
const videos = [];
for (const item of richContents) {
if (videos.length >= limit) break;
const v = extractFromItem(item);
if (v?.videoId) {
videos.push({ rank: videos.length + 1, ...v, url: 'https://www.youtube.com/watch?v=' + v.videoId });
}
}
// Pagination
if (videos.length < limit && apiKey && context) {
let contItem = richContents[richContents.length - 1];
while (videos.length < limit && contItem?.continuationItemRenderer) {
const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
if (!token) break;
const resp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context, continuation: token }),
});
if (!resp.ok) break;
const contData = await resp.json();
const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
if (!newItems.length) break;
for (const item of newItems) {
if (videos.length >= limit) break;
const v = extractFromItem(item);
if (v?.videoId) {
videos.push({ rank: videos.length + 1, ...v, url: 'https://www.youtube.com/watch?v=' + v.videoId });
}
}
contItem = newItems[newItems.length - 1];
}
}
return videos;
})()
`);
if (!Array.isArray(data)) {
const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
throw new CommandExecutionError(errMsg || 'Failed to fetch YouTube feed');
}
if (data.length === 0) {
throw new EmptyResultError('youtube feed');
}
return data;
},
});
+118
View File
@@ -0,0 +1,118 @@
/**
* YouTube history — watch history via InnerTube browse API (FEhistory).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'history',
description: 'Get YouTube watch history',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 30, help: 'Max videos to return (default 30, max 200)' },
],
columns: ['rank', 'title', 'channel', 'views', 'duration', 'url'],
func: async (page, kwargs) => {
const limit = Math.min(kwargs.limit || 30, 200);
await page.goto('https://www.youtube.com/feed/history');
await page.wait(3);
await page.autoScroll({ times: Math.min(Math.max(Math.ceil(limit / 20), 1), 8), delayMs: 1200 });
const data = await page.evaluate(`
(async () => {
const limit = ${limit};
const videos = [];
const seen = new Set();
const root = document.querySelector('ytd-two-column-browse-results-renderer #primary ytd-section-list-renderer');
if (!root) return { error: 'YouTube history list not found' };
function text(el) {
return (el?.textContent || '').replace(/\\s+/g, ' ').trim();
}
function push(entry) {
if (!entry?.url || seen.has(entry.url) || videos.length >= limit) return;
seen.add(entry.url);
videos.push({ rank: videos.length + 1, ...entry });
}
for (const section of root.querySelectorAll('ytd-item-section-renderer')) {
if (videos.length >= limit) break;
for (const renderer of section.querySelectorAll('yt-lockup-view-model, ytd-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer')) {
if (videos.length >= limit) break;
const link = renderer.querySelector('a[href^="/watch?v="]');
const href = link?.getAttribute('href') || '';
if (!href) continue;
const title =
link?.getAttribute('title')
|| text(renderer.querySelector('#video-title'))
|| text(renderer.querySelector('h3 a'))
|| text(renderer.querySelector('h3'))
|| text(link);
const channel =
text(renderer.querySelector('#channel-name a'))
|| text(renderer.querySelector('[aria-label^="前往频道:"]'))
|| text(renderer.querySelector('[aria-label^="Go to channel:"]'))
|| text(renderer.querySelector('ytd-channel-name'))
|| text(renderer.querySelector('#metadata #byline-container'))
|| '';
const metadata = Array.from(renderer.querySelectorAll('#metadata-line span, #metadata span, .metadata span'))
.map(node => text(node))
.filter(Boolean);
const lockupMetadata = Array.from(renderer.querySelectorAll('yt-content-metadata-view-model span, yt-lockup-metadata-view-model span'))
.map(node => text(node))
.filter(Boolean);
const combinedMetadata = (metadata.length ? metadata : lockupMetadata)
.filter(value => value && value !== title && value !== '•');
const inferredChannel = channel || combinedMetadata.find(value => !/观看|views|前|前に|ago|次观看|次查看|stream/i.test(value)) || '';
const inferredViews = combinedMetadata.find(value => /观看|views/i.test(value)) || '';
const inferredPublished = combinedMetadata.find(value => value !== inferredChannel && value !== inferredViews) || '';
const duration =
text(renderer.querySelector('ytd-thumbnail-overlay-time-status-renderer'))
|| text(renderer.querySelector('yt-thumbnail-badge-view-model'))
|| text(renderer.querySelector('badge-shape'))
|| '';
push({
title,
channel: inferredChannel,
views: inferredViews,
duration,
published: inferredPublished,
url: href.startsWith('http') ? href : 'https://www.youtube.com' + href,
});
}
for (const shortLink of section.querySelectorAll('a[href^="/shorts/"]')) {
if (videos.length >= limit) break;
const card = shortLink.closest('ytm-shorts-lockup-view-model-v2, ytm-shorts-lockup-view-model, ytd-reel-item-renderer') || shortLink.parentElement;
const href = shortLink.getAttribute('href') || '';
if (!href) continue;
const title = shortLink.getAttribute('title') || text(card?.querySelector('h3')) || text(shortLink);
const stats = Array.from(card?.querySelectorAll('span') || []).map(node => text(node)).filter(Boolean);
push({
title,
channel: 'Shorts',
views: stats.find(value => /观看|views/i.test(value)) || '',
duration: 'SHORT',
published: '',
url: href.startsWith('http') ? href : 'https://www.youtube.com' + href,
});
}
}
return videos.length ? videos : { error: 'No watch history items found on youtube.com/feed/history' };
})()
`);
if (!Array.isArray(data)) {
const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
throw new CommandExecutionError(errMsg || 'Failed to fetch watch history — make sure you are logged into YouTube');
}
if (data.length === 0) {
throw new EmptyResultError('youtube history');
}
return data;
},
});
+62
View File
@@ -0,0 +1,62 @@
/**
* YouTube like — like a video via InnerTube like API (requires SAPISIDHASH auth).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'like',
description: 'Like a YouTube video',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'url', required: true, positional: true, help: 'YouTube video URL or video ID' },
],
columns: ['status', 'message'],
func: async (page, kwargs) => {
const videoId = parseVideoId(String(kwargs.url));
await prepareYoutubeApiPage(page);
const result = await page.evaluate(`
(async () => {
${SAPISID_HASH_FN}
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
const authHash = await getSapisidHash('https://www.youtube.com');
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
const resp = await fetch('/youtubei/v1/like/like?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': authHash,
'X-Origin': 'https://www.youtube.com',
},
body: JSON.stringify({ context, target: { videoId: ${JSON.stringify(videoId)} } }),
});
if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const errStatus = body?.error?.status || '';
if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
}
return { ok: true };
})()
`);
if (result?.error === 'auth') {
throw new AuthRequiredError('www.youtube.com');
}
if (result?.error) {
throw new CommandExecutionError(result.message || 'Failed to like video');
}
return [{ status: 'success', message: 'Liked: ' + videoId }];
},
});
+97
View File
@@ -0,0 +1,97 @@
/**
* YouTube playlist — get playlist info and video list via InnerTube browse API.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { prepareYoutubeApiPage, FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
/**
* Parse a playlist ID from a URL or bare ID string.
*/
function parsePlaylistId(input) {
if (!input.startsWith('http'))
return input;
try {
const url = new URL(input);
return url.searchParams.get('list') || input;
}
catch {
return input;
}
}
cli({
site: 'youtube',
name: 'playlist',
description: 'Get YouTube playlist info and video list',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'id', required: true, positional: true, help: 'Playlist URL or playlist ID (PLxxxxxx)' },
{ name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' },
],
columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'],
func: async (page, kwargs) => {
const playlistId = parsePlaylistId(String(kwargs.id));
const limit = Math.min(kwargs.limit || 50, 200);
await prepareYoutubeApiPage(page);
const data = await page.evaluate(`
(async () => {
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
if (!apiKey || !context) return { error: 'YouTube config not found' };
const browseId = 'VL' + ${JSON.stringify(playlistId)};
const limit = ${limit};
${FETCH_BROWSE_FN}
const data = await fetchBrowse(apiKey, { context, browseId });
if (data.error) return data;
const header = data.header?.pageHeaderRenderer;
const title = header?.pageTitle || '';
const metaRows = header?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows || [];
const stats = metaRows.flatMap(r => (r.metadataParts || []).map(p => p.text?.content || '').filter(Boolean));
const sidebarItems = data.sidebar?.playlistSidebarRenderer?.items || [];
const secondaryInfo = sidebarItems.find(i => i.playlistSidebarSecondaryInfoRenderer)?.playlistSidebarSecondaryInfoRenderer;
const channelName = secondaryInfo?.videoOwner?.videoOwnerRenderer?.title?.runs?.[0]?.text || '';
const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || [];
const extractVideos = ${extractPlaylistVideos.toString()};
let videos = extractVideos(listContents);
let contItem = listContents[listContents.length - 1];
while (videos.length < limit && contItem?.continuationItemRenderer) {
const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
if (!token) break;
const contData = await fetchBrowse(apiKey, { context, continuation: token });
if (contData.error) break;
const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
if (!newItems.length) break;
videos = videos.concat(extractVideos(newItems));
contItem = newItems[newItems.length - 1];
}
return { title, channelName, stats, videos: videos.slice(0, limit) };
})()
`);
if (!data || typeof data !== 'object') {
throw new CommandExecutionError('Failed to fetch playlist data');
}
if (data.error) {
throw new CommandExecutionError(String(data.error));
}
if (!data.videos?.length) {
throw new EmptyResultError('youtube playlist');
}
const statsStr = (data.stats || []).join(' | ');
process.stderr.write(`${data.title} [${data.channelName}] ${statsStr}\n`);
return data.videos;
},
});
+71
View File
@@ -0,0 +1,71 @@
/**
* YouTube subscribe — subscribe to a channel via InnerTube subscription API.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'subscribe',
description: 'Subscribe to a YouTube channel',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'channel', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' },
],
columns: ['status', 'message'],
func: async (page, kwargs) => {
const channelInput = String(kwargs.channel);
await prepareYoutubeApiPage(page);
const result = await page.evaluate(`
(async () => {
${SAPISID_HASH_FN}
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
const authHash = await getSapisidHash('https://www.youtube.com');
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
${RESOLVE_CHANNEL_HANDLE_FN}
let channelId = ${JSON.stringify(channelInput)};
channelId = await resolveChannelHandle(channelId, apiKey, context);
if (!channelId.startsWith('UC')) {
return { error: 'arg', message: 'Could not resolve channel ID from: ' + ${JSON.stringify(channelInput)} };
}
const resp = await fetch('/youtubei/v1/subscription/subscribe?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': authHash,
'X-Origin': 'https://www.youtube.com',
},
body: JSON.stringify({ context, channelIds: [channelId] }),
});
if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const errStatus = body?.error?.status || '';
if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
}
return { ok: true, channelId };
})()
`);
if (result?.error === 'auth') {
throw new AuthRequiredError('www.youtube.com');
}
if (result?.error) {
throw new CommandExecutionError(result.message || 'Failed to subscribe');
}
return [{ status: 'success', message: 'Subscribed to: ' + (result.channelId || channelInput) }];
},
});
+57
View File
@@ -0,0 +1,57 @@
/**
* YouTube subscriptions — list of subscribed channels from /feed/channels.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { extractSubscriptionChannel } from './utils.js';
cli({
site: 'youtube',
name: 'subscriptions',
description: 'List subscribed YouTube channels',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 50, help: 'Max channels to return (default 50)' },
],
columns: ['rank', 'name', 'handle', 'subscribers', 'url'],
func: async (page, kwargs) => {
const limit = Math.min(kwargs.limit || 50, 1000);
await page.goto('https://www.youtube.com/feed/channels');
await page.wait(3);
const data = await page.evaluate(`
(async () => {
const d = window.ytInitialData;
if (!d) return { error: 'YouTube data not found — are you logged in?' };
const limit = ${limit};
const items = d.contents?.twoColumnBrowseResultsRenderer
?.tabs?.[0]?.tabRenderer?.content
?.sectionListRenderer?.contents?.[0]
?.itemSectionRenderer?.contents?.[0]
?.shelfRenderer?.content
?.expandedShelfContentsRenderer?.items || [];
const extractChannel = ${extractSubscriptionChannel.toString()};
const channels = [];
for (const item of items) {
if (channels.length >= limit) break;
const ch = extractChannel(item.channelRenderer);
if (ch?.name) channels.push(ch);
}
return channels;
})()
`);
if (!Array.isArray(data)) {
const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
throw new CommandExecutionError(errMsg || 'Failed to fetch subscriptions — make sure you are logged into YouTube');
}
if (data.length === 0) {
throw new EmptyResultError('youtube subscriptions');
}
return data.map((ch, i) => ({ rank: i + 1, ...ch }));
},
});
+62
View File
@@ -0,0 +1,62 @@
/**
* YouTube unlike — remove like from a video via InnerTube like API.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'unlike',
description: 'Remove like from a YouTube video',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'url', required: true, positional: true, help: 'YouTube video URL or video ID' },
],
columns: ['status', 'message'],
func: async (page, kwargs) => {
const videoId = parseVideoId(String(kwargs.url));
await prepareYoutubeApiPage(page);
const result = await page.evaluate(`
(async () => {
${SAPISID_HASH_FN}
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
const authHash = await getSapisidHash('https://www.youtube.com');
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
const resp = await fetch('/youtubei/v1/like/removelike?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': authHash,
'X-Origin': 'https://www.youtube.com',
},
body: JSON.stringify({ context, target: { videoId: ${JSON.stringify(videoId)} } }),
});
if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const errStatus = body?.error?.status || '';
if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
}
return { ok: true };
})()
`);
if (result?.error === 'auth') {
throw new AuthRequiredError('www.youtube.com');
}
if (result?.error) {
throw new CommandExecutionError(result.message || 'Failed to remove like');
}
return [{ status: 'success', message: 'Unliked: ' + videoId }];
},
});
+71
View File
@@ -0,0 +1,71 @@
/**
* YouTube unsubscribe — unsubscribe from a channel via InnerTube subscription API.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'unsubscribe',
description: 'Unsubscribe from a YouTube channel',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'channel', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' },
],
columns: ['status', 'message'],
func: async (page, kwargs) => {
const channelInput = String(kwargs.channel);
await prepareYoutubeApiPage(page);
const result = await page.evaluate(`
(async () => {
${SAPISID_HASH_FN}
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
const authHash = await getSapisidHash('https://www.youtube.com');
if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
${RESOLVE_CHANNEL_HANDLE_FN}
let channelId = ${JSON.stringify(channelInput)};
channelId = await resolveChannelHandle(channelId, apiKey, context);
if (!channelId.startsWith('UC')) {
return { error: 'arg', message: 'Could not resolve channel ID from: ' + ${JSON.stringify(channelInput)} };
}
const resp = await fetch('/youtubei/v1/subscription/unsubscribe?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': authHash,
'X-Origin': 'https://www.youtube.com',
},
body: JSON.stringify({ context, channelIds: [channelId] }),
});
if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const errStatus = body?.error?.status || '';
if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
}
return { ok: true, channelId };
})()
`);
if (result?.error === 'auth') {
throw new AuthRequiredError('www.youtube.com');
}
if (result?.error) {
throw new CommandExecutionError(result.message || 'Failed to unsubscribe');
}
return [{ status: 'success', message: 'Unsubscribed from: ' + (result.channelId || channelInput) }];
},
});
+122
View File
@@ -90,3 +90,125 @@ export async function prepareYoutubeApiPage(page) {
await page.goto('https://www.youtube.com', { waitUntil: 'none' });
await page.wait(2);
}
/**
* Inline InnerTube browse API helper for use inside page.evaluate() strings.
* Inject via FETCH_BROWSE_FN, then call: fetchBrowse(apiKey, body)
*/
export const FETCH_BROWSE_FN = `
async function fetchBrowse(apiKey, body) {
const resp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) return { error: 'InnerTube browse API returned HTTP ' + resp.status };
return resp.json();
}
`;
/**
* Extract video objects from playlistVideoRenderer items (playlists, watch-later).
* Pure function — inject into page.evaluate() via: extractPlaylistVideos.toString()
*/
export function extractPlaylistVideos(items) {
return items
.filter(i => i.playlistVideoRenderer)
.map(i => {
const v = i.playlistVideoRenderer;
const infoRuns = v.videoInfo?.runs || [];
return {
rank: parseInt(v.index?.simpleText || '0', 10),
title: v.title?.runs?.[0]?.text || '',
channel: v.shortBylineText?.runs?.[0]?.text || '',
duration: v.lengthText?.simpleText || '',
views: infoRuns[0]?.text || '',
published: infoRuns[2]?.text || '',
url: 'https://www.youtube.com/watch?v=' + v.videoId,
};
});
}
/**
* Normalize a subscribed channel entry from YouTube's channelRenderer payload.
* Different surfaces/locales may expose the handle in channelHandleText, canonicalBaseUrl,
* or, in some variants, overload one of the count fields with an @handle string.
*/
export function extractSubscriptionChannel(channelRenderer) {
const readText = (value) => {
if (!value)
return '';
if (typeof value.simpleText === 'string')
return value.simpleText.trim();
if (Array.isArray(value.runs)) {
return value.runs
.map((run) => run?.text || '')
.join('')
.trim();
}
return '';
};
const ch = channelRenderer || {};
const name = readText(ch.title);
const baseUrl = ch.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl || '';
const channelId = ch.channelId || ch.navigationEndpoint?.browseEndpoint?.browseId || '';
const subscriberCountText = readText(ch.subscriberCountText);
const videoCountText = readText(ch.videoCountText);
const handle = [
readText(ch.channelHandleText),
baseUrl.startsWith('/@') ? baseUrl.slice(1) : '',
subscriberCountText.startsWith('@') ? subscriberCountText : '',
videoCountText.startsWith('@') ? videoCountText : '',
].find(Boolean) || '';
const subscribers = [
!subscriberCountText.startsWith('@') ? subscriberCountText : '',
!videoCountText.startsWith('@') ? videoCountText : '',
].find(Boolean) || '';
const url = baseUrl
? 'https://www.youtube.com' + baseUrl
: channelId ? 'https://www.youtube.com/channel/' + channelId : '';
return { name, handle, subscribers, url };
}
/**
* Inline @handle → channelId resolver for use inside page.evaluate() strings.
* Inject via RESOLVE_CHANNEL_HANDLE_FN, then call: resolveChannelHandle(input, apiKey, context)
*/
export const RESOLVE_CHANNEL_HANDLE_FN = `
async function resolveChannelHandle(input, apiKey, context) {
if (!input.startsWith('@')) return input;
const resp = await fetch('/youtubei/v1/navigation/resolve_url?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context, url: 'https://www.youtube.com/' + input }),
});
if (!resp.ok) return input;
const data = await resp.json().catch(() => ({}));
return data.endpoint?.browseEndpoint?.browseId || input;
}
`;
/**
* Inline SAPISIDHASH helper for use inside page.evaluate() strings.
* YouTube write APIs (like, subscribe) require:
* Authorization: SAPISIDHASH {time}_{SHA1(time + " " + SAPISID + " " + origin)}
*/
export const SAPISID_HASH_FN = `
async function getSapisidHash(origin) {
const cookies = document.cookie.split('; ');
let sapisid = '';
for (const c of cookies) {
const eq = c.indexOf('=');
if (eq === -1) continue;
const name = c.slice(0, eq);
const val = c.slice(eq + 1);
if (name === '__Secure-3PAPISID' || name === 'SAPISID') {
sapisid = val;
if (name === '__Secure-3PAPISID') break;
}
}
if (!sapisid) return null;
const time = Math.floor(Date.now() / 1000);
const msgBuffer = new TextEncoder().encode(time + ' ' + sapisid + ' ' + origin);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'SAPISIDHASH ' + time + '_' + hashHex;
}
`;
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { extractJsonAssignmentFromHtml, prepareYoutubeApiPage } from './utils.js';
import { extractJsonAssignmentFromHtml, extractSubscriptionChannel, prepareYoutubeApiPage } from './utils.js';
describe('youtube utils', () => {
it('extractJsonAssignmentFromHtml parses bootstrap objects with nested braces in strings', () => {
const html = `
@@ -34,4 +34,35 @@ describe('youtube utils', () => {
expect(page.goto).toHaveBeenCalledWith('https://www.youtube.com', { waitUntil: 'none' });
expect(page.wait).toHaveBeenCalledWith(2);
});
it('extractSubscriptionChannel prefers explicit handle and subscriber count fields', () => {
expect(extractSubscriptionChannel({
title: { simpleText: 'OpenAI' },
channelHandleText: { runs: [{ text: '@openai' }] },
subscriberCountText: { simpleText: '1.23M subscribers' },
videoCountText: { simpleText: '123 videos' },
navigationEndpoint: { browseEndpoint: { canonicalBaseUrl: '/channel/UC123' } },
channelId: 'UC123',
})).toEqual({
name: 'OpenAI',
handle: '@openai',
subscribers: '1.23M subscribers',
url: 'https://www.youtube.com/channel/UC123',
});
});
it('extractSubscriptionChannel falls back when handle/count fields are overloaded', () => {
expect(extractSubscriptionChannel({
title: {
runs: [{ text: 'OpenAI' }],
},
subscriberCountText: { simpleText: '@openai' },
videoCountText: { simpleText: '1.23M subscribers' },
navigationEndpoint: { browseEndpoint: { canonicalBaseUrl: '/@openai' } },
channelId: 'UC123',
})).toEqual({
name: 'OpenAI',
handle: '@openai',
subscribers: '1.23M subscribers',
url: 'https://www.youtube.com/@openai',
});
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* YouTube watch-later — the user's Watch Later queue.
* Navigates to /playlist?list=WL and reads ytInitialData directly.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
cli({
site: 'youtube',
name: 'watch-later',
description: 'Get your YouTube Watch Later queue',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' },
],
columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'],
func: async (page, kwargs) => {
const limit = Math.min(kwargs.limit || 50, 200);
await page.goto('https://www.youtube.com/playlist?list=WL');
await page.wait(3);
const data = await page.evaluate(`
(async () => {
const d = window.ytInitialData;
if (!d) return { error: 'YouTube data not found — are you logged in?' };
const limit = ${limit};
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
const context = cfg.INNERTUBE_CONTEXT;
const header = d.header?.playlistHeaderRenderer;
const title = header?.title?.simpleText || 'Watch Later';
const stats = (header?.stats || [])
.map(s => s.runs?.map(r => r.text)?.join('') || s.simpleText || '')
.filter(Boolean);
const tabs = d.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || [];
${FETCH_BROWSE_FN}
const extractVideos = ${extractPlaylistVideos.toString()};
let videos = extractVideos(listContents);
let contItem = listContents[listContents.length - 1];
while (videos.length < limit && contItem?.continuationItemRenderer && apiKey && context) {
const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
if (!token) break;
const contData = await fetchBrowse(apiKey, { context, continuation: token });
if (contData.error) break;
const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
if (!newItems.length) break;
videos = videos.concat(extractVideos(newItems));
contItem = newItems[newItems.length - 1];
}
return { title, stats, videos: videos.slice(0, limit) };
})()
`);
if (!data || typeof data !== 'object') {
throw new CommandExecutionError('Failed to fetch Watch Later — make sure you are logged into YouTube');
}
if (data.error) {
throw new CommandExecutionError(String(data.error));
}
if (!data.videos?.length) {
throw new EmptyResultError('youtube watch-later');
}
const statsStr = (data.stats || []).join(' | ');
process.stderr.write(`${data.title} ${statsStr}\n`);
return data.videos;
},
});
+2
View File
@@ -92,11 +92,13 @@ export default defineConfig({
{ text: 'Instagram', link: '/adapters/browser/instagram' },
{ text: 'JD.com', link: '/adapters/browser/jd' },
{ text: 'Medium', link: '/adapters/browser/medium' },
{ text: 'Mubu', link: '/adapters/browser/mubu' },
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
{ text: 'Weixin', link: '/adapters/browser/weixin' },
{ text: 'Xianyu', link: '/adapters/browser/xianyu' },
{ text: 'Quark', link: '/adapters/browser/quark' },
{ text: 'Uiverse', link: '/adapters/browser/uiverse' },
],
},
{
+8 -1
View File
@@ -9,7 +9,7 @@
| `opencli bilibili hot` | |
| `opencli bilibili search` | |
| `opencli bilibili me` | |
| `opencli bilibili favorite` | |
| `opencli bilibili favorite` | Read your first favorite folder, or a specific folder with `--fid` |
| `opencli bilibili history` | |
| `opencli bilibili feed` | Read the following feed, or a specific user's dynamics by uid/name |
| `opencli bilibili feed-detail` | Read one dynamic in detail, including exclusive content |
@@ -32,6 +32,12 @@ opencli bilibili search 黑神话 --limit 10
# Read one creator's videos
opencli bilibili user-videos 2 --limit 10
# Read your first favorite folder
opencli bilibili favorite --limit 10
# Read a specific favorite folder
opencli bilibili favorite --fid 123456789 --limit 10
# Read following feed
opencli bilibili feed --limit 10
@@ -63,4 +69,5 @@ opencli bilibili hot -v
- `opencli bilibili feed` without `uid` reads your following feed
- `opencli bilibili feed <uid-or-name>` reads a specific user's dynamics
- `opencli bilibili favorite` defaults to the first favorite folder when `--fid` is omitted
- `feed-detail` expects the dynamic ID from a `https://t.bilibili.com/<id>` URL
@@ -6,19 +6,19 @@
| Command | Description |
|---------|-------------|
| `opencli chatgptweb image <prompt>` | Generate images in ChatGPT web and optionally save them locally |
| `opencli chatgpt image <prompt>` | Generate images in ChatGPT web and optionally save them locally |
## Usage Examples
```bash
# Generate an image and save it to the default directory
opencli chatgptweb image "a cyberpunk city at night"
opencli chatgpt image "a cyberpunk city at night"
# Save to a custom output directory
opencli chatgptweb image "a robot sketching on paper" --op ~/Downloads/chatgpt-images
opencli chatgpt image "a robot sketching on paper" --op ~/Downloads/chatgpt-images
# Only generate in ChatGPT and print the conversation link
opencli chatgptweb image "a tiny watercolor fox" --sd true
opencli chatgpt image "a tiny watercolor fox" --sd true
```
## Options
+7 -1
View File
@@ -31,9 +31,13 @@ opencli douban search --type music "周杰伦"
# 电影 Top 250
opencli douban top250 --limit 10
# 条目详情
# 电影详情
opencli douban subject 1292052
# 图书详情
opencli douban subject 2567698 --type book
opencli douban subject 2567698 --type book -f json
# 获取海报直链(默认 type=Rb)
opencli douban photos 30382501 --limit 20
@@ -60,3 +64,5 @@ opencli douban top250 -f json
- Chrome logged into `douban.com`
- Browser Bridge extension installed
图书搜索和图书详情在稳定批量使用时默认需要已登录的豆瓣浏览器会话。
+10
View File
@@ -8,6 +8,7 @@
|---------|-------------|
| `opencli grok ask` | Keep the default Grok ask behavior |
| `opencli grok ask --web` | Use the explicit grok.com consumer web UI flow |
| `opencli grok image` | Generate images via the Grok web UI and return the latest image URLs |
## Usage Examples
@@ -23,6 +24,12 @@ opencli grok ask --prompt "Hello" --web --new
# Set custom timeout (default: 120s)
opencli grok ask --prompt "Write a long essay" --web --timeout 180
# Generate an image and return the URLs
opencli grok image "a cyberpunk mechanical owl, neon purple and blue" --new true
# Save generated images to disk
opencli grok image "a watercolor lighthouse on a cliff" --out /tmp/grok-img --timeout 300
```
### Options
@@ -33,12 +40,15 @@ opencli grok ask --prompt "Write a long essay" --web --timeout 180
| `--timeout` | Wait timeout in seconds (default: 120) |
| `--new` | Start a new chat before sending (default: false) |
| `--web` | Opt into the explicit grok.com consumer web flow (default: false) |
| `--count` | Minimum images to wait for before returning (default: 1, `image` only) |
| `--out` | Directory to save generated images to disk (`image` only) |
## Behavior
- `opencli grok ask` keeps the upstream/default behavior intact.
- `opencli grok ask --web` switches to the newer hardened consumer-web implementation.
- The `--web` path adds stricter composer detection, clearer blocked/session-gated hints, and waits for a stabilized assistant bubble before returning.
- `opencli grok image` reuses the existing browser-backed Grok session, waits for the latest assistant image bubble to stabilize, and can optionally download the resulting images through the authenticated page context.
## Prerequisites
+61
View File
@@ -0,0 +1,61 @@
# 幕布 (Mubu)
**Mode**: 🔐 Browser · **Domain**: `mubu.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli mubu doc` | 读取文档内容(Markdown / 纯文本) |
| `opencli mubu docs` | 列出文档和文件夹 |
| `opencli mubu notes` | 读取速记(今日 / 指定日期范围) |
| `opencli mubu recent` | 最近编辑的文档 |
| `opencli mubu search` | 全文搜索文档节点 |
## Usage Examples
```bash
# Read a document in Markdown (default)
opencli mubu doc <doc-id>
# Read a document as plain text
opencli mubu doc <doc-id> --output text
# List documents in root folder
opencli mubu docs
# List starred (quick-access) documents
opencli mubu docs --starred
# List documents in a specific folder
opencli mubu docs --folder <folder-id>
# Read today's daily notes
opencli mubu notes
# Read notes for a specific date
opencli mubu notes --date 2026-04-10
# Read notes for an entire month
opencli mubu notes --month 2026-04
# List note dates with entry counts (no content)
opencli mubu notes --list --month 2026-04
# Read notes for a custom date range
opencli mubu notes --from 2026-01-01 --to 2026-03-31
# Show recently edited documents
opencli mubu recent --limit 10
# Full-text search
opencli mubu search "关键词"
# JSON output
opencli mubu docs -f json
```
## Prerequisites
- Chrome running and **logged into** mubu.com
- [Browser Bridge extension](/guide/browser-bridge) installed
+2
View File
@@ -19,6 +19,8 @@
| `opencli twitter reply` | |
| `opencli twitter delete` | |
| `opencli twitter like` | |
| `opencli twitter likes` | |
| `opencli twitter lists` | |
| `opencli twitter article` | |
| `opencli twitter follow` | |
| `opencli twitter unfollow` | |
+52
View File
@@ -0,0 +1,52 @@
# Uiverse
**Mode**: 🔐 Browser · **Domain**: `uiverse.io`
## Commands
| Command | Description |
|---------|-------------|
| `opencli uiverse code <input> --target html` | Export the raw component HTML |
| `opencli uiverse code <input> --target css` | Export the raw component CSS |
| `opencli uiverse code <input> --target react` | Export the React version shown in the Export dialog |
| `opencli uiverse code <input> --target vue` | Export the Vue single-file component shown in the Export dialog |
| `opencli uiverse preview <input>` | Capture only the component preview element, not the full page |
## Input Format
`<input>` supports two forms:
- Full URL: `https://uiverse.io/Galahhad/strong-squid-82`
- Short form: `Galahhad/strong-squid-82`
## Usage Examples
```bash
# Export HTML
opencli uiverse code "Galahhad/strong-squid-82" --target html -f json
# Export CSS
opencli uiverse code "Galahhad/strong-squid-82" --target css -f json
# Export React
opencli uiverse code "Galahhad/strong-squid-82" --target react -f json
# Export Vue
opencli uiverse code "Galahhad/strong-squid-82" --target vue -f json
# Capture only the preview element
opencli uiverse preview "Galahhad/strong-squid-82" --output ./uiverse-preview.png -f json
```
## Notes
- The `code` command resolves the component `post.id` from the detail page, then reads page loader data or the backing data endpoint.
- `react` and `vue` exports depend on the page's Export dialog, so they require Browser Bridge and a working browser session.
- `preview` uses the component HTML root signature plus visible-page heuristics to crop the preview element only.
- If `--output` is omitted, `preview` writes the PNG to a system temporary path.
- `--padding` defaults to `8` pixels and adds extra space around the cropped component.
## Prerequisites
- Chrome running
- [Browser Bridge extension](/guide/browser-bridge) installed
+4 -1
View File
@@ -38,9 +38,12 @@ opencli xiaohongshu search 旅行 -f json
# Other commands
opencli xiaohongshu feed
opencli xiaohongshu notifications
opencli xiaohongshu download <note-id or url>
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..."
opencli xiaohongshu download "https://xhslink.com/..."
```
> Note: `note` and `comments` now require a full signed note URL with `xsec_token`. `download` accepts either a signed note URL or an `xhslink` short link. Bare note IDs are no longer reliable on xiaohongshu.
## Prerequisites
- Chrome running and **logged into** xiaohongshu.com
+32 -5
View File
@@ -9,20 +9,47 @@
| `opencli xiaoyuzhou podcast` | |
| `opencli xiaoyuzhou podcast-episodes` | |
| `opencli xiaoyuzhou episode` | |
| `opencli xiaoyuzhou download` | Download episode audio |
| `opencli xiaoyuzhou transcript` | Download transcript JSON and extracted text (requires local credentials) |
## Usage Examples
```bash
# Quick start
opencli xiaoyuzhou podcast --limit 5
# Podcast profile
opencli xiaoyuzhou podcast 6013f9f58e2f7ee375cf4216
# Recent episodes
opencli xiaoyuzhou podcast-episodes 6013f9f58e2f7ee375cf4216 --limit 5
# Episode details
opencli xiaoyuzhou episode 69b3b675772ac2295bfc01d0
# Download episode audio
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
# Download transcript JSON + text
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
# JSON output
opencli xiaoyuzhou podcast -f json
opencli xiaoyuzhou episode 69b3b675772ac2295bfc01d0 -f json
# Verbose mode
opencli xiaoyuzhou podcast -v
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 -v
```
## Prerequisites
- No browser required — uses public API
- No browser required — uses public episode pages
- `transcript` requires local Xiaoyuzhou app credentials in `~/.opencli/xiaoyuzhou.json`
Example credential file:
```json
{
"access_token": "your-access-token",
"refresh_token": "your-refresh-token",
"device_id": "81ADBFD6-6921-482B-9AB9-A29E7CC7BB55",
"device_properties": "",
"expires_at": 0
}
```
+28 -9
View File
@@ -6,21 +6,40 @@
| Command | Description |
|---------|-------------|
| `opencli youtube search` | |
| `opencli youtube video` | |
| `opencli youtube transcript` | |
| `opencli youtube search` | Search videos |
| `opencli youtube video` | Get video metadata |
| `opencli youtube transcript` | Get video transcript/subtitles |
| `opencli youtube comments` | Get video comments |
| `opencli youtube channel` | Get channel info and videos |
| `opencli youtube playlist` | Get playlist video list |
| `opencli youtube feed` | Homepage recommended videos |
| `opencli youtube history` | Watch history |
| `opencli youtube watch-later` | Watch Later queue |
| `opencli youtube subscriptions` | List subscribed channels |
| `opencli youtube like` | Like a video |
| `opencli youtube unlike` | Remove like from a video |
| `opencli youtube subscribe` | Subscribe to a channel |
| `opencli youtube unsubscribe` | Unsubscribe from a channel |
## Usage Examples
```bash
# Quick start
opencli youtube search --limit 5
# Read commands
opencli youtube feed --limit 10
opencli youtube history --limit 20
opencli youtube watch-later --limit 50
opencli youtube subscriptions --limit 30
# JSON output
opencli youtube search -f json
# Search and video info
opencli youtube search "rust programming" --limit 5
opencli youtube video "https://www.youtube.com/watch?v=xxx"
opencli youtube transcript "https://www.youtube.com/watch?v=xxx"
# Verbose mode
opencli youtube search -v
# Write commands (requires login)
opencli youtube like "https://www.youtube.com/watch?v=xxx"
opencli youtube unlike "videoId"
opencli youtube subscribe "@ChannelHandle"
opencli youtube unsubscribe "UCxxxxxxxxxxxxxx"
```
## Prerequisites
@@ -1,4 +1,4 @@
# ChatGPT
# ChatGPT App
Control the **ChatGPT macOS Desktop App** directly from the terminal. OpenCLI supports two automation approaches for ChatGPT.
@@ -11,14 +11,14 @@ The current built-in commands use native AppleScript automation — no extra lau
2. Grant **Accessibility permissions** to your terminal app in **System Settings → Privacy & Security → Accessibility**.
### Commands
- `opencli chatgpt status`: Check if the ChatGPT app is currently running.
- `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
- `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
- `opencli chatgpt send "message" --model thinking`: Switch model/mode first, then send the message.
- `opencli chatgpt read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree.
- `opencli chatgpt ask "message"`: Send a prompt and wait for the visible reply in one shot.
- `opencli chatgpt ask "message" --model instant`: Run a one-shot prompt using a specific model/mode.
- `opencli chatgpt model thinking`: Switch the active ChatGPT model/mode without sending a message.
- `opencli chatgpt-app status`: Check if the ChatGPT app is currently running.
- `opencli chatgpt-app new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
- `opencli chatgpt-app send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
- `opencli chatgpt-app send "message" --model thinking`: Switch model/mode first, then send the message.
- `opencli chatgpt-app read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree.
- `opencli chatgpt-app ask "message"`: Send a prompt and wait for the visible reply in one shot.
- `opencli chatgpt-app ask "message" --model instant`: Run a one-shot prompt using a specific model/mode.
- `opencli chatgpt-app model thinking`: Switch the active ChatGPT model/mode without sending a message.
Supported model choices: `auto`, `instant`, `thinking`, `5.2-instant`, `5.2-thinking`.
+7 -5
View File
@@ -6,7 +6,7 @@ Run `opencli list` for the live registry.
| Site | Commands | Mode |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| **[twitter](./browser/twitter.md)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
| **[twitter](./browser/twitter.md)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `lists` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
| **[reddit](./browser/reddit.md)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
| **[tieba](./browser/tieba.md)** | `hot` `posts` `search` `read` | 🔐 Browser |
| **[hupu](./browser/hupu.md)** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 🌐 / 🔐 |
@@ -15,7 +15,7 @@ Run `opencli list` for the live registry.
| **[xiaohongshu](./browser/xiaohongshu.md)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xiaoe](./browser/xiaoe.md)** | `courses` `detail` `catalog` `play-url` `content` | 🔐 Browser |
| **[xueqiu](./browser/xueqiu.md)** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser |
| **[youtube](./browser/youtube.md)** | `search` `video` `transcript` | 🔐 Browser |
| **[youtube](./browser/youtube.md)** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 🔐 Browser |
| **[v2ex](./browser/v2ex.md)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[bloomberg](./browser/bloomberg.md)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
| **[weibo](./browser/weibo.md)** | `hot` `search` `feed` `user` `me` `post` `comments` | 🔐 Browser |
@@ -31,7 +31,7 @@ Run `opencli list` for the live registry.
| **[yollomi](./browser/yollomi.md)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
| **[linux-do](./browser/linux-do.md)** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` | 🔐 Browser |
| **[chaoxing](./browser/chaoxing.md)** | `assignments` `exams` | 🔐 Browser |
| **[grok](./browser/grok.md)** | `ask` | 🔐 Browser |
| **[grok](./browser/grok.md)** | `ask` `image` | 🔐 Browser |
| **[gemini](./browser/gemini.md)** | `new` `ask` `image` `deep-research` `deep-research-result` | 🔐 Browser |
| **[maimai](./browser/maimai.md)** | `search-talents` | 🔐 Browser |
| **[yuanbao](./browser/yuanbao.md)** | `new` `ask` | 🔐 Browser |
@@ -43,6 +43,7 @@ Run `opencli list` for the live registry.
| **[imdb](./browser/imdb.md)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 |
| **[instagram](./browser/instagram.md)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
| **[medium](./browser/medium.md)** | `feed` `search` `user` | 🔐 Browser |
| **[mubu](./browser/mubu.md)** | `doc` `docs` `notes` `recent` `search` | 🔐 Browser |
| **[sinablog](./browser/sinablog.md)** | `hot` `search` `article` `user` | 🔐 Browser |
| **[substack](./browser/substack.md)** | `feed` `search` `publication` | 🔐 Browser |
| **[pixiv](./browser/pixiv.md)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser |
@@ -63,6 +64,7 @@ Run `opencli list` for the live registry.
| **[douyin](./browser/douyin.md)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser |
| **[xianyu](./browser/xianyu.md)** | `search` `item` `chat` | 🔐 Browser |
| **[quark](./browser/quark.md)** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 🔐 Browser |
| **[uiverse](./browser/uiverse.md)** | `code` `preview` | 🔐 Browser |
## Public API Adapters
@@ -73,7 +75,7 @@ Run `opencli list` for the live registry.
| **[devto](./browser/devto.md)** | `top` `tag` `user` | 🌐 Public |
| **[dictionary](./browser/dictionary.md)** | `search` `synonyms` `examples` | 🌐 Public |
| **[apple-podcasts](./browser/apple-podcasts.md)** | `search` `episodes` `top` | 🌐 Public |
| **[xiaoyuzhou](./browser/xiaoyuzhou.md)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **[xiaoyuzhou](./browser/xiaoyuzhou.md)** | `podcast` `podcast-episodes` `episode` `download` `transcript` (local credentials required) | 🌐 Public |
| **[yahoo-finance](./browser/yahoo-finance.md)** | `quote` | 🌐 Public |
| **[arxiv](./browser/arxiv.md)** | `search` `paper` | 🌐 Public |
| **[paperreview](./browser/paperreview.md)** | `submit` `review` `feedback` | 🌐 Public |
@@ -95,7 +97,7 @@ Run `opencli list` for the live registry.
| **[Cursor](./desktop/cursor.md)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
| **[Codex](./desktop/codex.md)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` |
| **[Antigravity](./desktop/antigravity.md)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
| **[ChatGPT](./desktop/chatgpt.md)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` `model` |
| **[ChatGPT App](./desktop/chatgpt-app.md)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` `model` |
| **[ChatWise](./desktop/chatwise.md)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
| **[Notion](./desktop/notion.md)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
| **[Discord](./desktop/discord.md)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
+11 -1
View File
@@ -10,6 +10,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
| **douban** | Images | Downloads poster / still image lists from movie subjects |
| **xiaoyuzhou** | Audio, Transcript | Downloads episode audio from public pages and transcript JSON/text with local credentials |
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
@@ -28,7 +29,8 @@ brew install yt-dlp
```bash
# Download images/videos from Xiaohongshu note
opencli xiaohongshu download --note-id abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
# Download Bilibili video (requires yt-dlp)
opencli bilibili download --bvid BV1xxx --output ./bilibili
@@ -43,6 +45,12 @@ opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./
# Download Douban posters / stills
opencli douban download 30382501 --output ./douban
# Download Xiaoyuzhou episode audio
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
# Download Xiaoyuzhou transcript JSON + text
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
# Export Zhihu article to Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
@@ -53,6 +61,8 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
`opencli xiaoyuzhou transcript` requires local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## Pipeline Step
The `download` step can be used in pipeline adapters:
-8
View File
@@ -62,11 +62,3 @@ steps:
--remote-debugging-port=9222 &
```
:::
Set the browser executable path:
::: v-pre
```yaml
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
```
:::
+2 -9
View File
@@ -209,7 +209,7 @@ it('producthunt me fails gracefully without login', async () => {
|---|---|---|
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome。
### Sharding
@@ -236,14 +236,7 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
::: v-pre
```yaml
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
```
:::
CI 通过 `./.github/actions/setup-chrome` 准备真实 Chrome,再直接执行测试。
---
+1 -1
View File
@@ -8,7 +8,7 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
### Method 1: Download Pre-built Release (Recommended)
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension-v{version}.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the unzipped folder.
+6 -5
View File
@@ -11,12 +11,13 @@ OpenCLI turns **any website** or **Electron app** into a command-line interface
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
- **Browser Automation** — `browser` gives AI agents direct browser control: click, type, extract, screenshot — fully scriptable.
- **Website → CLI** — Turn any website into a deterministic CLI: 87+ pre-built adapters, or generate your own with `opencli generate`.
- **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.
- **Self-healing setup** — `opencli doctor` auto-starts the daemon and diagnoses extension + live browser connectivity.
- **Dynamic Loader** — Simply drop `.js` adapters into the `clis/` folder for auto-registration.
- **Dual-Engine Architecture** — Supports both declarative pipeline adapters and robust browser runtime TypeScript injections.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `browser` controls the browser directly.
- **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.
## Quick Start
+11 -11
View File
@@ -15,21 +15,21 @@ hero:
features:
- icon: 🖥️
title: CLI All Electron
details: Turn ANY Electron application into a CLI tool — Cursor, Codex, Antigravity, ChatGPT, Notion, and more. AI can control itself natively.
title: Desktop App Control
details: Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
- icon: 🌐
title: Browser Automation
details: "AI agents get direct browser control: click, type, extract, screenshot — any interaction, fully scriptable."
- icon: 🔐
title: Account Safe
details: Reuses Chrome's logged-in state. Your credentials never leave the browser — no tokens, no exposed passwords.
- icon: 🤖
title: AI Agent Ready
details: "explore discovers APIs, synthesize generates adapters, cascade finds auth strategies. Built for AI-first workflows."
- icon:
title: Dual-Engine Architecture
details: Supports both declarative pipeline adapters and robust browser runtime TypeScript injections for maximum flexibility.
- icon: 🔧
title: Self-Healing Setup
details: "opencli doctor auto-starts the daemon and diagnoses extension + live browser connectivity."
- icon: 📦
title: Dynamic Loader
details: Simply drop .js adapters into the clis/ folder for auto-registration. Zero boilerplate.
- icon: 💰
title: Zero LLM Cost
details: No tokens consumed at runtime. Run 10,000 times and pay nothing.
- icon: 🔁
title: Deterministic
details: Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
---
@@ -35,7 +35,7 @@
In `src/constants.ts`, add after the `DEFAULT_DAEMON_PORT` line:
```typescript
/** Default idle timeout before daemon auto-exits (ms). Override via OPENCLI_DAEMON_TIMEOUT env var. */
/** Default idle timeout before daemon auto-exits (ms). */
export const DEFAULT_DAEMON_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours
```
@@ -174,7 +174,7 @@ Replace the `IDLE_TIMEOUT` constant (line 27):
import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT);
const IDLE_TIMEOUT = DEFAULT_DAEMON_IDLE_TIMEOUT;
```
Replace the idle timer state and `resetIdleTimer` function (lines 37, 49-57) with the `IdleManager` class:
@@ -54,12 +54,11 @@ If either signal is active, the daemon stays alive. This means:
- Recent CLI activity keeps the daemon alive even if Extension temporarily
disconnects (Chrome restarting, Extension updating)
**Timeout value:** 4 hours by default, configurable via `OPENCLI_DAEMON_TIMEOUT`
environment variable. Value in milliseconds. Set to `0` to disable timeout entirely.
**Timeout value:** 4 hours by default.
```typescript
const DEFAULT_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_IDLE_TIMEOUT);
const IDLE_TIMEOUT = DEFAULT_IDLE_TIMEOUT;
```
**Timer implementation:**
+1 -1
View File
@@ -8,7 +8,7 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
### 方法 1:下载预构建版本(推荐)
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension-v{version}.zip`
2. 解压后打开 `chrome://extensions`,启用**开发者模式**。
3. 点击**加载已解压的扩展程序**,选择解压后的文件夹。
+11 -5
View File
@@ -15,15 +15,21 @@ hero:
features:
- icon: 🖥️
title: CLI 所有 Electron 应用
details: 将任何 Electron 应用变成 CLI 工具 — Cursor、Codex、Antigravity、ChatGPT、Notion 等。AI 可以原生控制自身
title: 桌面应用控制
details: 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等
- icon: 🌐
title: 浏览器自动化
details: AI Agent 直接控制浏览器:点击、输入、提取、截图 — 任何交互,完全可编程。
- icon: 🔐
title: 账号安全
details: 复用 Chrome 登录态,凭证永远不会离开浏览器 — 无 token,无密码泄露。
- icon: 🤖
title: AI Agent 就绪
details: explore 发现 APIsynthesize 生成适配器,cascade 查找认证策略。为 AI 优先工作流而生。
- icon:
title: 双引擎架构
details: 同时支持声明式数据管道和强大的浏览器运行时 JavaScript 注入
- icon: 💰
title: 零 LLM 成本
details: 运行时不消耗模型 token。跑 10,000 次也不花一分钱
- icon: 🔁
title: 确定性输出
details: 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
---
+40
View File
@@ -195,6 +195,46 @@ describe('background tab isolation', () => {
expect(update).not.toHaveBeenCalled();
});
it('keeps the debugger attached during navigation when network capture is active', async () => {
const { chrome, tabs } = createChromeMock();
const onUpdatedListeners: Array<(id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => void> = [];
chrome.tabs.onUpdated.addListener = vi.fn((fn) => { onUpdatedListeners.push(fn); });
chrome.tabs.onUpdated.removeListener = vi.fn((fn) => {
const idx = onUpdatedListeners.indexOf(fn);
if (idx >= 0) onUpdatedListeners.splice(idx, 1);
});
chrome.tabs.update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => {
const tab = tabs.find((entry) => entry.id === tabId);
if (!tab) throw new Error(`Unknown tab ${tabId}`);
if (updates.active !== undefined) tab.active = updates.active;
if (updates.url !== undefined) tab.url = updates.url;
tab.status = 'complete';
for (const listener of [...onUpdatedListeners]) {
listener(tabId, { status: 'complete', url: tab.url }, tab as chrome.tabs.Tab);
}
return tab;
});
vi.stubGlobal('chrome', chrome);
const detachMock = vi.fn(async () => {});
vi.doMock('./cdp', () => ({
registerListeners: vi.fn(),
hasActiveNetworkCapture: vi.fn(() => true),
detach: detachMock,
}));
const mod = await import('./background');
mod.__test__.setAutomationWindowId('site:eos', 1);
const result = await mod.__test__.handleNavigate(
{ id: 'capture-nav', action: 'navigate', url: 'https://eos.douyin.com/livesite/live/current', workspace: 'site:eos' },
'site:eos',
);
expect(result.ok).toBe(true);
expect(detachMock).not.toHaveBeenCalled();
});
it('keeps hash routes distinct when comparing target URLs', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);
+7 -3
View File
@@ -543,13 +543,17 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false });
}
// Detach any existing debugger before top-level navigation.
// Detach any existing debugger before top-level navigation unless network
// capture is already armed on this tab. Otherwise we would clear the capture
// state right before the page load we are trying to observe.
// 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);
// re-attach after navigation when capture is not active.
if (!executor.hasActiveNetworkCapture(tabId)) {
await executor.detach(tabId);
}
await chrome.tabs.update(tabId, { url: targetUrl });
+4
View File
@@ -341,6 +341,10 @@ export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureE
return entries;
}
export function hasActiveNetworkCapture(tabId: number): boolean {
return networkCaptures.has(tabId);
}
export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
+147 -140
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "1.7.2",
"version": "1.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.7.2",
"version": "1.7.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -410,7 +410,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -423,7 +422,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -435,7 +433,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -913,9 +910,9 @@
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -932,9 +929,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -942,9 +939,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [
"arm64"
],
@@ -959,9 +956,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [
"arm64"
],
@@ -976,9 +973,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [
"x64"
],
@@ -993,9 +990,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [
"x64"
],
@@ -1010,9 +1007,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [
"arm"
],
@@ -1027,9 +1024,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [
"arm64"
],
@@ -1044,9 +1041,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [
"arm64"
],
@@ -1061,9 +1058,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [
"ppc64"
],
@@ -1078,9 +1075,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [
"s390x"
],
@@ -1095,9 +1092,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [
"x64"
],
@@ -1112,9 +1109,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [
"x64"
],
@@ -1129,9 +1126,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [
"arm64"
],
@@ -1146,9 +1143,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [
"wasm32"
],
@@ -1156,16 +1153,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [
"arm64"
],
@@ -1180,9 +1179,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [
"x64"
],
@@ -1197,9 +1196,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"dev": true,
"license": "MIT"
},
@@ -1736,13 +1735,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/turndown": {
@@ -1784,16 +1783,16 @@
"license": "ISC"
},
"node_modules/@vitest/expect": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1802,13 +1801,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.2",
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1829,9 +1828,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1842,13 +1841,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.2",
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
@@ -1856,14 +1855,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1872,9 +1871,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1882,13 +1881,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3185,14 +3184,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3201,21 +3200,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
}
},
"node_modules/rollup": {
@@ -3419,14 +3418,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -3508,18 +3507,18 @@
}
},
"node_modules/undici": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.0.2.tgz",
"integrity": "sha512-B9MeU5wuFhkFAuNeA19K2GDFcQXZxq33fL0nRy2Aq30wdufZbyyvxW3/ChaeipXVfy/wUweZyzovQGk39+9k2w==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz",
"integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
@@ -3627,16 +3626,16 @@
}
},
"node_modules/vite": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz",
"integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==",
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -4251,19 +4250,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.2",
"@vitest/mocker": "4.1.2",
"@vitest/pretty-format": "4.1.2",
"@vitest/runner": "4.1.2",
"@vitest/snapshot": "4.1.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -4291,10 +4290,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.2",
"@vitest/browser-preview": "4.1.2",
"@vitest/browser-webdriverio": "4.1.2",
"@vitest/ui": "4.1.2",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -4318,6 +4319,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.7.2",
"version": "1.7.4",
"publishConfig": {
"access": "public"
},

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