diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f11b5133..dd788be6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## Unreleased + +### Bug Fixes + +* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors. Douyin user video comment fetch failures, Jike SSR JSON parse failures, and WeRead search-page fetch failures now throw `CommandExecutionError`; true empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`. + +### Internal + +* **audit** — stop flagging sentinel fallback strings inside thrown error messages as `silent-sentinel` violations. These are typed failure diagnostics rather than fake row data, reducing the typed-error baseline to actual adapter output fallbacks. + +## [1.7.22](https://github.com/jackwener/opencli/compare/v1.7.21...v1.7.22) (2026-05-15) + +External CLI ergonomics + two adapter envelope/auth fixes. New `longbridge` external CLI entry; `opencli list` / root help now render human-readable brand labels for executables whose bare name is ambiguous. + +### Features + +* **external** — add the Longbridge CLI as a built-in external CLI passthrough (`opencli longbridge ...`) for Longbridge OpenAPI market data, account, and trading commands. ([#1584](https://github.com/jackwener/opencli/issues/1584)) +* **external-cli** — render brand alias `name(package)` in `opencli list` and root help when the bare executable name is ambiguous. Built-in entries `ntn` → `ntn(notion)`, `dws` → `dws(DingTalk Workspace)`, `wecom-cli` → `wecom-cli(企业微信)` now self-explain in help output. `package` field is repurposed to cover both upstream distribution names (e.g. `tg-cli`) and human-readable brand labels (e.g. `notion`, `企业微信`). ([#1585](https://github.com/jackwener/opencli/issues/1585)) + +### Bug Fixes + +* **boss** — map `code=24` (identity mismatch) to `AuthRequiredError` so re-login is signaled instead of surfacing as a generic API error. ([#1573](https://github.com/jackwener/opencli/issues/1573)) +* **weibo** — unwrap Browser Bridge `page.evaluate` envelopes in read adapters. ([#1568](https://github.com/jackwener/opencli/issues/1568)) + +## [1.7.21](https://github.com/jackwener/opencli/compare/v1.7.20...v1.7.21) (2026-05-14) + +Adapter polish release: new web search adapters, better Browser Bridge tab group reuse, and social adapters returning to one-shot tab leases. Extension package version is bumped to 1.0.15 for the Browser Bridge fix. + +### Features + +* **search** — add DuckDuckGo, Brave, and Yahoo web search adapters. ([#1546](https://github.com/jackwener/opencli/issues/1546)) +* **boss** — support job-seeker `chatlist` and `chatmsg` adapters. ([#1539](https://github.com/jackwener/opencli/issues/1539)) + +### Bug Fixes + +* **extension** — reuse existing `OpenCLI Adapter` tab groups before creating new ones, including cross-window discovery, legacy `OpenCLI` title fallback, and deterministic candidate selection. ([#1541](https://github.com/jackwener/opencli/issues/1541)) +* **twitter, reddit** — default browser-backed social adapters back to ephemeral tab leases. Twitter/X and Reddit commands now release their site tab after each run while keeping the shared Adapter window available for reuse; persistent sessions remain reserved for AI/chat-style adapters that need long-lived conversation state. ([#1569](https://github.com/jackwener/opencli/issues/1569)) +* **xiaohongshu, rednote** — unwrap Browser Bridge `page.evaluate` envelopes in search adapters. ([#1561](https://github.com/jackwener/opencli/issues/1561)) +* **facebook/feed** — add fallback extraction for empty article nodes. ([#1538](https://github.com/jackwener/opencli/issues/1538)) + +### Internal + +* **ci** — add Windows native binding lockfile entries for Rolldown/Rollup optional packages. ([#1563](https://github.com/jackwener/opencli/issues/1563)) +* **extension** — add regression coverage for the adapter tab group `groupId` tiebreaker. ([#1566](https://github.com/jackwener/opencli/issues/1566)) + +## [1.7.20](https://github.com/jackwener/opencli/compare/v1.7.19...v1.7.20) (2026-05-14) + +External CLI surface cleanup + Browser Bridge WebSocket lifecycle hardening. Two BREAKING changes around external CLIs: built-in `tg`/`discord`/`wx` (was `tg-cli`/`discord-cli`/`wx-cli`) now match their real binary names, and Notion's in-tree CDP adapter is replaced by the official `ntn` external CLI. + +### ⚠ BREAKING CHANGES + +* **notion** — remove the in-tree `clis/notion/` CDP-on-Desktop adapter (8 commands: `status` / `search` / `read` / `new` / `write` / `sidebar` / `favorites` / `export`). Notion has shipped an official CLI at , registered as a first-class external CLI in `external-clis.yaml`. Migration: install `ntn` from (`curl -fsSL https://ntn.dev | bash`), then use `opencli ntn `. Auto-install is intentionally not configured because the official installer is a shell script while OpenCLI external installs run shell-free command strings. The official CLI uses the public Notion API rather than reverse-engineering the Desktop UI, so it survives Notion app updates and exposes a wider command surface (blocks / databases / properties / comments) than the reverse-engineered adapter could. ([#1559](https://github.com/jackwener/opencli/issues/1559)) +* **external** — drop the `-cli` suffix from built-in external CLI subcommand names. `opencli tg-cli`, `opencli discord-cli`, `opencli wx-cli` are now `opencli tg`, `opencli discord`, `opencli wx`, matching the real binary names that those tools install as. Root help still shows the package lineage as `tg(tg-cli)` / `discord(discord-cli)` / `wx(wx-cli)`. ([#1544](https://github.com/jackwener/opencli/issues/1544)) + +### Features + +* **twitter** — `bookmarks` and `bookmark-folder` now include media via `extractMedia`, reaching parity with `timeline` / `search`. ([#1555](https://github.com/jackwener/opencli/issues/1555)) +* **twitter/list-tweets** — include media via `extractMedia` (parity with `timeline` / `search`). ([#1464](https://github.com/jackwener/opencli/issues/1464)) + +### Bug Fixes + +* **daemon** — report ambiguous browser command outcomes with a distinct `command_result_unknown` errorCode and `503` when the extension WebSocket drops between command dispatch and result delivery. `sendCommandRaw()` treats this code as hard non-retryable, so write-side commands (`navigate` / `click` / `type` / `eval`) won't be silently re-issued and double-executed. Daemon exposes a `commandResultUnknown` counter on `/status` for future observability. ([#1558](https://github.com/jackwener/opencli/issues/1558)) +* **extension** — keep active daemon WebSocket; stale sockets no longer clobber active connection (`onopen` / `onclose` / `onmessage` are all gated by `ws !== thisWs` short-circuit), and `safeSend` only fires when `readyState === OPEN`. ([#1540](https://github.com/jackwener/opencli/issues/1540)) +* **extension** — coalesce concurrent daemon WebSocket connects via an in-flight promise. Startup / keepalive / reconnect triggering `connect()` during the daemon-probe or context-lookup async gap no longer creates duplicate real WebSocket connections. ([#1554](https://github.com/jackwener/opencli/issues/1554)) +* **external** — distinguish external CLI executable names from distribution/project names in root help. Built-in aliases such as `tg`, `discord`, `wx` remain the callable `opencli ...` entrypoints while help renders `tg(tg-cli)`, `discord(discord-cli)`, `wx(wx-cli)` to show their package lineage. ([#1560](https://github.com/jackwener/opencli/issues/1560)) + +### Docs + +* **browser** — clarify named session lifecycle in the Browser Bridge guide. ([#1542](https://github.com/jackwener/opencli/issues/1542)) + ## [1.7.19](https://github.com/jackwener/opencli/compare/v1.7.18...v1.7.19) (2026-05-14) Major hotfix + simplification batch. Extension bumped to 1.0.14. Node floor lowered to v20 so the long tail of Node v20–v21.6 users no longer crashes at module load. `opencli browser` user surface replaces required-flag `--session ` with a `` positional. `page.evaluate(fn, ...args)` adds a type-safe alternative to the implicit auto-IIFE string form. Twitter cursor pagination no longer silently caps at ~500 items. diff --git a/README.md b/README.md index 400f97dc2..53d1fdb25 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # OpenCLI -> **Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.** -> Reuse your logged-in browser, automate live workflows, and crystallize repeated actions into reusable CLI commands. +> **Convert any website into a CLI & Drive your logged-in browser from AI agents.** +> Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents. +> Or drive your logged-in browser to do anything — navigate, fill forms, click, extract, automate. [![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md) [![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli) @@ -14,19 +15,15 @@ OpenCLI gives you one surface for three different kinds of automation: - **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. - **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`, `tg`, `discord`, `wx`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion. +It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT. ## 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. -- **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. -- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification. -- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, tg, discord, wx, etc). -- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing. -- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly. +- **Live Browser Automation** — Drive your logged-in Chrome from AI agents: navigate, fill forms, click, extract. Credentials stay in the browser. +- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT) directly via CDP. +- **Multi-profile Browser Bridge** — Route commands to specific Chrome profiles via `--profile` or `OPENCLI_PROFILE`. +- **100+ adapters + CLI Hub** — Built-in site commands (bilibili / xiaohongshu / twitter / hackernews / ...) plus external CLI passthrough (`gh`, `docker`, `ntn`, `longbridge`). +- **Zero LLM cost at runtime** — Deterministic output, no tokens consumed. --- @@ -127,7 +124,7 @@ npx skills add jackwener/opencli --skill smart-search |-------|------------|-------------------------------| | **opencli-adapter-author** | Operate a site in real time, or write a reusable adapter for a new site | "Help me check my Xiaohongshu notifications" / "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" | | **opencli-autofix** | Repair a broken adapter when a built-in command fails | "`opencli zhihu hot` is returning empty — fix it" | -| **opencli-browser** | Browser automation reference for AI agents | "Use browser commands to scrape this page" | +| **opencli-browser** | Browser automation reference for AI agents | "Help me fill out this form" / "Use browser commands to scrape this page" | | **opencli-usage** | Quick reference for all OpenCLI commands and sites | "What commands does OpenCLI have for Twitter?" | | **smart-search** | Search across existing OpenCLI capabilities | "Find me a Bilibili trending adapter" | @@ -250,7 +247,7 @@ To load the source Browser Bridge extension: |------|----------| | **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` | | **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` | -| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` | +| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `summary` `video` `user-videos` | | **tieba** | `hot` `posts` `search` `read` | | **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | | **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | @@ -276,6 +273,7 @@ To load the source Browser Bridge extension: | **wanfang** | `search` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | | **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` | +| **youdao** | `note` | 100+ site surfaces in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** @@ -283,19 +281,21 @@ To load the source Browser Bridge extension: ## CLI Hub -OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install ` automatically before re-running the command). +OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install when a safe package-manager command is configured. | External CLI | Description | Example | |--------------|-------------|---------| | **gh** | GitHub CLI | `opencli gh pr list --limit 5` | | **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` | | **docker** | Docker | `opencli docker ps` | +| **longbridge** | Longbridge CLI — market data, account management, and trading via Longbridge OpenAPI | `opencli longbridge quote TSLA.US --format json` | +| **ntn** | Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments | `opencli ntn pages list` | | **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` | | **dws** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dws msg send --to user "hello"` | | **wecom-cli** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom-cli msg send --to user "hello"` | -| **tg** | Telegram — local-first sync, search, and export via MTProto for AI agents | `opencli tg search "AI news" -f json` | -| **discord** | Discord — local-first sync, search, and export via SQLite for AI agents | `opencli discord recent --channel general` | -| **wx** | WeChat — query local WeChat data: sessions, messages, search, contacts, export | `opencli wx search "OpenCLI"` | +| **tg(tg-cli)** | Telegram — local-first sync, search, and export via MTProto for AI agents | `opencli tg search "AI news" -f json` | +| **discord(discord-cli)** | Discord — local-first sync, search, and export via SQLite for AI agents | `opencli discord recent --channel general` | +| **wx(wx-cli)** | WeChat — query local WeChat data: sessions, messages, search, contacts, export | `opencli wx search "OpenCLI"` | | **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` | **Register your own** — add any local CLI so AI agents can discover it via `opencli list`: @@ -304,6 +304,8 @@ OpenCLI acts as a universal hub for your existing command-line tools — unified opencli external register mycli ``` +**Manual install** — some external CLIs use official shell-script installers rather than shell-free package-manager commands. For `ntn`, install from first, then run `opencli ntn ...`. + ### Desktop App Adapters Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation: @@ -315,7 +317,6 @@ Control Electron desktop apps directly from the terminal. Each adapter has its o | **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) | | **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) | | **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) | -| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) | | **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) | | **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) | diff --git a/README.zh-CN.md b/README.zh-CN.md index d366a0590..ef61d4293 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,8 @@ # OpenCLI -> **把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。** -> 复用浏览器登录态,先自动化真实操作,再把高频流程沉淀成可复用的 CLI 命令。 +> **把任意网站变成 CLI & 让 AI Agent 操控你的登录态浏览器。** +> 把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。 +> 或者直接操控你的登录态浏览器做任何事 —— 导航、填表单、点击、抓取、自动化。 [![English](https://img.shields.io/badge/docs-English-1D4ED8?style=flat-square)](./README.md) [![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli) @@ -14,18 +15,15 @@ OpenCLI 可以用同一套 CLI 做三类事情: - **让 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`、`tg`、`discord`、`wx` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。 +除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT 等 Electron 应用。 ## 亮点 -- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)。 -- **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 发现、字段解码、适配器编写、验证的全流程。 -- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian、tg、discord、wx 等)。 -- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。 -- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。 +- **登录态浏览器自动化** — 让 AI Agent 驱动你的已登录 Chrome:导航、填表单、点击、提取。凭证永远不离开浏览器。 +- **桌面应用控制** — 通过 CDP 直接驱动 Electron 应用(Cursor、Codex、ChatGPT)。 +- **多 Profile 浏览器桥接** — 通过 `--profile` 或 `OPENCLI_PROFILE` 把命令路由到指定 Chrome profile。 +- **100+ 适配器 + CLI 枢纽** — 内置站点命令(B站 / 小红书 / Twitter / HackerNews / ...)加外部 CLI 透传(`gh`、`docker`、`ntn`、`longbridge`)。 +- **零 LLM 运行成本** — 确定性输出,运行时不消耗 token。 ## 快速开始 @@ -111,7 +109,7 @@ npx skills add jackwener/opencli --skill smart-search |-------|---------|-------------------| | **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" | | **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" | -| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" | +| **opencli-browser** | 浏览器自动化参考文档 | "帮我填一下这个表单" / "用浏览器命令抓取这个页面" | | **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" | | **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" | @@ -236,12 +234,11 @@ npm link | **tieba** | `hot` `posts` `search` `read` | 浏览器 | | **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` | 浏览器 | +| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 | | **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `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` | 桌面端 | -| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 | | **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 | | **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 | @@ -263,6 +260,7 @@ npm link | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 | | **weixin** | `download` | 浏览器 | | **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 | +| **youdao** | `note` | 公开 | | **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 | | **coupang** | `search` `add-to-cart` | 浏览器 | | **bbc** | `news` | 公共 API | @@ -281,7 +279,7 @@ npm link | **reuters** | `search` | 浏览器 | | **smzdm** | `search` | 浏览器 | | **web** | `read` | 浏览器 | -| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 | +| **weibo** | `hot` `search` `feed` `user` `user-posts` `me` `post` `comments` | 浏览器 | | **yahoo-finance** | `quote` | 浏览器 | | **sinafinance** | `news` | 🌐 公开 | | **barchart** | `quote` `options` `greeks` `flow` | 浏览器 | @@ -333,17 +331,19 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、 | **gh** | GitHub CLI | `opencli gh pr list --limit 5` | | **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` | | **docker** | Docker 命令行工具 | `opencli docker ps` | +| **longbridge** | Longbridge CLI — 通过 Longbridge OpenAPI 获取行情、账户和交易能力 | `opencli longbridge quote TSLA.US --format json` | +| **ntn** | Notion CLI — 基于官方 Notion API 的页面、数据库、块、搜索、评论命令 | `opencli ntn pages list` | | **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` | | **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` | | **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` | -| **tg** | Telegram CLI — 基于 MTProto 的本地优先同步、搜索、导出,面向 AI Agent | `opencli tg search "AI news" -f json` | -| **discord** | Discord CLI — 基于 SQLite 的本地优先同步、搜索、导出,面向 AI Agent | `opencli discord recent --channel general` | -| **wx** | 微信本地数据 CLI — 会话、聊天记录、搜索、联系人、导出 | `opencli wx search "OpenCLI"` | +| **tg(tg-cli)** | Telegram CLI — 基于 MTProto 的本地优先同步、搜索、导出,面向 AI Agent | `opencli tg search "AI news" -f json` | +| **discord(discord-cli)** | Discord CLI — 基于 SQLite 的本地优先同步、搜索、导出,面向 AI Agent | `opencli discord recent --channel general` | +| **wx(wx-cli)** | 微信本地数据 CLI — 会话、聊天记录、搜索、联系人、导出 | `opencli wx search "OpenCLI"` | | **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` | **零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。 -**自动安装**:如果你运行 `opencli gh ...` 时系统中还没有 `gh`,OpenCLI 会优先尝试通过系统包管理器安装,然后自动重试命令。 +**自动安装**:如果某个外部 CLI 配置了安全的包管理器安装命令,OpenCLI 会优先尝试安装后再执行;`ntn` 的官方安装方式是 shell 脚本,请先按 手动安装。 **注册自定义本地 CLI**: @@ -362,7 +362,6 @@ opencli register mycli | **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) | | **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) | | **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) | -| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) | | **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) | | **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) | diff --git a/cli-manifest.json b/cli-manifest.json index dba1d0eb7..943d03930 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -1821,7 +1821,7 @@ "type": "int", "default": 10, "required": false, - "help": "Number of near-the-money strikes per type" + "help": "Number of near-the-money strikes per type (1-100)" } ], "columns": [ @@ -2463,6 +2463,32 @@ "sourceFile": "bilibili/subtitle.js", "navigateBefore": true }, + { + "site": "bilibili", + "name": "summary", + "description": "获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)", + "access": "read", + "domain": "www.bilibili.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "bvid", + "type": "str", + "required": true, + "positional": true, + "help": "Video BV ID / URL / b23.tv short link" + } + ], + "columns": [ + "time", + "content" + ], + "type": "js", + "modulePath": "bilibili/summary.js", + "sourceFile": "bilibili/summary.js", + "navigateBefore": "https://www.bilibili.com" + }, { "site": "bilibili", "name": "user-videos", @@ -3484,7 +3510,7 @@ { "site": "boss", "name": "chatlist", - "description": "BOSS直聘查看聊天列表(招聘端)", + "description": "BOSS直聘查看聊天列表(招聘端/求职端)", "access": "read", "domain": "www.zhipin.com", "strategy": "cookie", @@ -3509,12 +3535,26 @@ "type": "str", "default": "0", "required": false, - "help": "Filter by job ID (0=all)" + "help": "Filter by job ID (0=all, boss side only)" + }, + { + "name": "side", + "type": "str", + "default": "auto", + "required": false, + "help": "Identity side: auto (default), boss (recruiter), or geek (job-seeker)", + "choices": [ + "auto", + "boss", + "geek" + ] } ], "columns": [ "name", + "company", "job", + "title", "last_msg", "last_time", "uid", @@ -3528,7 +3568,7 @@ { "site": "boss", "name": "chatmsg", - "description": "BOSS直聘查看与候选人的聊天消息", + "description": "BOSS直聘查看聊天消息历史(招聘端/求职端)", "access": "read", "domain": "www.zhipin.com", "strategy": "cookie", @@ -3547,6 +3587,18 @@ "default": 1, "required": false, "help": "Page number" + }, + { + "name": "side", + "type": "str", + "default": "auto", + "required": false, + "help": "Identity side: auto (default), boss (recruiter), or geek (job-seeker)", + "choices": [ + "auto", + "boss", + "geek" + ] } ], "columns": [ @@ -4009,6 +4061,47 @@ "sourceFile": "boss/stats.js", "navigateBefore": false }, + { + "site": "brave", + "name": "search", + "description": "Search Brave Search", + "access": "read", + "domain": "search.brave.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results per page (max 18)" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Page offset (0, 1, 2...). Brave returns ~18 results per page" + } + ], + "columns": [ + "rank", + "title", + "url", + "snippet" + ], + "type": "js", + "modulePath": "brave/search.js", + "sourceFile": "brave/search.js" + }, { "site": "chaoxing", "name": "assignments", @@ -8705,6 +8798,93 @@ "sourceFile": "douyin/videos.js", "navigateBefore": "https://creator.douyin.com" }, + { + "site": "duckduckgo", + "name": "search", + "description": "Search DuckDuckGo", + "access": "read", + "domain": "html.duckduckgo.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results per page (1-10). For multi-page, use --offset" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Result offset for pagination (0, 10, 20...). Uses XHR POST internally" + }, + { + "name": "region", + "type": "str", + "required": false, + "help": "Region code (e.g. jp-jp, us-en, cn-zh). Default: all regions" + }, + { + "name": "time", + "type": "str", + "required": false, + "help": "Time range: d (day), w (week), m (month), y (year)" + } + ], + "columns": [ + "rank", + "title", + "url", + "snippet", + "displayUrl", + "icon", + "resultType" + ], + "type": "js", + "modulePath": "duckduckgo/search.js", + "sourceFile": "duckduckgo/search.js" + }, + { + "site": "duckduckgo", + "name": "suggest", + "description": "DuckDuckGo search suggestions", + "access": "read", + "domain": "duckduckgo.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search query prefix" + }, + { + "name": "limit", + "type": "int", + "default": 8, + "required": false, + "help": "Max number of suggestions" + } + ], + "columns": [ + "phrase" + ], + "type": "js", + "modulePath": "duckduckgo/suggest.js", + "sourceFile": "duckduckgo/suggest.js" + }, { "site": "eastmoney", "name": "announcement", @@ -9388,7 +9568,7 @@ "type": "js", "modulePath": "facebook/feed.js", "sourceFile": "facebook/feed.js", - "navigateBefore": "https://www.facebook.com" + "navigateBefore": false }, { "site": "facebook", @@ -9734,6 +9914,50 @@ "modulePath": "flathub/search.js", "sourceFile": "flathub/search.js" }, + { + "site": "flomo", + "name": "memos", + "description": "List your Flomo memos", + "access": "read", + "domain": "flomoapp.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of memos to fetch (1-200)" + }, + { + "name": "since", + "type": "int", + "required": false, + "help": "Only memos updated after this Unix timestamp in seconds" + }, + { + "name": "slug", + "type": "str", + "required": false, + "help": "Pagination cursor from a previous memo page" + } + ], + "columns": [ + "id", + "url", + "content", + "slug", + "tags", + "images", + "created_at", + "updated_at" + ], + "type": "js", + "modulePath": "flomo/memos.js", + "sourceFile": "flomo/memos.js", + "navigateBefore": "https://v.flomoapp.com/" + }, { "site": "gemini", "name": "ask", @@ -14147,96 +14371,57 @@ }, { "site": "linkedin", - "name": "search", - "description": "Search LinkedIn jobs", - "access": "read", + "name": "connect", + "description": "Fail-closed LinkedIn connection request sender that verifies the exact profile before optionally sending a note", + "access": "write", "domain": "www.linkedin.com", - "strategy": "cookie", + "strategy": "ui", "browser": true, "args": [ { - "name": "query", + "name": "profile-url", "type": "string", "required": true, "positional": true, - "help": "Job search keywords" + "help": "Exact LinkedIn profile URL to open and verify" }, { - "name": "location", + "name": "expected-name", "type": "string", - "required": false, - "help": "Location text such as San Francisco Bay Area" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of jobs to return (max 100)" + "required": true, + "help": "Expected visible profile name" }, { - "name": "start", - "type": "int", - "default": 0, + "name": "note", + "type": "string", + "default": "", "required": false, - "help": "Result offset for pagination" + "help": "Optional connection note, max 300 chars" }, { - "name": "details", + "name": "send", "type": "bool", "default": false, "required": false, - "help": "Include full job description and apply URL (slower)" - }, - { - "name": "company", - "type": "string", - "required": false, - "help": "Comma-separated company names or LinkedIn company IDs" - }, - { - "name": "experience-level", - "type": "string", - "required": false, - "help": "Comma-separated: internship, entry, associate, mid-senior, director, executive" - }, - { - "name": "job-type", - "type": "string", - "required": false, - "help": "Comma-separated: full-time, part-time, contract, temporary, volunteer, internship, other" - }, - { - "name": "date-posted", - "type": "string", - "required": false, - "help": "One of: any, month, week, 24h" - }, - { - "name": "remote", - "type": "string", - "required": false, - "help": "Comma-separated: on-site, hybrid, remote" + "help": "Actually click Send. Default is dry-run verification only." } ], "columns": [ - "rank", - "title", - "company", - "location", - "listed", - "salary", - "url" + "status", + "recipient", + "reason", + "profile_url", + "note_chars" ], "type": "js", - "modulePath": "linkedin/search.js", - "sourceFile": "linkedin/search.js", - "navigateBefore": "https://www.linkedin.com" + "modulePath": "linkedin/connect.js", + "sourceFile": "linkedin/connect.js", + "navigateBefore": true }, { "site": "linkedin", - "name": "timeline", - "description": "Read LinkedIn home timeline posts", + "name": "inbox", + "description": "List LinkedIn messaging inbox conversations and unread messages", "access": "read", "domain": "www.linkedin.com", "strategy": "cookie", @@ -14245,14 +14430,251 @@ { "name": "limit", "type": "int", - "default": 20, + "default": 40, "required": false, - "help": "Number of posts to return (max 100)" + "help": "Maximum conversations to return (1-100)" + }, + { + "name": "unread-only", + "type": "bool", + "default": false, + "required": false, + "help": "Return only conversations with unread messages" } ], "columns": [ "rank", - "author", + "thread_url", + "thread_id", + "person_name", + "last_message_preview", + "unread", + "counterparty_type", + "category", + "timestamp" + ], + "type": "js", + "modulePath": "linkedin/inbox.js", + "sourceFile": "linkedin/inbox.js", + "navigateBefore": "https://www.linkedin.com" + }, + { + "site": "linkedin", + "name": "safe-send", + "description": "Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending", + "access": "write", + "domain": "www.linkedin.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "thread-url", + "type": "str", + "required": true, + "help": "Exact LinkedIn messaging thread URL to open and verify" + }, + { + "name": "expected-name", + "type": "str", + "required": true, + "help": "Expected visible recipient name in the active thread header" + }, + { + "name": "message", + "type": "str", + "required": true, + "help": "Message body to send or dry-run" + }, + { + "name": "expected-last-text", + "type": "str", + "required": false, + "help": "Substring expected in the currently visible latest conversation context" + }, + { + "name": "expected-last-hash", + "type": "str", + "required": false, + "help": "SHA-256 hash of expected latest visible message text" + }, + { + "name": "send", + "type": "bool", + "default": false, + "required": false, + "help": "Actually click Send. Default is dry-run verification only." + }, + { + "name": "screenshot", + "type": "bool", + "default": false, + "required": false, + "help": "Capture a screenshot during verification" + } + ], + "columns": [ + "status", + "recipient", + "reason", + "thread_url", + "message_chars", + "screenshot" + ], + "type": "js", + "modulePath": "linkedin/safe-send.js", + "sourceFile": "linkedin/safe-send.js", + "navigateBefore": true + }, + { + "site": "linkedin", + "name": "search", + "description": "Search LinkedIn jobs", + "access": "read", + "domain": "www.linkedin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Job search keywords" + }, + { + "name": "location", + "type": "string", + "required": false, + "help": "Location text such as San Francisco Bay Area" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of jobs to return (max 100)" + }, + { + "name": "start", + "type": "int", + "default": 0, + "required": false, + "help": "Result offset for pagination" + }, + { + "name": "details", + "type": "bool", + "default": false, + "required": false, + "help": "Include full job description and apply URL (slower)" + }, + { + "name": "company", + "type": "string", + "required": false, + "help": "Comma-separated company names or LinkedIn company IDs" + }, + { + "name": "experience-level", + "type": "string", + "required": false, + "help": "Comma-separated: internship, entry, associate, mid-senior, director, executive" + }, + { + "name": "job-type", + "type": "string", + "required": false, + "help": "Comma-separated: full-time, part-time, contract, temporary, volunteer, internship, other" + }, + { + "name": "date-posted", + "type": "string", + "required": false, + "help": "One of: any, month, week, 24h" + }, + { + "name": "remote", + "type": "string", + "required": false, + "help": "Comma-separated: on-site, hybrid, remote" + } + ], + "columns": [ + "rank", + "title", + "company", + "location", + "listed", + "salary", + "url" + ], + "type": "js", + "modulePath": "linkedin/search.js", + "sourceFile": "linkedin/search.js", + "navigateBefore": "https://www.linkedin.com" + }, + { + "site": "linkedin", + "name": "thread-snapshot", + "description": "Load a LinkedIn messaging thread, scroll for available history, and return a full context snapshot", + "access": "read", + "domain": "www.linkedin.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "thread-url", + "type": "str", + "required": true, + "help": "Exact LinkedIn messaging thread URL to open and snapshot" + }, + { + "name": "max-scrolls", + "type": "number", + "default": 30, + "required": false, + "help": "Maximum upward scroll attempts to load older messages" + }, + { + "name": "json", + "type": "bool", + "default": false, + "required": false, + "help": "Return only JSON snapshot string in the snapshot_json field" + } + ], + "columns": [ + "thread_url", + "recipient", + "message_count", + "latest_text", + "snapshot_json" + ], + "type": "js", + "modulePath": "linkedin/thread-snapshot.js", + "sourceFile": "linkedin/thread-snapshot.js", + "navigateBefore": true + }, + { + "site": "linkedin", + "name": "timeline", + "description": "Read LinkedIn home timeline posts", + "access": "read", + "domain": "www.linkedin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts to return (max 100)" + } + ], + "columns": [ + "rank", + "author", "author_url", "headline", "text", @@ -15754,181 +16176,6 @@ "sourceFile": "notebooklm/summary.js", "navigateBefore": false }, - { - "site": "notion", - "name": "export", - "description": "Export the current Notion page as Markdown", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file (default: /tmp/notion-export.md)" - } - ], - "columns": [ - "Status", - "File" - ], - "type": "js", - "modulePath": "notion/export.js", - "sourceFile": "notion/export.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "favorites", - "description": "List pages from the Notion Favorites section in the sidebar", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Title", - "Icon" - ], - "type": "js", - "modulePath": "notion/favorites.js", - "sourceFile": "notion/favorites.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "new", - "description": "Create a new page in Notion", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "title", - "type": "str", - "required": false, - "positional": true, - "help": "Page title (optional)" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "notion/new.js", - "sourceFile": "notion/new.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "read", - "description": "Read the content of the currently open Notion page", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Title", - "Content" - ], - "type": "js", - "modulePath": "notion/read.js", - "sourceFile": "notion/read.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "search", - "description": "Search pages and databases in Notion via Quick Find (Cmd+P)", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - } - ], - "columns": [ - "Index", - "Title" - ], - "type": "js", - "modulePath": "notion/search.js", - "sourceFile": "notion/search.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "sidebar", - "description": "List pages and databases from the Notion sidebar", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Title" - ], - "type": "js", - "modulePath": "notion/sidebar.js", - "sourceFile": "notion/sidebar.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "status", - "description": "Check active CDP connection to Notion Desktop", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "notion/status.js", - "sourceFile": "notion/status.js", - "navigateBefore": true - }, - { - "site": "notion", - "name": "write", - "description": "Append text content to the currently open Notion page", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Text to append to the page" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "notion/write.js", - "sourceFile": "notion/write.js", - "navigateBefore": true - }, { "site": "nowcoder", "name": "companies", @@ -19166,8 +19413,7 @@ "type": "js", "modulePath": "reddit/comment.js", "sourceFile": "reddit/comment.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19197,8 +19443,7 @@ "type": "js", "modulePath": "reddit/frontpage.js", "sourceFile": "reddit/frontpage.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19230,8 +19475,7 @@ "type": "js", "modulePath": "reddit/home.js", "sourceFile": "reddit/home.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19300,8 +19544,7 @@ "type": "js", "modulePath": "reddit/popular.js", "sourceFile": "reddit/popular.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19378,8 +19621,7 @@ "type": "js", "modulePath": "reddit/read.js", "sourceFile": "reddit/read.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19445,8 +19687,7 @@ "type": "js", "modulePath": "reddit/save.js", "sourceFile": "reddit/save.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19475,8 +19716,7 @@ "type": "js", "modulePath": "reddit/saved.js", "sourceFile": "reddit/saved.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19534,8 +19774,7 @@ "type": "js", "modulePath": "reddit/search.js", "sourceFile": "reddit/search.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19585,8 +19824,7 @@ "type": "js", "modulePath": "reddit/subreddit.js", "sourceFile": "reddit/subreddit.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19612,8 +19850,7 @@ "type": "js", "modulePath": "reddit/subreddit-info.js", "sourceFile": "reddit/subreddit-info.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19646,8 +19883,7 @@ "type": "js", "modulePath": "reddit/subscribe.js", "sourceFile": "reddit/subscribe.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19680,8 +19916,7 @@ "type": "js", "modulePath": "reddit/upvote.js", "sourceFile": "reddit/upvote.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19710,8 +19945,7 @@ "type": "js", "modulePath": "reddit/upvoted.js", "sourceFile": "reddit/upvoted.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19737,8 +19971,7 @@ "type": "js", "modulePath": "reddit/user.js", "sourceFile": "reddit/user.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19773,8 +20006,7 @@ "type": "js", "modulePath": "reddit/user-comments.js", "sourceFile": "reddit/user-comments.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19810,8 +20042,7 @@ "type": "js", "modulePath": "reddit/user-posts.js", "sourceFile": "reddit/user-posts.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "reddit", @@ -19829,8 +20060,7 @@ "type": "js", "modulePath": "reddit/whoami.js", "sourceFile": "reddit/whoami.js", - "navigateBefore": "https://reddit.com", - "siteSession": "persistent" + "navigateBefore": "https://reddit.com" }, { "site": "rednote", @@ -22710,8 +22940,7 @@ "type": "js", "modulePath": "twitter/article.js", "sourceFile": "twitter/article.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -22804,13 +23033,14 @@ "retweets", "bookmarks", "created_at", - "url" + "url", + "has_media", + "media_urls" ], "type": "js", "modulePath": "twitter/bookmark-folder.js", "sourceFile": "twitter/bookmark-folder.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -22830,8 +23060,7 @@ "type": "js", "modulePath": "twitter/bookmark-folders.js", "sourceFile": "twitter/bookmark-folders.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -22865,13 +23094,14 @@ "retweets", "bookmarks", "created_at", - "url" + "url", + "has_media", + "media_urls" ], "type": "js", "modulePath": "twitter/bookmarks.js", "sourceFile": "twitter/bookmarks.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -22945,8 +23175,7 @@ "type": "js", "modulePath": "twitter/download.js", "sourceFile": "twitter/download.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23006,8 +23235,7 @@ "type": "js", "modulePath": "twitter/followers.js", "sourceFile": "twitter/followers.js", - "navigateBefore": true, - "siteSession": "persistent" + "navigateBefore": true }, { "site": "twitter", @@ -23042,8 +23270,7 @@ "type": "js", "modulePath": "twitter/following.js", "sourceFile": "twitter/following.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23143,8 +23370,7 @@ "type": "js", "modulePath": "twitter/likes.js", "sourceFile": "twitter/likes.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23264,8 +23490,7 @@ "type": "js", "modulePath": "twitter/list-tweets.js", "sourceFile": "twitter/list-tweets.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23294,8 +23519,7 @@ "type": "js", "modulePath": "twitter/lists.js", "sourceFile": "twitter/lists.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23324,8 +23548,7 @@ "type": "js", "modulePath": "twitter/notifications.js", "sourceFile": "twitter/notifications.js", - "navigateBefore": true, - "siteSession": "persistent" + "navigateBefore": true }, { "site": "twitter", @@ -23395,8 +23618,7 @@ "type": "js", "modulePath": "twitter/profile.js", "sourceFile": "twitter/profile.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23665,8 +23887,7 @@ "type": "js", "modulePath": "twitter/search.js", "sourceFile": "twitter/search.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23712,8 +23933,7 @@ "type": "js", "modulePath": "twitter/thread.js", "sourceFile": "twitter/thread.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23766,8 +23986,7 @@ "type": "js", "modulePath": "twitter/timeline.js", "sourceFile": "twitter/timeline.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23794,8 +24013,7 @@ "type": "js", "modulePath": "twitter/trending.js", "sourceFile": "twitter/trending.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -23845,8 +24063,7 @@ "type": "js", "modulePath": "twitter/tweets.js", "sourceFile": "twitter/tweets.js", - "navigateBefore": "https://x.com", - "siteSession": "persistent" + "navigateBefore": "https://x.com" }, { "site": "twitter", @@ -24827,6 +25044,68 @@ "sourceFile": "weibo/user.js", "navigateBefore": "https://weibo.com" }, + { + "site": "weibo", + "name": "user-posts", + "description": "List Weibo posts from a user, optionally filtered by date range", + "access": "read", + "domain": "weibo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "User ID (numeric uid) or screen name" + }, + { + "name": "start", + "type": "str", + "required": false, + "help": "Start date in Asia/Shanghai (YYYY-MM-DD)" + }, + { + "name": "end", + "type": "str", + "required": false, + "help": "End date in Asia/Shanghai (YYYY-MM-DD)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts (1-100)" + }, + { + "name": "include-retweets", + "type": "boolean", + "default": false, + "required": false, + "help": "Include retweets" + } + ], + "columns": [ + "rank", + "id", + "mblogid", + "author", + "uid", + "text", + "time", + "reposts", + "comments", + "likes", + "pic_count", + "url" + ], + "type": "js", + "modulePath": "weibo/user-posts.js", + "sourceFile": "weibo/user-posts.js", + "navigateBefore": "https://weibo.com" + }, { "site": "weixin", "name": "create-draft", @@ -26963,6 +27242,47 @@ "sourceFile": "xueqiu/watchlist.js", "navigateBefore": "https://xueqiu.com" }, + { + "site": "yahoo", + "name": "search", + "description": "Search Yahoo (powered by Bing)", + "access": "read", + "domain": "search.yahoo.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 7, + "required": false, + "help": "Number of results per page (max 7)" + }, + { + "name": "page", + "type": "int", + "default": 1, + "required": false, + "help": "Page number (1, 2, 3...). Yahoo returns ~7 results per page" + } + ], + "columns": [ + "rank", + "title", + "url", + "snippet" + ], + "type": "js", + "modulePath": "yahoo/search.js", + "sourceFile": "yahoo/search.js" + }, { "site": "yahoo-finance", "name": "quote", @@ -27600,6 +27920,36 @@ "sourceFile": "yollomi/video.js", "navigateBefore": "https://yollomi.com" }, + { + "site": "youdao", + "name": "note", + "description": "Read a public shared Youdao Note", + "access": "read", + "domain": "share.note.youdao.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "Full share URL of the Youdao Note" + } + ], + "columns": [ + "title", + "content", + "summary", + "keywords", + "created_at", + "file_size", + "url" + ], + "type": "js", + "modulePath": "youdao/note.js", + "sourceFile": "youdao/note.js" + }, { "site": "youtube", "name": "channel", diff --git a/clis/_shared/search-adapter.js b/clis/_shared/search-adapter.js new file mode 100644 index 000000000..b9e985342 --- /dev/null +++ b/clis/_shared/search-adapter.js @@ -0,0 +1,70 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +export function requireSearchQuery(value, label = 'keyword') { + const query = String(value ?? '').trim(); + if (!query) { + throw new ArgumentError(`${label} cannot be empty`); + } + return query; +} + +export function requireBoundedInteger(value, defaultValue, min, max, label) { + const raw = value ?? defaultValue; + const parsed = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(parsed)) { + throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`); + } + if (parsed < min || parsed > max) { + throw new ArgumentError(`${label} must be between ${min} and ${max}, got ${parsed}`); + } + return parsed; +} + +export function requireNonNegativeInteger(value, defaultValue, label) { + const raw = value ?? defaultValue; + const parsed = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new ArgumentError(`${label} must be a non-negative integer, got ${JSON.stringify(value)}`); + } + return parsed; +} + +export function unwrapBrowserResult(value) { + if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) { + return value.data; + } + return value; +} + +export function requireRows(value, label) { + const rows = unwrapBrowserResult(value); + if (!Array.isArray(rows)) { + throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array of result rows.`); + } + return rows; +} + +export function toHttpsUrl(value, baseUrl) { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + try { + const url = new URL(raw, baseUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + return url.href; + } catch { + return ''; + } +} + +export function emptySearchResults(site, query) { + return new EmptyResultError(`${site} search`, `No ${site} results matched "${query}".`); +} + +export async function runBrowserStep(label, fn) { + try { + return await fn(); + } catch (error) { + if (error?.code || error?.name === 'ArgumentError') throw error; + throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`); + } +} diff --git a/clis/barchart/greeks.js b/clis/barchart/greeks.js index 003730de6..095139e54 100644 --- a/clis/barchart/greeks.js +++ b/clis/barchart/greeks.js @@ -4,6 +4,47 @@ * Auth: CSRF token from + session cookies. */ import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +const DEFAULT_LIMIT = 10; +const MIN_LIMIT = 1; +const MAX_LIMIT = 100; + +function normalizeSymbol(value) { + const symbol = String(value ?? '').trim().toUpperCase(); + if (!symbol) throw new ArgumentError('symbol is required'); + return symbol; +} + +function normalizeExpiration(value) { + const expiration = String(value ?? '').trim(); + if (!expiration) return ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(expiration)) { + throw new ArgumentError('--expiration must use YYYY-MM-DD format'); + } + const parsed = new Date(`${expiration}T00:00:00Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== expiration) { + throw new ArgumentError('--expiration must be a valid calendar date'); + } + return expiration; +} + +function parseLimit(value) { + if (value === undefined || value === null || value === '') return DEFAULT_LIMIT; + const limit = Number(value); + if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) { + throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`); + } + return limit; +} + +function unwrapBrowserResult(value) { + if (value && typeof value === 'object' && 'session' in value && 'data' in value) { + return value.data; + } + return value; +} + cli({ site: 'barchart', name: 'greeks', @@ -14,19 +55,19 @@ cli({ args: [ { name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' }, { name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' }, - { name: 'limit', type: 'int', default: 10, help: 'Number of near-the-money strikes per type' }, + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Number of near-the-money strikes per type (1-100)' }, ], columns: [ 'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho', 'volume', 'openInterest', 'expiration', ], func: async (page, kwargs) => { - const symbol = kwargs.symbol.toUpperCase().trim(); - const expiration = kwargs.expiration ?? ''; - const limit = kwargs.limit ?? 10; + const symbol = normalizeSymbol(kwargs.symbol); + const expiration = normalizeExpiration(kwargs.expiration); + const limit = parseLimit(kwargs.limit); await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`); await page.wait(4); - const data = await page.evaluate(` + const data = unwrapBrowserResult(await page.evaluate(` (async () => { const sym = ${JSON.stringify(symbol)}; const expDate = ${JSON.stringify(expiration)}; @@ -45,39 +86,53 @@ cli({ + '&fields=' + fields + '&raw=1'; if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate); const resp = await fetch(url, { credentials: 'include', headers }); - if (resp.ok) { - const d = await resp.json(); - let items = d?.data || []; + if (!resp.ok) { + return { ok: false, reason: 'http', status: resp.status, statusText: resp.statusText || '' }; + } + + const d = await resp.json(); + const allItems = d?.data; + if (!Array.isArray(allItems)) { + return { ok: false, reason: 'malformed' }; + } + let items = allItems; - if (!expDate) { - const expirations = items - .map(i => (i.raw || i).expirationDate || null) - .filter(Boolean) - .sort((a, b) => { - const aTime = Date.parse(a); - const bTime = Date.parse(b); - if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0; - if (Number.isNaN(aTime)) return 1; - if (Number.isNaN(bTime)) return -1; - return aTime - bTime; - }); - const nearestExpiration = expirations[0]; - if (nearestExpiration) { - items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration); - } + if (!expDate) { + const expirations = items + .map(i => (i.raw || i).expirationDate || null) + .filter(Boolean) + .sort((a, b) => { + const aTime = Date.parse(a); + const bTime = Date.parse(b); + if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0; + if (Number.isNaN(aTime)) return 1; + if (Number.isNaN(bTime)) return -1; + return aTime - bTime; + }); + const nearestExpiration = expirations[0]; + if (nearestExpiration) { + items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration); } + } + + // Separate calls and puts, sort by distance from current price. + const calls = items + .filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call') + .sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999)) + .slice(0, limit); + const puts = items + .filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put') + .sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999)) + .slice(0, limit); + const selected = [...calls, ...puts]; - // Separate calls and puts, sort by distance from current price - const calls = items - .filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call') - .sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999)) - .slice(0, limit); - const puts = items - .filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put') - .sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999)) - .slice(0, limit); + if (items.length > 0 && selected.length === 0) { + return { ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' }; + } - return [...calls, ...puts].map(i => { + return { + ok: true, + rows: selected.map(i => { const r = i.raw || i; return { type: r.optionType, @@ -93,28 +148,61 @@ cli({ openInterest: r.openInterest, expiration: r.expirationDate, }; - }); - } - } catch(e) {} - - return []; + }) + }; + } catch(e) { + return { ok: false, reason: 'exception', message: e?.message || String(e) }; + } })() - `); - if (!data || !Array.isArray(data)) - return []; - return data.map(r => ({ - type: r.type || '', - strike: r.strike, - last: r.last != null ? Number(Number(r.last).toFixed(2)) : null, - iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null, - delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null, - gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null, - theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null, - vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null, - rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null, - volume: r.volume, - openInterest: r.openInterest, - expiration: r.expiration ?? null, - })); + `)); + if (!data || data.ok !== true) { + if (data?.reason === 'http') { + throw new CommandExecutionError(`Barchart greeks request failed: HTTP ${data.status}${data.statusText ? ` ${data.statusText}` : ''}`); + } + if (data?.reason === 'malformed') { + throw new CommandExecutionError(`Barchart greeks returned an unreadable options payload${data.message ? `: ${data.message}` : ''}`); + } + if (data?.reason === 'exception') { + throw new CommandExecutionError(`Barchart greeks request failed: ${data.message || 'unknown error'}`); + } + throw new CommandExecutionError(`Failed to fetch Barchart greeks for ${symbol}`); + } + if (!Array.isArray(data.rows)) { + throw new CommandExecutionError('Barchart greeks returned an unreadable options payload'); + } + if (data.rows.length === 0) { + throw new EmptyResultError('barchart greeks', `No option greeks were returned for ${symbol}. Confirm the symbol, expiration, and Barchart login state.`); + } + return data.rows.map(r => { + if (!r || typeof r !== 'object' || Array.isArray(r)) { + throw new CommandExecutionError('Barchart greeks returned a malformed option row'); + } + const type = String(r.type || '').trim(); + const expirationValue = String(r.expiration || '').trim(); + if (!/^(call|put)$/i.test(type) || r.strike === undefined || r.strike === null || r.strike === '' || !expirationValue) { + throw new CommandExecutionError('Barchart greeks returned a malformed option row identity'); + } + return { + type, + strike: r.strike, + last: r.last != null ? Number(Number(r.last).toFixed(2)) : null, + iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null, + delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null, + gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null, + theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null, + vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null, + rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null, + volume: r.volume, + openInterest: r.openInterest, + expiration: expirationValue, + }; + }); }, }); + +export const __test__ = { + normalizeSymbol, + normalizeExpiration, + parseLimit, + unwrapBrowserResult, +}; diff --git a/clis/barchart/greeks.test.js b/clis/barchart/greeks.test.js new file mode 100644 index 000000000..d3963c108 --- /dev/null +++ b/clis/barchart/greeks.test.js @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import './greeks.js'; + +const { normalizeExpiration, normalizeSymbol, parseLimit, unwrapBrowserResult } = await import('./greeks.js').then((m) => m.__test__); + +function makePage(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + }; +} + +describe('barchart greeks command', () => { + const command = getRegistry().get('barchart/greeks'); + + it('registers with the expected shape', () => { + expect(command).toBeDefined(); + expect(command.access).toBe('read'); + expect(command.browser).toBe(true); + expect(command.columns).toEqual([ + 'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho', + 'volume', 'openInterest', 'expiration', + ]); + }); + + it('maps returned option rows without changing the declared output shape', async () => { + const page = makePage({ + session: 'site:barchart', + data: { + ok: true, + rows: [ + { + type: 'Call', + strike: 190, + last: 3.456, + iv: 21.234, + delta: 0.56789, + gamma: 0.01234, + theta: -0.12345, + vega: 0.23456, + rho: 0.03456, + volume: 123, + openInterest: 456, + expiration: '2026-06-19', + }, + ], + }, + }); + + const rows = await command.func(page, { symbol: 'aapl', limit: 1 }); + + expect(page.goto).toHaveBeenCalledWith('https://www.barchart.com/stocks/quotes/AAPL/options'); + expect(page.wait).toHaveBeenCalledWith(4); + expect(rows).toEqual([ + { + type: 'Call', + strike: 190, + last: 3.46, + iv: '21.23%', + delta: 0.5679, + gamma: 0.0123, + theta: -0.1235, + vega: 0.2346, + rho: 0.0346, + volume: 123, + openInterest: 456, + expiration: '2026-06-19', + }, + ]); + }); + + it('validates args before browser navigation and unwraps bridge envelopes', async () => { + expect(normalizeSymbol(' aapl ')).toBe('AAPL'); + expect(normalizeExpiration('2026-06-19')).toBe('2026-06-19'); + expect(parseLimit(undefined)).toBe(10); + expect(parseLimit(100)).toBe(100); + expect(unwrapBrowserResult({ session: 'site:barchart', data: { ok: true } })).toEqual({ ok: true }); + + await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: '', limit: 1 })) + .rejects.toBeInstanceOf(ArgumentError); + await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', expiration: '2026-02-30', limit: 1 })) + .rejects.toBeInstanceOf(ArgumentError); + await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', limit: 101 })) + .rejects.toBeInstanceOf(ArgumentError); + }); + + it('embeds expiration and limit in the browser-side request script', async () => { + const page = makePage({ + ok: true, + rows: [{ + type: 'Put', + strike: 185, + last: null, + iv: null, + delta: null, + gamma: null, + theta: null, + vega: null, + rho: null, + volume: 0, + openInterest: 0, + expiration: '2026-07-17', + }], + }); + + await command.func(page, { symbol: 'MSFT', expiration: '2026-07-17', limit: 7 }); + const script = page.evaluate.mock.calls[0][0]; + + expect(script).toContain('const expDate = "2026-07-17"'); + expect(script).toContain('const limit = 7'); + expect(script).toContain("url += '&expirationDate=' + encodeURIComponent(expDate)"); + }); + + it('throws CommandExecutionError for HTTP, malformed, exception, and missing payload states', async () => { + await expect(command.func(makePage({ ok: false, reason: 'http', status: 403, statusText: 'Forbidden' }), { symbol: 'AAPL' })) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(command.func(makePage({ ok: false, reason: 'malformed' }), { symbol: 'AAPL' })) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(command.func(makePage({ ok: false, reason: 'exception', message: 'network down' }), { symbol: 'AAPL' })) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(command.func(makePage({ ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' }), { symbol: 'AAPL' })) + .rejects.toThrow('call or put identities'); + await expect(command.func(makePage(null), { symbol: 'AAPL' })) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(command.func(makePage({ ok: true, rows: 'bad' }), { symbol: 'AAPL' })) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(command.func(makePage({ ok: true, rows: [{ type: 'Call', strike: null, expiration: '' }] }), { symbol: 'AAPL' })) + .rejects.toThrow('malformed option row identity'); + }); + + it('throws EmptyResultError when Barchart returns no greeks rows', async () => { + await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL' })) + .rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/bilibili/summary.js b/clis/bilibili/summary.js new file mode 100644 index 000000000..d046f8cb4 --- /dev/null +++ b/clis/bilibili/summary.js @@ -0,0 +1,167 @@ +/** + * Bilibili summary — fetches the official AI-generated video summary (the "AI总结" + * shown on the video page) via /x/web-interface/view/conclusion/get. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { apiGet, resolveBvid } from './utils.js'; + +const BILIBILI_HOST_RE = /(^|\.)bilibili\.com$/i; +const B23_HOST_RE = /(^|\.)b23\.tv$/i; +const BVID_RE = /^BV[A-Za-z0-9]+$/; + +function formatTime(seconds) { + const s = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (n) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`; +} + +async function readBvid(raw) { + const input = String(raw ?? '').trim(); + if (!input) { + throw new ArgumentError('bilibili summary bvid cannot be empty', 'Pass a BV ID, Bilibili video URL, or b23.tv short link.'); + } + if (BVID_RE.test(input)) { + return input; + } + let parsed = null; + try { + parsed = new URL(input); + } catch { + // Bare b23.tv short codes are accepted by the shared resolver. + } + if (parsed) { + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new ArgumentError('Bilibili summary URL must use http or https'); + } + if (BILIBILI_HOST_RE.test(parsed.hostname)) { + const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i); + if (!match) { + throw new ArgumentError('Bilibili summary URL must contain a BV video id'); + } + return match[1]; + } + if (!B23_HOST_RE.test(parsed.hostname)) { + throw new ArgumentError('Bilibili summary URL must be a bilibili.com or b23.tv URL'); + } + } + try { + return await resolveBvid(input); + } catch (error) { + throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${input}`, error instanceof Error ? error.message : String(error)); + } +} + +function requireOkPayload(payload, label) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`); + } + if (payload.code !== 0) { + const message = payload.message ?? 'unknown error'; + if (payload.code === -101 || payload.code === -403 || /登录|权限|forbidden|permission|login/i.test(String(message))) { + throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`); + } + throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`); + } + return payload.data; +} + +function readModelResult(data, bvid) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new CommandExecutionError('Bilibili conclusion API returned malformed data'); + } + if (data.code !== 0) { + throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`); + } + let modelResult = data.model_result; + if (typeof modelResult === 'string') { + try { + modelResult = JSON.parse(modelResult); + } catch { + throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result JSON'); + } + } + if (!modelResult || typeof modelResult !== 'object' || Array.isArray(modelResult)) { + throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result'); + } + const summary = String(modelResult.summary ?? '').trim(); + if (!summary) { + throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`); + } + const outline = modelResult.outline ?? []; + if (!Array.isArray(outline)) { + throw new CommandExecutionError('Bilibili conclusion API returned malformed outline'); + } + return { summary, outline }; +} + +function rowsFromModel(model) { + const rows = [{ time: '', content: model.summary }]; + for (const section of model.outline) { + if (!section || typeof section !== 'object' || Array.isArray(section)) { + throw new CommandExecutionError('Bilibili conclusion API returned malformed outline section'); + } + const sectionTitle = String(section.title ?? '').trim(); + const sectionTime = formatTime(section.timestamp); + if (sectionTitle) { + rows.push({ time: sectionTime, content: `# ${sectionTitle}` }); + } + const points = section.part_outline ?? []; + if (!Array.isArray(points)) { + throw new CommandExecutionError('Bilibili conclusion API returned malformed part outline'); + } + for (const point of points) { + if (!point || typeof point !== 'object' || Array.isArray(point)) { + throw new CommandExecutionError('Bilibili conclusion API returned malformed outline point'); + } + const content = String(point.content ?? '').trim(); + if (content) { + rows.push({ time: formatTime(point.timestamp), content }); + } + } + } + return rows; +} + +var command = cli({ + site: 'bilibili', + name: 'summary', + access: 'read', + description: '获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)', + domain: 'www.bilibili.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' }, + ], + columns: ['time', 'content'], + func: async (page, kwargs) => { + if (!page) { + throw new CommandExecutionError('Browser session required for bilibili summary'); + } + const bvid = await readBvid(kwargs.bvid); + const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } }); + const viewData = requireOkPayload(view, 'view'); + const cid = viewData?.cid; + const upMid = viewData?.owner?.mid; + if (!cid || !upMid) { + throw new CommandExecutionError(`Bilibili view API did not return cid/up_mid for ${bvid}`); + } + const conclusion = await apiGet(page, '/x/web-interface/view/conclusion/get', { + params: { bvid, cid, up_mid: upMid }, + signed: true, + }); + const conclusionData = requireOkPayload(conclusion, 'conclusion'); + return rowsFromModel(readModelResult(conclusionData, bvid)); + }, +}); + +export const __test__ = { + command, + formatTime, + readBvid, + readModelResult, + rowsFromModel, +}; diff --git a/clis/bilibili/summary.test.js b/clis/bilibili/summary.test.js new file mode 100644 index 000000000..0394ad27d --- /dev/null +++ b/clis/bilibili/summary.test.js @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +const { mockApiGet, mockResolveBvid } = vi.hoisted(() => ({ + mockApiGet: vi.fn(), + mockResolveBvid: vi.fn(), +})); + +vi.mock('./utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + apiGet: mockApiGet, + resolveBvid: mockResolveBvid, +})); + +import { getRegistry } from '@jackwener/opencli/registry'; +import './summary.js'; + +describe('bilibili summary', () => { + const command = getRegistry().get('bilibili/summary'); + const page = {}; + + beforeEach(() => { + mockApiGet.mockReset(); + mockResolveBvid.mockReset(); + mockResolveBvid.mockRejectedValue(new Error('short link not found')); + }); + + function mockView(data = { aid: 114, cid: 222, owner: { mid: 333 } }) { + mockApiGet.mockResolvedValueOnce({ code: 0, data }); + } + + function mockConclusion(modelResult) { + mockApiGet.mockResolvedValueOnce({ + code: 0, + data: { + code: 0, + model_result: modelResult, + }, + }); + } + + it('returns the summary plus timestamped outline rows', async () => { + mockView(); + mockConclusion({ + summary: '整体总结', + outline: [ + { + title: '第一节', + timestamp: 0, + part_outline: [ + { timestamp: 12, content: '要点A' }, + { timestamp: 3725, content: '要点B' }, + ], + }, + ], + }); + + const result = await command.func(page, { bvid: 'BV1xxx' }); + + expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1xxx' } }); + expect(mockApiGet).toHaveBeenNthCalledWith(2, page, '/x/web-interface/view/conclusion/get', { + params: { bvid: 'BV1xxx', cid: 222, up_mid: 333 }, + signed: true, + }); + expect(result).toEqual([ + { time: '', content: '整体总结' }, + { time: '00:00', content: '# 第一节' }, + { time: '00:12', content: '要点A' }, + { time: '1:02:05', content: '要点B' }, + ]); + }); + + it('returns just the summary when the video has no outline', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion({ summary: '只有总结', outline: [] }); + + await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([ + { time: '', content: '只有总结' }, + ]); + }); + + it('parses model_result when Bilibili returns it as a JSON string', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion(JSON.stringify({ summary: '字符串总结', outline: [] })); + + await expect(command.func(page, { bvid: 'BV1xxx' })).resolves.toEqual([ + { time: '', content: '字符串总结' }, + ]); + }); + + it('normalizes Bilibili video URLs before calling the APIs', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion({ summary: 'URL 总结', outline: [] }); + + await command.func(page, { + bvid: 'https://www.bilibili.com/video/BV1abc12345/?spm_id_from=333.1007', + }); + + expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BV1abc12345' } }); + }); + + it('resolves b23.tv short links through the shared resolver', async () => { + mockResolveBvid.mockResolvedValueOnce('BVshort12345'); + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion({ summary: '短链总结', outline: [] }); + + await command.func(page, { bvid: 'https://b23.tv/abc' }); + + expect(mockResolveBvid).toHaveBeenCalledWith('https://b23.tv/abc'); + expect(mockApiGet).toHaveBeenNthCalledWith(1, page, '/x/web-interface/view', { params: { bvid: 'BVshort12345' } }); + }); + + it('rejects invalid inputs before calling Bilibili APIs', async () => { + const cases = [ + '', + 'javascript:alert(1)', + 'https://example.com/video/BV1abc12345', + 'https://share.note.youdao.com/video/BV1abc12345', + 'https://www.bilibili.com/read/cv12345', + ]; + + for (const bvid of cases) { + await expect(command.func(page, { bvid })).rejects.toBeInstanceOf(ArgumentError); + } + expect(mockApiGet).not.toHaveBeenCalled(); + }); + + it('maps unresolved short-code inputs to ArgumentError without calling APIs', async () => { + await expect(command.func(page, { bvid: 'not-a-bv' })).rejects.toBeInstanceOf(ArgumentError); + + expect(mockResolveBvid).toHaveBeenCalledWith('not-a-bv'); + expect(mockApiGet).not.toHaveBeenCalled(); + }); + + it('throws EmptyResultError when Bilibili has not generated an AI summary for the video', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockApiGet.mockResolvedValueOnce({ code: 0, data: { code: 1, model_result: {} } }); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('throws CommandExecutionError when the view payload is malformed', async () => { + mockApiGet.mockResolvedValueOnce({ code: 0, data: {} }); + + await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy( + (err) => err instanceof CommandExecutionError && /cid\/up_mid/.test(err.message), + ); + }); + + it('throws CommandExecutionError when the view API returns a non-auth error', async () => { + mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' }); + + await expect(command.func(page, { bvid: 'BVbroken' })).rejects.toSatisfy( + (err) => err instanceof CommandExecutionError && /啥都木有.*-404/.test(err.message), + ); + }); + + it('maps conclusion auth or permission errors to AuthRequiredError', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockApiGet.mockResolvedValueOnce({ code: -403, message: '访问权限不足' }); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('maps conclusion non-auth API errors to CommandExecutionError', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockApiGet.mockResolvedValueOnce({ code: -500, message: 'server error' }); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy( + (err) => err instanceof CommandExecutionError && /server error.*-500/.test(err.message), + ); + }); + + it('throws CommandExecutionError for malformed conclusion API payloads', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockApiGet.mockResolvedValueOnce(null); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('throws CommandExecutionError for malformed model_result JSON', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion('{bad json'); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy( + (err) => err instanceof CommandExecutionError && /model_result JSON/.test(err.message), + ); + }); + + it('throws CommandExecutionError for malformed outline shapes', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion({ summary: '坏 outline', outline: {} }); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy( + (err) => err instanceof CommandExecutionError && /outline/.test(err.message), + ); + }); + + it('throws CommandExecutionError for malformed part outline shapes', async () => { + mockView({ aid: 1, cid: 2, owner: { mid: 3 } }); + mockConclusion({ + summary: '坏 part_outline', + outline: [{ title: '段落', timestamp: 0, part_outline: {} }], + }); + + await expect(command.func(page, { bvid: 'BV1xxx' })).rejects.toSatisfy( + (err) => err instanceof CommandExecutionError && /part outline/.test(err.message), + ); + }); +}); diff --git a/clis/boss/chatlist.js b/clis/boss/chatlist.js index aaadec883..d0105451f 100644 --- a/clis/boss/chatlist.js +++ b/clis/boss/chatlist.js @@ -1,10 +1,60 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { requirePage, navigateToChat, fetchFriendList } from './utils.js'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { + requirePage, navigateToChat, navigateToGeekChat, + fetchFriendList, fetchGeekFriendLabelList, fetchGeekFriendInfoList, + readEncryptSystemId, assertOk, IDENTITY_MISMATCH_CODE, + readPositiveInteger, +} from './utils.js'; + +function formatMsgTime(ms) { + if (!ms) return ''; + return new Date(ms).toLocaleString('zh-CN'); +} + +function mapBossRow(f) { + return { + name: f.name || '', + company: '', + job: f.jobName || '', + title: '', + last_msg: f.lastMessageInfo?.text || '', + last_time: f.lastTime || '', + uid: f.encryptUid || '', + security_id: f.securityId || '', + }; +} + +async function buildGeekRows(page, limit) { + const encryptSystemId = await readEncryptSystemId(page); + const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId }); + if (labelList.length === 0) { + return []; + } + const slicedLabels = labelList.slice(0, limit); + const friendIds = slicedLabels.map((f) => f.friendId).filter(Boolean); + const enriched = await fetchGeekFriendInfoList(page, friendIds); + const enrichMap = new Map(enriched.map((f) => [String(f.friendId ?? f.uid), f])); + return slicedLabels.map((f) => { + const e = enrichMap.get(String(f.friendId)) || {}; + return { + name: e.name || f.name || '', + company: e.brandName || f.brandName || '', + job: e.jobName || f.jobName || '', + title: e.bossTitle || f.bossTitle || '', + last_msg: e.lastMessageInfo?.showText || e.lastMsg || f.lastMsg || '', + last_time: e.lastTime || formatMsgTime(e.lastMessageInfo?.msgTime) || formatMsgTime(f.updateTime) || '', + uid: e.encryptUid || f.encryptFriendId || String(e.uid ?? e.friendId ?? f.friendId ?? ''), + security_id: e.securityId || '', + }; + }); +} + cli({ site: 'boss', name: 'chatlist', access: 'read', - description: 'BOSS直聘查看聊天列表(招聘端)', + description: 'BOSS直聘查看聊天列表(招聘端/求职端)', domain: 'www.zhipin.com', strategy: Strategy.COOKIE, navigateBefore: false, @@ -12,23 +62,55 @@ cli({ args: [ { name: 'page', type: 'int', default: 1, help: 'Page number' }, { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, - { name: 'job-id', default: '0', help: 'Filter by job ID (0=all)' }, + { name: 'job-id', default: '0', help: 'Filter by job ID (0=all, boss side only)' }, + { name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' }, ], - columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'], + columns: ['name', 'company', 'job', 'title', 'last_msg', 'last_time', 'uid', 'security_id'], func: async (page, kwargs) => { requirePage(page); + const limit = readPositiveInteger(kwargs.limit, 'chatlist --limit', 20, 100); + const pageNum = readPositiveInteger(kwargs.page, 'chatlist --page', 1); + const side = kwargs.side || 'auto'; + + if (side === 'boss') { + await navigateToChat(page); + const friends = await fetchFriendList(page, { + pageNum, + jobId: kwargs['job-id'] || '0', + }); + if (friends.length === 0) + throw new EmptyResultError('boss chatlist', 'No recruiter-side chat sessions were returned.'); + return friends.slice(0, limit).map(mapBossRow); + } + + if (side === 'geek') { + await navigateToGeekChat(page); + const rows = await buildGeekRows(page, limit); + if (rows.length === 0) + throw new EmptyResultError('boss chatlist', 'No job-seeker-side chat sessions were returned.'); + return rows; + } + + // auto: try recruiter first, fall back to geek on identity mismatch await navigateToChat(page); - const friends = await fetchFriendList(page, { - pageNum: kwargs.page || 1, + const bossResult = await fetchFriendList(page, { + pageNum, jobId: kwargs['job-id'] || '0', + allowNonZero: true, }); - return friends.slice(0, kwargs.limit || 20).map((f) => ({ - name: f.name || '', - job: f.jobName || '', - last_msg: f.lastMessageInfo?.text || '', - last_time: f.lastTime || '', - uid: f.encryptUid || '', - security_id: f.securityId || '', - })); + if (Array.isArray(bossResult)) { + if (bossResult.length === 0) + throw new EmptyResultError('boss chatlist', 'No recruiter-side chat sessions were returned.'); + return bossResult.slice(0, limit).map(mapBossRow); + } + if (bossResult.code === IDENTITY_MISMATCH_CODE) { + await navigateToGeekChat(page); + const rows = await buildGeekRows(page, limit); + if (rows.length === 0) + throw new EmptyResultError('boss chatlist', 'No job-seeker-side chat sessions were returned.'); + return rows; + } + assertOk(bossResult); + throw new CommandExecutionError('Boss chatlist returned an unexpected response'); }, }); diff --git a/clis/boss/chatlist.test.js b/clis/boss/chatlist.test.js new file mode 100644 index 000000000..856f9fda7 --- /dev/null +++ b/clis/boss/chatlist.test.js @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import './chatlist.js'; + +const BOSS_FRIEND = { + name: '张三', + jobName: '后端工程师', + lastMessageInfo: { text: '你好' }, + lastTime: '2024-01-01 10:00', + encryptUid: 'enc-boss-uid', + securityId: 'boss-sec-id', +}; + +const GEEK_LABEL_FRIEND = { + friendId: 12345, + name: '李四', + brandName: '字节跳动', + jobName: '产品经理', + bossTitle: 'HR', + lastMsg: '感谢投递', + updateTime: 1704067200000, + encryptFriendId: 'enc-geek-uid', +}; + +const GEEK_ENRICHED = { + friendId: 12345, + uid: 99999, + name: '李四', + brandName: '字节跳动', + jobName: '产品经理', + bossTitle: 'HR总监', + encryptUid: 'enc-geek-uid', + securityId: 'geek-sec-id', + lastMessageInfo: { showText: '感谢投递', msgTime: 1704067200000 }, + lastTime: '2024-01-01', +}; + +function createPageMock(evaluateImpl) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(evaluateImpl), + }; +} + +describe('boss chatlist', () => { + const command = getRegistry().get('boss/chatlist'); + + it('--side boss preserves existing behavior with 8-column output', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' }); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index')); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: '张三', + company: '', + job: '后端工程师', + title: '', + last_msg: '你好', + uid: 'enc-boss-uid', + security_id: 'boss-sec-id', + }); + }); + + it('--side geek maps enriched getGeekFriendList data into 8 columns', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_ENRICHED] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' }); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat')); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: '李四', + company: '字节跳动', + job: '产品经理', + title: 'HR总监', + uid: 'enc-geek-uid', + security_id: 'geek-sec-id', + }); + }); + + it('--side geek falls back to label fields when enrichment has no match', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' }); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('李四'); + expect(rows[0].company).toBe('字节跳动'); + expect(rows[0].security_id).toBe(''); + }); + + it('rejects invalid --limit before navigating', async () => { + const page = createPageMock(async () => ({})); + await expect( + command.func(page, { page: 1, limit: 0, 'job-id': '0', side: 'geek' }) + ).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('--side geek reports a true empty chat list as EmptyResultError', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [] } }; + } + return {}; + }); + await expect( + command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' }) + ).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('treats malformed geek enrichment payload as CommandExecutionError', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: {} }; + } + return {}; + }); + await expect( + command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' }) + ).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('treats null Boss API payload as CommandExecutionError', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) return null; + return {}; + }); + await expect( + command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' }) + ).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('maps expired Boss cookies to AuthRequiredError', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 7, message: 'Cookie 已过期' }; + } + return {}; + }); + await expect( + command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' }) + ).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('--side auto falls back to geek when recruiter returns code 24', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 24, message: '请切换身份后再试' }; + } + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_ENRICHED] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' }); + expect(rows).toHaveLength(1); + expect(rows[0].company).toBe('字节跳动'); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat')); + }); + + it('--side auto uses recruiter results when code 0 and does not call geek API', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + return {}; + }); + const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' }); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('张三'); + const evaluateCalls = page.evaluate.mock.calls.map((c) => c[0]); + expect(evaluateCalls.some((s) => s.includes('geekFilterByLabel'))).toBe(false); + }); + + it('registers --side as a choices-constrained arg defaulting to auto', () => { + const sideArg = command.args.find((a) => a.name === 'side'); + expect(sideArg?.choices).toEqual(['auto', 'boss', 'geek']); + expect(sideArg?.default).toBe('auto'); + }); +}); diff --git a/clis/boss/chatmsg.js b/clis/boss/chatmsg.js index a9ae945cb..d422b4f70 100644 --- a/clis/boss/chatmsg.js +++ b/clis/boss/chatmsg.js @@ -1,10 +1,72 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { + requirePage, navigateToChat, navigateToGeekChat, + bossFetch, findFriendByUid, findGeekFriendByUid, + fetchGeekHistoryMsg, readEncryptSystemId, + assertOk, IDENTITY_MISMATCH_CODE, + readPositiveInteger, readRequiredString, +} from './utils.js'; + +const TYPE_MAP = { + 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', + 6: '名片', 7: '语音', 8: '视频', 9: '表情', +}; + +function mapBossMsg(m, friend) { + const fromObj = m.from || {}; + const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false; + return { + from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name), + type: TYPE_MAP[m.type] || `其他(${m.type})`, + text: m.text || m.body?.text || '', + time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', + }; +} + +function mapGeekMsg(m, friend) { + const fromUid = m.from && m.from.uid; + const isFromBoss = fromUid != null && String(fromUid) === String(friend.uid); + return { + from: isFromBoss ? '对方' : '我', + type: TYPE_MAP[m.type] || `其他(${m.type})`, + text: m.text || m.body?.text || m.body?.content || m.body?.showText || + JSON.stringify(m.body || {}).slice(0, 120), + time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', + }; +} + +async function bossChatMsg(page, kwargs, existingFriend) { + const friend = existingFriend ?? await findFriendByUid(page, kwargs.uid); + if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该候选人'); + if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息'); + const gid = friend.uid; + const securityId = encodeURIComponent(friend.securityId); + const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; + const msgData = await bossFetch(page, msgUrl); + const messages = msgData.zpData?.messages ?? msgData.zpData?.historyMsgList; + if (!Array.isArray(messages)) { + throw new CommandExecutionError('Boss recruiter history response did not include a message list'); + } + if (messages.length === 0) { + throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.'); + } + return messages.map((m) => mapBossMsg(m, friend)); +} + +async function geekChatMsg(page, kwargs, encryptSystemId) { + const friend = await findGeekFriendByUid(page, kwargs.uid, { encryptSystemId }); + if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该聊天(geek 侧)'); + if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息'); + const messages = await fetchGeekHistoryMsg(page, friend, { page: kwargs.page }); + return messages.map((m) => mapGeekMsg(m, friend)); +} + cli({ site: 'boss', name: 'chatmsg', access: 'read', - description: 'BOSS直聘查看与候选人的聊天消息', + description: 'BOSS直聘查看聊天消息历史(招聘端/求职端)', domain: 'www.zhipin.com', strategy: Strategy.COOKIE, navigateBefore: false, @@ -12,32 +74,44 @@ cli({ args: [ { name: 'uid', required: true, positional: true, help: 'Encrypted UID (from chatlist)' }, { name: 'page', type: 'int', default: 1, help: 'Page number' }, + { name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' }, ], columns: ['from', 'type', 'text', 'time'], func: async (page, kwargs) => { requirePage(page); + const uid = readRequiredString(kwargs.uid, 'chatmsg uid'); + const pageNum = readPositiveInteger(kwargs.page, 'chatmsg --page', 1); + const normalizedKwargs = { ...kwargs, uid, page: pageNum }; + const side = kwargs.side || 'auto'; + + if (side === 'boss') { + await navigateToChat(page); + return await bossChatMsg(page, normalizedKwargs); + } + + if (side === 'geek') { + await navigateToGeekChat(page); + const encryptSystemId = await readEncryptSystemId(page); + return await geekChatMsg(page, normalizedKwargs, encryptSystemId); + } + + // auto: try recruiter first, fall back to geek when not found or identity mismatch await navigateToChat(page); - const friend = await findFriendByUid(page, kwargs.uid); - if (!friend) - throw new Error('未找到该候选人'); - const gid = friend.uid; - const securityId = encodeURIComponent(friend.securityId); - const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; - const msgData = await bossFetch(page, msgUrl); - const TYPE_MAP = { - 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', - 6: '名片', 7: '语音', 8: '视频', 9: '表情', - }; - const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || []; - return messages.map((m) => { - const fromObj = m.from || {}; - const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false; - return { - from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name), - type: TYPE_MAP[m.type] || '其他(' + m.type + ')', - text: m.text || m.body?.text || '', - time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', - }; - }); + const bossResult = await findFriendByUid(page, uid, { allowNonZero: true }); + if (bossResult?.friend) { + return await bossChatMsg(page, normalizedKwargs, bossResult.friend); + } + // Not found or identity mismatch — check for hard errors before falling back + if (bossResult?.code && bossResult.code !== 0 && bossResult.code !== IDENTITY_MISMATCH_CODE) { + assertOk(bossResult); + } + // Fall back to geek side + await navigateToGeekChat(page); + const encryptSystemId = await readEncryptSystemId(page); + const geekFriend = await findGeekFriendByUid(page, uid, { encryptSystemId }); + if (!geekFriend) throw new EmptyResultError('boss chatmsg', 'uid 在招聘端与求职端聊天列表中均未找到'); + if (!geekFriend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息'); + const messages = await fetchGeekHistoryMsg(page, geekFriend, { page: pageNum }); + return messages.map((m) => mapGeekMsg(m, geekFriend)); }, }); diff --git a/clis/boss/chatmsg.test.js b/clis/boss/chatmsg.test.js new file mode 100644 index 000000000..bf4c0b38b --- /dev/null +++ b/clis/boss/chatmsg.test.js @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import './chatmsg.js'; + +const BOSS_FRIEND = { + uid: 12345, + encryptUid: 'enc-boss-uid', + securityId: 'boss-sec-id', + name: '候选人甲', +}; +const BOSS_MSGS = [ + { type: 1, text: 'Hello', from: { uid: 99999, name: 'HR' }, time: 1704067200000 }, + { type: 1, text: '感谢', from: { uid: 12345, name: '候选人甲' }, time: 1704067201000 }, +]; + +const GEEK_FRIEND_LABEL = { + friendId: 11111, + encryptFriendId: 'enc-geek-uid', + name: 'Boss张', + brandName: '公司A', +}; +const GEEK_FRIEND_ENRICHED = { + friendId: 11111, + uid: 67890, + encryptUid: 'enc-geek-uid', + securityId: 'geek-sec-id', + name: 'Boss张', +}; +const GEEK_MSGS = [ + { type: 1, text: '欢迎投递', received: true, time: 1704067200000, from: { uid: 67890, name: 'Boss张' } }, + { type: 1, text: '谢谢', received: true, time: 1704067201000, from: { uid: 99999, name: '我' } }, +]; + +function createPageMock(evaluateImpl) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(evaluateImpl), + }; +} + +describe('boss chatmsg', () => { + const command = getRegistry().get('boss/chatmsg'); + + it('rejects empty uid before navigating', async () => { + const page = createPageMock(async () => ({})); + await expect( + command.func(page, { uid: ' ', page: 1, side: 'geek' }) + ).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects invalid --page before navigating', async () => { + const page = createPageMock(async () => ({})); + await expect( + command.func(page, { uid: 'enc-geek-uid', page: 0, side: 'geek' }) + ).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('--side boss preserves existing behavior', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + if (script.includes('boss/historyMsg')) { + return { code: 0, zpData: { messages: BOSS_MSGS } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' }); + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index')); + expect(rows).toHaveLength(2); + expect(rows[0].from).toBe('我'); + expect(rows[1].from).toBe('候选人甲'); + }); + + it('--side geek calls historyMsg with bossId, securityId, page, c=20, src=0', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: GEEK_MSGS } }; + } + return {}; + }); + await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }); + const historyScript = page.evaluate.mock.calls.find((c) => c[0].includes('geek/historyMsg'))?.[0]; + expect(historyScript).toBeDefined(); + expect(historyScript).toContain('bossId=67890'); + expect(historyScript).toContain('securityId='); + expect(historyScript).toContain('page=1'); + expect(historyScript).toContain('c=20'); + expect(historyScript).toContain('src=0'); + }); + + it('--side geek uses from.uid to determine direction, not received flag', async () => { + // Both messages have received:true (mirrors real geek historyMsg API behaviour) + // Direction is determined by whether m.from.uid matches the boss's uid (67890) + const msgsAllReceived = [ + { type: 1, text: '欢迎投递', received: true, time: 1704067200000, from: { uid: 67890, name: 'Boss张' } }, + { type: 1, text: '谢谢', received: true, time: 1704067201000, from: { uid: 99999, name: '我' } }, + ]; + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: msgsAllReceived } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }); + // from.uid=67890 matches friend.uid=67890 → boss sent it → '对方' + expect(rows[0].from).toBe('对方'); + // from.uid=99999 does not match → geek sent it → '我' + expect(rows[1].from).toBe('我'); + }); + + it('non-text message body does not crash and produces truncated JSON', async () => { + const nonTextMsg = { type: 99, received: true, time: 1704067200000, body: { action: 'resume_request', detail: 'X' } }; + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: [nonTextMsg] } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }); + expect(rows).toHaveLength(1); + expect(rows[0].text).toContain('resume_request'); + }); + + it('--side auto falls back to geek when recruiter returns code 24', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 24, message: '请切换身份后再试' }; + } + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: GEEK_MSGS } }; + } + return {}; + }); + const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'auto' }); + expect(rows).toHaveLength(2); + expect(rows[0].from).toBe('对方'); + }); + + it('--side geek throws when uid is not found in geek chat list', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [] } }; + } + return {}; + }); + await expect( + command.func(page, { uid: 'unknown-uid', page: 1, side: 'geek' }) + ).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('--side boss maps expired cookies to AuthRequiredError', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 7, message: 'Cookie 已过期' }; + } + return {}; + }); + await expect( + command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' }) + ).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('--side boss treats missing history list as parser drift', async () => { + const page = createPageMock(async (script) => { + if (script.includes('getBossFriendListV2')) { + return { code: 0, zpData: { friendList: [BOSS_FRIEND] } }; + } + if (script.includes('boss/historyMsg')) { + return { code: 0, zpData: {} }; + } + return {}; + }); + await expect( + command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' }) + ).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('--side geek reports an empty history as EmptyResultError', async () => { + const page = createPageMock(async (script) => { + if (script.includes('document.cookie')) return 'test-enc-sys-id'; + if (script.includes('geekFilterByLabel')) { + return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } }; + } + if (script.includes('getGeekFriendList.json')) { + return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } }; + } + if (script.includes('geek/historyMsg')) { + return { code: 0, zpData: { messages: [] } }; + } + return {}; + }); + await expect( + command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' }) + ).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/boss/utils.js b/clis/boss/utils.js index 92404ac9c..edb7d499e 100644 --- a/clis/boss/utils.js +++ b/clis/boss/utils.js @@ -1,8 +1,11 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + // ── Constants ─────────────────────────────────────────────────────────────── const BOSS_DOMAIN = 'www.zhipin.com'; const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`; const COOKIE_EXPIRED_CODES = new Set([7, 37]); const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。'; +const RECRUITER_ONLY_MSG = '该命令仅支持招聘端(BOSS 端)账号,请使用招聘者账号登录后重试。'; const DEFAULT_TIMEOUT = 15_000; // ── Core helpers ──────────────────────────────────────────────────────────── /** @@ -10,7 +13,24 @@ const DEFAULT_TIMEOUT = 15_000; */ export function requirePage(page) { if (!page) - throw new Error('Browser page required'); + throw new CommandExecutionError('Browser page required'); +} +export function readPositiveInteger(raw, name, fallback, max) { + const value = raw === undefined || raw === null || raw === '' ? fallback : Number(raw); + if (!Number.isInteger(value) || value < 1) { + throw new ArgumentError(`boss ${name} must be a positive integer`); + } + if (max !== undefined && value > max) { + throw new ArgumentError(`boss ${name} must be <= ${max}`); + } + return value; +} +export function readRequiredString(raw, name) { + const value = String(raw ?? '').trim(); + if (!value) { + throw new ArgumentError(`boss ${name} cannot be empty`); + } + return value; } /** * Navigate to BOSS chat page and wait for it to settle. @@ -33,19 +53,37 @@ export async function navigateTo(page, url, waitSeconds = 1) { */ export function checkAuth(data) { if (COOKIE_EXPIRED_CODES.has(data.code)) { - throw new Error(COOKIE_EXPIRED_MSG); + throw new AuthRequiredError(BOSS_DOMAIN, COOKIE_EXPIRED_MSG); + } +} +/** + * Map BOSS code=24 ("请切换身份后再试") to a typed AuthRequiredError. + * Recruiter-only commands (recommend, joblist, stats, resume, mark, + * exchange, invite, greet, batchgreet) have no geek-side equivalent; + * surfacing this as a generic COMMAND_EXEC hides what the user must do. + * chatlist / chatmsg avoid this path by using `allowNonZero: true` and + * branching to the geek-side fetch when they see code 24. + */ +function checkRecruiterSide(data) { + if (data.code === IDENTITY_MISMATCH_CODE) { + throw new AuthRequiredError(BOSS_DOMAIN, RECRUITER_ONLY_MSG); } } /** * Throw if the API response is not code 0. - * Checks for cookie expiry first, then throws with the provided message. + * Checks for cookie expiry first, then identity mismatch, then throws + * with the provided message. */ export function assertOk(data, errorPrefix) { + if (!data || typeof data !== 'object') { + throw new CommandExecutionError(`${errorPrefix ? `${errorPrefix}: ` : ''}Boss API returned malformed response`); + } if (data.code === 0) return; checkAuth(data); + checkRecruiterSide(data); const prefix = errorPrefix ? `${errorPrefix}: ` : ''; - throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`); + throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`); } /** * Make a credentialed XHR request via page.evaluate(). @@ -80,7 +118,19 @@ export async function bossFetch(page, url, opts = {}) { }); } `; - const data = await page.evaluate(script); + let data; + try { + data = await page.evaluate(script); + } catch (error) { + if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new CommandExecutionError(`Boss API request failed: ${message}`); + } + if (!data || typeof data !== 'object') { + throw new CommandExecutionError('Boss API returned malformed response'); + } // Auto-check auth unless caller opts out if (!opts.allowNonZero && data.code !== 0) { assertOk(data); @@ -95,8 +145,13 @@ export async function fetchFriendList(page, opts = {}) { const pageNum = opts.pageNum ?? 1; const jobId = opts.jobId ?? '0'; const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`; - const data = await bossFetch(page, url); - return data.zpData?.friendList || []; + const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero }); + if (opts.allowNonZero && data.code !== 0) return data; + const list = data.zpData?.friendList; + if (!Array.isArray(list)) { + throw new CommandExecutionError('Boss friend list response did not include zpData.friendList'); + } + return list; } /** * Fetch the recommended candidates (greetRecSortList). @@ -104,7 +159,11 @@ export async function fetchFriendList(page, opts = {}) { export async function fetchRecommendList(page) { const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/greetRecSortList`; const data = await bossFetch(page, url); - return data.zpData?.friendList || []; + const list = data.zpData?.friendList; + if (!Array.isArray(list)) { + throw new CommandExecutionError('Boss recommend response did not include zpData.friendList'); + } + return list; } /** * Find a friend by encryptUid, searching through friend list and optionally greet list. @@ -115,10 +174,14 @@ export async function findFriendByUid(page, encryptUid, opts = {}) { const checkGreetList = opts.checkGreetList ?? false; // Search friend list pages for (let p = 1; p <= maxPages; p++) { - const friends = await fetchFriendList(page, { pageNum: p }); + const result = await fetchFriendList(page, { pageNum: p, allowNonZero: opts.allowNonZero }); + if (opts.allowNonZero && !Array.isArray(result)) { + return { friend: null, code: result.code }; + } + const friends = Array.isArray(result) ? result : []; const found = friends.find((f) => f.encryptUid === encryptUid); if (found) - return found; + return opts.allowNonZero ? { friend: found, code: 0 } : found; if (friends.length === 0) break; } @@ -127,9 +190,9 @@ export async function findFriendByUid(page, encryptUid, opts = {}) { const greetList = await fetchRecommendList(page); const found = greetList.find((f) => f.encryptUid === encryptUid); if (found) - return found; + return opts.allowNonZero ? { friend: found, code: 0 } : found; } - return null; + return opts.allowNonZero ? { friend: null, code: 0 } : null; } // ── UI automation helpers ─────────────────────────────────────────────────── /** @@ -221,3 +284,185 @@ export function verbose(msg) { console.error(`[opencli:boss] ${msg}`); } } +// ── Geek-side helpers ──────────────────────────────────────────────────────── +export const IDENTITY_MISMATCH_CODE = 24; +const GEEK_CHAT_URL = `https://${BOSS_DOMAIN}/web/geek/chat`; +/** + * Navigate to the job-seeker chat page. + * Establishes the cookie + JS-global context needed for geek-side API calls. + */ +export async function navigateToGeekChat(page, waitSeconds = 2) { + await page.goto(GEEK_CHAT_URL); + await page.wait({ time: waitSeconds }); +} +/** + * Read the encryptSystemId value required by the geek-side list API. + * Strategy (in order): + * 1. Vue app state / Pinia stores / $route.query (Option 1 — runtime source) + * 2. performance.getEntriesByType('resource') — parse from geekFilterByLabel URL + * that the page itself already issued (Option 2 — most deterministic) + * 3. cookie, inline