Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8bc3e73f |
@@ -50,20 +50,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Guard: adapter rows must not silently emit keys omitted from `columns`.
|
||||
# Existing findings are tracked in scripts/silent-column-drop-baseline.json;
|
||||
# this gate rejects newly introduced drops while allowing incremental cleanup.
|
||||
- name: Check silent column drops
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run check:silent-column-drop
|
||||
|
||||
# Guard: adapters should fail with typed errors instead of silently
|
||||
# returning empty arrays, clamping user input, or inventing sentinel data.
|
||||
# Existing findings are tracked in scripts/typed-error-lint-baseline.json.
|
||||
- name: Check typed-error lint baseline
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run check:typed-error-lint
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
|
||||
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
|
||||
|
||||
@@ -2,56 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **help / build** — every positional arg must now declare a non-empty `help` string. The build-manifest step fails closed when a positional has empty / whitespace-only / missing `help`, so `opencli <site> <cmd> --help` always shows callers what each parameter is for. Pre-existing offenders (`twitter followers/following/list-add/list-remove/list-tweets/search/thread`, `reddit search/subreddit/user/user-comments/user-posts`, `douyin stats/update`, `bilibili subtitle`, `jike search`) now have explicit help text — most notably `twitter followers [user]` and `following [user]` now document that omitting the user fetches the currently logged-in account.
|
||||
|
||||
## [1.7.14](https://github.com/jackwener/opencli/compare/v1.7.13...v1.7.14) (2026-05-08)
|
||||
|
||||
### Features
|
||||
|
||||
* **help** — adapter help is now agent-friendly: per-command listings drop the `[options]` noise from globally-shared options (`--format`, `--trace`, `-v`, `-h`, etc.) and only mention them at the site level, so `opencli twitter` etc. read like a flat command index. ([#1401](https://github.com/jackwener/opencli/issues/1401))
|
||||
* **twitter** — write-action symmetry P0: add `unlike`, `retweet`, `unretweet`, and `quote` to round out the read/write coverage. ([#1400](https://github.com/jackwener/opencli/issues/1400))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **browser daemon** — `npm install -g @jackwener/opencli@latest` now correctly auto-restarts a stale ready-state daemon so users pick up the new version without a manual `opencli daemon restart`. ([#1399](https://github.com/jackwener/opencli/issues/1399))
|
||||
|
||||
## [1.7.13](https://github.com/jackwener/opencli/compare/v1.7.12...v1.7.13) (2026-05-07)
|
||||
|
||||
Extension bumped to 1.0.6 (screenshot `--width` / `--height` / `--full-page` flags, automation tab group color marker, automation container reuse fix).
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **linux-do** — remove deprecated compatibility shims `linux-do hot`, `linux-do category`, `linux-do latest`. Use `linux-do feed --view top --period <period>`, `linux-do feed --category <id-or-name>`, and `linux-do feed --view latest` instead.
|
||||
* **grok ask** — drop the `--web` flag and the legacy `<textarea>` composer path. The default flow is now the only path and uses the current ProseMirror+TipTap composer (the path that used to require `--web true`). Existing scripts passing `--web` will get an "unknown option" error from commander; remove the flag.
|
||||
* **env** — rename `OPENCLI_BROWSER_TIMEOUT` to `OPENCLI_BROWSER_IDLE_TIMEOUT`. The variable controls workspace lease idle release time, not per-command runtime; the new name reflects that. Old name was undocumented and removed without a fallback.
|
||||
* **registry** — remove the unused `Strategy.HEADER`; adapter authors should use `Strategy.COOKIE` and set headers explicitly inside browser-side fetches.
|
||||
|
||||
### Features
|
||||
|
||||
* **observation** — add trace artifact primitives, `browser console`, `browser network --since/--follow/--failed`, and adapter `--trace=retain-on-failure` for failure-retained browser evidence.
|
||||
* **autofix** — retire `OPENCLI_DIAGNOSTIC`; adapter repair now uses `--trace retain-on-failure`, trace `summary.md`, and error-envelope trace metadata.
|
||||
* **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929))
|
||||
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
|
||||
* **browser session** — adapter commands can opt into site-level tab reuse with `browserSession.reuse = 'site'`; Grok and other browser-backed LLM adapters now keep a shared site tab by default, and users can override with `--reuse <none|site>`.
|
||||
* **chatgpt** — add browser-web baseline commands: `ask`, `send`, `read`, `history`, `detail`, `new`, and `status`.
|
||||
* **grok** — add browser-web baseline commands: `read`, `history`, `detail`, `new`, `send`, and `status` (existing `ask` and `image` unchanged).
|
||||
* **yuanbao** — add browser-web baseline commands: `send`, `status`, `read`, `history`, and `detail` (joining the existing `ask` and `new`).
|
||||
* **qwen** — add `detail` command for opening a specific historical conversation by id.
|
||||
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **pipeline / capabilityRouting** — the `fill` pipeline step (introduced in [#1222](https://github.com/jackwener/opencli/issues/1222)) now correctly triggers a browser session and gets transient retry coverage; previously a pipeline using only `fill` could crash on a missing page object. ([#1393](https://github.com/jackwener/opencli/issues/1393))
|
||||
* **xiaohongshu publish** — improve image publishing reliability via creator-center URL routing, tab priority handling, and DataTransfer fallback.
|
||||
* **youtube** — use watch-page HTML for transcript captions to recover when the public transcript API is unavailable.
|
||||
* **desktop adapters** — restore 11 desktop adapter commands that were lost from the manifest due to a factory-pattern regression.
|
||||
|
||||
### Internal
|
||||
|
||||
* **cleanup** — remove dead `src/analysis.ts` (179 lines, 0 importers), retire `OPENCLI_DIAGNOSTIC` test residue, derive validator step allowlist from the live pipeline registry to prevent future drift.
|
||||
|
||||
## [1.7.8](https://github.com/jackwener/opencli/compare/v1.7.7...v1.7.8) (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
OpenCLI gives you one surface for three different kinds of automation:
|
||||
|
||||
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
|
||||
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
|
||||
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
|
||||
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
|
||||
|
||||
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.
|
||||
@@ -19,7 +19,7 @@ It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other
|
||||
## Highlights
|
||||
|
||||
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
|
||||
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type/fill, extract, screenshot — all through your logged-in Chrome session.
|
||||
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type, extract, screenshot — all through your logged-in Chrome session.
|
||||
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
|
||||
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
|
||||
@@ -150,7 +150,7 @@ The agent handles all the `opencli browser` commands internally — you just des
|
||||
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
|
||||
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — capability search
|
||||
|
||||
Available browser commands include `open`, `state`, `click`, `type`, `fill`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
|
||||
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
|
||||
|
||||
`opencli browser open <url>` and `opencli browser tab new [url]` both return a target ID. Use `opencli browser tab list` to inspect the target IDs of tabs that already exist, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted `opencli browser ...` commands.
|
||||
|
||||
@@ -172,7 +172,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
||||
|
||||
1. Recon the site and classify its pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
|
||||
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`.
|
||||
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
|
||||
4. Decode response fields and design output columns.
|
||||
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
||||
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
|
||||
@@ -200,7 +200,6 @@ OpenCLI is not only for websites. It can also:
|
||||
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
|
||||
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open the automation container in the foreground (useful for debugging). The `--focus` flag sets this. |
|
||||
| `OPENCLI_LIVE` | `false` | Set to `1` to keep the automation lease open after an adapter command finishes (useful for inspection). The `--live` flag sets this. |
|
||||
| `OPENCLI_BROWSER_REUSE` | adapter default | Set to `none` or `site` to override adapter browser tab reuse. The `--reuse <none\|site>` flag sets this. |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
|
||||
@@ -208,7 +207,7 @@ OpenCLI is not only for websites. It can also:
|
||||
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
|
||||
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
|
||||
|
||||
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation lease open until you run `opencli browser close` or the idle timeout expires. Some interactive adapters default to `--reuse site` so repeated commands continue in the same site tab; pass `--reuse none` for a one-shot tab.
|
||||
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation lease open until you run `opencli browser close` or the idle timeout expires.
|
||||
|
||||
## Update
|
||||
|
||||
@@ -265,7 +264,7 @@ To load the source Browser Bridge extension:
|
||||
| **yuanbao** | `new` `ask` |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
|
||||
| **xianyu** | `search` `item` `chat` `publish` |
|
||||
| **xianyu** | `search` `item` `chat` |
|
||||
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
|
||||
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
|
||||
| **uiverse** | `code` `preview` |
|
||||
@@ -407,7 +406,7 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski
|
||||
|
||||
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
|
||||
- Decide auth strategy (`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`).
|
||||
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
|
||||
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
|
||||
- Verify with `opencli browser verify <site>/<name>` before shipping.
|
||||
|
||||
|
||||
+8
-10
@@ -11,7 +11,7 @@
|
||||
OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
|
||||
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
|
||||
- **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。
|
||||
- **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入、提取任意网页内容。
|
||||
- **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。
|
||||
|
||||
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
|
||||
@@ -19,7 +19,7 @@ OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
## 亮点
|
||||
|
||||
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)。
|
||||
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入/填充、提取、截图——全部通过你的已登录 Chrome 会话完成。
|
||||
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入、提取、截图——全部通过你的已登录 Chrome 会话完成。
|
||||
- **网站 → CLI** — 把任何网站变成确定性 CLI:100+ 站点能力已注册,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
|
||||
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
|
||||
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
|
||||
@@ -134,7 +134,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
|
||||
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — 能力搜索
|
||||
|
||||
`browser` 可用命令包括:`open`、`state`、`click`、`type`、`fill`、`select`、`keys`、`wait`、`get`、`find`、`extract`、`frames`、`screenshot`、`scroll`、`back`、`eval`、`network`、`tab list`、`tab new`、`tab select`、`tab close`、`init`、`verify`、`close`。
|
||||
`browser` 可用命令包括:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`find`、`extract`、`frames`、`screenshot`、`scroll`、`back`、`eval`、`network`、`tab list`、`tab new`、`tab select`、`tab close`、`init`、`verify`、`close`。
|
||||
|
||||
`opencli browser open <url>` 和 `opencli browser tab new [url]` 都会返回 target ID。`opencli browser tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为后续未指定 target 的 `opencli browser ...` 命令的默认目标。
|
||||
|
||||
@@ -156,7 +156,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
|
||||
1. 侦察站点,分类 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
||||
3. 定认证策略——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
|
||||
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
|
||||
4. 字段解码 + 设计输出列
|
||||
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
||||
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
||||
@@ -238,7 +238,7 @@ npm link
|
||||
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `history` `export` | 桌面端 |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
|
||||
@@ -271,8 +271,6 @@ npm link
|
||||
| **devto** | `top` `tag` `user` | 公开 |
|
||||
| **dictionary** | `search` `synonyms` `examples` | 公开 |
|
||||
| **arxiv** | `search` `paper` | 公开 |
|
||||
| **pubmed** | `search` `article` `author` `citations` `related` | 公开 |
|
||||
| **openreview** | `search` `venue` `paper` `reviews` | 公开 |
|
||||
| **paperreview** | `submit` `review` `feedback` | 公开 |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
@@ -291,7 +289,7 @@ npm link
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
|
||||
| **jimeng** | `generate` `history` | 浏览器 |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
|
||||
| **linux-do** | `feed` `search` `categories` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
|
||||
| **linux-do** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
|
||||
| **steam** | `top-sellers` | 公开 |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
|
||||
@@ -316,7 +314,7 @@ npm link
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
|
||||
| **xianyu** | `search` `item` `chat` `publish` | 浏览器 |
|
||||
| **xianyu** | `search` `item` `chat` | 浏览器 |
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
|
||||
@@ -506,7 +504,7 @@ opencli plugin uninstall my-tool # 卸载
|
||||
|
||||
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
|
||||
- 定认证策略(`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`)
|
||||
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
|
||||
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
|
||||
- 交付前用 `opencli browser verify <site>/<name>` 验证
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Use Cases
|
||||
|
||||
Real-world examples of how people use OpenCLI.
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to share your use case? Submit a PR that adds a new `.md` file to this directory.
|
||||
|
||||
Each file is one use case — describe what you wanted to do, which commands you used, and the result.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Daily RL research monitor
|
||||
|
||||
A 30-second morning routine that surfaces what changed overnight in reinforcement-learning and large-model research, without opening a browser.
|
||||
|
||||
## What I wanted
|
||||
|
||||
Before reading anything, decide where to spend my 20 minutes of paper time:
|
||||
|
||||
- which `cs.LG` and `cs.AI` papers landed in the last 24 hours
|
||||
- which OpenReview submissions at recent venues (NeurIPS 2025 right now, ICLR 2024 / NeurIPS 2024 as historical reference) carry titles and primary areas relevant to my work
|
||||
- which papers the Hugging Face Daily Papers community is talking about today
|
||||
|
||||
Skim signals, then drill in. The point is to filter, not to read everything.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 1. arxiv recent in the two relevant categories (newest 30 each)
|
||||
opencli arxiv recent cs.LG --limit 30 -f json > /tmp/lg.json
|
||||
opencli arxiv recent cs.AI --limit 30 -f json > /tmp/ai.json
|
||||
|
||||
# 2. NeurIPS 2025 oral track from OpenReview (use natural-language
|
||||
# venue text; the EMPTY_RESULT error helpfully echoes valid syntax
|
||||
# if a venue is not yet open)
|
||||
opencli openreview venue "NeurIPS 2025 oral" --limit 50 -f json > /tmp/neurips.json
|
||||
|
||||
# 3. Hugging Face Daily Papers (community-upvoted research)
|
||||
opencli hf top --period daily --limit 20 -f json > /tmp/hf.json
|
||||
```
|
||||
|
||||
That is the entire collection step. The four files together are the whole signal surface for one morning.
|
||||
|
||||
## What I do with the output
|
||||
|
||||
Pipe the four JSON files into a one-shot LLM digest with a fixed prompt:
|
||||
|
||||
```
|
||||
Here are four JSON arrays of papers from the last 24 hours.
|
||||
Group them into:
|
||||
1. Direct hits on RLHF / preference optimization / reasoning RL.
|
||||
2. Adjacent (offline RL, world models, agent benchmarks).
|
||||
3. Notable infra (training, evaluation, data).
|
||||
For each, give me title + arxiv id + one-sentence why-it-matters.
|
||||
Skip everything that is review / survey / position paper.
|
||||
```
|
||||
|
||||
The LLM compresses ~120 entries into a 10-line shortlist in seconds. I then open whichever 2 to 3 papers actually clear the bar.
|
||||
|
||||
## Why CLI beats the browser version
|
||||
|
||||
- Four pages of clicking and scrolling collapses into four `opencli` calls.
|
||||
- The output is structured JSON, so the digest prompt can reason about it deterministically. No copy-paste, no "I missed paper 14".
|
||||
- Works inside any agent loop. A scheduled task can run the four commands, push them to an LLM, and message the digest somewhere. No browser kept open.
|
||||
- Zero token cost on the OpenCLI side. The only paid step is the digest call at the end.
|
||||
|
||||
The arxiv adapter's `recent <category>` (added in #1289) is the lever here. Without it I would have to fall back to the arxiv listings page, which means scraping HTML in agent code instead of consuming a structured listing.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Find a paper's implementation and follow-up work
|
||||
|
||||
Given a single paper title or arxiv id, walk three sources in one chain to find the canonical reference, follow-up citations, and any community-fine-tuned models or Spaces that already build on it.
|
||||
|
||||
## What I wanted
|
||||
|
||||
I read a paper abstract, decide it is interesting, and want to answer three questions before deciding to actually re-read the paper or reproduce it:
|
||||
|
||||
1. Has anyone already implemented or fine-tuned on top of it (Hugging Face)?
|
||||
2. Who has cited or extended it (dblp / OpenReview)?
|
||||
3. What is the canonical bibliographic record (dblp key for citation, full arxiv metadata for reading)?
|
||||
|
||||
Doing this in a browser means three tabs and two minutes of context-switching. The point is to compress that into one shell pipeline.
|
||||
|
||||
## Commands
|
||||
|
||||
Worked example: "Direct Preference Optimization" (DPO).
|
||||
|
||||
```bash
|
||||
# 1. Canonical arxiv record (full abstract, authors, pdf url, categories).
|
||||
# Note: arxiv free-text search ranks by recency, so the original DPO
|
||||
# paper does not always come back first. When the canonical id is
|
||||
# already known, hit `arxiv paper <id>` directly.
|
||||
opencli arxiv search "Direct Preference Optimization" --limit 5 -f json
|
||||
opencli arxiv paper 2305.18290 -f json
|
||||
|
||||
# 2. dblp bibliography record + co-authors + venue history
|
||||
opencli dblp search "Direct Preference Optimization" --limit 5 -f json
|
||||
|
||||
# 3. Community uptake on Hugging Face: trending Daily Papers that mention DPO
|
||||
opencli hf top --period monthly --limit 50 -f json | jq '.[] | select(.title | test("DPO|preference"; "i"))'
|
||||
|
||||
# 4. Conference review record (if posted to OpenReview)
|
||||
opencli openreview search "Direct Preference Optimization" --limit 5 -f json
|
||||
```
|
||||
|
||||
Three of the four are public-strategy adapters, no browser session needed. The OpenReview call also lands without auth for public venues.
|
||||
|
||||
## What I do with the output
|
||||
|
||||
For DPO the chain produces:
|
||||
|
||||
- arxiv record: paper id `2305.18290`, full abstract, pdf link.
|
||||
- dblp record: canonical key `conf/nips/RafailovSMMEF23`, NeurIPS 2023, co-author list (useful to find related work by same lab).
|
||||
- HF Daily Papers (last 30 days): every paper whose title mentions DPO or preference. Each one is a candidate "follow-up work I should know about".
|
||||
- OpenReview: the original submission's review thread, if posted (lets me see what reviewers actually pushed back on, which is more useful than the published abstract).
|
||||
|
||||
I dump all four JSON outputs into a single LLM call with the prompt: *"Build a one-paragraph 'state of the field' summary for this paper as of today. Cite each follow-up by arxiv id."* That gives me a research-debt brief in 30 seconds.
|
||||
|
||||
## Why this is worth a CLI chain
|
||||
|
||||
- Each adapter alone is just "search a website". The value is the chain. Four `opencli` calls feed into one LLM call. No browser, no copy-paste.
|
||||
- Output is identifier-rich (arxiv id, dblp key, venue id, HF paper id). I can re-feed any of those into the next call, e.g. once I find a follow-up arxiv id from HF Daily Papers I run `opencli arxiv paper <new-id>` immediately.
|
||||
- Survives use inside an agent loop. Same chain runs unattended for a batch of 20 papers from a reading list.
|
||||
- Zero token cost for the discovery half. Only the final summary step pays for inference.
|
||||
|
||||
Without `opencli dblp search` (added in #1299) and `opencli openreview search` (added in #1294), this whole pipeline used to require either web scraping in agent code or paying for a research-paper API. Both adapters being public-strategy means they slot in cleanly.
|
||||
@@ -1,75 +0,0 @@
|
||||
# Track a conference's accepted papers and reviews from the terminal
|
||||
|
||||
Once an OpenReview venue opens its decisions (or releases reviews publicly during the discussion phase), I want a one-shot way to pull the full venue listing and dive into individual review threads, without clicking through 200+ submission pages.
|
||||
|
||||
## What I wanted
|
||||
|
||||
For each major venue I follow (ICLR, NeurIPS, ICML), the same three things every time decisions are visible:
|
||||
|
||||
1. The full list of accepted papers at the venue, with titles and forum ids.
|
||||
2. For any paper I flagged interesting from the list: the full review thread, including reviewer scores, rebuttals, and the AC's decision rationale.
|
||||
3. A way to pipe both into LLM-driven shortlisting ("which of these 100 oral papers actually intersect with my research direction").
|
||||
|
||||
The OpenReview UI is fine for one paper at a time, but unusable for batch reasoning across the whole acceptance list.
|
||||
|
||||
## Commands
|
||||
|
||||
Worked example: ICLR 2024 oral track, then drill into one paper's reviews using a real forum id.
|
||||
|
||||
```bash
|
||||
# 1. Full list of papers at a venue (natural-language venue text;
|
||||
# if the venue is not yet open OpenReview returns EMPTY_RESULT
|
||||
# with a help line listing valid forms)
|
||||
opencli openreview venue "ICLR 2024 oral" --limit 200 -f json > /tmp/iclr-2024.json
|
||||
|
||||
# 2. Pick a forum id from the listing, fetch the full review thread.
|
||||
# Example: "Proving Test Set Contamination in Black-Box Language Models"
|
||||
opencli openreview reviews KS8mIvetg2 -f json > /tmp/reviews.json
|
||||
|
||||
# 3. Single paper metadata if needed
|
||||
opencli openreview paper KS8mIvetg2 -f json
|
||||
```
|
||||
|
||||
`venue` returns each entry with a forum id you can hand straight back into `reviews` and `paper`. No id lookup gymnastics. `reviews` returns the full thread as a JSON array: a `PAPER` row with the abstract, then one `REVIEW` row per reviewer (with `rating`, `confidence`, summary, weaknesses, questions), followed by author rebuttals and the AC's decision rationale.
|
||||
|
||||
## What I do with the output
|
||||
|
||||
Two distinct workflows depending on the phase of the venue:
|
||||
|
||||
### Phase A: filtering the acceptance list
|
||||
|
||||
After `venue` returns 200 entries, dump the JSON into an LLM with the prompt:
|
||||
|
||||
```
|
||||
Here is the full acceptance list at <venue>. Filter to papers that intersect
|
||||
with my research interests:
|
||||
- reinforcement learning from preference / reward feedback
|
||||
- reasoning training (process reward, RLVR, RLHF variants)
|
||||
- long-horizon agent benchmarks
|
||||
For each match: title + forum_id + one-sentence why-it-matters.
|
||||
```
|
||||
|
||||
This collapses 200 papers to a 10-paper shortlist in seconds. The forum ids are the keys I will use in Phase B.
|
||||
|
||||
### Phase B: depth-reading the shortlist
|
||||
|
||||
For each shortlisted forum id, run `opencli openreview reviews <forum-id>` and feed the JSON to an LLM with the prompt:
|
||||
|
||||
```
|
||||
Summarize the review thread:
|
||||
- reviewer scores
|
||||
- the strongest critique
|
||||
- whether the rebuttal addressed it
|
||||
- final decision and AC rationale
|
||||
```
|
||||
|
||||
This is faster than reading three reviews + rebuttal + meta-review per paper. For 10 papers this turns 60 minutes of OpenReview clicking into 10 minutes of summary reading, then I open the actual reviews only for papers where the summary flagged something worth knowing.
|
||||
|
||||
## Why this beats opening OpenReview
|
||||
|
||||
- One `venue` call replaces scrolling a paginated UI for 200+ papers.
|
||||
- `reviews` returns the entire thread as JSON, so an LLM can reason over the whole review-rebuttal-decision arc at once. The web view forces you to scroll three reviews + N rebuttals + meta separately.
|
||||
- Forum ids returned from `venue` are stable and reusable across calls. Easy to keep a personal reading list as `forum-ids.txt` and run `for id in $(cat forum-ids.txt); do opencli openreview reviews $id; done`.
|
||||
- The whole loop is public-strategy. No login required for venues with public reviewing.
|
||||
|
||||
`opencli openreview` (added in #1294) is the lever. Before this adapter existed, the same workflow needed either OpenReview's Python client or HTML scraping inside agent code. Both have higher friction than `opencli openreview reviews <forum-id>` returning structured JSON in one shot.
|
||||
+333
-7059
File diff suppressed because it is too large
Load Diff
@@ -183,7 +183,6 @@ export async function extractAssetsForInput(page, input) {
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'assets',
|
||||
access: 'read',
|
||||
description: '列出 1688 商品页可提取的图片/视频素材',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -42,7 +42,6 @@ function toDownloadItems(offerId, assets) {
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'download',
|
||||
access: 'read',
|
||||
description: '批量下载 1688 商品页可提取的图片和视频素材',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -156,7 +156,6 @@ async function readItemPayload(page, itemUrl) {
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'item',
|
||||
access: 'read',
|
||||
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
+1
-2
@@ -275,7 +275,6 @@ async function collectSearchRows(page, query, limit) {
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '1688 商品搜索(结果候选、卖家链接、价格/MOQ/销量文本)',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
@@ -294,7 +293,7 @@ cli({
|
||||
help: `结果数量上限(默认 ${SEARCH_LIMIT_DEFAULT},最大 ${SEARCH_LIMIT_MAX})`,
|
||||
},
|
||||
],
|
||||
columns: ['rank', 'offer_id', 'title', 'item_url', 'price_text', 'moq_text', 'seller_name', 'member_id', 'location'],
|
||||
columns: ['rank', 'title', 'price_text', 'moq_text', 'seller_name', 'location'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = String(kwargs.query ?? '');
|
||||
const limit = parseSearchLimit(kwargs.limit);
|
||||
|
||||
@@ -167,7 +167,6 @@ function hasAnyEvidence(storePayload, contactPayload, seed) {
|
||||
cli({
|
||||
site: '1688',
|
||||
name: 'store',
|
||||
access: 'read',
|
||||
description: '1688 店铺/供应商公开信息(联系方式、主营、入驻年限、公开服务信号)',
|
||||
domain: 'www.1688.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 精华帖 — Discuz guide=digest view.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'digest',
|
||||
access: 'read',
|
||||
description: '一亩三分地 精华帖(编辑推荐 / 加精)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50)' },
|
||||
],
|
||||
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
|
||||
func: async (args) => {
|
||||
const limit = normalizeLimit(args.limit, 20, 50);
|
||||
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=digest`);
|
||||
const items = parseThreadList(html);
|
||||
return items.slice(0, limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
tid: t.tid,
|
||||
title: t.title,
|
||||
forum: t.forum,
|
||||
author: t.author,
|
||||
replies: t.replies,
|
||||
views: t.views,
|
||||
lastReplyTime: t.lastReplyTime,
|
||||
url: t.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 版块帖子列表 — /bbs/forum-<fid>-<page>.html
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { fetchHtml, parseThreadList, parseThreadRows, normalizeLimit, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'forum',
|
||||
access: 'read',
|
||||
description: '浏览一亩三分地某个版块的帖子列表(按 fid)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'fid', required: true, positional: true, help: '版块 ID,例如 145(海外面经)、198(海外职位内推)、27(研究生申请)' },
|
||||
{ name: 'page', type: 'int', default: 1, help: '页码(默认 1)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50)' },
|
||||
],
|
||||
columns: ['rank', 'tid', 'kind', 'title', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
|
||||
func: async (args) => {
|
||||
const fid = String(args.fid || '').trim();
|
||||
if (!/^\d+$/.test(fid)) {
|
||||
throw new ArgumentError('fid must be a numeric forum id', 'e.g. 145 for 海外面经');
|
||||
}
|
||||
const pageNum = Number(args.page ?? 1);
|
||||
if (!Number.isInteger(pageNum) || pageNum <= 0) {
|
||||
throw new ArgumentError('page must be a positive integer');
|
||||
}
|
||||
const limit = normalizeLimit(args.limit, 20, 50);
|
||||
const html = await fetchHtml(`${BASE}/forum-${fid}-${pageNum}.html`);
|
||||
const rows = parseThreadRows(html);
|
||||
if (rows.length === 0) {
|
||||
// Forum may be sub-category-only — surface gracefully as empty with hint.
|
||||
return [];
|
||||
}
|
||||
const items = parseThreadList(html);
|
||||
return items.slice(0, limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
tid: t.tid,
|
||||
kind: t.kind === 'stickthread' ? '置顶' : '普通',
|
||||
title: t.title,
|
||||
author: t.author,
|
||||
replies: t.replies,
|
||||
views: t.views,
|
||||
lastReplyTime: t.lastReplyTime,
|
||||
url: t.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 所有版块清单 — parsed from /bbs/forum.php
|
||||
*
|
||||
* Each forum card has:
|
||||
* <a href="forum-<fid>-1.html" ... class="... overflow-hidden whitespace-nowrap hidden desktop:block">版块名</a>
|
||||
* and an adjacent description element. We dedupe by fid and return name + url.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchHtml, decodeEntities, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'forums',
|
||||
access: 'read',
|
||||
description: '一亩三分地 所有版块(fid + 版块名)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'filter', type: 'string', default: '', help: '按版块名关键字过滤(子串匹配,中英文)' },
|
||||
],
|
||||
columns: ['fid', 'name', 'url'],
|
||||
func: async (args) => {
|
||||
const html = await fetchHtml(`${BASE}/forum.php`);
|
||||
const seen = new Map();
|
||||
const re = /<a href="forum-(\d+)-1\.html"[^>]*class="[^"]*overflow-hidden[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const fid = m[1];
|
||||
let name = decodeEntities(m[2].trim());
|
||||
// Some subforum labels are wrapped in brackets — unwrap for display parity.
|
||||
name = name.replace(/^\[(.+)\]$/, '$1').trim();
|
||||
if (!name || seen.has(fid)) continue;
|
||||
seen.set(fid, name);
|
||||
}
|
||||
const filter = String(args.filter || '').toLowerCase().trim();
|
||||
const out = [];
|
||||
for (const [fid, name] of seen) {
|
||||
if (filter && !name.toLowerCase().includes(filter)) continue;
|
||||
out.push({ fid, name, url: `${BASE}/forum-${fid}-1.html` });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 热门帖子 — Discuz guide=hot view.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'hot',
|
||||
access: 'read',
|
||||
description: '一亩三分地 今日热门帖子(按热度排序,约 50 条)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50)' },
|
||||
],
|
||||
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
|
||||
func: async (args) => {
|
||||
const limit = normalizeLimit(args.limit, 20, 50);
|
||||
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=hot`);
|
||||
const items = parseThreadList(html);
|
||||
return items.slice(0, limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
tid: t.tid,
|
||||
title: t.title,
|
||||
forum: t.forum,
|
||||
author: t.author,
|
||||
replies: t.replies,
|
||||
views: t.views,
|
||||
lastReplyTime: t.lastReplyTime,
|
||||
url: t.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 最新帖子 — Discuz guide=new view.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'latest',
|
||||
access: 'read',
|
||||
description: '一亩三分地 最新发帖(按发帖时间倒序)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50)' },
|
||||
],
|
||||
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'postTime', 'url'],
|
||||
func: async (args) => {
|
||||
const limit = normalizeLimit(args.limit, 20, 50);
|
||||
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=new`);
|
||||
const items = parseThreadList(html);
|
||||
return items.slice(0, limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
tid: t.tid,
|
||||
title: t.title,
|
||||
forum: t.forum,
|
||||
author: t.author,
|
||||
replies: t.replies,
|
||||
views: t.views,
|
||||
postTime: t.postTime,
|
||||
url: t.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 我的通知 — 坛友互动 / 点评 / @我 等
|
||||
*
|
||||
* /bbs/home.php?mod=space&do=notice&view=interactive needs login cookie.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchHtml, decodeEntities, getCookie, stripHtml, truncate, normalizePositiveInteger, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'notifications',
|
||||
access: 'read',
|
||||
description: '一亩三分地 站内通知(互动 / 点评 / @ 我;需要登录)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'kind', type: 'string', default: 'mypost',
|
||||
help: '通知类型:mypost(我的帖子) / interactive(互动) / system(系统) / app(应用)' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数' },
|
||||
],
|
||||
columns: ['index', 'from', 'summary', 'time', 'threadUrl'],
|
||||
func: async (page, args) => {
|
||||
const kind = String(args.kind || 'mypost').trim();
|
||||
const cookie = await getCookie(page);
|
||||
const url = `${BASE}/home.php?mod=space&do=notice&view=${encodeURIComponent(kind)}`;
|
||||
const html = await fetchHtml(url, { cookie, headers: { Referer: `${BASE}/` } });
|
||||
|
||||
if (/<title>提示信息/.test(html) && /请登录/.test(html)) {
|
||||
throw new AuthRequiredError('www.1point3acres.com', '请先登录一亩三分地');
|
||||
}
|
||||
|
||||
// "No notifications" is a real empty result, not a synthetic data row.
|
||||
if (/暂时没有提醒内容/.test(html)) {
|
||||
throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
const limit = normalizePositiveInteger(args.limit, 20, 'limit');
|
||||
|
||||
// Pattern 1: standard Discuz <dl class="cl">…</dl> block per notice.
|
||||
const dlRe = /<dl class="[^"]*cl[^"]*"[^>]*>([\s\S]*?)<\/dl>/g;
|
||||
let m;
|
||||
let i = 0;
|
||||
while ((m = dlRe.exec(html)) && rows.length < limit) {
|
||||
const block = m[1];
|
||||
const from = decodeEntities((block.match(/<dt>([\s\S]*?)<\/dt>/) || [, ''])[1])
|
||||
.replace(/<[^>]+>/g, '').trim();
|
||||
const summaryRaw = (block.match(/<dd class="ntc_body">([\s\S]*?)<\/dd>/) ||
|
||||
block.match(/<dd>([\s\S]*?)<\/dd>/) || [, ''])[1];
|
||||
const summary = truncate(stripHtml(summaryRaw), 200);
|
||||
const time = ((block.match(/<dd class="[^"]*xg1[^"]*"[^>]*>([\s\S]*?)<\/dd>/) || [, ''])[1] || '')
|
||||
.replace(/<[^>]+>/g, '').trim();
|
||||
const linkMatch = summaryRaw.match(/href="([^"]*thread-\d+[^"]*)"/);
|
||||
const threadUrl = linkMatch ? (linkMatch[1].startsWith('http') ? linkMatch[1] : `${BASE}/${linkMatch[1]}`) : '';
|
||||
i += 1;
|
||||
if (!from && !summary) continue;
|
||||
rows.push({ index: i, from, summary, time, threadUrl });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 站内搜索 — /bbs/search.php?mod=forum
|
||||
*
|
||||
* Guests get a "请登录" alert page, so this command needs the live browser
|
||||
* session's cookie. Discuz routes search through a 302 redirect to
|
||||
* search.php?searchid=<ID>. Node fetch follows redirects automatically as
|
||||
* long as we pass the session cookie along.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchHtml, parseSearchList, assertNotGuestAlert, getCookie, decodeEntities, normalizeLimit, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '一亩三分地 站内关键字搜索(需要登录)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: '搜索关键字' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50)' },
|
||||
{ name: 'fid', type: 'string', default: '', help: '限定版块 ID(可选)' },
|
||||
],
|
||||
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'postTime', 'url'],
|
||||
func: async (page, args) => {
|
||||
const query = String(args.query || '').trim();
|
||||
if (!query) throw new ArgumentError('query 不能为空');
|
||||
const limit = normalizeLimit(args.limit, 20, 50);
|
||||
const fid = String(args.fid || '').trim();
|
||||
|
||||
const cookie = await getCookie(page);
|
||||
const qs = new URLSearchParams({
|
||||
mod: 'forum',
|
||||
srchtxt: query,
|
||||
searchsubmit: 'yes',
|
||||
...(fid ? { srchfid: fid } : {}),
|
||||
});
|
||||
const url = `${BASE}/search.php?${qs.toString()}`;
|
||||
|
||||
// Node fetch with the session cookie — Discuz's 302 to search.php?searchid=…
|
||||
// is followed by default.
|
||||
const html = await fetchHtml(url, {
|
||||
cookie,
|
||||
headers: { Referer: `${BASE}/` },
|
||||
});
|
||||
assertNotGuestAlert(html);
|
||||
|
||||
const items = parseSearchList(html);
|
||||
if (items.length === 0) {
|
||||
const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\/p>/);
|
||||
if (hint) {
|
||||
throw new EmptyResultError('1point3acres search', decodeEntities(hint[1].trim()));
|
||||
}
|
||||
throw new EmptyResultError('1point3acres search', `No results for "${query}"`);
|
||||
}
|
||||
return items.slice(0, limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
tid: t.tid,
|
||||
title: t.title,
|
||||
forum: t.forum,
|
||||
author: t.author,
|
||||
replies: t.replies,
|
||||
views: t.views,
|
||||
postTime: t.postTime,
|
||||
url: t.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 帖子详情 — /bbs/thread-<tid>-<page>-1.html
|
||||
*
|
||||
* Returns one row per post on the requested page. First row (floor=1) is the
|
||||
* main post; the rest are replies. Columns are shaped so `--limit 1` gives
|
||||
* just the main post, and larger limits walk down the thread.
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchHtml, decodeEntities, stripHtml, truncate, normalizePositiveInteger, BASE } from './utils.js';
|
||||
|
||||
function extract(html, regex, group = 1) {
|
||||
const m = html.match(regex);
|
||||
return m ? m[group] : '';
|
||||
}
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'thread',
|
||||
access: 'read',
|
||||
description: '一亩三分地 帖子详情 + 楼层(主楼 + 回复)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'tid', required: true, positional: true, help: '帖子 ID(数字,见 `hot`/`latest` 返回的 tid)' },
|
||||
{ name: 'page', type: 'int', default: 1, help: '楼层分页页码(默认 1)' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: '返回楼层条数(默认 10,含主楼)' },
|
||||
{ name: 'contentLimit', type: 'int', default: 400, help: '每楼正文截断长度(默认 400 字符,最少 50)' },
|
||||
],
|
||||
columns: ['floor', 'pid', 'author', 'postTime', 'content', 'url'],
|
||||
func: async (args) => {
|
||||
const tid = String(args.tid || '').trim();
|
||||
if (!/^\d+$/.test(tid)) {
|
||||
throw new ArgumentError('tid must be a numeric thread id');
|
||||
}
|
||||
const page = normalizePositiveInteger(args.page, 1, 'page');
|
||||
const limit = normalizePositiveInteger(args.limit, 10, 'limit');
|
||||
const contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });
|
||||
|
||||
const url = `${BASE}/thread-${tid}-${page}-1.html`;
|
||||
const html = await fetchHtml(url);
|
||||
|
||||
// Sanity: real thread page will contain postlist + at least one post div.
|
||||
if (!/id="postlist"/.test(html) && !/id="post_\d+"/.test(html)) {
|
||||
throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在或被删除`);
|
||||
}
|
||||
|
||||
// Split posts: each post block is bounded by <div id="post_<PID>">…</div> next post or postlist end.
|
||||
// NOTE: intermediate objects intentionally use postId/body/offset (not pid/html/start) to
|
||||
// avoid being mistaken for row-shaped objects by the silent-column-drop audit.
|
||||
const postBlocks = [];
|
||||
const re = /<div id="post_(\d+)"[^>]*>/g;
|
||||
const offsets = [];
|
||||
let m;
|
||||
while ((m = re.exec(html))) offsets.push({ postId: m[1], offset: m.index });
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
const segStart = offsets[i].offset;
|
||||
const segEnd = i + 1 < offsets.length ? offsets[i + 1].offset : html.length;
|
||||
postBlocks.push({ postId: offsets[i].postId, body: html.slice(segStart, segEnd) });
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < postBlocks.length && rows.length < limit; i++) {
|
||||
const { postId: pid, body: block } = postBlocks[i];
|
||||
// Discuz authi block holds the author link + post time metadata.
|
||||
const authiMatch = block.match(/<div class="authi"[\s\S]*?<\/div>/);
|
||||
const authiBlock = authiMatch ? authiMatch[0] : '';
|
||||
const authorCandidates = [
|
||||
/<a [^>]*class="[^"]*\bxi2\b[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/,
|
||||
/<a [^>]*href="space-uid-\d+\.html"[^>]*>\s*([^<]+?)\s*<\/a>/,
|
||||
/<a [^>]*class="[^"]*\bxw1\b[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/,
|
||||
];
|
||||
let author = '';
|
||||
for (const re of authorCandidates) {
|
||||
const v = decodeEntities(extract(authiBlock || block, re));
|
||||
if (v && !/匿名卡|变色卡|关贴卡/.test(v)) { author = v; break; }
|
||||
}
|
||||
// Time: prefer <span title="YYYY-MM-DD HH:MM:SS"> (per-post, precise).
|
||||
// <meta itemprop="datePublished"> is the *thread* publish time on this site — avoid.
|
||||
const postTime = extract(authiBlock, /<span title="([^"]+)">/) ||
|
||||
extract(block, /id="authorposton\d+"[^>]*>\s*<span title="([^"]+)">/) ||
|
||||
extract(block, /id="authorposton\d+"[^>]*>\s*([^<]+?)\s*</) ||
|
||||
extract(block, /<meta itemprop="datePublished" content="([^"]+)"/);
|
||||
// Floor: first post on page 1 is the 楼主, subsequent posts carry <em>N#</em>.
|
||||
const floorEm = extract(block, /<em>(\d+)<\/em>\s*#?\s*<\/a>/) ||
|
||||
extract(block, /id="postnum\d+"[^>]*>\s*<em>(\d+)<\/em>/);
|
||||
const isMainPost = page === 1 && i === 0;
|
||||
const floor = floorEm ? Number(floorEm) : (isMainPost ? 1 : (page - 1) * 10 + i + 1);
|
||||
const contentMatch = block.match(/id="postmessage_\d+"[^>]*>([\s\S]*?)<\/td>/);
|
||||
const content = truncate(stripHtml(contentMatch ? contentMatch[1] : ''), contentLimit);
|
||||
rows.push({
|
||||
floor,
|
||||
pid,
|
||||
author,
|
||||
postTime: postTime.trim(),
|
||||
content,
|
||||
url: `${BASE}/forum.php?mod=redirect&goto=findpost&ptid=${tid}&pid=${pid}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach the thread title + forum name as a leading synthetic row only when rows exist
|
||||
// and only for page 1, so agents get the title without needing a separate call.
|
||||
if (page === 1 && rows.length > 0) {
|
||||
const title = decodeEntities(
|
||||
extract(html, /<span id="thread_subject">([^<]+)<\/span>/).trim() ||
|
||||
extract(html, /<title>([^<]+?)\s*[-|]/).trim()
|
||||
);
|
||||
rows[0].content = title ? `【${title}】\n${rows[0].content}` : rows[0].content;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 第 ${page} 页没有可读取楼层`);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 一亩三分地 用户资料 — /bbs/space-uid-<uid>.html or /bbs/space-username-<name>.html
|
||||
*
|
||||
* Guest-visible fields: username, uid, user group, register/last-access times,
|
||||
* post/thread/digest counts, credits, rice (大米 — site currency), profile URL.
|
||||
* Users can be queried by numeric uid or by username (both routes are public).
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { fetchHtml, decodeEntities, BASE } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: '1point3acres',
|
||||
name: 'user',
|
||||
access: 'read',
|
||||
description: '一亩三分地 用户空间(用户组 / 积分 / 大米 / 帖子数 等)',
|
||||
domain: 'www.1point3acres.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'who', required: true, positional: true, help: '用户名或 uid(纯数字按 uid 查,否则按用户名)' },
|
||||
],
|
||||
columns: [
|
||||
'uid', 'username', 'group', 'credits', 'rice',
|
||||
'posts', 'threads', 'digests', 'registerTime', 'lastAccess', 'profileUrl',
|
||||
],
|
||||
func: async (args) => {
|
||||
const who = String(args.who || '').trim();
|
||||
if (!who) throw new ArgumentError('who 不能为空', '传用户名或数字 uid');
|
||||
const url = /^\d+$/.test(who)
|
||||
? `${BASE}/space-uid-${who}.html`
|
||||
: `${BASE}/space-username-${encodeURIComponent(who)}.html`;
|
||||
|
||||
const html = await fetchHtml(url);
|
||||
if (/<title>提示信息/.test(html) && /(没有找到|不存在)/.test(html)) {
|
||||
throw new EmptyResultError('1point3acres user', `用户 "${who}" 不存在`);
|
||||
}
|
||||
|
||||
const pick = (re) => {
|
||||
const m = html.match(re);
|
||||
return m ? decodeEntities(m[1].trim()) : '';
|
||||
};
|
||||
// <li>KEY: VAL</li> — tolerant of optional <span>, colons fullwidth/半角, 颗/根/粒 suffixes.
|
||||
const pickLi = (label) => {
|
||||
const re = new RegExp(`<li>\\s*${label}[::\\s]*(?:<[^>]+>)?\\s*([^<]+?)\\s*(?:<|$)`);
|
||||
const m = html.match(re);
|
||||
return m ? decodeEntities(m[1].trim()) : '';
|
||||
};
|
||||
|
||||
const username =
|
||||
pick(/<p class="mtm[^"]*"[^>]*>\s*<a [^>]*>([^<]+?)<\/a>/) ||
|
||||
pick(/<title>([^<]+?)的个人资料/);
|
||||
const uid = pick(/uid=(\d+)/) || pick(/space-uid-(\d+)\.html/);
|
||||
const group = pickLi('用户组');
|
||||
const credits = pickLi('积分');
|
||||
const rice = pickLi('大米');
|
||||
const posts = pickLi('帖子数');
|
||||
const threads = pickLi('主题数');
|
||||
const digests = pickLi('精华数');
|
||||
const registerTime = pickLi('注册时间');
|
||||
const lastAccess = pickLi('最后访问');
|
||||
|
||||
return [{
|
||||
uid,
|
||||
username,
|
||||
group,
|
||||
credits,
|
||||
rice,
|
||||
posts,
|
||||
threads,
|
||||
digests,
|
||||
registerTime,
|
||||
lastAccess,
|
||||
profileUrl: uid ? `${BASE}/space-uid-${uid}.html` : url,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* Shared helpers for 一亩三分地 (1point3acres.com) adapters.
|
||||
*
|
||||
* Site is a Discuz!X PHP BBS that serves GBK-encoded HTML.
|
||||
* - Thread listings: /bbs/forum.php?mod=guide&view={hot|new|digest|newthread}
|
||||
* - Forum: /bbs/forum-<fid>-<page>.html
|
||||
* - Thread detail: /bbs/thread-<tid>-<page>-1.html
|
||||
* - User profile: /bbs/space-uid-<uid>.html or /bbs/space-username-<name>.html
|
||||
* - Search: /bbs/search.php?mod=forum (COOKIE — guests get an alert page)
|
||||
*/
|
||||
import { AuthRequiredError, ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const BASE = 'https://www.1point3acres.com/bbs';
|
||||
|
||||
/**
|
||||
* Validate `limit` per typed-fail-fast convention (no silent clamp).
|
||||
* Throws ArgumentError on non-positive / non-integer / out-of-range input.
|
||||
*/
|
||||
export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {
|
||||
const limit = normalizePositiveInteger(value, defaultValue, label);
|
||||
if (limit > maxValue) {
|
||||
throw new ArgumentError(`${label} must be <= ${maxValue}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
/** Validate a positive integer argument without silently flooring/clamping. */
|
||||
export function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {
|
||||
const raw = value ?? defaultValue;
|
||||
const limit = Number(raw);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
if (limit < min) {
|
||||
throw new ArgumentError(`${label} must be >= ${min}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0 Safari/537.36';
|
||||
|
||||
/** Fetch a GBK-encoded Discuz page and return decoded UTF-8 HTML. */
|
||||
export async function fetchHtml(url, { headers = {}, cookie = '' } = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`1point3acres request failed: ${error?.message || error}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new CommandExecutionError(`1point3acres request failed: HTTP ${res.status} ${res.statusText} from ${url}`);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
return new TextDecoder('gbk').decode(buf);
|
||||
}
|
||||
|
||||
/** Pull cookie string from the live browser session for this domain.
|
||||
* Discuz auth cookies (4Oaf_61d6_*, session) are HttpOnly and set on the
|
||||
* root domain `.1point3acres.com`, so we need `getCookies` (not document.cookie)
|
||||
* AND we need to query both host + root domain and merge.
|
||||
*/
|
||||
export async function getCookie(page) {
|
||||
if (!page) return '';
|
||||
const seen = new Map();
|
||||
if (typeof page.getCookies === 'function') {
|
||||
for (const opts of [{ domain: 'www.1point3acres.com' }, { domain: '.1point3acres.com' }]) {
|
||||
try {
|
||||
const cookies = await page.getCookies(opts);
|
||||
for (const c of cookies || []) {
|
||||
if (!seen.has(c.name)) seen.set(c.name, c.value);
|
||||
}
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
}
|
||||
if (seen.size > 0) {
|
||||
return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
try {
|
||||
const result = await page.evaluate('document.cookie');
|
||||
return typeof result === 'string' ? result : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect the "you are a guest" alert page that Discuz returns for protected actions. */
|
||||
export function assertNotGuestAlert(html, domain = 'www.1point3acres.com') {
|
||||
if (/<title>提示信息 \| 一亩三分地<\/title>/.test(html) && /无法进行此操作/.test(html)) {
|
||||
throw new AuthRequiredError(domain, '需要登录一亩三分地后再使用该命令');
|
||||
}
|
||||
}
|
||||
|
||||
const ENTITY_MAP = {
|
||||
' ': ' ', '&': '&', '<': '<', '>': '>',
|
||||
'"': '"', ''': "'", ''': "'",
|
||||
};
|
||||
|
||||
/** Decode HTML entities (numeric + common named). */
|
||||
export function decodeEntities(s) {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
||||
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
||||
.replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, m => ENTITY_MAP[m] || m);
|
||||
}
|
||||
|
||||
/** Strip HTML tags and collapse whitespace, returning plain text. */
|
||||
export function stripHtml(html) {
|
||||
if (!html) return '';
|
||||
return decodeEntities(
|
||||
String(html)
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|tr)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/** Truncate text to n characters with ellipsis. */
|
||||
export function truncate(s, n = 300) {
|
||||
if (!s) return '';
|
||||
return s.length > n ? s.slice(0, n) + '…' : s;
|
||||
}
|
||||
|
||||
/** Extract all <tbody id="normalthread_*"> blocks from a forum/guide page. */
|
||||
export function parseThreadRows(html) {
|
||||
const rows = [];
|
||||
const re = /<tbody id="(normalthread|stickthread)_(\d+)"[^>]*>([\s\S]*?)<\/tbody>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const [, kind, tid, inner] = m;
|
||||
rows.push({ kind, tid, inner });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Parse a single Discuz thread row (inner HTML of the tbody). */
|
||||
export function parseThreadRow({ kind, tid, inner }) {
|
||||
const titleMatches = [...inner.matchAll(/<a [^>]*class="[^"]*\bxst\b[^"]*"[^>]*>([^<]+)<\/a>/g)];
|
||||
const title = titleMatches.length
|
||||
? decodeEntities(titleMatches[titleMatches.length - 1][1].trim())
|
||||
: '';
|
||||
|
||||
const forumMatch = inner.match(/<a href="forum-(\d+)-1\.html"[^>]*target="_blank"[^>]*>([^<]+)<\/a>/);
|
||||
const fid = forumMatch ? forumMatch[1] : '';
|
||||
const forumName = forumMatch ? decodeEntities(forumMatch[2].trim()) : '';
|
||||
|
||||
// <td class="by"> blocks; first with <cite> = author, last with <cite> = last reply
|
||||
const byBlocks = [...inner.matchAll(/<td class="by"[^>]*>([\s\S]*?)<\/td>/g)].map(m => m[1]);
|
||||
const readCite = (block) => {
|
||||
const m = block.match(/<cite[^>]*>([\s\S]*?)<\/cite>/);
|
||||
if (!m) return '';
|
||||
return decodeEntities(m[1].replace(/<[^>]+>/g, '').trim());
|
||||
};
|
||||
const readTime = (block) => {
|
||||
const titleM = block.match(/<span [^>]*title="([^"]+)"[^>]*>/);
|
||||
if (titleM) return titleM[1].trim();
|
||||
const plainA = block.match(/<em>[\s\S]*?<a [^>]*>\s*([^<]+?)\s*<\/a>/);
|
||||
if (plainA) return decodeEntities(plainA[1].trim());
|
||||
const plainSpan = block.match(/<em>[\s\S]*?<span[^>]*>\s*([^<]+?)\s*<\/span>/);
|
||||
if (plainSpan) return decodeEntities(plainSpan[1].trim());
|
||||
const bare = block.match(/<em>\s*([^<]+?)\s*<\/em>/);
|
||||
return bare ? decodeEntities(bare[1].trim()) : '';
|
||||
};
|
||||
let authorBlock = '';
|
||||
let lastBlock = '';
|
||||
for (const b of byBlocks) {
|
||||
if (/<cite/.test(b)) {
|
||||
if (!authorBlock) authorBlock = b;
|
||||
lastBlock = b;
|
||||
}
|
||||
}
|
||||
const author = authorBlock ? readCite(authorBlock) : '';
|
||||
const postTime = authorBlock ? readTime(authorBlock) : '';
|
||||
const lastReplyUser = lastBlock && lastBlock !== authorBlock ? readCite(lastBlock) : '';
|
||||
const lastReplyTime = lastBlock && lastBlock !== authorBlock ? readTime(lastBlock) : '';
|
||||
|
||||
const numMatch = inner.match(/<td class="num"[^>]*>\s*<a[^>]*class="xi2"[^>]*>(\d+)<\/a>(?:\s*<em>(\d+)<\/em>)?/);
|
||||
const replies = numMatch ? Number(numMatch[1]) : 0;
|
||||
const views = numMatch && numMatch[2] ? Number(numMatch[2]) : 0;
|
||||
return {
|
||||
tid,
|
||||
kind,
|
||||
title,
|
||||
author,
|
||||
forum: forumName,
|
||||
fid,
|
||||
replies,
|
||||
views,
|
||||
postTime,
|
||||
lastReplyUser,
|
||||
lastReplyTime,
|
||||
url: `${BASE}/thread-${tid}-1-1.html`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Quick one-shot listing parser used by hot/latest/digest/forum. */
|
||||
export function parseThreadList(html) {
|
||||
return parseThreadRows(html).map(parseThreadRow).filter(t => t.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Discuz search results page (different HTML shape than forum listings).
|
||||
* Each hit is <li class="pbw" id="TID"> containing h3 > a[href*="tid=TID"],
|
||||
* <p class="xg1">N 个回复 - M 次查看</p>, and a time/author/forum <p>.
|
||||
*/
|
||||
export function parseSearchList(html) {
|
||||
const items = [];
|
||||
const re = /<li class="pbw" id="(\d+)">([\s\S]*?)<\/li>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const [, tid, inner] = m;
|
||||
const titleMatch = inner.match(/<h3[^>]*>\s*<a [^>]*>([\s\S]*?)<\/a>/);
|
||||
const titleRaw = titleMatch ? titleMatch[1] : '';
|
||||
const title = decodeEntities(titleRaw.replace(/<[^>]+>/g, '')).trim();
|
||||
if (!title) continue;
|
||||
|
||||
const statsMatch = inner.match(/<p class="xg1">\s*([\d,]+)\s*个回复\s*-\s*([\d,]+)\s*次查看\s*<\/p>/);
|
||||
const replies = statsMatch ? Number(statsMatch[1].replace(/,/g, '')) : 0;
|
||||
const views = statsMatch ? Number(statsMatch[2].replace(/,/g, '')) : 0;
|
||||
|
||||
const metaMatch = inner.match(/<p>\s*<span>([^<]+)<\/span>[\s\S]*?<a [^>]*space-uid-\d+[^>]*>([^<]+?)<\/a>[\s\S]*?<a [^>]*href="forum-(\d+)-[^"]*"[^>]*>([^<]+?)<\/a>/);
|
||||
const postTime = metaMatch ? decodeEntities(metaMatch[1].trim()) : '';
|
||||
const author = metaMatch ? decodeEntities(metaMatch[2].trim()) : '';
|
||||
const fid = metaMatch ? metaMatch[3] : '';
|
||||
const forumName = metaMatch ? decodeEntities(metaMatch[4].trim()) : '';
|
||||
|
||||
items.push({
|
||||
tid, title, author, forum: forumName, fid,
|
||||
replies, views, postTime,
|
||||
// Search pages don't show lastReplyTime separately — surface postTime instead.
|
||||
lastReplyUser: '', lastReplyTime: postTime,
|
||||
url: `${BASE}/thread-${tid}-1-1.html`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export { UA };
|
||||
@@ -13,7 +13,6 @@ function parseArticleId(input) {
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'article',
|
||||
access: 'read',
|
||||
description: '获取36氪文章正文内容',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
|
||||
@@ -26,7 +26,6 @@ function buildHotListUrl(listType, date = new Date()) {
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'hot',
|
||||
access: 'read',
|
||||
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'news',
|
||||
access: 'read',
|
||||
description: 'Latest tech/startup news from 36kr (36氪)',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CliError } from '@jackwener/opencli/errors';
|
||||
cli({
|
||||
site: '36kr',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '搜索36氪文章',
|
||||
domain: 'www.36kr.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { JOBS_ORIGIN, requirePage, navigateTo, parseCompanyJobCard } from './uti
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'company',
|
||||
access: 'read',
|
||||
description: '51job 公司简介 + 在招职位(按 encCoId)',
|
||||
domain: 'jobs.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { JOBS_ORIGIN, requirePage, navigateTo } from './utils.js';
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'detail',
|
||||
access: 'read',
|
||||
description: '51job 职位详情(按 jobId)',
|
||||
domain: 'jobs.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'hot',
|
||||
access: 'read',
|
||||
description: '51job 推荐职位(按城市/行业/排序浏览)',
|
||||
domain: 'we.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
cli({
|
||||
site: '51job',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '51job 前程无忧关键词职位搜索',
|
||||
domain: 'we.51job.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -14,7 +14,6 @@ export function makeScreenshotCommand(site, displayName, extra = {}) {
|
||||
...extra,
|
||||
site,
|
||||
name: 'screenshot',
|
||||
access: 'read',
|
||||
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
@@ -47,7 +46,6 @@ export function makeStatusCommand(site, displayName, extra = {}) {
|
||||
...extra,
|
||||
site,
|
||||
name: 'status',
|
||||
access: 'read',
|
||||
description: `Check active CDP connection to ${label}`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
@@ -69,7 +67,6 @@ export function makeNewCommand(site, displayName, extra = {}) {
|
||||
...extra,
|
||||
site,
|
||||
name: 'new',
|
||||
access: 'write',
|
||||
description: `Start a new ${label} session`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
@@ -90,7 +87,6 @@ export function makeDumpCommand(site) {
|
||||
return cli({
|
||||
site,
|
||||
name: 'dump',
|
||||
access: 'read',
|
||||
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError, getErrorMessage } from '@jackwener/opencli/errors';
|
||||
|
||||
const AIBASE_DAILY_URL = 'https://www.aibase.com/zh/daily';
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
function normalizeLimit(value) {
|
||||
const raw = value ?? DEFAULT_LIMIT;
|
||||
const limit = Number(raw);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new ArgumentError('limit must be a positive integer', `Example: opencli aibase news --limit ${DEFAULT_LIMIT}`);
|
||||
}
|
||||
if (limit > MAX_LIMIT) {
|
||||
throw new ArgumentError(`limit must be <= ${MAX_LIMIT}`, `Example: opencli aibase news --limit ${MAX_LIMIT}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function buildExtractAibaseNewsJs() {
|
||||
return `
|
||||
(() => {
|
||||
const anchors = Array.from(document.querySelectorAll('.bg-white .grid a[href], a[href*="/zh/daily/"]'))
|
||||
.filter((anchor) => {
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
const text = (anchor.innerText || anchor.textContent || '').trim();
|
||||
return text && href && !href.endsWith('/zh/daily') && !href.endsWith('/zh/daily/');
|
||||
});
|
||||
if (anchors.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'selector-missing',
|
||||
title: document.title || '',
|
||||
bodyText: (document.body?.innerText || document.body?.textContent || '').slice(0, 500),
|
||||
};
|
||||
}
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const anchor of anchors) {
|
||||
const url = new URL(anchor.getAttribute('href'), location.href).href;
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
rows.push({
|
||||
rank: rows.length + 1,
|
||||
title: anchor.innerText || anchor.textContent || '',
|
||||
url,
|
||||
});
|
||||
}
|
||||
return { ok: true, rows };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function toRows(payload, limit) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new CommandExecutionError('AIbase daily page returned an unreadable payload');
|
||||
}
|
||||
if (!payload.ok) {
|
||||
const reason = typeof payload.reason === 'string' && payload.reason.trim() ? payload.reason.trim() : 'selector-drift';
|
||||
throw new CommandExecutionError(
|
||||
`AIbase daily selector drift: ${reason}`,
|
||||
payload.title ? `Page title: ${payload.title}` : undefined,
|
||||
);
|
||||
}
|
||||
const rows = (Array.isArray(payload.rows) ? payload.rows : [])
|
||||
.map((row, index) => ({
|
||||
rank: index + 1,
|
||||
title: normalizeText(row.title),
|
||||
url: normalizeText(row.url),
|
||||
}))
|
||||
.filter((row) => row.title && row.url);
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError('aibase news', 'AIbase daily page loaded, but no article rows with title and URL were extracted.');
|
||||
}
|
||||
return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
|
||||
}
|
||||
|
||||
async function loadAibaseNews(page, args) {
|
||||
const limit = normalizeLimit(args.limit);
|
||||
await page.goto(AIBASE_DAILY_URL, { waitUntil: 'load', settleMs: 3000 });
|
||||
const payload = await page.evaluate(buildExtractAibaseNewsJs()).catch((error) => {
|
||||
throw new CommandExecutionError(`Failed to extract AIbase daily news: ${getErrorMessage(error)}`);
|
||||
});
|
||||
return toRows(payload, limit);
|
||||
}
|
||||
|
||||
export const aibaseNewsCommand = cli({
|
||||
site: 'aibase',
|
||||
name: 'news',
|
||||
access: 'read',
|
||||
description: 'AIbase 日报 - 每天三分钟关注AI行业趋势',
|
||||
domain: 'www.aibase.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of news items to return (max ${MAX_LIMIT})` },
|
||||
],
|
||||
columns: ['rank', 'title', 'url'],
|
||||
func: loadAibaseNews,
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
buildExtractAibaseNewsJs,
|
||||
normalizeLimit,
|
||||
toRows,
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { aibaseNewsCommand, __test__ } from './news.js';
|
||||
|
||||
function runBrowserScript(html, script, url = 'https://www.aibase.com/zh/daily') {
|
||||
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
|
||||
return dom.window.eval(script);
|
||||
}
|
||||
|
||||
function makePage(evaluateResult) {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
};
|
||||
}
|
||||
|
||||
describe('aibase/news', () => {
|
||||
it('registers stable URL in columns', () => {
|
||||
expect(aibaseNewsCommand.access).toBe('read');
|
||||
expect(aibaseNewsCommand.columns).toEqual(['rank', 'title', 'url']);
|
||||
});
|
||||
|
||||
it('validates limit before browser navigation', async () => {
|
||||
const page = makePage({ ok: true, rows: [] });
|
||||
await expect(aibaseNewsCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(aibaseNewsCommand.func(page, { limit: 51 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts and deduplicates AIbase daily rows', async () => {
|
||||
const html = `
|
||||
<div class="bg-white">
|
||||
<div class="grid">
|
||||
<a href="/zh/daily/123"> First AI daily item </a>
|
||||
<a href="/zh/daily/123"> First AI daily item duplicate </a>
|
||||
<a href="/zh/daily/456"> Second AI daily item </a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const payload = runBrowserScript(html, __test__.buildExtractAibaseNewsJs());
|
||||
const page = makePage(payload);
|
||||
|
||||
const rows = await aibaseNewsCommand.func(page, { limit: 2 });
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.aibase.com/zh/daily', { waitUntil: 'load', settleMs: 3000 });
|
||||
expect(rows).toEqual([
|
||||
{ rank: 1, title: 'First AI daily item', url: 'https://www.aibase.com/zh/daily/123' },
|
||||
{ rank: 2, title: 'Second AI daily item', url: 'https://www.aibase.com/zh/daily/456' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps selector drift and empty rows to typed errors', async () => {
|
||||
await expect(aibaseNewsCommand.func(makePage({ ok: false, reason: 'selector-missing' }), { limit: 1 }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
await expect(aibaseNewsCommand.func(makePage({ ok: true, rows: [{ title: '', url: '' }] }), { limit: 1 }))
|
||||
.rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { cli } from '@jackwener/opencli/registry';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'bestsellers',
|
||||
access: 'read',
|
||||
listType: 'bestsellers',
|
||||
description: 'Amazon Best Sellers pages for category candidate discovery',
|
||||
}));
|
||||
|
||||
@@ -85,7 +85,6 @@ async function readDiscussionPayload(page, input, limit) {
|
||||
cli({
|
||||
site: 'amazon',
|
||||
name: 'discussion',
|
||||
access: 'read',
|
||||
description: 'Amazon review summary and sample customer discussion from product review pages',
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -3,8 +3,35 @@ import { AuthRequiredError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { __test__ } from './discussion.js';
|
||||
import './discussion.js';
|
||||
import { createPageMock } from '../test-utils.js';
|
||||
|
||||
function createPageMock(evaluateResults) {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
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({ forms: [], orphanFields: [] }),
|
||||
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([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('amazon discussion normalization', () => {
|
||||
it('normalizes review summary and sample reviews', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli } from '@jackwener/opencli/registry';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'movers-shakers',
|
||||
access: 'read',
|
||||
listType: 'movers_shakers',
|
||||
description: 'Amazon Movers & Shakers pages for short-term growth signals',
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli } from '@jackwener/opencli/registry';
|
||||
import { createRankingCliOptions } from './rankings.js';
|
||||
cli(createRankingCliOptions({
|
||||
commandName: 'new-releases',
|
||||
access: 'read',
|
||||
listType: 'new_releases',
|
||||
description: 'Amazon New Releases pages for early momentum discovery',
|
||||
}));
|
||||
|
||||
@@ -106,7 +106,6 @@ async function readOfferPayload(page, input) {
|
||||
cli({
|
||||
site: 'amazon',
|
||||
name: 'offer',
|
||||
access: 'read',
|
||||
description: 'Amazon seller, buy box, and fulfillment facts from the product page',
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -65,7 +65,6 @@ async function readProductPayload(page, input) {
|
||||
cli({
|
||||
site: 'amazon',
|
||||
name: 'product',
|
||||
access: 'read',
|
||||
description: 'Amazon product page facts for candidate validation',
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -142,7 +142,6 @@ export function createRankingCliOptions(definition) {
|
||||
return {
|
||||
site: 'amazon',
|
||||
name: definition.commandName,
|
||||
access: definition.access ?? 'read',
|
||||
description: definition.description,
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -49,7 +49,6 @@ async function readSearchPayload(page, query) {
|
||||
cli({
|
||||
site: 'amazon',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Amazon search results for product discovery and coarse filtering',
|
||||
domain: 'amazon.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as fs from 'node:fs';
|
||||
export const dumpCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'dump',
|
||||
access: 'read',
|
||||
description: 'Dump the DOM to help AI understand the UI',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const extractCodeCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'extract-code',
|
||||
access: 'read',
|
||||
description: 'Extract multi-line code blocks from the current Antigravity conversation',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const modelCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'model',
|
||||
access: 'read',
|
||||
description: 'Switch the active LLM model in Antigravity',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const newCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'new',
|
||||
access: 'read',
|
||||
description: 'Start a new conversation / clear context in Antigravity',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const readCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'read',
|
||||
access: 'read',
|
||||
description: 'Read the latest chat messages from Antigravity AI',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const sendCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'send',
|
||||
access: 'write',
|
||||
description: 'Send a message to Antigravity AI via the internal Lexical editor',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const statusCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'status',
|
||||
access: 'read',
|
||||
description: 'Check Antigravity CDP connection and get current page state',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
|
||||
@@ -2,14 +2,12 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const watchCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'watch',
|
||||
access: 'read',
|
||||
description: 'Stream new chat messages from Antigravity in real-time',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'timeout', type: 'int', required: false, default: 86400, help: 'Max seconds to keep watching (default: 86400 — 24h)' },
|
||||
],
|
||||
args: [],
|
||||
timeoutSeconds: 86400, // Run for up to 24 hours
|
||||
columns: [], // We use direct stdout streaming
|
||||
func: async (page) => {
|
||||
console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
|
||||
|
||||
@@ -4,7 +4,6 @@ import { itunesFetch, formatDuration, formatDate } from './utils.js';
|
||||
cli({
|
||||
site: 'apple-podcasts',
|
||||
name: 'episodes',
|
||||
access: 'read',
|
||||
description: 'List recent episodes of an Apple Podcast (use ID from search)',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { itunesFetch } from './utils.js';
|
||||
cli({
|
||||
site: 'apple-podcasts',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Search Apple Podcasts',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
|
||||
@@ -6,7 +6,6 @@ const CHARTS_TIMEOUT_MS = 15_000;
|
||||
cli({
|
||||
site: 'apple-podcasts',
|
||||
name: 'top',
|
||||
access: 'read',
|
||||
description: 'Top podcasts chart on Apple Podcasts',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// arxiv author — list papers authored by a person, newest first.
|
||||
//
|
||||
// arXiv's public API supports `au:` prefix queries. Author names on arXiv are
|
||||
// not stable IDs, so this is a best-effort fuzzy match — the same person can
|
||||
// appear under multiple spellings ("Y. Bengio" vs "Yoshua Bengio").
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { arxivFetch, normalizeArxivLimit, parseEntries } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
name: 'author',
|
||||
access: 'read',
|
||||
description: 'List arXiv papers by a given author (newest first)',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'author', positional: true, required: true, help: 'Author name (e.g. "Yoshua Bengio" or "Y Bengio")' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max papers to return (max 50)' },
|
||||
],
|
||||
columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
|
||||
func: async (args) => {
|
||||
const authorText = String(args.author || '').trim();
|
||||
if (!authorText) {
|
||||
throw new ArgumentError('arxiv author cannot be empty', 'Example: opencli arxiv author "Yoshua Bengio"');
|
||||
}
|
||||
const limit = normalizeArxivLimit(args.limit, 20, 50);
|
||||
// Quote the value so multi-word author names match as a phrase.
|
||||
const query = encodeURIComponent(`au:"${authorText}"`);
|
||||
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=submittedDate&sortOrder=descending`);
|
||||
const entries = parseEntries(xml);
|
||||
if (!entries.length) {
|
||||
throw new EmptyResultError('arxiv author', `No papers found for author "${authorText}". Try alternate spellings (e.g. initials).`);
|
||||
}
|
||||
return entries.map(e => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
authors: e.authors,
|
||||
published: e.published,
|
||||
primary_category: e.primary_category,
|
||||
url: e.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { arxivFetch, parseEntries } from './utils.js';
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
name: 'paper',
|
||||
access: 'read',
|
||||
description: 'Get arXiv paper details by ID',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { arxivFetch, normalizeArxivCategory, normalizeArxivLimit, parseEntries }
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
name: 'recent',
|
||||
access: 'read',
|
||||
description: 'List recent arXiv submissions in a category',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { arxivFetch, normalizeArxivLimit, parseEntries } from './utils.js';
|
||||
cli({
|
||||
site: 'arxiv',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Search arXiv papers',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
||||
cli({
|
||||
site: 'baidu-scholar',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '百度学术搜索',
|
||||
domain: 'xueshu.baidu.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
@@ -14,6 +13,7 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'authors', 'journal', 'year', 'cited', 'url'],
|
||||
navigateBefore: false,
|
||||
func: async (page, kwargs) => {
|
||||
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
||||
const query = requireNonEmptyQuery(kwargs.query);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'band',
|
||||
name: 'bands',
|
||||
access: 'read',
|
||||
description: 'List all Bands you belong to',
|
||||
domain: 'www.band.us',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -12,7 +12,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'band',
|
||||
name: 'mentions',
|
||||
access: 'read',
|
||||
description: 'Show Band notifications where you are @mentioned',
|
||||
domain: 'www.band.us',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
|
||||
@@ -18,7 +18,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'band',
|
||||
name: 'post',
|
||||
access: 'read',
|
||||
description: 'Export full content of a post including comments',
|
||||
domain: 'www.band.us',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'band',
|
||||
name: 'posts',
|
||||
access: 'read',
|
||||
description: 'List posts from a Band',
|
||||
domain: 'www.band.us',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'flow',
|
||||
access: 'read',
|
||||
description: 'Barchart unusual options activity / options flow',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'greeks',
|
||||
access: 'read',
|
||||
description: 'Barchart options greeks overview (IV, delta, gamma, theta, vega)',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'options',
|
||||
access: 'read',
|
||||
description: 'Barchart options chain with greeks, IV, volume, and open interest',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'quote',
|
||||
access: 'read',
|
||||
description: 'Barchart stock quote with price, volume, and key metrics',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'bbc',
|
||||
name: 'news',
|
||||
access: 'read',
|
||||
description: 'BBC News headlines (RSS)',
|
||||
domain: 'www.bbc.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// bbc topic — BBC News headlines for a specific category, via public RSS.
|
||||
//
|
||||
// BBC publishes per-section RSS feeds at
|
||||
// `https://feeds.bbci.co.uk/news/<topic>/rss.xml`. We expose the eight
|
||||
// canonical sections and reject anything else with a typed argument error
|
||||
// so the user knows the supported set.
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { bbcFetchRss, parseRssItems, pubDateToIso, requireBoundedInt } from './utils.js';
|
||||
|
||||
const TOPICS = [
|
||||
'world',
|
||||
'business',
|
||||
'politics',
|
||||
'health',
|
||||
'education',
|
||||
'science_and_environment',
|
||||
'technology',
|
||||
'entertainment_and_arts',
|
||||
];
|
||||
|
||||
cli({
|
||||
site: 'bbc',
|
||||
name: 'topic',
|
||||
access: 'read',
|
||||
description: 'BBC News headlines for a specific section (RSS feed)',
|
||||
domain: 'www.bbc.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'topic', positional: true, required: true, help: `Section name (${TOPICS.join(' / ')})` },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max headlines (1-50)' },
|
||||
],
|
||||
columns: ['rank', 'title', 'description', 'pubDate', 'url'],
|
||||
func: async (args) => {
|
||||
const raw = String(args.topic ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
||||
if (!TOPICS.includes(raw)) {
|
||||
throw new ArgumentError(
|
||||
`bbc topic "${args.topic}" is not supported`,
|
||||
`Supported topics: ${TOPICS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const limit = requireBoundedInt(args.limit, 20, 50);
|
||||
const xml = await bbcFetchRss(`${raw}/rss.xml`, `bbc topic ${raw}`);
|
||||
const items = parseRssItems(xml);
|
||||
if (!items.length) {
|
||||
throw new EmptyResultError('bbc topic', `BBC ${raw} feed returned no items.`);
|
||||
}
|
||||
return items.slice(0, limit).map((it, i) => ({
|
||||
rank: i + 1,
|
||||
title: it.title,
|
||||
description: it.description,
|
||||
pubDate: pubDateToIso(it.pubDate),
|
||||
url: it.link,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// Shared helpers for the bbc adapters that hit BBC's public RSS feeds.
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const BBC_FEED_BASE = 'https://feeds.bbci.co.uk/news';
|
||||
const UA = 'opencli-bbc-adapter (+https://github.com/jackwener/opencli)';
|
||||
|
||||
const HTML_ENTITIES = {
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ',
|
||||
};
|
||||
|
||||
export function decodeHtmlEntities(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||
.replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/g, (m) => HTML_ENTITIES[m] || m);
|
||||
}
|
||||
|
||||
/** Extract `<tag>…</tag>` (CDATA-aware) from a block. */
|
||||
export function extractRssTag(block, tag) {
|
||||
const cdata = block.match(new RegExp(`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*<\\/${tag}>`));
|
||||
if (cdata) return cdata[1];
|
||||
const plain = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
|
||||
return plain ? plain[1] : '';
|
||||
}
|
||||
|
||||
export function parseRssItems(xml) {
|
||||
const out = [];
|
||||
const re = /<item[^>]*>([\s\S]*?)<\/item>/g;
|
||||
let m;
|
||||
while ((m = re.exec(String(xml || ''))) !== null) {
|
||||
const block = m[1];
|
||||
out.push({
|
||||
title: decodeHtmlEntities(extractRssTag(block, 'title')).trim(),
|
||||
description: decodeHtmlEntities(extractRssTag(block, 'description')).trim(),
|
||||
link: decodeHtmlEntities(extractRssTag(block, 'link')).trim(),
|
||||
pubDate: decodeHtmlEntities(extractRssTag(block, 'pubDate')).trim(),
|
||||
guid: decodeHtmlEntities(extractRssTag(block, 'guid')).trim(),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
||||
const raw = value ?? defaultValue;
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new ArgumentError(`bbc ${label} must be a positive integer`);
|
||||
}
|
||||
if (n > maxValue) {
|
||||
throw new ArgumentError(`bbc ${label} must be <= ${maxValue}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function bbcFetchRss(path, label) {
|
||||
const url = `${BBC_FEED_BASE}/${path}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/rss+xml, application/xml' } });
|
||||
}
|
||||
catch (err) {
|
||||
throw new CommandExecutionError(
|
||||
`${label} request failed: ${err?.message ?? err}`,
|
||||
'Check that feeds.bbci.co.uk is reachable from this network.',
|
||||
);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new CommandExecutionError(`${label} returned HTTP ${resp.status} (${url})`);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
/** Convert RFC-822 pubDate to ISO `YYYY-MM-DD`; empty string on parse failure. */
|
||||
export function pubDateToIso(value) {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { apiGet, resolveBvid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'comments',
|
||||
access: 'read',
|
||||
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -14,7 +14,6 @@ import { resolveBvid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'download',
|
||||
access: 'read',
|
||||
description: '下载B站视频(需要 yt-dlp)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { apiGet } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'dynamic',
|
||||
access: 'read',
|
||||
description: 'Get Bilibili user dynamic feed',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { apiGet, payloadData, getSelfUid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'favorite',
|
||||
access: 'write',
|
||||
description: '我的收藏夹',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -65,7 +65,6 @@ function parseItem(item) {
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'feed',
|
||||
access: 'read',
|
||||
description: '动态时间线(不传 uid 查关注时间线,传 uid 查指定用户动态)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
@@ -135,7 +134,6 @@ cli({
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'feed-detail',
|
||||
access: 'read',
|
||||
description: '查看 Bilibili 动态详情(支持充电专属内容)',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { fetchJson, getSelfUid, resolveUid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'following',
|
||||
access: 'read',
|
||||
description: '获取 Bilibili 用户的关注列表',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { apiGet, payloadData } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'history',
|
||||
access: 'read',
|
||||
description: '我的观看历史',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cli } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'hot',
|
||||
access: 'read',
|
||||
description: 'B站热门视频',
|
||||
domain: 'www.bilibili.com',
|
||||
args: [
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { apiGet, getSelfUid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili', name: 'me', access: 'read', description: 'My Bilibili profile info', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
|
||||
site: 'bilibili', name: 'me', description: 'My Bilibili profile info', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
|
||||
args: [],
|
||||
columns: ['name', 'uid', 'level', 'coins', 'followers', 'following'],
|
||||
func: async (page) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { apiGet } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'ranking',
|
||||
access: 'read',
|
||||
description: 'Get Bilibili video ranking board',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { apiGet, stripHtml } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili', name: 'search', access: 'read', description: 'Search Bilibili videos or users', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
|
||||
site: 'bilibili', name: 'search', description: 'Search Bilibili videos or users', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
||||
{ name: 'type', default: 'video', help: 'video or user' },
|
||||
|
||||
@@ -4,11 +4,10 @@ import { apiGet, resolveBvid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'subtitle',
|
||||
access: 'read',
|
||||
description: '获取 Bilibili 视频的字幕',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'bvid', required: true, positional: true, help: 'Bilibili 视频 BV ID(如 BV1xx411c7mD),或视频 URL / b23.tv 短链' },
|
||||
{ name: 'bvid', required: true, positional: true },
|
||||
{ name: 'lang', required: false, help: '字幕语言代码 (如 zh-CN, en-US, ai-zh),默认取第一个' },
|
||||
],
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
|
||||
@@ -3,7 +3,6 @@ import { apiGet, payloadData, resolveUid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'user-videos',
|
||||
access: 'read',
|
||||
description: '查看指定用户的投稿视频',
|
||||
domain: 'www.bilibili.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { apiGet, resolveBvid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
name: 'video',
|
||||
access: 'read',
|
||||
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'asks',
|
||||
access: 'read',
|
||||
description: 'Order book ask prices for a trading pair',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'depth',
|
||||
access: 'read',
|
||||
description: 'Order book bid and ask prices for a trading pair',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'gainers',
|
||||
access: 'read',
|
||||
description: 'Top gaining trading pairs by 24h price change',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'klines',
|
||||
access: 'read',
|
||||
description: 'Candlestick/kline data for a trading pair',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'losers',
|
||||
access: 'read',
|
||||
description: 'Top losing trading pairs by 24h price change',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'pairs',
|
||||
access: 'read',
|
||||
description: 'List active trading pairs on Binance',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'price',
|
||||
access: 'read',
|
||||
description: 'Quick price check for a trading pair',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'prices',
|
||||
access: 'read',
|
||||
description: 'Latest prices for all trading pairs',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'ticker',
|
||||
access: 'read',
|
||||
description: '24h ticker statistics for top trading pairs by volume',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'top',
|
||||
access: 'read',
|
||||
description: 'Top trading pairs by 24h volume on Binance',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'binance',
|
||||
name: 'trades',
|
||||
access: 'read',
|
||||
description: 'Recent trades for a trading pair',
|
||||
domain: 'data-api.binance.vision',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'businessweek',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Businessweek top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'economics',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Economics top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BLOOMBERG_FEEDS } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'feeds',
|
||||
access: 'read',
|
||||
description: 'List the Bloomberg RSS feed aliases used by the adapter',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fetchBloombergFeed } from './utils.js';
|
||||
cli({
|
||||
site: 'bloomberg',
|
||||
name: 'industries',
|
||||
access: 'read',
|
||||
description: 'Bloomberg Industries top stories (RSS)',
|
||||
domain: 'feeds.bloomberg.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user