Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3076c12d6c | |||
| 44147e54c1 | |||
| 677e37b7a4 | |||
| d48c71b993 | |||
| 6fbeda951e | |||
| 9bcdaaa0be | |||
| db70a3aaf3 | |||
| 0040081f2b | |||
| 16d597cfce | |||
| ba3a674d7b | |||
| 0e38fd8c37 | |||
| cd48917a39 | |||
| 45d6f5b09f | |||
| 3ebc46f978 | |||
| 6e29845dc3 | |||
| 88bce1becf | |||
| ca68f3999b | |||
| 847c8317b6 | |||
| 741bcf9b6e | |||
| 44388d21fc | |||
| 745ce459d1 | |||
| a5cd0dc307 | |||
| beabed4bad | |||
| fa208ec761 | |||
| 56a727cc04 | |||
| feedaf93b4 | |||
| 9ebb921c89 | |||
| 9ac2e1d8ef | |||
| 2aee4caa10 | |||
| 323fe8857c | |||
| ff6563d12a | |||
| c42b040af4 | |||
| 79a15e8353 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ dist/
|
||||
!extension/dist/
|
||||
*.tsbuildinfo
|
||||
.opencli/
|
||||
.worktrees/
|
||||
.mcp.json
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+18
-13
@@ -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,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
@@ -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,
|
||||
@@ -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: '搜索关键词' },
|
||||
|
||||
@@ -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
@@ -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)],
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 }];
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 ?? []) || '(空)',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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 + ` })`);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
lines.push(nodesToMarkdown(node.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return lines.filter(Boolean).join('\n');
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 : '-',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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 }];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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) }];
|
||||
},
|
||||
});
|
||||
@@ -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 }));
|
||||
},
|
||||
});
|
||||
@@ -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 }];
|
||||
},
|
||||
});
|
||||
@@ -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) }];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
图书搜索和图书详情在稳定批量使用时默认需要已登录的豆瓣浏览器会话。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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` | |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
```
|
||||
:::
|
||||
|
||||
@@ -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,再直接执行测试。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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:**
|
||||
|
||||
@@ -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
@@ -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 发现 API,synthesize 生成适配器,cascade 查找认证策略。为 AI 优先工作流而生。
|
||||
- icon: ⚡
|
||||
title: 双引擎架构
|
||||
details: 同时支持声明式数据管道和强大的浏览器运行时 JavaScript 注入。
|
||||
- icon: 💰
|
||||
title: 零 LLM 成本
|
||||
details: 运行时不消耗模型 token。跑 10,000 次也不花一分钱。
|
||||
- icon: 🔁
|
||||
title: 确定性输出
|
||||
details: 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
|
||||
---
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+147
-140
@@ -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
@@ -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
Reference in New Issue
Block a user