Compare commits

..

172 Commits

Author SHA1 Message Date
jackwener ce8bc3e73f docs(stackoverflow): clarify read fetches answers up to --answers-limit (not 'all')
Follow-up from PR #1293 review: 'all answers' was misleading because
the implementation is limit-bounded (default 10, max 100) rather than
unbounded pagination. Spell out the actual contract — including the
accepted-answer-outside-page fallback path — so users don't expect
infinite-scroll behaviour.

Non-blocking docs-only change flagged by codex-mini1 + First-principles-1
during #1293 review.
2026-05-04 19:18:13 +08:00
jakevin c1a4bd3b7e feat(stackoverflow): surface question_id on listings + new read <id> (#1293)
* feat(stackoverflow): surface question_id + metadata on listings, add `read <id>`

Agent-native gap: all 4 stackoverflow listings (`hot`, `search`,
`unanswered`, `bounties`) only emitted `[title, score, answers, url]`,
which means an agent could see a hot question but had no `id` to round-
trip into a body read, no `tags` to filter by topic, no `views` to gauge
demand, and no `is_answered` / `creation_date` / `author` to triage.
There also wasn't a `read` adapter, so reading a SO question through
opencli was impossible.

Listings (`hot` / `search` / `bounties` / `unanswered`):
- Add `rank`, `id` (question_id), `views`, `is_answered` (skipped on
  `unanswered` since always false), `tags` (joined), `author`
  (owner.display_name), `creation_date` columns.
- Pass `pagesize` to the upstream API instead of fetching the default
  page and trimming locally.

New `stackoverflow read <id>`:
- 4-call fan-out against the public Stack Exchange API
  (`/questions/{id}` + `/questions/{id}/comments` +
  `/questions/{id}/answers` + batched `/answers/a;b;c/comments`).
- Returns `POST` + `Q-COMMENT` + `ANSWER` + `A-COMMENT` rows mirroring
  the `hackernews read` and `lobsters read` shape.
- Accepted answer is always surfaced first and tagged `accepted='true'`;
  remaining answers follow in descending vote order, capped by
  `--answers-limit`.
- HTML body cleanup: tags stripped, `<pre><code>` preserved, `<code>`
  inline-fenced, `<li>` rendered as `- `, comments indented with `> `.
- Entity decoding: a shared `decodeEntities` handles named (incl.
  `&hellip;`/`&copy;`/etc), decimal (`&#246;`), and hex (`&#x27;`)
  forms, applied to both bodies AND `display_name` (otherwise users
  like `Jonas K&#246;lker` come through mojibaked).
- Typed fail-fast: `ArgumentError` for non-numeric id and
  `--max-length < 100` (with no-fetch assertion); `EmptyResultError`
  when `items` is empty; `CommandExecutionError` for HTTP non-2xx and
  for Stack Exchange's in-band `error_id` envelopes (throttle / quota).
  No silent clamps anywhere.

Tests: 14 vitest assertions
- 4 listing column-shape (incl. `unanswered` skipping `is_answered` and
  `bounties` keeping its `bounty` column position)
- 10 read-adapter cases: registration / args / strategy + 3 typed-error
  fail-fast paths (with no-fetch assertion on the pre-fetch ones) + the
  full POST/Q-COMMENT/ANSWER/A-COMMENT row order with accepted-first +
  the answer-comments fetch verified to batch ids semicolon-joined +
  HTML entity decoding (named/decimal/hex) on both body and display_name
  + answers-limit honored when there are more answers than the cap.

Live verification:
- `stackoverflow hot --limit 2` → `id`/`tags`/`views`/`is_answered`/
  `author` populated.
- `stackoverflow search "async await" --limit 1`,
  `stackoverflow unanswered --limit 1` → same shape.
- `stackoverflow read 79935770` and the very-long classic question
  `stackoverflow read 11227809 --answers-limit 1 --comments-limit 2`
  → produces the threaded POST/Q-COMMENT/ANSWER/A-COMMENT structure
  with proper entity decoding (`Jonas Kölker` reads correctly).
- `stackoverflow read not-numeric` → exits with `ARGUMENT`.
- `stackoverflow read 999999999` → exits with `EMPTY_RESULT`.

* fix(stackoverflow): wrap fetch/json/coerce paths in typed errors

Apply the 3 lessons from PR #1292 (devto) review at merge time, before
B-group hits this PR:

1. CLI args may arrive as strings (e.g. `--max-length 50` → `'50'`).
   The bare `Number.isInteger(value)` in `requirePositiveInt` /
   `requireMinInt` would accept negative-but-coerced numbers and reject
   string-form integers. Now the helpers `coerceInt` first then validate,
   and the rejection message echoes the raw input via `JSON.stringify`.

2. `await fetch(url)` and `await res.json()` were not wrapped — a network
   blip would surface as a raw `TypeError` and a maintenance HTML page
   would surface as a raw `SyntaxError`. Both are now caught and rethrown
   as `CommandExecutionError` with hints, matching the in-band error_id
   path.

Tests: +3 cases (17 total)
- fetch network failure → CommandExecutionError
- malformed JSON body → CommandExecutionError
- string-form max-length "50" / "abc" rejected with ArgumentError before
  fetching

* fix(stackoverflow): avoid partial read fanout
2026-05-04 19:10:50 +08:00
jakevin 5a839701ab feat(lobsters): surface short_id + created_at on listings, add read <short_id> (#1291)
* feat(lobsters): surface short_id + created_at on listings, add `read <short_id>`

Same agent-native gap as the just-merged hackernews PR (#1288):

1. The 4 listings (hot / newest / active / tag) didn't surface each story's
   `short_id`. Agents could see the title and a comments URL but couldn't
   pass the id back into a follow-up command. Add `id` (= `short_id`) and
   `created_at` columns; `created_at` is cheap signal for "how stale is this".

2. There was no way to read a story + comment tree from the CLI. Lobsters
   makes this nicer than HN: `https://lobste.rs/s/<short_id>.json` returns
   the story plus a flat `comments[]` array where each entry already carries
   `parent_comment` and `depth`, so we get the full thread in one HTTP call
   and just DFS using the parent map.

`read` mirrors the `hackernews read` shape (POST row + L0/L1/… indented
comments, `[+N more replies]` stubs at depth/limit cutoffs) so the two
adapters feel the same to agents that already learned one. Same typed
fail-fast envelope: `ArgumentError` on bad short_id / non-positive limit /
depth / replies, `EmptyResultError` on 404 or empty body, `CommandExecutionError`
on other HTTP failures.

Tests cover all 4 listings (column shape + map step), `read` registration,
positional arg shape, ArgumentError fail-fast (no fetch on bad input),
EmptyResultError on 404, threaded-tree assembly from a flat `comments[]`,
and the `+N more replies` depth-cutoff path.

* test(lobsters): lock read fail-fast coverage

* docs(lobsters): list read command
2026-05-04 18:55:51 +08:00
jakevin 68485cc54e feat(devto): surface article id on listings + new read <id> (#1292)
* feat(devto): surface article id + published_at on listings, add `read <id>`

Agent-native gap: devto listings (`top`/`tag`/`user`) didn't include the
article `id`, so an agent couldn't round-trip from a listing into a body
read. They also dropped `reading_time` and `published_at`, which are cheap
signals the API gives you for free.

Changes:
- `top` / `tag` / `user`: add `id`, `reading_time`, `published_at` columns
  alongside existing rank/title/etc. `user` keeps its no-author shape since
  it's already user-scoped.
- New `devto read <id>`: hits `dev.to/api/articles/<id>` and returns one
  row with the article body (truncated by `--max-length`, default 20000,
  min 100). DEV.to's public API does not expose comments yet, so this is
  intentionally a single-row reader rather than a HN/lobsters-style
  threaded tree — if/when comments become public we can extend to
  POST + L0/L1.
- Typed fail-fast: `ArgumentError` for non-numeric id and for `--max-length`
  below 100; `EmptyResultError` on 404; `CommandExecutionError` for other
  non-2xx HTTP statuses. No silent clamps.
- Defensive tag normalization: the `/api/articles/<id>` endpoint returns
  `tag_list` as a comma-string and `tags` as an array (the opposite shape
  from listing endpoints). Caught this on live verification — both shapes
  now collapse to a comma-joined string.

Tests: 12 vitest assertions covering listing column shape (all 3) +
register/args/strategy + typed-error fail-fast paths + happy-path body
extraction + truncation marker + alternate tag_list shape.

Live verification: `devto top --limit 3` and `devto read 3602287` both
return the expected agent-native shape.

* fix(devto): harden article read contract
2026-05-04 18:55:14 +08:00
jakevin aa8d4b72f7 fix(twitter): drop permanently-N/A tweets column from trending (#1290)
X removed the post-count caption from each cell on `/explore/tabs/trending`.
The adapter still iterated `divs[2..]` looking for a numeric text node and
fell back to the literal string "N/A" when it found none — which was every
row, on every call. We were emitting a silent-wrong column for every result.

Drop the column and the no-longer-relevant scan loop. Add a regression test
on the columns shape so the column doesn't slip back in.

Live runs of `opencli twitter trending` previously returned rows like
`{rank: 1, topic: "...", tweets: "N/A", category: "..."}` — the `N/A` was
not a transient outage, it was structural.
2026-05-04 18:31:06 +08:00
jakevin e848594519 feat(arxiv): full abstract/authors + surface pdf/categories/comment + new recent <category> (#1289)
* feat(arxiv): full abstract/authors, surface pdf+categories+comment, add `recent <category>`

`paper` was silently truncating the abstract to 200 chars and dropping all but
the first 3 authors — agents calling it for a paper summary lost data. Stop
truncating, return all authors, and surface the rest of what the Atom feed
already gives us: pdf url (`<link rel="related">`), all `categories`,
`primary_category`, and the author `comment` (page count, conference, etc.).

`search` keeps a compact list shape (no abstract column, but adds
`primary_category`).

New `arxiv recent <category>` lists newest submissions in a category sorted by
`submittedDate desc` — fills a gap (previously you had to know a search term
to surface anything). Validates the category string and rejects malformed
input via `ArgumentError`.

`search` also switches its no-results path from `CliError('NOT_FOUND', ...)`
to `EmptyResultError` to match the convention other public-API adapters use.

Tests cover: command registration, full-abstract / all-authors parsing, XML
entity decoding in titles, pdf/categories/comment extraction, and category
validation.

* fix(arxiv): harden category and limit validation
2026-05-04 18:27:13 +08:00
jakevin 977105b0f6 feat(hackernews): add read <id> and surface item id on every listing (#1288)
* feat(hackernews): add `read <id>` and surface item id on every listing

Two related agent-flow gaps in the HN adapters:

1. `top`/`best`/`ask`/`new`/`show`/`jobs`/`search` all carry the HN item
   id internally (firebase items are fetched by id; algolia hits include
   `objectID`) but drop it before output. Without an id column the agent
   can see the title but has no handle to follow up with.

2. There was no way to read a story's discussion. The whole reason an
   agent looks at HN is the comments — and that capability was missing.

This PR adds:

- `id` column on every listing adapter (firebase items: numeric id;
  algolia search hits: `objectID` string). Existing column order is
  preserved otherwise.
- `hackernews read <id>` — public/non-browser adapter that fetches the
  story plus a tree of top-level comments + inline replies via
  `https://hacker-news.firebaseio.com/v0/item/<id>.json`. Mirrors the
  `reddit read` shape (`type/author/score/text`) so agents can use both
  with one mental model. HTML-only fields (comment text) are converted
  to plain text with anchor URLs preserved.
- Column-contract tests covering all listings + the new read adapter.
- Doc entry under `docs/adapters/browser/hackernews.md`.

Tested locally via `~/.opencli/clis/hackernews/` overrides:
  opencli hackernews top --limit 3            # id present
  opencli hackernews search rust --limit 2    # id (objectID) present
  opencli hackernews read 47999636 --limit 5  # threaded output

* fix(hackernews): typed fail-fast for read
2026-05-04 18:25:57 +08:00
jakevin 413bbbe819 fix(douban): drop unparseable fields from movie-hot, add id/votes (#1285)
* fix(douban): drop unparseable fields from movie-hot, add id/votes

The chart page (movie.douban.com/chart) only exposes a single comma-joined
text dump in `.pl2 p`, of the shape:

  <release_dates...> / <actors...> / <regions...> / <director_zh> /
  <runtime>分钟 / <other_titles> / <genres> / <director with English> /
  <languages>

The previous `loadDoubanMovieHot` tried to anchor on the release-date
regex and take `parts[releaseIndex - 1]` as director and
`parts[releaseIndex - 2]` as region. That breaks in two ways:

1. Most entries have multiple release dates back-to-back, so the
   "anchor minus one" position is itself a date. Director output becomes
   `'2025-09-07(多伦多电影节)'` and region is empty — silent wrong data.
2. For entries with a single release date, the offsets land on actor
   names, not director / region.

The page does not actually carry a clean director or region per row —
that's only available on the subject detail page. Trying to reconstruct
either from the chart string is the canonical "verify passes but data is
wrong" failure (success-rate-pitfalls §2 sibling DOM contamination).

Fix: drop `director`, `region`, `quote` from the listing. Surface what
the chart page actually provides reliably:
- `id`     — extracted from the subject URL, ready for `douban subject`
- `votes`  — from `.star .pl` (`(62484人评价)`), useful as popularity signal
- existing `rank`, `title`, `rating`, `year`, `url`

Agents that need director / region should follow up with
`opencli douban subject <id>`, which is already wired for that data.

* fix(douban): fail fast on empty movie hot
2026-05-04 16:12:18 +08:00
jakevin 11ceca4fc2 fix(bilibili,reddit): add identifier and url columns to hot lists (#1284)
* fix(bilibili,reddit): add identifier and url columns to hot lists

Both `bilibili hot` and `reddit hot` previously dropped their per-row
identifier and URL on the way out, breaking the typical agent flow where
the next call needs a `bvid` / `postId` to fetch detail or comments.

- bilibili/hot: add `bvid` and `url` columns (constructed from bvid)
- reddit/hot: surface `postId`, `author`, `url` (already in evaluate but
  dropped in map)

Tested via local `~/.opencli/clis/<site>/hot.js` overrides.

* test(bilibili,reddit): lock hot list identifier columns
2026-05-04 16:10:49 +08:00
jakevin be5234ced1 fix(doctor): remove adapter analyze tip (#1283) 2026-05-04 13:37:13 +08:00
jakevin 1da105edea revert: offscreen daemon bridge
Revert PR #1280 and restore the previous Browser Bridge service-worker transport while PR #1229-style recovery messaging is pursued.
2026-05-03 23:14:37 +08:00
jakevin ca25f65bf7 fix(extension): move daemon bridge to offscreen document
Move the Browser Bridge daemon WebSocket out of the MV3 service worker and into an offscreen document. Remove the popup/action UI and obsolete extension log forwarding now that doctor is the diagnostic surface.
2026-05-03 22:20:54 +08:00
jakevin 0f29790795 feat(browser): add dialog handling and CDP DOM primitives (#1278)
* feat(browser): add dialog handling and CDP DOM primitives

* fix(browser): narrow dialog error detection
2026-05-03 21:28:30 +08:00
Kagura 98062a21c9 fix: isolate browser workspace per command
Fix concurrent browser-backed commands for the same site by using a unique workspace per command execution. Closes #1114.
2026-05-03 21:20:47 +08:00
jakevin a353db5fbe chore(cli): remove duplicate root help summary logic (#1277)
* chore(cli): remove duplicate root help summary logic

* chore(test): remove unused commander adapter imports
2026-05-03 20:06:37 +08:00
jakevin 1d407ab62f fix(cli): show adapter subcommands in root help (#1276)
* fix(cli): show adapter subcommands in root help

* fix(cli): summarize built-in root help groups
2026-05-03 19:55:59 +08:00
jakevin 1b19b3ebe9 chore: bump version to 1.7.11 (#1275)
Release / release (push) Has been cancelled
2026-05-03 19:35:22 +08:00
jakevin d60e1cf43d fix(browser): route type and keys through native input (#1274)
Fixes #1265 by routing browser type/keys through existing native CDP input primitives, with DOM fallbacks and direct CDPPage parity.
2026-05-03 19:32:31 +08:00
jakevin 1cd1253d46 feat(instagram): add collection-delete adapter
Pairs with the new collection-create adapter so users (and future
fixture-teardown logic) can clean up saved-post collections from CLI.

- POST /api/v1/collections/{id}/delete/ with multipart module_name=collection_settings
- Accepts collection name (case-insensitive) or numeric collection_id; resolves
  via /collections/list/ first so unknown / duplicate names error explicitly
  instead of bubbling up a 404 or silently deleting the wrong one.
2026-05-03 19:05:55 +08:00
jakevin 7869bdb2ca feat(browser): polish adapter author verify workflow 2026-05-03 19:03:38 +08:00
jakevin 2e93ac6e63 fix(release): build before manifest drift check (#1269) 2026-05-03 18:40:39 +08:00
jakevin de0d74bf62 fix(build-manifest): fail loud on import errors and refuse stale dist (#1268)
The previous implementation silently skipped any adapter whose import
failed (catch + warn-to-stderr + return []), then printed a successful
" Manifest compiled: N entries". When dist/ was stale (e.g. after
renaming an export the JS adapters re-import) every adapter using that
export would fail to load, get skipped, and the script still exited 0.
An agent reading exit codes to gate work would commit the resulting
manifest and silently delete dozens of unrelated adapter entries.

Three layers of defense:

1. Distinguish skip kinds. Files that don't call `cli(...)` are still
   silently dropped (helpers / type modules). Files that look like CLI
   modules but fail to import now throw `ManifestImportError`. The
   batch scanner aggregates failures and `main()` exits 1 with an
   explicit list, leaving the existing manifest on disk untouched.

2. Net-deletion safety net. `main()` diffs the new entries against the
   committed manifest and refuses to overwrite when entries would be
   removed. `--allow-removals=N` (or bare `--allow-removals` for any)
   is the explicit opt-in; the error message tells the caller exactly
   what value to pass.

3. Runtime dist guard. `node dist/src/build-manifest.js` now refuses
   to run with a clear pointer at `npm run build-manifest` (which uses
   tsx). The npm script itself is migrated to `tsx src/build-manifest.ts`
   so no project-level command points at the compiled copy anymore.

Release CI gains a manifest-drift gate (build-manifest + git diff
--exit-code) so a tag push can never publish stale or silently-shrunk
manifests. The existing CI check on PRs is preserved.

`ManifestEntry` is split into `src/manifest-types.ts` so runtime code
(discovery.ts) imports the type without pulling the build-time
compiler module.

Tests:
- `loadManifestEntries` throws ManifestImportError on import failure
- helper modules without cli() are still silently skipped
- `scanClisDir` aggregates per-adapter failures
- `diffRemovedEntries` returns expected site/name diff
- `parseBuildManifestArgs` reads --allow-removals[=N]
2026-05-03 18:27:23 +08:00
jakevin a9e0ca648f fix(extension): remove status-row left border accent (#1267)
WAWQAQ feedback: the green left border on the status row looked
disconnected — only on the top half of the card, creating an awkward
stub. Connection state is already conveyed clearly by the colored dot
and the "Connected to daemon" / "Disconnected" text, so the border was
redundant decoration.

Drop the .card.connected/.disconnected/.connecting border-left rules.
No JS or layout changes; cleaner surface, fewer visual variants.
2026-05-03 18:18:30 +08:00
jakevin bebc7aa35e chore: bump version to 1.7.10 (extension 1.0.4) (#1266)
Release / release (push) Has been cancelled
2026-05-03 18:00:35 +08:00
jakevin 061fba100d feat(extension): polish popup UI with merged card and copy contextId (#1262)
- Merge status row and profile row into a single rounded card with a
  brand-colored left border accent indicating connection state
- Render contextId inline next to a "Profile" label with a Copy button,
  letting users paste it into `opencli profile rename` without manual
  selection (replaces the old full-width code block treatment)
- Show daemon version inline in the status row when connected, and
  render the extension version as a tag in the popup header — both
  surface version information that helps diagnose stale-daemon issues
- Forward both versions through the existing `getStatus` background
  message: extension reads its own version from the manifest, daemon
  version is fetched best-effort from `/status` with a 1.5s timeout so
  popup never hangs when the daemon is unreachable
2026-05-03 17:37:51 +08:00
jakevin e364ec6b9c feat(browser): pass trace through verify (#1263) 2026-05-03 17:32:02 +08:00
jakevin 765eb56c99 feat(daemon): surface stale versions and restart (#1261) 2026-05-03 17:20:54 +08:00
jakevin 5f72770eff feat(instagram): add collection-create + collection filter for saved (#1192) (#1260)
Closes #1192. Two changes:

1. New `instagram collection-create <name>` adapter wraps
   `POST /api/v1/collections/create/` (multipart `name` +
   `module_name=collection_create`, X-IG-App-ID + X-CSRFToken).
2. `instagram saved` gains an optional `--collection <name>` flag.
   When set, the adapter resolves the name to a collection id via
   `/api/v1/collections/list/` (case-insensitive trim match) and then
   fetches `/api/v1/feed/collection/{id}/posts/`. Unknown names throw
   with the available list so callers can self-correct.

Both verified end-to-end against a live IG account. Verify fixtures
under ~/.opencli/sites/instagram/verify/ ship the
patterns/notEmpty/mustBeTruthy guards from the latest adapter-author
skill (success-rate-pitfalls §1, §4, §8).
2026-05-03 16:33:15 +08:00
jakevin 3017ca78aa chore: bump version to 1.7.9 (extension 1.0.3) (#1259)
Release / release (push) Has been cancelled
2026-05-03 15:46:48 +08:00
jakevin 7e68e19f0d feat(trace): prune retained artifacts (#1258) 2026-05-03 15:34:30 +08:00
jakevin 4ceb3314fe refactor(trace): retire diagnostic repair path (#1257)
* refactor(trace): retire diagnostic repair path

* chore(trace): clarify artifact summary guidance

* chore(trace): version trace receipt schema
2026-05-03 15:19:44 +08:00
Jack He 5f0cce7b22 feat(weibo): add favorites + publish CLI commands (#1253)
* feat(weibo): add favorites + publish CLI commands

Consolidates #1253 (favorites) and #1254 (publish) into a single PR per maintainer request.

- clis/weibo/favorites.ts: cookie-mode fetch of authenticated user's favorites via weibo.com/u/page/fav/{uid}
- clis/weibo/publish.js: UI-automation post (text up to 2000 chars, up to 9 images jpg/png/gif/webp)
- cli-manifest.json regenerated to include the new commands

Note: favorites.ts uses TypeScript syntax but build-manifest.js scans only *.js — favorites is currently NOT registered in the manifest. Reviewers please check whether to rename to .js or whether the manifest scanner should learn .ts.

Authored-by: hszhsz <heshaoz1990@gmail.com>

* fix(weibo): harden favorites and publish commands

* fix(weibo): publish without execute gate

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 15:00:38 +08:00
Benjamin Liu 284c96133b feat(claude): add Claude adapter (#1252)
* feat(claude): add Claude adapter

Adds a Claude (claude.ai) browser adapter family with seven commands
modeled on the existing clis/deepseek/ pattern: ask, send, new, status,
read, history, detail.

Closes #1251

* feat(claude): align send command columns with doubao

Match the established Status / SubmittedBy / InjectedText shape used by
doubao send so agent loops can rely on a consistent fire-and-forget
output across AI chat adapters.

* fix(claude): preserve DOM order in getVisibleMessages

The previous implementation queried user-message and assistant-message
nodes in two passes, which serialized as [u1, u2, u3, a1, a2, a3] for
multi-turn chats instead of the correct conversation order. Single
combined query preserves DOM order so claude read / detail return
turns in the order the user reads them on the page.

* docs(claude): note --live requirement for read across invocations

* fix(claude): fail fast on auth and empty states

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 14:55:41 +08:00
jakevin eac17b361e feat(observation): add runtime trace capture (#1255) 2026-05-03 14:38:59 +08:00
jakevin aa33262ef7 docs: narrow smart-search trigger description (#1248) 2026-05-02 16:51:17 +08:00
jakevin a0b2df1448 docs: refresh stale entry and developer docs (#1244) 2026-05-02 12:31:48 +08:00
jakevin fc7245f9f6 chore: enforce node 21 baseline (#1242) 2026-05-02 09:30:28 +08:00
jakevin 88bcd814ee refactor: simplify diagnostics and low-use errors (#1241) 2026-05-02 09:28:26 +08:00
jakevin 2fd7272559 docs: clarify opencli extension paths (#1240) 2026-05-02 09:27:17 +08:00
jakevin c131b4b435 fix: stabilize manifest paths on Windows
Normalize manifest sourceFile paths and test symlink type behavior on Windows.
2026-05-02 08:44:38 +08:00
jakevin f202d65f87 refactor(cli): move external management under external (#1238) 2026-05-02 01:40:36 +08:00
jakevin 5c871dd7a3 refactor(adapter): split browser command signatures (#1237) 2026-05-02 01:33:37 +08:00
jakevin b41ee2b671 feat(update-check): show extension update notice on exit (#1236)
* feat(update-check): show extension update notice on exit

The CLI exit hook already prints "Update available" when a newer @jackwener/opencli is on npm. Extension updates were only surfaced inside `opencli doctor`, so users running normal browser commands had no signal that the Chrome extension was out of date.

Solution piggybacks on the existing 24h background fetch:
- Daemon writes the live extensionVersion + lastSeenAt into the shared cache on every hello handshake (rare event, one fs.writeFileSync).
- CLI exit hook reads the cache it already loads and prints an extra extension notice when a newer release is available and the cache is fresh (<7d).
- writeCache becomes a read-merge-write so the daemon's currentExtensionVersion and the CLI's npm latestVersion don't clobber each other.

Net cost on the CLI hot path: zero new I/O, zero new daemon contact. The notice formatter is split into a pure helper (buildUpdateNotices) so the staleness window, equality, and combined-notice cases are unit-tested without touching disk or stderr.

* fix(update-check): tolerate partial cache when daemon writes first

Self-review caught a TypeError path: if the daemon's hello handler runs `recordExtensionVersion` before the CLI's npm fetch ever populated the cache, the resulting cache file has only `currentExtensionVersion` + `extensionLastSeenAt` and no `latestVersion`. The next CLI run then fed `undefined` into `isNewer`, which calls `.replace(...)` on it.

- Mark `lastCheck` and `latestVersion` optional in the cache schema (the merge pattern means either side may write first).
- Guard the CLI notice on `cache.latestVersion` being defined before comparing.
- Guard `checkForUpdateBackground`'s 24h short-circuit on `lastCheck` being defined.
- Add a test for the daemon-only cache case.
2026-05-02 01:03:37 +08:00
lakako 6c077237a8 feat(zhihu) add collection list and list collection content (#1234)
* feat(zhihu): add collection command to list favorite items

Add new 'opencli zhihu collection' command that:
- Lists items from a Zhihu collection (requires login)
- Supports pagination with --offset and --limit parameters
- Handles multiple content types: answer, article, pin
- Shows collection statistics: total count, total pages, current page

* feat(zhihu): split collection into collection and collections commands

- Rename zhihu collection list functionality to zhihu collections
- Keep zhihu collection for viewing specific collection contents by ID
- Convert collection.ts to collection.js so build-manifest picks it up
- Add tests for both commands
- Update cli-manifest.json

* fix(zhihu): harden collection read commands

---------

Co-authored-by: Developer <developer@example.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-02 00:59:36 +08:00
jakevin 8dd9d578d4 feat(browser): support multiple Chrome profiles (#1235)
* feat(browser): support multiple chrome profiles

* fix(browser): tighten profile popup context id

* fix(browser): harden profile routing edge cases

* refactor(browser): remove unnecessary profile id guard
2026-05-02 00:52:44 +08:00
hanzi d65cccd7d8 feat(facebook): add marketplace read commands (#1221)
* feat(facebook): add marketplace read commands

* feat(facebook): add marketplace reply draft command

* fix(facebook): parse narrow spaces in marketplace inbox

* fix(facebook): keep marketplace commands read-only

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:18:57 +08:00
AstroHan c0aea6c1ae fix(twitter): paginate following results
* fix(twitter/following): switch from INTERCEPT+autoScroll to COOKIE+cursor pagination

The previous INTERCEPT strategy relied on autoScroll to trigger Twitter's
pagination by scrolling document.body. Twitter's virtual list doesn't grow
document.body.scrollHeight, so scrolls stopped triggering API calls after
the first few pages, capping results at ~50 regardless of limit.

Now uses Strategy.COOKIE with explicit cursor-based GraphQL pagination
(same pattern as twitter/likes), which correctly fetches all pages.

Fixes #1230

* fix(twitter): harden following pagination

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:13:54 +08:00
huanghuoguoguo 349b4bab49 feat(boss): add --jobType filter, fix experience codes, surface bossOnline (#1231)
* feat(boss): add jobType filter and bossOnline output

Add --jobType param (全职/兼职/实习 = 1901/1902/1903) so callers can
exclude internships at the API layer instead of post-filtering by name
keywords. Without this, --experience 应届 returns a mix of 校招 and 实习
because BOSS bundles both under code 108.

Also surface bossOnline (Y/empty) in results so callers can prioritize
HRs currently online — this is the only activity signal exposed by the
web API; 'recently active' / 'newly posted' filters are mobile-only and
not accepted by /wapi/zpgeek/search/joblist.json.

* fix(boss): correct experience codes (应届=102, not 108)

The previous EXP_MAP was off by ~2 across the board. Verified each
code by clicking BOSS web's filter UI and reading the URL:

  108 = 在校生 (interns)         was: '在校/应届','应届' → 108 (wrong)
  102 = 应届生 (校招 full-time)   was: '1-3年' → 102      (wrong)
  101 = 经验不限                  was: '1年以内' → 101    (wrong)
  103 = 1年以内                   was missing
  104 = 1-3年                     was: '3-5年' → 103      (wrong)
  105 = 3-5年                     was: '5-10年' → 104     (wrong)
  106 = 5-10年                    was: '10年以上' → 105   (wrong)
  107 = 10年以上                  was missing

This is why --experience 应届 had been returning mostly 实习生 jobs:
it was secretly querying 在校生 (108). The fix makes 应届 actually
mean 应届生 (102 = 校招), and lets users pick 在校生 (108) explicitly
when they do want internships.

* fix(boss): validate job type filter

* fix(boss): keep legacy campus experience alias

---------

Co-authored-by: youhh <youhh@1051233107@qq.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:10:52 +08:00
Benjamin Liu 094ff0da80 feat(deepseek): add vision mode support
* feat(deepseek): add vision mode support

DeepSeek added a third model "识图模式" (Vision Mode) that accepts
image uploads for visual understanding. Add vision to the --model
choices, update selectModel to use explicit index mapping for all
three models, skip the search toggle in vision mode (not available),
and extend waitForFilePreview to detect image thumbnails via send
button state since vision mode shows a preview image instead of a
filename label.

Also catch "Not allowed" errors from setFileInput (Cloudflare may
block CDP file operations) so the DataTransfer fallback can run.

Closes #1215

* fix(deepseek): harden vision upload mode

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:03:01 +08:00
Benjamin Liu 25e86532a3 fix(chatgpt): fix image generation detection and output path (#1218)
* fix(chatgpt): fix image generation detection and output path

Three fixes for chatgpt image command:

1. Page navigation: ChatGPT redirects away from the conversation
   after sending. Poll for the /c/ URL after send, then periodically
   reload the conversation page during image wait to pick up
   asynchronously rendered images.

2. Composer selector: add fallback selectors for the chat input
   since ChatGPT uses different aria-labels across UI versions.

3. Output path: the default '~/Pictures/chatgpt' was passed as a
   literal string without tilde expansion, creating a directory
   named '~' in the working directory. Removed the string default
   and use os.homedir() fallback instead.

Fixes #1206

* fix(chatgpt): fail fast on image export failures

* fix(chatgpt): avoid reloads during image generation

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:01:34 +08:00
m72900024 8dee08bc4c fix(chatgpt-app): support Traditional Chinese UI labels
* fix(chatgpt-app): support Traditional Chinese UI labels

The send button and Options button matchers only included Simplified
Chinese ("发送", "选项"). On macOS systems with Traditional Chinese as
the system language, the ChatGPT desktop app exposes "傳送" and "選項"
via the Accessibility API, causing `chatgpt-app send` to fail with
"Could not find send button" and `chatgpt-app model` to fail with
"Could not find Options button" for zh-TW / zh-HK users.

Verified via AXUIElement walk on ChatGPT 1.2026.104 / macOS 26 with
system language set to Traditional Chinese.

The "Stop generating" detection at line 314 already handles Traditional
Chinese because 停止生成 uses identical glyphs in both writing systems.
"Legacy models" at line 261 still lacks any Chinese variant but is not
addressed here since the Traditional Chinese translation has not been
verified on a live UI.

* test(chatgpt-app): cover traditional chinese ax labels

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-30 12:20:39 +08:00
Benjamin Liu c86b6826a4 fix(zhihu): fix identity detection, comment, answer, and search (#1207)
* fix(zhihu): fix identity detection, comment, answer, and search

Identity detection: Zhihu removed __INITIAL_STATE__ and moved the
user avatar from a profile link into a button. Added fallback that
extracts the user slug from the header avatar alt text.

Comment and answer: Zhihu moved the comment editor into a Modal
and changed the submit button behavior, breaking the UI-based
write flow. Replaced with direct API calls (POST /api/v4/answers/
{id}/comments and POST /api/v4/questions/{id}/answers) which are
reliable and much simpler.

Search: Zhihu's search API now returns mixed result types (ads,
education, hot_timing) alongside search_result. Updated the filter
to select by object.type (answer/article/question) and increased
fetch size to compensate for non-content results.

Fixes #1198

* fix(zhihu): rewrite like, follow, favorite to use API

Same DOM breakage as comment/answer. Replaced UI-based click
flows with direct Zhihu API calls for all write commands.

* fix(zhihu): harden api write regressions

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-29 15:01:33 +08:00
Jean Zhang c264531586 feat(zlibrary): add search and info commands (#1211)
* feat(zlibrary): add search and info commands

Add Z-Library adapter with two browser-based commands:

- `search` — Search books by title, author, or ISBN.
  Navigates to /s/<query> and extracts results from
  <z-bookcard> shadow DOM custom elements.

- `info` — Get book details and available download formats
  from a book page URL.

Uses Strategy.COOKIE with browser automation to bypass
Cloudflare protection. The adapter reuses the user's existing
Z-Library login cookies from system Chrome.

Known limitation: actual file downloading requires Playwright's
download event handling (page.on('download')). OpenCLI's browser
automation does not currently intercept file downloads. Users
needing to download files should use Playwright to navigate to
the book URLs discovered by this adapter.

* fix(zlibrary): harden input and empty extraction

---------

Co-authored-by: jean <jean@jeandeMacBook-Pro.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-29 14:48:38 +08:00
jakevin 10baf02060 feat(web): make read render-aware (#1209)
* feat(web): make read render-aware

* fix(web): fail when networkidle readiness is unmet

* test(web): avoid unhandled networkidle rejection
2026-04-28 23:07:47 +08:00
jakevin dff3fd8950 feat(browser): manage owned workspaces as tab leases (#1204)
* feat(browser): manage owned workspaces as tab leases

* fix(browser): harden lease reconciliation paths
2026-04-28 21:05:33 +08:00
Xeron ff571fc965 fix(jd): separate main and detail image extraction (#1205)
* fix(jd): separate item image extraction

* chore: update CLI manifest

* test(jd): update item adapter expectations

* fix(jd): collect CSS detail images

* fix(jd): extract detail images from scripts and frames

* fix(jd): recover detail images and selected specs

* fix(jd): fail fast on blocked item extraction

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-28 21:05:18 +08:00
Benjamin Liu 08a2428306 fix(deepseek): fix send button detection and file upload (#1166)
* fix(deepseek): fix send button detection in sendMessage

The previous selector `btn.closest('div')?.querySelector('textarea')`
always returned null because the button itself is a div, so
closest('div') returns the button, which has no textarea inside.
This caused every send to fall through to the Enter key fallback.

Walk up from the textarea to find the input container, then select
the last enabled non-toggle button with an SVG icon (the send
button). Excludes `.ds-toggle-button` elements (DeepThink / Search
toggles) so only the actual send button is clicked.

* fix(deepseek): fail closed when upload never enables send

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-28 14:18:29 +08:00
dependabot[bot] 02b3033954 chore(deps): bump jsdom from 29.0.2 to 29.1.0 (#1199)
Bumps [jsdom](https://github.com/jsdom/jsdom) from 29.0.2 to 29.1.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.2...v29.1.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 14:10:13 +08:00
jakevin 141ec95c01 feat(browser): bind current tab to bound workspace (#1196)
* feat(browser): bind current tab to bound workspace

* docs(browser): document bound session idle semantics

* test(extension): cover bind-current owned-overwrite refusal

Adds regression for the second guard in handleBindCurrent that refuses
binding when the bound:* workspace already has an owned automation
window. Previously only the non-bound prefix path was tested.

* refactor(browser): rename bind command

* fix(browser): bind only current window tabs

* fix(browser): fail unbind when detach command fails
2026-04-27 17:35:37 +08:00
Benjamin Liu bc9ae39cfc feat(google-scholar): add cite and profile commands, fix search dedup (#1176)
* feat(google-scholar): add cite and profile commands, fix search dedup

- cite: get BibTeX/EndNote/RefMan/RefWorks citation for a paper.
  Clicks the cite button in search results and fetches the citation
  content from Google's citation endpoint.

- profile: view an author's Google Scholar profile (h-index,
  i10-index, citation count, top papers). Accepts author name
  or Scholar user ID.

- search: fix duplicate results caused by CSS selector matching
  both outer container (.gs_r.gs_or.gs_scl) and inner child
  (.gs_ri) for each paper.

Closes #1174, closes #1175

* fix(google-scholar): fail fast on cite and profile misses

* fix(google-scholar): document new commands and lock dedup test

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-27 15:36:49 +08:00
CissiBot 02dbbb1c18 fix(uiverse): harden navigation retries and preview lookup (#1171)
Pre-navigate Uiverse commands and retry detached browser bridge failures so code and preview flows stop falling back to about:blank. Broaden preview element matching for input-root components and cover the new navigation contract in tests.
2026-04-27 15:33:23 +08:00
yorick 07760d00ba fix: separate author name from date text in search results (#1173)
* separate author name from date text in search results

* fix(xiaohongshu): constrain author date stripping

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-27 15:28:03 +08:00
hanzi ac80c4962b Fix twitter post image uploads (#1180) 2026-04-27 15:25:42 +08:00
wjjsn e2b595272b fix(doubao): update message selectors for DOM restructure (#1190)
- Replace broken data-testid selectors with class-based selectors
- Message list: [class*="message-list-S2Fv2S"], .container-PvPoAn
- User messages: [class*="bg-g-send-msg-bubble"]
- Assistant messages: [class*="bg-g-receive-msg-bubble"]
- Add stopLines for UI noise: 请仔细甄别, 下载电脑版

Fixes #1183
2026-04-27 15:20:56 +08:00
darthjaja 23beb9508c fix(youtube): channel videos-tab fallback reads wrong tab from InnerTube response (#1164)
* fix(youtube): channel videos-tab fallback reads wrong tab from InnerTube response

After PR #1109, `opencli youtube channel <id>` still returns empty
`recent_videos` for channels whose Home tab is empty AND whose InnerTube
`/youtubei/v1/browse` response includes multiple tabs.

Root cause: the fallback fetch sends a browse request with the Videos
tab's `params`. The response, however, includes ALL tabs (Home, Videos,
Shorts, ...), with only the requested tab marked `selected: true`. The
existing code reads `tabs?.[0]?.tabRenderer?.content?.richGridRenderer?.contents`
— for multi-tab responses `tabs[0]` is Home (empty), so `richGrid` ends
up `[]` and `recentVideos` stays empty. PR #1109's test channels happened
to return single-tab lists with Videos at index 0, masking the bug.

Fix: find the tab with `selected: true` instead of assuming `tabs[0]`.

Reproducer: `opencli youtube channel UC44DSuDgw7_qccvZzIK3Jpg`
(杀鱼伟-Vi, ~3.1K subs, posts daily). Returns 0 videos pre-patch, 30+
videos post-patch.

`npm run typecheck` clean, `npm test` passes (1952/1952).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(youtube): preserve videos tab fallback

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-27 15:16:09 +08:00
wjjsn 9cd247d74d fix(doubao): use ID selector for send button (#1188)
* fix(doubao): use ID selector for send button

The clickSendButtonScript was searching for the send button by walking up
the DOM tree only 2 levels from the textarea, but the actual send button
#flow-end-msg-send is at level 5. This caused message sending to fail.

Fix by directly selecting the button via its ID.

* test(doubao): update send button selector assertions

* fix(doubao): keep send-button fallback contract

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-27 15:07:59 +08:00
sontjer f099e4cb3a fix(toutiao): fix NON_TITLE_LINES scope error in articles parser (#1179)
* fix(toutiao): move NON_TITLE_LINES inside function scope

NON_TITLE_LINES was defined outside parseToutiaoArticlesText() as a
module-level const. When the function is serialized via .toString()
and injected into browser evaluate context, outer scope variables
are not available, causing 'NON_TITLE_LINES is not defined' error.

Fix: move NON_TITLE_LINES inside the function so it's included in
the serialized string.

* test(toutiao): cover serialized articles parser

---------

Co-authored-by: sontjer <sontjer@github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-27 15:06:54 +08:00
jakevin a999dcec83 docs: update extension install to Chrome Web Store (#1194)
* docs: update extension install to Chrome Web Store link

Extension is now published on Chrome Web Store. Replace manual
download/unpack instructions with the store link across READMEs
and skill docs.

* docs: restore manual install as Option B alongside Chrome Web Store
2026-04-27 14:36:44 +08:00
jakevin ca8459c400 fix(browser): keep text/javascript API responses in network output 2026-04-27 14:23:01 +08:00
Benjamin Liu 54ffc88283 fix(web): preserve button text in web read output (#1185)
The shared article-download pipeline strips all <button> elements
via STRIPPED_TAGS, which is correct for article adapters (zhihu,
weixin) but causes web read to silently lose meaningful button
content like "Download All" on generic pages.

Override the button stripping in web read's configureTurndown
callback so button text is preserved as inline content.

Fixes #1184
2026-04-26 20:47:03 +08:00
jakevin d9c96f7e3b chore: bump version to 1.7.8 (#1178)
Release / release (push) Has been cancelled
2026-04-25 22:16:05 +08:00
jakevin 0e9e1ce953 chore(extension): restore pre-1.6.8 neon terminal icons (#1177)
Restore the original icons (commit b2fa7da) that were replaced by the
v1.6.8 "refresh icons" change in e9867dc. Per user feedback, the original
neon `>_` design read more clearly and was preferred over the abstract
arrow + dash variant.

Reverts only the four icon PNGs (16/32/48/128); manifest, popup, and
extension version stay where they are.
2026-04-25 21:20:14 +08:00
Ray的新范式 766677422d fix(chatgpt-app): use AX send flow and support zh-CN generating state (#1135)
* fix(chatgpt-app): use AX send flow and support zh-CN generating state

* fix(chatgpt-app): fail fast on stale AX send path

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-24 19:52:51 +08:00
Benjamin Liu c0a49e4b44 feat(weixin): add create-draft and drafts commands for Official Account (#1095)
* feat(weixin): add publish (create draft with cover) and drafts (list drafts)

Closes #441

* fix(weixin): rename publish to create-draft to match issue #441 proposal

* fix(weixin): fail fast on draft auth and empty states

* test(weixin): align adapter imports with repo style

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-24 19:39:34 +08:00
GanFanNewOrder 6827de4ab2 fix(amazon): fall back discussion to product page (#1154)
* fix(amazon): fall back discussion to product page

* fix(amazon): tighten sign-in fallback detection

---------

Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-23 17:25:46 +08:00
Aaron Chang 43873326c8 feat(toutiao): add articles adapter for 头条号 creator dashboard (#1148)
* feat(toutiao): add articles adapter for 头条号 creator dashboard

Add adapter to fetch article list and stats from 头条号 creator backend (mp.toutiao.com).
Supports pagination (1-4 pages) and returns title, date, status, views, reads, likes, comments.

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

* fix(toutiao): preserve short article titles

---------

Co-authored-by: Aaron Chang <yugenchang@future.ov>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-23 17:24:42 +08:00
Benjamin Liu c8eedee760 fix(deepseek): fix history titles and resume conversation on ask (#1153)
* fix(deepseek): fix history titles and resume conversation on ask

- history: use link.innerText instead of link.querySelector('div') for
  title extraction. DeepSeek changed sidebar DOM; the first child div
  is now an empty ds-focus-ring element, causing all titles to show as
  (untitled).

- ask: when workspace is recycled (idle timeout) and --new is false
  (default), click the most recent sidebar conversation link to resume
  it instead of staying on the blank new-chat page. Skip model
  selection when inside an existing conversation since the selector is
  only rendered on the new-chat page.

- ensureOnDeepSeek: return boolean indicating whether navigation
  occurred, so callers can react to workspace recycling.

Closes #1149

* fix(deepseek): fail fast on explicit model resume

* fix(cli): expose only explicit option sources

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-23 17:10:23 +08:00
GanFanNewOrder 9870258075 feat(powerchina): add procurement search adapter (#1155)
* feat(powerchina): add procurement search adapter

* fix(powerchina): stabilize api detail urls

---------

Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-23 17:01:38 +08:00
Benjamin Liu a3d0185afa fix(sinafinance): match stock symbol in addition to name (#1158)
* fix(sinafinance): match stock symbol in addition to name

The scoring function only compared user input against the Chinese
display name (p[4] from suggest API), so searching "AAPL" matched
"AAPLU" (score 0.8) over Apple Inc. whose name field is "苹果"
(score 0). Check the symbol field first for exact and partial matches.

Fixes #1157

* docs(sinafinance): add missing commands to adapter index

The index table only listed `news` for sinafinance. Added the other
three commands (`rolling-news`, `stock`, `stock-rank`) and updated
the mode from Public to hybrid since rolling-news and stock-rank
require a browser.

Fixes #1156

* test(sinafinance): lock stock symbol matching

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-23 14:17:24 +08:00
jakevin 9c2eb07037 chore: bump version to 1.7.7 (#1152)
Release / release (push) Has been cancelled
2026-04-23 00:03:11 +08:00
jakevin 60114f99ba fix: quality audit bug fixes across core modules (#1151)
* fix: address quality audit bugs across core modules

- output.ts: fix elapsed=0 not showing (falsy check → undefined check)
- cdp.ts: log WebSocket parse errors and getResponseBody failures in verbose mode
- launcher.ts: replace sync execFileSync('sleep') with async setTimeout
- daemon.ts: add missing extensionCompatRange=null in error handler
- errors.ts: add recursion depth limit to serializeCause
- download/index.ts: remove Promise constructor anti-pattern (void async IIFE),
  use cookie.expirationDate instead of hardcoded 1-year expiry

* fix: log network interceptor parse failures, use correct exit codes

- captureNetworkItems: log JSON.parse failure in verbose mode instead of silent swallow
- emitNetworkError: use USAGE_ERROR only for invalid_args/filter/max_body,
  GENERIC_ERROR for runtime failures (capture_failed, cache_expired, etc.)

* test: add regression tests for elapsed=0 and deep cause chain truncation
2026-04-22 23:55:52 +08:00
jakevin f88b965dc5 fix(daemon): allow extension ping CORS (#1150) 2026-04-22 23:46:44 +08:00
jakevin 648390eacd feat(web,download): absorb #1048 — video/audio/iframe + --stdout (#1146)
* feat(web,download): absorb #1048 media + --stdout into web read

Distill the useful pieces of the abandoned PR #1048 (`web md`) into the
existing shared pipeline instead of introducing a parallel command:

- Turndown rules for <video> / <audio> / <iframe>. Video and audio are
  emitted as inline HTML so renderers that support it keep playback,
  and iframes degrade to markdown links (title + src) so embedded
  content (YouTube, CodePen, …) stays reachable. `iframe` moves out of
  STRIPPED_TAGS since it's now handled explicitly.
- `stdout` option on ArticleDownloadOptions: writes the full markdown
  to process.stdout, skips image download + mkdir + file write, and
  reports saved='-'. Remote image URLs stay intact so piped output is
  self-contained.
- `web read --stdout` wires the above through.
- Lazy-load src rewrite: the extractor now promotes data-src /
  data-original / data-lazy-src / data-srcset onto `src` before the
  HTML is frozen, so the markdown body and the image-download list
  reference the same URL (previously a page with placeholder.gif +
  data-src produced broken image links in the output).

Nothing in #1048 that overlapped with the already-merged #1143
hardening was kept — no new Readability wiring, no duplicate Turndown
config, no new command.

* fix(web): keep stdout streaming output clean

* fix(tests): update iframe e2e assertion and drop relative src import

- article-extract e2e fixture test: iframe now converts to a markdown
  link instead of being stripped, so assert the YouTube embed link
  survives rather than asserting its absence.
- clis/web/read.test.js: replace vi.importActual('../../src/registry.js')
  with a direct __test__.command export from read.js; the relative
  import into src/ tripped the package-exports adapter guardrail.
2026-04-22 18:42:38 +08:00
Kagura 733ac0747d fix(deepseek): separate thinking process from response in --think mode (#1142)
* fix(deepseek): separate thinking process from response in --think mode (#1124)

When --think is enabled, the response now includes separate fields:
- response: clean final answer only
- thinking: chain-of-thought reasoning content
- thinking_time: time spent thinking (e.g. '1')

Supports both English ('Thought for X seconds') and Chinese
('已思考(用时 X 秒)') thinking header patterns.

Fixes #1124

* chore: regenerate cli-manifest.json

* fix(deepseek): DOM-level think/response separation, dynamic columns

Blocker 1: Replace fragile split(/\n\n+/) heuristic in parseThinkingResponse()
with DOM-level extraction in waitForResponse(). The page evaluate now queries
distinct DOM nodes (.ds-markdown--think vs .ds-markdown) for thinking and
response content. The text-level parser falls back to treating everything
after the header as thinking (no split), avoiding silent corruption of
multi-paragraph content.

Blocker 2: Remove static columns declaration from askCommand. The renderer
infers columns from row keys, so non-think output only shows 'response'
while think output shows all three columns.

Tests added for multi-paragraph thinking, multi-paragraph answer, and
non-think column regression guard.

* chore: regenerate cli-manifest.json
2026-04-22 18:03:37 +08:00
jakevin e83148a2c1 feat(download): harden HTML→Markdown pipeline (#1143)
* feat(download): harden HTML→Markdown pipeline

Inspired by the MD-This-Page / markdown-viewer-extension analysis, tighten
the shared article→Markdown converter used by zhihu/weixin/web adapters:

- enable turndown-plugin-gfm (tables, strikethrough, task lists)
- strip script/style/noscript/iframe/canvas/form/button/dialog unconditionally
- strip SVG via a dedicated rule (not in HTMLElementTagNameMap)
- drop base64 data-URI images so they don't bloat .md output
- post-process: collapse NBSP, lone bullet/middle-dot residue,
  trailing whitespace, and 3+ blank lines
- frontmatter shape guarantees ≤2 consecutive newlines even when
  some metadata fields are absent

Adds a minimal local .d.ts for turndown-plugin-gfm and 6 new tests
covering GFM conversion, tag stripping, base64 drop, and whitespace cleanup.

* fix(download): emit canonical markdown strikethrough

* feat(download,browser): finish article pipeline polish

Per the follow-up from the MD-This-Page / markdown-viewer-extension
analysis, land the remaining items in the same PR instead of splitting:

article-download.ts
- extend STRIPPED_TAGS with header/footer/nav/aside (page chrome; the
  article's title/author/publishTime are supplied as separate fields on
  ArticleData, so duplicated DOM is redundant)
- new option ArticleDownloadOptions.cleanSelectors — per-adapter CSS
  selector list removed before conversion, applied as a Turndown rule
  via node.matches so invalid selectors fail silently

browser/article-extract.ts (new)
- generic Readability-based extraction that runs in-page via CDP
  evaluate (no jsdom in Node)
- short-circuits non-HTML documents (text/plain, JSON, XML) and the
  single-<pre> "browser rendering a plain text file" case
- clones the document before any mutation (preserves live page state
  for subsequent snapshot / click)
- isProbablyReaderable gate, Readability.parse on the clone, then a
  fallback chain main → [role="main"] → #main-content → … → body
- library sources are JSON-embedded and eval'd inside a Function scope
  so their backticks / module.exports guards don't collide with the
  surrounding IIFE

Tests
- article-download: page-chrome strip, cleanSelectors match + invalid
  selector silently ignored (2 new)
- article-extract: JS generation contents, default fallback chain,
  response normalization, null / malformed handling, and a Function()
  parse check to catch any template-literal break-out in the embedded
  Readability sources (8 new)

* fix(download): honor selector cleanup in fallback paths

* test(e2e): real-site regression for hardened article pipeline

Adds tests/e2e/article-download-pipeline.test.ts driving `opencli web read`
through 6 representative pages (example.com baseline, Wikipedia GFM tables,
MDN metadata, GitHub fenced code, Vercel SSR blog, Ruan Yifeng CJK+images)
and asserting the post-processing invariants: no base64/script/style leaks,
no blank-line runs, no residue, no trailing whitespace, no NBSP.

Graceful skip on bot detection / transient CDP errors, with a single retry.

All 6 sites pass locally (37s total).

* test(browser): add article extraction e2e fixtures
2026-04-22 14:49:35 +08:00
jakevin 3ec98b9405 feat(51job): comprehensive adapter (search / hot / detail / company) (#1132)
* feat(51job): add comprehensive 51job adapter (search / hot / detail / company)

Four adapters covering the main 51job surface:

- `51job search <keyword>` — keyword job search via we.51job.com/api/job/search-pc.
  Rich filters: --area (40+ city name/alias → 6-digit code), --salary, --experience,
  --degree, --companyType, --companySize, --sort, --page, --limit. Response already
  carries full jobDescribe + HR + company + encCoId, so most callers won't need detail.

- `51job hot` — same endpoint with empty keyword, returns 51job's recommendation feed.

- `51job detail <jobId>` — scrapes jobs.51job.com/x/<jobId>.html. Returns description,
  welfare tags, category, address, age requirement, company meta.

- `51job company <encCoId>` — scrapes jobs.51job.com/all/co<encCoId>.html. Job cards
  carry a `sensorsdata` JSON attribute, so we parse that instead of fragile DOM text.
  Company meta from `.c-info.ellipsis`, intro from `#companyIntroRef`.

All four are Strategy.COOKIE + browser:true + navigateBefore:false. 51job sits
behind Aliyun WAF — bare curl / Node-side fetch always hits the slider challenge
(tried copying acw_sc__v2 + ssxmod_itna cookies to Node, WAF also checks TLS
fingerprint and JS execution). Only reliable path is browser-context fetch via
`page.evaluate(fetch(url, {credentials:'include'}))`, so utils.js exports
`pageFetchJson` that wraps this pattern + detects WAF-served HTML.

Verify fixtures included (~/.opencli/sites/51job/verify/*.json) — four adapters
pass `opencli browser verify 51job/<cmd>` with rowCount / columns / types /
patterns / notEmpty checks. Eyeballed jobId 171699769 on jobs.51job.com/suzhou
matches adapter output.

* fix(51job): tighten city handling and docs

* chore: regenerate cli-manifest.json after 51job column cleanup
2026-04-22 13:16:23 +08:00
lwyang 00d54135ad feat(weread): add ai-outline command (#1141)
* feat(weread): add ai-outline command for AI-generated book outlines

Two-step API flow: fetch chapter UIDs via authenticated chapterInfos,
then retrieve hierarchical AI outline from public outline endpoint.

Supports --depth to control detail level (2=topics, 3=key points,
4=full details) and --raw for structured output (chapter/idx/level/text)
suitable for programmatic consumption.

Closes #1140

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

* fix(weread): tighten ai-outline auth contract

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-22 13:13:11 +08:00
lwyang 69d3a73390 fix(weread/book): add fallback selectors for reader page without cover (#1138)
* fix(weread/book): add fallback selectors for reader page without cover

When the private API session expires, `loadReaderFallbackResult` navigates
to the reader URL. The page now sometimes skips the cover/flyleaf and
renders reading content directly, causing the wait for cover/flyleaf title
selectors to time out.

- Add `.readerTopBar_title_link` to `page.wait` selector (always present)
- Use cascading `firstText()` for title: cover → flyleaf → outline → top bar
- Use cascading `firstText()` for author: cover → flyleaf → outline → document.title

Fixes #1137

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

* fix(weread/book): parse author from trailing title segments

* fix(weread): avoid author guess from document title

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-22 12:30:33 +08:00
Mike Jing 5460a18d71 fix(xiaoyuzhou): correct podcast-episodes API endpoint (#1129)
* fix(xiaoyuzhou): correct podcast-episodes API endpoint

The endpoint `/v1/podcast/listEpisode` returns 404. The correct
endpoint is `/v1/episode/list` (verified against Xiaoyuzhou iOS app
traffic; also matches the `episode-list` implementation in
ultrazg/xyz, a widely-used Xiaoyuzhou API wrapper).

Additionally, the server requires an `order` field in the request
body (returns 400 if omitted). Add `order: 'desc'` so callers get
the latest episodes first, matching typical UX for a podcast feed.

Before:  podcast-episodes -> HTTP 404 for every podcast
After:   podcast-episodes returns the N most recent episodes

Tested against real podcast 626b46ea9cbbf0451cf5a962
(张小珺|商业访谈录) — now returns 140 episodes correctly.

* test(xiaoyuzhou): lock podcast episodes endpoint

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-22 12:17:00 +08:00
jakevin dc724262f2 feat: agent-native retrospective — analyze / verify guards / fixture content checks (#1133)
* feat: agent-native retrospective — analyze / verify guards / fixture content checks

Post-mortem on slow 1point3acres + 51job adapter sessions, consolidated
into one PR. Scope is "reduce uncertainty and catch silent failures"
— the two things that sink agent success rate on first-time adapters.

Changes:
- `browser analyze <url>` — one command returns pattern (A/B/C/D),
  anti-bot vendor (Aliyun/Cloudflare/Akamai/Geetest), nearest adapter,
  and a single-sentence recommended_next_step. Replaces the three-step
  open/wait/network recon loop when it can reach a confident verdict.
- `browser wait xhr <regex>` — poll for a specific XHR URL instead of
  blind `wait time N`, so SPA data-arrival barriers are deterministic.
- Fixture `mustNotContain` / `mustBeTruthy` — catch two silent-failure
  modes `notEmpty` misses: content contamination (sibling DOM bleed)
  and `|| 0` / `|| false` fallbacks.
- `browser verify` post-success site-memory check + `--strict-memory`
  — verify-green no longer hides the case where `~/.opencli/sites/`
  was never written back. Memory only materializes if authors write it.
- CI: guard that committed `cli-manifest.json` matches a fresh build.
  Main was already drifted (#1118 left stale ordering + a missing arg);
  this PR regenerates the manifest and will catch the next drift.

Docs (opencli-adapter-author + opencli-autofix skills):
- `success-rate-pitfalls.md` — 10 concrete silent-failure scenarios
  seen in real adapter sessions, each with defense via fixture /
  adapter patterns.
- `autofix` gains discipline rule #6: verify pattern failure means
  tighten the adapter, never loosen the fixture.
- `site-recon.md` leads with `browser analyze`; `api-discovery.md`
  adds a §0 covering WAF vendor detection and cross-subdomain CORS
  (the two gotchas that burned the 51job session).
- `wait time 3` → `wait time 2`, with `wait xhr` as the robust choice.

* fix: make output-dir defaults host-independent in manifest

Three adapters (chatgpt/image, gemini/image, instagram/download) baked
`path.join(os.homedir(), ...)` into the `default` field of their args.
The committed manifest therefore carried my personal `/Users/jakevin/...`
paths — which agents running on a different host saw as surprising
defaults. The drift guard I just added to CI caught it on the first run.

Runtime behavior is unchanged: each adapter still falls back to
`path.join(os.homedir(), …)` inside `func` when the kwarg is absent.
Only the displayed / registered default becomes a tilde-path.

* fix(cli): enforce strict-memory without fixture

* fix(browser): harden analyze and xhr guards

* fix(browser): fallback to interceptor buffer
2026-04-22 01:59:19 +08:00
jakevin 5935191e04 feat(verify): fixture-based value validation + skill docs for COOKIE pitfalls (#1131)
* feat(verify): fixture-based value validation + skill docs for COOKIE pitfalls

`opencli browser verify` now loads `~/.opencli/sites/<site>/verify/<cmd>.json`
when present and validates row count / columns / types / patterns / notEmpty
against the live adapter output. Without a fixture, behavior is unchanged
(just runs the adapter and prints). New flags `--write-fixture`,
`--update-fixture`, `--no-fixture` seed / refresh / bypass the spec.

Motivation: previous verify only checked that the adapter exited 0 and
produced *something* — shape regressions (author name bleeding across rows,
a column silently becoming null after a site refresh, duplicated thread-level
time on every post) all passed "✓ Adapter works!" and shipped broken.

Skill doc updates (opencli-adapter-author):
- adapter-template.md: new "COOKIE adapter 骨架" section — HttpOnly +
  dual-domain cookie read via `page.getCookies`, Node-side fetch for HTML
  (explaining why `page.evaluate(fetch(...))` is the wrong tool when
  `navigateBefore: false` or the response is non-UTF-8), and empty-state
  sentinel row over `[]`
- api-discovery.md §4: note that BBS engines (Discuz/phpBB/vBulletin) set
  auth cookies on the root domain + HttpOnly, so single-domain `getCookies`
  calls silently miss them
- SKILL.md Step 10/12: make `--write-fixture` part of the runbook, forbid
  debug dumps outside `~/.opencli/sites/<site>/fixtures/` or `/tmp/`

* fix(verify): support positional argv in fixture args + site-memory docs

Reviewer feedback blocker: fixture.args was Record<string, unknown>,
expanded as --key value only, so positional-subject adapters
(<tid>/<url>/<query>) couldn't be verified. Repo convention is
"主语优先 positional".

- verify-fixture.ts: args now accepts Record<string, unknown> | unknown[].
  Object → --k v pairs; array → verbatim passthrough. New helper
  expandFixtureArgs() centralizes the branching.
- cli.ts verify action: swap inline expansion for expandFixtureArgs().
- verify-fixture.test.ts: 6 new cases covering array form, mixed
  positional+flag, empty shapes, passthrough stringification.
- site-memory.md: Layer 2 tree now lists verify/<cmd>.json; new schema
  block distinguishes it from fixtures/<cmd>-<ts>.json; runbook timing
  section gets a Step 10 verify-write row. Repo-tree debug-dump ban
  clarified.
- adapter-template.md: new "Verify fixture" section with named-flag and
  positional recipes, honest about --write-fixture only seeding named.

Smoke-tested 1point3acres/thread (positional <tid>): fixture round-trip
green (args=["1173710","--limit","2"]).
2026-04-22 00:08:19 +08:00
jakevin 0710678986 docs: fix stale references in READMEs and autofix skill doc (#1130)
- Add missing skills (opencli-browser, opencli-usage) to install list, table, and references
- Add missing browser commands (find, extract, frames)
- Update adapter command lists (twitter tweets, bilibili comments, xiaohongshu note+comments, xiaoyuzhou auth, amazon rankings, hackernews)
- Fix CLI Hub names: dingtalk→dws, wecom→wecom-cli
- Fix exit codes example: opencli github issues→opencli gh issue list
- Fix autofix skill doc: page.waitForSelector→page.wait({ selector })
2026-04-21 23:28:13 +08:00
Ray a6d1eca204 fix(bilibili): resolve full video URLs and preserve full description (#1118)
Two issues surfaced post-merge of #1110 by the Copilot reviewer:

1. Help text and docs advertise `video URL` as a valid input for
   `opencli bilibili video <bvid>`, but the original implementation
   delegated the whole input to `resolveBvid()` — which only recognises
   bare `BV...` IDs and `b23.tv` short codes. A canonical bilibili URL
   like `https://www.bilibili.com/video/BV.../` therefore got rewritten
   to `https://b23.tv/www.bilibili.com/video/BV.../` and failed before
   ever calling the view API.

   Fix: pre-extract the BV ID from `bilibili.com/video/<BV>...` and
   `bilibili.com/bangumi/play/<BV>...` URLs (www / m. / with or without
   query string) in `video.js`, and fall through to `resolveBvid()`
   only for bare BV IDs and `b23.tv` links.

2. `description` was being truncated to 200 chars with whitespace
   collapsed before being returned. JSON/YAML consumers silently lost
   the full `desc` value. Other bilibili adapters return raw fields.

   Fix: return the full `d.desc` verbatim and let consumers/display
   layers handle formatting.

Adds four regression tests for the URL paths (full URL, URL with query
string, m.bilibili.com mobile URL) and one for description integrity
(> 200 chars, preserved verbatim).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:14:04 +08:00
Benjamin Liu 92efa38aba fix(deepseek): use position-based model selection instead of text matching (#1123)
* fix(deepseek): use position-based model selection instead of text matching

Fixes #1111

* fix(deepseek): preserve explicit instant model contract

* fix(deepseek): guard expert selector arity

---------

Co-authored-by: Benjamin Liu <beneecs@Benjamins-Mac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-21 23:10:17 +08:00
Dylanwoo 666a955fac feat(twitter): expose has_media and media_urls columns (#1115)
Adds two additive columns to the Twitter read commands (search, timeline,
tweets, thread, likes):

- has_media: boolean — true if the tweet contains any photo, video, or GIF
- media_urls: string[] — photo URLs and mp4 variant URLs for videos/GIFs,
  extracted from legacy.extended_entities.media (falls back to entities.media)

The INTERCEPT/COOKIE payloads already carry this data; this change only
extends the row-mapping layer, so no new network work is needed. Pattern
mirrors #465 (time column).

Shared extraction helper lives in clis/twitter/shared.js so all five
adapters stay consistent, with unit coverage for photo, video (mp4 variant
selection), animated_gif, entities.media fallback, and the empty case.

Closes #1107
2026-04-21 23:02:22 +08:00
jakevin 2ad417949a docs(skills): restore and rewrite opencli-usage as orientation skill (#1128)
* docs(skills): restore and rewrite opencli-usage as orientation skill

The original opencli-usage skill was deleted in PR #1094 as part of the
skill consolidation, but its role (top-level orientation to what opencli
is, how to discover adapters, what flags/env/formats are universal, and
which specialized skill to load next) was not covered elsewhere. Restore
it, but deliberately NOT as a verbatim copy:

- Drop the hand-maintained 100-adapter table. There are 100+ adapters
  and the list moves every week — `opencli list -f json` is the source
  of truth agents should call at the start of a task.
- Replace it with the meta-structure agents actually need: the three
  pillars (adapters / browser driving / external CLI passthrough), the
  strategy tags (PUBLIC | COOKIE | HEADER | INTERCEPT | UI | LOCAL)
  and what each implies for prerequisites, universal flags (-f, -v),
  output formats, env vars, self-repair hook, adapter authoring paths,
  plugins, external CLI passthrough.
- Explicitly list the commands PR #1094 removed (`explore`, `record`,
  `web` / `desktop` top-level groups) so agents don't attempt them.
- Cross-link to the four post-consolidation skills: opencli-browser
  (ad-hoc driving), opencli-adapter-author (writing adapters),
  opencli-autofix (repair flow), smart-search (search routing).

Adapter-author description updated to stop claiming it replaces
opencli-usage.

* docs(skills): tighten opencli-usage validate + doctor scope per review

- validate: describe as registry-level semantic check (description, domain,
  pipeline step names, func|pipeline|_lazy presence, arg duplicates), not
  YAML/TS syntax check — matches src/validate.ts
- doctor: narrow to browser-bridge diagnostic; PUBLIC/LOCAL adapters, list,
  validate, verify, plugins, and external-CLI passthrough do not need it
2026-04-21 22:04:09 +08:00
jakevin 9675f6262e docs: add CHANGELOG entry for 1.7.6 (#1127)
Backfill the 1.7.6 section that was missing from the release PR.
Covers window lifecycle flags, selector-first browser interactions,
agent-native payload, compound form fields, three new adapter commands,
four fixes, skill doc updates, and extension 1.0.2 body-truncation
contract unification.
2026-04-21 21:46:43 +08:00
jakevin dba333d1f9 chore: bump version to 1.7.6, extension to 1.0.2 (#1126)
Release / release (push) Has been cancelled
2026-04-21 21:42:46 +08:00
jakevin 7c35935861 docs: sync live and focus window docs (#1125) 2026-04-21 21:41:47 +08:00
jakevin d36bee04bb feat(cli): add --live and --focus flags for automation window lifecycle (#1122)
--live (OPENCLI_LIVE=1) keeps the automation window open after an adapter
command finishes, so agents or humans can inspect the page state. Default
behavior (immediate closeWindow) is unchanged.

--focus (OPENCLI_WINDOW_FOCUSED=1) surfaces the existing env-var toggle as a
CLI flag so users don't need to shell-export to see the window in foreground.

Both flags are parsed early in main.ts and stripped from argv, so they can be
placed anywhere on the command line and work on any subcommand (adapter or
browser).
2026-04-21 21:14:42 +08:00
jakevin 2f66d48a47 docs(skills): restore and upgrade opencli-browser skill (#1119)
Restore `skills/opencli-browser/SKILL.md`, deleted in #1094, rewritten for
the post-#1116 browser CLI surface: selector-first target contract,
`match_level { exact | stable | reidentified }`, compound fields for
date/time/select/file, structured error codes with `available` vs
`candidates`, new `find` / `extract` / `network --filter` commands,
html tree budgets, tabs/frames, cost guide, recipes, pitfalls.

Review tightened two contract-drift bugs before merge:
- `browser tab list` envelope field is `page`, not `targetId`
- `network --ttl` default is `24h`, not `~5min`

2 reviewers green (codex-mini1, First-principles-1); CI all-green.
2026-04-21 17:32:33 +08:00
jakevin 04a5a171d5 feat(browser): compound expansion + cascading stale-ref + bbox 0.99 dedup (#1116)
* feat(browser): compound expansion + cascading stale-ref + bbox 0.99 dedup

Three agent-native upgrades inspired by browser-use, landed as one PR
because they share the same target / snapshot / find surface.

  1. Compound expansion (compound.ts)
     Date/time/datetime-local/month/week, select, and file inputs now
     emit a `compound` JSON field on `browser find --css` entries with
     format, current value, min/max (date family), full options list
     + selected (select), accept / multiple / files[] (file). Kills
     the three biggest form-page failure modes (wrong date format,
     guessed options, re-uploaded files) without extra round-trips.

  2. Cascading stale-ref (target-resolver.ts)
     Numeric ref resolution now walks three tiers before giving up:
     exact → stable (tag + strong id match, soft signals drifted) →
     reidentified (original ref lost, fingerprint uniquely found a
     live element; re-tag + refresh identity). Every success envelope
     carries `match_level` so callers can tell which tier matched.
     SPA re-renders / i18n label swaps no longer stall agents.

  3. BBox 0.99 containment for interactive descendants (dom-snapshot.ts)
     Adds a second dedup tier on top of the existing 0.95 non-interactive
     one. When a parent is a propagator (tag a/button OR role button/
     link/menuitem/tab/option) and a child is interactive but
     undistinctive (no aria-label/id/testid/name/form-control), fold
     it into the parent — removes `[1]<button> [2]<svg> [3]<span>`
     noise on icon buttons.

Tests: 287/287 pass (src/browser + src/cli.test.ts). Typecheck clean.

* fix(browser): address reviewer blockers on PR #1116

- compound select: walk ALL options to collect selected labels, not just
  the first 50 we serialize. Fixes dropdowns where the selected entry
  sits past COMPOUND_SELECT_OPTIONS_CAP (e.g. country lists, timezones)
  reporting current: "" even though the user picked a valid option.
- match_level: propagate the cascading match tier
  (exact / stable / reidentified) through IPage.click/typeText/scrollTo,
  BasePage, and the cli command envelopes (click / type / select /
  get text|value|html|attributes). Agents now see in JSON that the
  resolver had to fall back, instead of the tier being swallowed.
- compound contract is now also emitted by `browser state`
  (per-ref compounds: sidecar) and by `browser get html --as json`
  (compound field on each node), not only by `browser find --css`.
  Closes the gap where agents using the default snapshot still
  round-tripped `find` for every date / select / file control.

Adds targeted regression tests for each blocker + updates cli.test.ts
mocks to the new envelope shape.
2026-04-21 17:02:01 +08:00
Chris Chen f7fd805ef8 fix(twitter): add 5s timeout to resolveTwitterQueryId to prevent hang (#1106)
The resolveTwitterQueryId() function in shared.js fetches an external JSON
file from GitHub without a timeout. If the network request stalls, the
function never resolves and the twitter article command hangs indefinitely.

Add a 5-second AbortController timeout so the fetch fails fast and falls
back to the local script-scanning strategy. This fixes the reported hang
when opencli twitter article loses network connectivity.
2026-04-21 15:58:40 +08:00
Ray b92755597c feat(bilibili): add video command (#1110)
* feat(bilibili): add video command

Add `opencli bilibili video <bvid|url|short-link>` to fetch one
video's metadata via the public /x/web-interface/view endpoint.

Returns title, author, category, publish time, duration, view /
danmaku / reply / like / coin / favorite / share counts, parts,
thumbnail, and description as a key/value table.

Reuses `resolveBvid` and `apiGet` from clis/bilibili/utils.js to
stay consistent with the existing bilibili adapters
(subtitle/search/etc. all follow the same navigate + apiGet
pattern). Non-zero API codes surface as CommandExecutionError.

Fills a visible gap: existing bilibili commands cover search,
hot, subtitle, ranking, user-videos etc., but nothing returned
metadata for a single video — `web read` only gets a DOM shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(manifest): register bilibili video command

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-21 15:58:32 +08:00
Benjamin Liu b9bb3020a4 feat(deepseek): add file upload support via --file flag (#1093)
* WIP: deepseek file upload (blocked by 30s idle timeout)

* feat(deepseek): add file upload support via --file flag

Closes #1092

* fix(deepseek): use native file input path for --file

---------

Co-authored-by: Benjamin Liu <beneecs@Benjamins-Mac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-21 15:49:37 +08:00
Kagura b3db955da3 fix(youtube): fall back to Videos tab when Home tab has no videos (#1109)
* fix(youtube): fall back to Videos tab when Home tab has no videos (#1108)

Some channels have no video shelves on their Home tab, causing
`opencli youtube channel <id>` to return an empty `recent_videos` list
even though the channel has videos visible in the browser.

When the Home tab extraction finds zero videos, the command now makes
a second InnerTube browse request to the Videos tab and extracts from
its richGridRenderer format.

* fix(youtube): make Videos tab fallback locale-safe

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-21 15:45:32 +08:00
jakevin 8a8f048a89 feat(browser): selector-first find + get/click/type/select (A2+A3) (#1112)
* feat(browser): selector-first find + get/click/type/select with JSON envelope

A2: new `browser find --css <sel>` — structured JSON (matches_n + entries[]) so
agents can go from semantic selector directly to a list of candidates without
parsing free-text snapshot output. Per-entry shape: nth/ref/tag/role/text/attrs/
visible. Attr whitelist kept small (11 high-signal fields), invisible elements
still returned so agents can reason about offscreen vs missing.

A3: get text/value/attributes now accept a selector-first <target> (numeric ref
OR CSS) and emit `{value, matches_n}`. Bonus scope (approved by reviewers):
click/type/select share the same contract with `--nth <n>`, emitting
`{clicked|typed|selected, target, matches_n, ...}` on success.

Unified structured error envelope across all selector-first commands:
  { error: { code, message, hint?, candidates?, matches_n? } }
with codes invalid_selector / selector_not_found / selector_ambiguous /
selector_nth_out_of_range (CSS) plus not_found / stale_ref (numeric ref).

Write commands reject multi-match CSS without `--nth` as selector_ambiguous;
reads default to "first match wins" but always expose matches_n so agents
notice ambiguity. `resolveTargetJs` is the single source of truth; click /
typeText / scrollTo share a `runResolve` helper in BasePage.

No back-compat shims per design directive.

125 targeted tests green; tsc clean.

* fix(browser): unify selector surface + allocate fresh refs in find

Two blockers from PR #1112 review:

1. First-principles-1 (blocker): `browser find --css` now allocates fresh
   numeric refs for untagged matches. It scans `window.__opencli_ref_identity`
   (and any stray `data-opencli-ref` attrs) for the current max, allocates
   `max+1` upward, writes `data-opencli-ref` on the element, and populates
   the identity map with the same fingerprint shape snapshot uses (tag,
   role, text, ariaLabel, id, testId). `find -> click <ref>` now works on
   fresh pages without requiring `browser state` first. Type changed from
   `ref: number | null` to `ref: number`.

2. codex-mini1 (blocker): removed the `isCssLike` regex
   (`^[a-zA-Z#.\[]`) in `resolveTargetJs`. Valid selectors like `:root`,
   `:has(...)`, `*` used to short-circuit to "Cannot parse target" before
   reaching `querySelectorAll`, so `find --css` accepted them but
   `get/click/type/select` did not. Now: numeric → ref path, everything
   else → querySelectorAll, and the browser parser decides. Same selector
   surface across all selector-first commands.

Tests added:
- target-resolver: pseudo-selectors flow into CSS branch (not rejection)
- find: ref allocation writes attribute + identity map; fingerprint shape matches resolver
- cli: find envelope now expects numeric refs

127 targeted tests green; tsc clean.
2026-04-21 13:47:19 +08:00
jakevin acb08a4050 feat(browser): agent-native payload — network bodies, html tree budgets, extract command (#1104)
* feat(browser): agent-native payload — network bodies, html tree budgets, extract command

Three fixes/additions driven by agent-usage gaps, as one complete change:

- network (P0 fix): lift silent 4000-char body truncation in CDP + extension
  paths to an 8MB memory-guard cap, and surface body_truncated / body_full_size
  / body_truncation_reason in the --detail envelope so the agent sees when a
  body was cut. List view also exposes body_truncated_count and per-entry flag.
  Adds --max-body flag for explicit caller-side capping.

- get html --as json (P1): add --depth / --children-max / --text-max budget
  knobs on the tree serializer, plus a truncated={depth,children_dropped,
  text_truncated} envelope that only appears when a budget is hit. Lets the
  agent narrow DOM output without walking away empty-handed.

- extract (P2 new command): agent-native article/content channel. Scope →
  denoise (strip nav/header/footer/scripts/forms/etc.) → HTML→markdown via
  existing htmlToMarkdown → paragraph-boundary-aware chunk with stateless
  next_start_char resume cursor. Agents no longer misuse `get html` to read.

* fix(browser): unify body-truncation signal contract across raw/detail/fallback

Addresses review blockers on #1104:

- NETWORK_INTERCEPTOR_JS fallback no longer silently drops bodies above the
  per-entry cap. Raised cap to 1 MiB (ring stays at 200 entries), and on
  overflow keeps the string prefix + sets `bodyTruncated` / `bodyFullSize`
  so `browser network` propagates the same agent-visible signal the CDP /
  extension paths emit.

- `CachedNetworkEntry` schema switches from internal camelCase
  `bodyTruncated` to the user-facing `body_truncated` / `body_full_size`
  fields. `--raw` emits cache entries verbatim, so this removes the
  snake_case/camelCase split across list / --detail / --raw.

- Adds a `--raw` truncation-contract test that also asserts the camelCase
  fields do not leak through.
2026-04-21 12:17:49 +08:00
jakevin 37020c4348 feat(browser): add network --filter <fields> for agent-native request discovery (#1103)
Agents often know what fields a target request's body should contain
but not which captured request carries it. --filter lets them declare
the field set and get back only matching entries.

Matching is "any-segment": a field matches when it equals any segment
name of any inferShape() path (ignoring root $, array indices, and
bracket-quoted key syntax). Multiple fields AND together. Case-sensitive.

- invalid_filter for empty / commas-only values
- invalid_args when combined with --detail (mutually exclusive)
- 0 matches is a valid empty result, not an error
- persisted cache stays unfiltered so later --detail lookups still resolve

Envelope gains `filter` (echo) and `filter_dropped` (count of entries
passing the static-resource filter but not --filter). Existing --raw
and --all compose normally.
2026-04-21 02:38:49 +08:00
jakevin 6cf5cb2f25 feat(browser): remove silent html truncation, add --as json (#1102)
* feat(browser): remove silent html truncation, add --as json tree output

`browser get html` had two agent-hostile defaults:

1. A silent 50000-char cap on the returned HTML — agents that got a
   truncated page had no signal they were looking at half the DOM.
2. Only raw HTML string output, forcing agents to re-parse for
   structured extraction.

Changes:

- Default output is now the full outerHTML, no truncation
- `--max <n>` opts in to a character cap; when the cap actually
  trips, the HTML is prepended with
  `<!-- opencli: truncated N of M chars; re-run without --max ... -->`
  so agents always see the signal
- `--as json` returns `{selector, matched, tree}` where `tree` is
  `{tag, attrs, text, children}` recursively. `matched` is the full
  count of selector matches so agents know when more elements exist
  beyond the first. `text` is the node's own direct text children,
  whitespace-collapsed; child elements live in `children`.
- `--selector` not matching any element now emits structured
  `{error:{code:"selector_not_found", ...}}` with a non-zero exit
  code, in both raw and json modes (was `(empty)` stdout previously,
  indistinguishable from empty element)
- Invalid `--as` / negative `--max` emit structured
  `invalid_format` / `invalid_max` error codes

Extracted the tree serializer as `src/browser/html-tree.ts` so the
JS expression can be unit-tested against a DOM stub.

* fix(browser get html): structured errors for invalid selector & strict --max

Both edges previously bypassed the structured-error contract introduced in
#1102, which agents rely on for branching:

- Invalid CSS selector: querySelector(All) would throw SyntaxError through
  page.evaluate into the generic exception path. Wrap the lookup in try/catch
  inside page context for both raw and --as json paths; surface as
  {error:{code:"invalid_selector", message}} + non-zero exit.

- --max validation: parseInt silently accepted "1.5" -> 1 and "10abc" -> 10.
  Switch to a strict /^\\d+$/ check so fractional, negative, and non-numeric
  values all return {error:{code:"invalid_max"}}; validation runs up front so
  bad values never reach the page.

Covered by new unit tests in cli.test.ts (fractional, non-numeric, invalid
selector on raw + json) and html-tree.test.ts (SyntaxError -> invalidSelector
envelope).

Co-authored-by: freemandealer <freeman.zhang1992@gmail.com>

---------

Co-authored-by: freemandealer <freeman.zhang1992@gmail.com>
2026-04-21 02:16:44 +08:00
jakevin 7fd8bd6fdc feat(browser): rewrite network for agent-native discovery (#1100)
* feat(browser): rewrite network command for agent-native discovery

Replace the index-based list + pretty-printed --detail flow with a
structured JSON interface built around stable keys, body-shape previews,
and a persistent capture cache. Agents can now reference captured
requests by operationName (GraphQL) or `METHOD host+pathname` (REST)
instead of array indexes that shift on every rerun.

- `browser network` now emits JSON: `{workspace, captured_at, count,
  filtered_out, entries: [{key, method, status, url, ct, size, shape}],
  detail_hint}` — no body payloads by default
- Shape inference (src/browser/shape.ts) walks response JSON into a
  flat path -> descriptor map with depth cap 6 and a 2KB budget per
  entry, so agents see structure without paying body tokens
- Stable key generator (src/browser/network-key.ts) derives
  `operationName` from graphql URLs and `METHOD host+pathname`
  elsewhere, disambiguating collisions with `#N` suffixes
- Persistent cache (src/browser/network-cache.ts) snapshots every
  capture to `~/.opencli/cache/browser-network/<workspace>.json` with
  a 24h TTL, so `--detail <key>` survives later commands
- `--detail <key>` returns `{key, url, method, status, ct, size, shape,
  body}` with structured error codes (cache_missing / cache_expired /
  cache_corrupt / key_not_found, the latter including available_keys)
- Add `--raw` for agents that want every full body inline, `--ttl` for
  cache lookups
- Update opencli-adapter-author + opencli-autofix skill docs to
  reference `--detail <key>` and the shape-first discovery flow

Supersedes the cache prototype in #1051.

Co-authored-by: freemandealer <freeman.zhang1992@gmail.com>

* fix(browser): structured errors for capture/save, shape budget guard

Self-review findings on the network refactor:

- captureNetworkItems throwing (browser crashed / CDP dropped) now emits
  `error.code: capture_failed` on stdout rather than leaking a bare
  stderr line from browserAction's generic handler — agents get a
  parseable JSON blob on every failure path, matching the design goal.
- saveNetworkCache throwing (disk full, read-only path) is a soft
  failure: the captured data is already in hand, so surface a
  `cache_warning` field in the envelope and keep going instead of
  aborting. `--detail` lookups on that run will miss the cache but the
  listing still reaches the agent.
- shape.ts: guard the sub-walk on `add()`'s return value so the
  "budget hits on the array/object descriptor itself" path can never
  emit a stray child without its parent marker.
- network-key.ts: document that `#N` suffixes start at `#2` — the first
  occurrence stays bare, there is no `#1`. Matches test + code.

Added regression tests: `capture_failed` on readNetworkCapture throw,
`cache_warning` on persistence failure, shape budget hit on array descriptor.

Co-authored-by: freemandealer <freeman.zhang1992@gmail.com>

---------

Co-authored-by: freemandealer <freeman.zhang1992@gmail.com>
2026-04-21 01:32:00 +08:00
jakevin 295c5237cb fix(jianyu): keep accessible detail urls in search (#1099) 2026-04-21 00:34:15 +08:00
jakevin 93395653f4 feat(twitter): add tweets command for fetching user's recent posts (#1098)
* feat(twitter): add tweets command for fetching a user's recent posts

Adds `opencli twitter tweets <username> [--limit N]` to pull a user's
most recent chronological tweets via the UserTweets GraphQL endpoint.
Long posts resolve via note_tweet, pinned entries are skipped, and
retweets are flagged. QueryIds resolve dynamically through
`resolveTwitterQueryId` with hardcoded fallbacks.

* fix(twitter): expose retweet flag in tweets output
2026-04-21 00:26:57 +08:00
jakevin 51e3ac4708 docs: add CHANGELOG entry for 1.7.5 (#1097)
Mirror GitHub Release notes for v1.7.5 (PR #1096, tag a0b2155).
2026-04-20 23:00:35 +08:00
GanFanNewOrder 4d25b2b99e fix(jianyu): block inaccessible detail links and verification pages (#918)
* fix(jianyu): filter blocked detail links

* fix(jianyu): keep recency filter opt-in

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-20 23:00:10 +08:00
jakevin a0b2155510 chore: bump version to 1.7.5, extension to 1.0.1 (#1096)
Release / release (push) Has been cancelled
2026-04-20 22:53:08 +08:00
jakevin afa5e6046c refactor: consolidate 6 skills into 3, remove mechanical commands (#1094)
* refactor: consolidate 6 skills into 3, remove mechanical commands

Replaces opencli-oneshot / opencli-explorer / opencli-browser /
opencli-usage with a single opencli-adapter-author skill that takes
the AI agent end-to-end: site recon, API discovery, field decoding,
adapter coding, and `opencli browser verify`.

Removes the mechanical commands (`explore`, `synthesize`, `generate`,
`cascade`, `record`) and their src/tests — they were codegen scaffolding
meant for agents, which the new skill handles more flexibly via
`opencli browser` primitives.

Skill highlights:
- Top-level decision tree + 12-step runbook
- 5 site patterns (SPA / SSR / JSONP / Token / Streaming)
- 5-layer API discovery (network → initial state → bundle → token → interceptor)
- Field decode playbook (self-explanatory → codes → sort-key comparison)
- Output design guide (columns, types, order, ≤15 per adapter)
- Two-layer site memory: in-repo seeds for eastmoney/xueqiu/bilibili/tonghuashun
  plus local `~/.opencli/sites/<site>/` runtime workspace

Kept skills: opencli-autofix (now points to adapter-author for rewrites),
smart-search. Kept primitives: `browser *`, `doctor`, `list`, `validate`,
`verify`, `<site> <cmd>`, `plugin *`, `completion`.

No backward compatibility shims. Full test suite (1605 tests) passes.

* review fixes: honest coverage, hard memory-hit path, typo, stale docs

- site-memory hit path no longer jumps to writing adapter; forces Step 5
  endpoint re-verification + Step 7 field check, and 30-day expiry
- site-memory.md now specifies exact schemas for endpoints.json /
  field-map.json / notes.md / fixtures + write-back timing rules
- coverage-matrix.md marks unverified patterns as 🟡 with an evidence
  section citing coingecko dry run + PR #1091 eastmoney + bilibili
- eastmoney seed typo: resolveSecids -> resolveSecid (and splitSymbols)
- docs/developer/ai-workflow.md rewritten to teach the adapter-author
  skill + opencli browser * primitives (dropped generate/synthesize/
  cascade/explore references)
- ts-adapter.md, getting-started.md, CHANGELOG.md:87 updated to point
  at opencli-adapter-author

* fix(ci): resync package-lock + drop stale built-in list reference

- Regenerate package-lock.json to restore @emnapi/core + @emnapi/runtime
  entries that got dropped during the rebase — `npm ci` was failing on all
  CI jobs (build / audit / docs-build / bun-test / unit-test)
- docs/guide/getting-started.md: built-in list dropped `explore`, now
  reads (list, validate, verify, browser, doctor, plugin...)

* fix(ci): restore package-lock.json from main (unrelated lockfile churn)
2026-04-20 22:00:17 +08:00
jakevin 0f903f544b chore(clis/eastmoney): mirror 13 adapters + _secid helper as Phase A oracle (#1091)
Mirror the remaining 13 read-oriented adapters and the shared _secid.js
helper from the author's local workspace into the repo, so that
clis/eastmoney/ becomes the full Phase A codegen regression oracle
described in OpenCLI Improvement Spec v1.1 §B.10.

Total repo oracle after this PR: 14 adapters under clis/eastmoney/
(hot-rank.js already exists; this PR adds the other 13) plus the
_secid.js normalize helper.

Covers the two schema-expressiveness gaps discovered during prep:
- CSV row_format: kline.js decodes "YYYYMMDD,open,close,..." strings
- :row_index source: convertible.js derives rank = i + 1

_secid.js is the canonical example of the v1.1 §B.7 helper contract
(pure normalize/derive function, serializable I/O, no env/fs/net/session
access, does not drive pagination/retry/fallback).

This PR is oracle-only, carries no framework changes. Phase A framework
PR depends on this merging first so the codegen diff target is stable.

Refs: task #177 / spec v1.1 §B.10
2026-04-20 18:42:46 +08:00
Benjamin Liu 163974652e feat(deepseek): add DeepSeek browser adapter with ask, new, status, read, history (#1088)
Closes #548
2026-04-20 16:27:30 +08:00
Benjamin Liu be2c1cd452 feat(download): show saved file path in web read and weixin download output (#1042)
* feat(download): show saved file path in web read and weixin download output

Closes #1038

* test(download): cover saved article path

---------

Co-authored-by: Benjamin Liu <beneecs@Benjamins-Mac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-20 13:25:02 +08:00
jakevin 1ecbf7a17c Revert "feat(output): optimize table formatting with width capping and key/value layout (#1081)" (#1085)
This reverts commit 3bbea014e5.
2026-04-19 21:47:35 +08:00
Benjamin Liu 3bbea014e5 feat(output): optimize table formatting with width capping and key/value layout (#1081)
* feat(output): optimize table formatting with column width capping and key/value layout

Closes #1017

* test(output): cover key-value and width-capped tables

* fix(output): truncate capped table cells

* test(output): make table assertions color-safe

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-19 21:39:29 +08:00
Eagle 6b2f4cdc31 feat(browser): add cross-origin iframe support via CDP execution contexts (#1084)
* feat(browser): add cross-origin iframe support via CDP execution contexts

Enable interaction with cross-origin iframes through CDP's execution
context mechanism, without requiring content scripts or all_frames.

- Track frame execution contexts via Runtime.executionContextCreated events
- Add 'frames' action to list all child frames (including cross-origin)
- Support frameIndex in 'exec' action to evaluate JS in specific frames
- Add Page.frames() and Page.evaluateInFrame() APIs for CLI consumers
- Tag cross-origin iframes with [F0]/[F1] indices in DOM snapshots
- Add Page.getFrameTree to CDP allowlist

Closes #1077

Change-Id: Id03361ddb616912dff3bfa8e59e8b68716de590b

* fix(browser): align cross-origin iframe routing contract

* fix(browser): unify iframe frame-index routing

---------

Co-authored-by: xuezhangying <xuezhangying@bytedance.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-19 20:41:59 +08:00
zhengyu fbdb1b24dc fix(browser): harden multi-tab routing and target isolation (#1072)
* fix(browser): harden multi-tab routing and target isolation

- make daemon command ids collision-resistant and retry duplicate pending ids\n- add validated tab list/new/select/close flows with persisted default targets\n- keep untargeted browser commands on the default tab unless tab select changes it\n- document tab targeting and add unit, extension, and e2e coverage for concurrent multi-tab execution

* fix(browser): keep default tab stable after tab new

* fix(browser): close remaining tab routing gates

* docs(browser): align target id wording

* docs(browser): refine target id examples

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-19 18:48:45 +08:00
jakevin fe59ee990c docs: rewrite browser sections — browser is for AI Agents, not manual use (#1080)
From first principles, `opencli browser` commands exist for AI Agents to
operate websites through the browser skill. Reframe both READMEs to reflect
this: show users how to install the skill into their AI agent and describe
tasks in natural language, rather than listing raw CLI commands.
2026-04-19 02:19:10 +08:00
Ocean bb21e7e831 feat(twitter): GraphQL-based lists + list-tweets + list-add/remove (#1076)
* feat(twitter): rewrite lists via GraphQL + add list-tweets

The DOM-scraping / detail-click approach in PR #1053 remained fragile
against X's frequent overview-page rendering changes and slow (N+1 page
loads per list). Rewrite `twitter lists` to call
`ListsManagementPageTimeline` GraphQL directly — one request returns all
owned + subscribed lists with id/name/member_count/subscriber_count/mode.

Also add `twitter list-tweets <listId>` for pulling the tweet stream from
a list, completing the read-side chain (lists → pick an id → list-tweets).

- lists: drop positional `user` arg (GraphQL returns only logged-in
  user's lists), add `id` column, change followers to exact integer from
  subscriber_count.
- list-tweets: same GraphQL pattern as bookmarks/likes (BEARER + ct0 +
  dynamic queryId with static fallback + cursor pagination).
- Delete obsolete lists-parser.js and lists.d.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(twitter): add list-add / list-remove with Save-button commit

Two new commands to toggle list membership. X's list dialog uses a
"click-to-stage, Save-to-commit" pattern — clicking a row only updates
optimistic UI; the actual POST fires when the user clicks the top-right
"Save" button. Pressing ESC or the close-X silently cancels the change.

Implementation:
- Resolve listId → name via ListsManagementPageTimeline GraphQL, so we
  match the dialog row by name (dialog rows have no data-testid listId).
- Open profile page → DOM click "…" menu → "Add/remove from Lists".
- Scroll dialog to locate target row (virtualized list).
- page.nativeClick on row — trusted CDP Input.dispatchMouseEvent fires
  React's onclick, flips aria-checked (.click() alone does not suffice;
  X ignores non-trusted events for list mutations).
- page.nativeClick on the Save button — commits to server.
- Verify by re-fetching ListsManagementPageTimeline and diffing
  member_count: success only if N→N±1. No silent successes.

This fixes the pattern where batch `list-add` calls returned success for
every user but committed zero to the server (optimistic UI lied).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: stabilize twitter list manifest and query ids

* docs: add twitter list command discoverability

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-19 01:42:42 +08:00
Pandas886 b65df6b1e2 fix(zsxq): separate content field from title, remove title truncation (#1079)
* fix(zsxq): separate content field from title, remove title truncation

- Split getTopicText to return only title, add getTopicContent for body text
- Remove .slice(0, 120) that was truncating titles
- content field now contains full body text instead of duplicating title

* fix(zsxq): preserve title fallback for body-only topics

---------

Co-authored-by: huzekang <huzekang@opencode.ai>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-19 01:42:31 +08:00
Mu 0cd63562f2 feat: migrate academic and policy adapters (#243)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-17 15:46:32 +08:00
jakevin 02d637f3d2 fix(e2e): accept CONFIG_ERROR (exit 78) in xiaoyuzhou E2E guard (#1066)
PR #1059 migrated xiaoyuzhou from SSR scraping to authenticated API.
The E2E tests run without credentials, producing exit code 78
(CONFIG_ERROR). The existing `isExpectedChineseSiteRestriction` guard
only caught FETCH_ERROR, PARSE_ERROR, and NOT_FOUND — not config
errors from missing auth credentials.
2026-04-17 12:02:49 +08:00
jakevin 44d87879d8 refactor: clean up design debt — deprecated APIs, duplicated validation, dead plugin wrappers (#1065)
Three improvements from the design debt audit:

1. Remove deprecated `tabId` field and `getActiveTabId()` method
   - Delete `tabId` from DaemonCommand (daemon-client.ts) and Command (protocol.ts)
   - Delete `getActiveTabId()` from IPage interface (types.ts) and Page class (page.ts)
   - Update extension resolveCommandTabId() to remove legacy fallback
   - Update handleTabs select case to remove tabId check
   - The tab→page migration is now complete

2. Unify argument validation into single code path
   - Remove `normalizeArgValue()` from commanderAdapter.ts
   - Commander adapter now passes raw values to prepareCommandArgs()
   - All coercion (bool, int, number) and validation (required, choices)
     happens once in coerceAndValidateArgs() in execution.ts
   - Eliminates duplicated boolean normalization

3. Remove dead plugin filesystem wrappers
   - Delete `promoteDir()` — never called in production code
   - Delete `replaceDir()` — thin wrapper over beginReplaceDir, never called
   - Remove corresponding test-only exports and tests
   - Rename PromoteDirFsOps → ReplaceDirFsOps to match remaining usage
   - Transaction infrastructure (runTransaction, beginReplaceDir,
     beginReplaceSymlink) retained — used by publishStandalonePlugin
     and publishMonorepoPlugins for atomic multi-step operations
2026-04-17 10:52:57 +08:00
jakevin cb9521d52d fix(extension): per-workspace idle timeout for browser sessions (#1064)
* fix(extension): per-workspace idle timeout for browser sessions (#1058)

The global 30s WINDOW_IDLE_TIMEOUT was too aggressive for interactive
`opencli browser` commands where users type manually between invocations.

- browser:*/operate:* workspaces now default to 10 min idle timeout
- Adapter workspaces keep the existing 30s timeout
- Support custom timeout via OPENCLI_BROWSER_TIMEOUT env var (seconds)
  or command-level idleTimeout parameter
- Surface sessionExpired warning when a new window is created after
  the previous session timed out
- Fix stale comment (said 120s, actual was 30s)

Closes #1058

* fix: resolve sessionExpired double-delete race and timeout override lifecycle

Addresses @codex-coder review blockers:

1. sessionExpired flag was never set because getAutomationWindow()
   consumed expiredWorkspaces before handleCommand() could check it.
   Fix: use .has() in getAutomationWindow, only .delete() in handleCommand.

2. workspaceTimeoutOverrides was never cleaned up — once set, it
   persisted until extension restart. Fix: clear override on idle
   timeout expiry, explicit close-window, and borrowed-session detach.

Adds 5 tests covering:
- browser:* uses 10min timeout (not 30s)
- sessionExpired flag is set and consumed correctly
- workspaceTimeoutOverrides cleared on idle expiry
- workspaceTimeoutOverrides cleared on explicit close
- idleTimeout from command applies to workspace override

* refactor: remove sessionExpired warning per product decision

@WAWQAQ decided session-expired warning is not needed.
Remove expiredWorkspaces tracking, sessionExpired flag from protocol,
and related CLI-side warning code. Keep per-workspace timeout and
override lifecycle cleanup.

* fix: clean up workspaceTimeoutOverrides on user-initiated window close

The windows.onRemoved listener was missing workspaceTimeoutOverrides
cleanup, causing stale overrides to persist across sessions when users
manually close the automation window.
2026-04-17 10:51:39 +08:00
jakevin 025df31ce5 refactor(antigravity): keep timeout parsing local (#1063) 2026-04-17 10:11:46 +08:00
deepziyu 8a8f4a1778 fix(antigravity): implement configurable timeout and auto-reconnect for serve (#859)
* fix(antigravity): implement configurable timeout and auto-reconnect for serve

* fix(antigravity): avoid private runtime import

* docs(antigravity): document serve timeout options

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-17 10:04:08 +08:00
Kagura ab44d9f542 fix(xiaoyuzhou): migrate from broken SSR scraping to authenticated API (fixes #1023) (#1059)
* fix(xiaoyuzhou): migrate from broken SSR scraping to authenticated API (fixes #1023)

Xiaoyuzhou removed SSR rendering — /podcast/<id> and /episode/<id> pages
now return 404, breaking fetchPageProps() which scraped __NEXT_DATA__.

Migrate podcast, podcast-episodes, episode, and download commands to use
the existing authenticated API client (requestXiaoyuzhouJson) that
transcript.js already uses successfully.

Changes:
- podcast.js: use /v1/podcast/get API endpoint
- podcast-episodes.js: use /v1/podcast/listEpisode API endpoint
- episode.js: use /v1/episode/get API endpoint
- download.js: use /v1/episode/get API endpoint
- utils.js: remove unused fetchPageProps, keep format helpers
- Update all affected tests (download.test.js, utils.test.js)
- Change strategy from PUBLIC to LOCAL (requires credentials)

* fix(xiaoyuzhou): align local strategy contract

* fix(xiaoyuzhou): align local api metadata

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-16 23:15:40 +08:00
jakevin 4ebada6b99 docs: add adapter docs for eastmoney, tdx, ths (#1061)
#1025 merged hot-rank adapters for eastmoney/tdx/ths but the
accompanying docs were missing. This breaks the Doc Check CI
workflow on every PR ('--strict' mode, exits non-zero when
`scripts/check-doc-coverage.sh` finds adapters without docs),
blocking merges across the board.

Adds a doc page per adapter, registers them in the adapters
index table, and adds sidebar entries in the VitePress config.
2026-04-16 22:57:03 +08:00
AstroHan bc06d99c83 fix(xiaohongshu): detect current draft save success (#1060) 2026-04-16 22:55:29 +08:00
AstroHan 3738cd2595 fix(twitter): repair lists scraping from detail pages (#1053) 2026-04-16 14:33:18 +08:00
AstroHan 240dccd754 fix(xiaohongshu): verify title input sticks on publish (#1050) 2026-04-16 14:30:37 +08:00
Cosmostima 44b4107f36 feat(nowcoder): add 牛客网 adapter with 16 commands (#1036)
* feat(nowcoder): add 牛客网 adapter with 16 commands

Add adapters for Nowcoder (牛客网), China's leading tech job-seeking
and interview preparation community.

- 7 Public commands: hot, trending, topics, recommend, creators, companies, jobs
- 9 Cookie commands: search, suggest, experience, referral, salary, papers, practice, notifications, detail
- All post-list commands include id field for drill-down to detail
- Documentation: adapter page, index table, sidebar entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(nowcoder): register adapter and document usage

---------

Co-authored-by: tima <tima@cosmos-macmini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-15 22:51:55 +08:00
jakevin 3076c12d6c chore: bump version to 1.7.4 (#1045)
Release / release (push) Has been cancelled
2026-04-15 15:50:30 +08:00
Howard 44147e54c1 feat(youtube): add feed, history, watch-later, subscriptions, playlist, like, unlike, subscribe, unsubscribe (#1029)
* feat(youtube): add feed, history, watch-later, subscriptions, playlist, like, unlike, subscribe, unsubscribe

* fix(youtube): normalize subscriptions channel fields

* docs(skills): add youtube command coverage

---------

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

* feat(xiaoyuzhou): add transcript download support

* docs(xiaoyuzhou): clarify credential file requirement

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

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

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

---------

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

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

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

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

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

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

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

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

* fix: register hot stock ranking adapters

---------

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

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

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

Fix this in two layers:

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

Before:

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

After:

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

* fix: reset network capture flags on closeWindow()

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

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

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

---------

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

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

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

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

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

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

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

* fix: fail explicitly when stale daemon replacement fails

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

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

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

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

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

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

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

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

* fix(grok): harden image flow and docs

---------

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

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

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

* fix(manifest): register uiverse commands

* docs(uiverse): add usage examples

---------

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

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

---------

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

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

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

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

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

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

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

* feat: migrate scrollTo to unified resolver pipeline

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

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

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

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

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

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

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

* fix(manifest): sync twitter bookmarks columns

---------

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

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 10:18:45 +08:00
dependabot[bot] 745ce459d1 chore(deps): bump undici from 8.0.2 to 8.1.0 (#1003)
Bumps [undici](https://github.com/nodejs/undici) from 8.0.2 to 8.1.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.0.2...v8.1.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 10:18:32 +08:00
dependabot[bot] a5cd0dc307 chore(deps): bump @types/node from 25.5.2 to 25.6.0 (#1004)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 10:18:24 +08:00
dependabot[bot] beabed4bad chore(deps): bump vitest from 4.1.2 to 4.1.4 (#1005)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

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

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

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

* docs: update extension zip filename to versioned format

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

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

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

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

---------

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

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

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

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

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

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

Closes #283

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

- cli-manifest.json: update site/modulePath/sourceFile from chatgpt to chatgpt-app
- skills/opencli-usage/desktop.md: update commands from chatgpt to chatgpt-app
2026-04-13 14:33:32 +08:00
jakevin 79a15e8353 Remove unused OPENCLI_SKIP_FETCH env var (#987)
The adapter sync already has version caching (skips if same version)
and makes no network requests, so this opt-out flag adds no value.
2026-04-13 14:09:10 +08:00
604 changed files with 54209 additions and 13418 deletions
+4 -9
View File
@@ -46,25 +46,20 @@ jobs:
run: |
EXT_VERSION=$(node -p "require('./extension/package.json').version")
cd extension-package
zip -r ../opencli-extension.zip .
cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v7
with:
name: opencli-extension-build
path: |
opencli-extension.zip
opencli-extension-v*.zip
path: opencli-extension-v*.zip
retention-days: 7
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v3.0.0
with:
files: |
opencli-extension.zip
opencli-extension-v*.zip
files: opencli-extension-v*.zip
draft: false
prerelease: false
env:
+12 -4
View File
@@ -38,6 +38,18 @@ jobs:
- name: Build
run: npm run build
# Guard: committed cli-manifest.json must match the one build regenerates.
# Prevents silent drift where unrelated adapter entries vanish or change
# across PRs (agent hits unexpected manifest diff → surgical-merge churn).
- name: Check cli-manifest.json is up-to-date
if: runner.os == 'Linux'
shell: bash
run: |
if ! git diff --exit-code -- cli-manifest.json; then
echo "::error::cli-manifest.json is out of sync with the source. Run 'npm run build' and commit the result."
exit 1
fi
# ── Unit tests (vitest shard) ──
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
@@ -136,12 +148,8 @@ jobs:
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
- name: Run smoke tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
timeout-minutes: 15
-4
View File
@@ -64,11 +64,7 @@ jobs:
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
- name: Run E2E tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
+14 -5
View File
@@ -26,6 +26,17 @@ jobs:
- name: Type check
run: npx tsc --noEmit
# Build before the manifest drift gate: adapter modules import
# @jackwener/opencli/* through package exports, which resolve to dist/.
# A fresh release checkout has no dist/ until the full build runs.
- name: Build package and verify cli-manifest.json is up-to-date
run: |
npm run build
if ! git diff --exit-code -- cli-manifest.json; then
echo "::error::cli-manifest.json drift detected at release time. Run 'npm run build' locally and commit the result before tagging."
exit 1
fi
- name: Install extension dependencies
run: npm ci
working-directory: extension
@@ -40,17 +51,15 @@ jobs:
- name: Create extension ZIP
run: |
EXT_VERSION=$(node -p "require('./extension/package.json').version")
EXT_VERSION=$(jq -r .version extension/package.json)
cd extension-package
zip -r ../opencli-extension.zip .
cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v3.0.0
with:
generate_release_notes: true
files: |
opencli-extension.zip
opencli-extension-v*.zip
- name: Publish to npm
+1
View File
@@ -3,6 +3,7 @@ dist/
!extension/dist/
*.tsbuildinfo
.opencli/
.worktrees/
.mcp.json
*.log
.DS_Store
+129 -1
View File
@@ -1,5 +1,133 @@
# Changelog
## Unreleased
### Features
* **observation** — add trace artifact primitives, `browser console`, `browser network --since/--follow/--failed`, and adapter `--trace=retain-on-failure` for failure-retained browser evidence.
* **autofix** — retire `OPENCLI_DIAGNOSTIC`; adapter repair now uses `--trace retain-on-failure`, trace `summary.md`, and error-envelope trace metadata.
* **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929))
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
## [1.7.8](https://github.com/jackwener/opencli/compare/v1.7.7...v1.7.8) (2026-04-25)
### Features
* **powerchina** — procurement search adapter. ([#1155](https://github.com/jackwener/opencli/issues/1155))
* **toutiao** — `articles` adapter for 头条号 creator dashboard. ([#1148](https://github.com/jackwener/opencli/issues/1148))
* **weixin** — `create-draft` and `drafts` commands for Official Account. ([#1095](https://github.com/jackwener/opencli/issues/1095))
### Bug Fixes
* **chatgpt-app** — use AX send flow and support zh-CN generating state. ([#1135](https://github.com/jackwener/opencli/issues/1135))
* **deepseek** — fix history titles and resume conversation on `ask`. ([#1153](https://github.com/jackwener/opencli/issues/1153))
* **amazon** — fall back discussion to product page. ([#1154](https://github.com/jackwener/opencli/issues/1154))
* **sinafinance** — match stock symbol in addition to name. ([#1158](https://github.com/jackwener/opencli/issues/1158))
### Chores
* **extension** — restore pre-1.6.8 neon terminal icons. ([#1177](https://github.com/jackwener/opencli/issues/1177))
## [1.7.7](https://github.com/jackwener/opencli/compare/v1.7.6...v1.7.7) (2026-04-23)
### Features
* **51job** — comprehensive adapter: `search`, `hot`, `detail`, `company`. ([#1132](https://github.com/jackwener/opencli/issues/1132))
* **weread** — `ai-outline` command for AI-generated book outlines. ([#1141](https://github.com/jackwener/opencli/issues/1141))
* **web/download** — video/audio/iframe download + `--stdout` streaming. ([#1146](https://github.com/jackwener/opencli/issues/1146))
* **download** — hardened HTML→Markdown pipeline with better element handling. ([#1143](https://github.com/jackwener/opencli/issues/1143))
* **verify** — fixture-based value validation + skill docs for COOKIE pitfalls. ([#1131](https://github.com/jackwener/opencli/issues/1131))
* **agent-native retrospective** — analyze / verify guards / fixture content checks. ([#1133](https://github.com/jackwener/opencli/issues/1133))
* **twitter** — expose `has_media` and `media_urls` columns. ([#1115](https://github.com/jackwener/opencli/issues/1115))
### Bug Fixes
* **core** — quality audit fixes: elapsed=0 display, daemon error handler state reset, cause chain truncation guard, download cookie expiry, launcher async kill, verbose error logging. ([#1151](https://github.com/jackwener/opencli/issues/1151))
* **daemon** — allow extension ping CORS for reachability probing. ([#1150](https://github.com/jackwener/opencli/issues/1150))
* **deepseek** — separate thinking process from response in `--think` mode. ([#1142](https://github.com/jackwener/opencli/issues/1142))
* **deepseek** — use position-based model selection instead of text matching. ([#1123](https://github.com/jackwener/opencli/issues/1123))
* **weread/book** — add fallback selectors for reader page without cover. ([#1138](https://github.com/jackwener/opencli/issues/1138))
* **xiaoyuzhou** — correct podcast-episodes API endpoint. ([#1129](https://github.com/jackwener/opencli/issues/1129))
* **bilibili** — resolve full video URLs and preserve full description. ([#1118](https://github.com/jackwener/opencli/issues/1118))
### Docs
* Fix stale references in READMEs and autofix skill doc. ([#1130](https://github.com/jackwener/opencli/issues/1130))
* Restore and rewrite `opencli-usage` as orientation skill. ([#1128](https://github.com/jackwener/opencli/issues/1128))
## [1.7.6](https://github.com/jackwener/opencli/compare/v1.7.5...v1.7.6) (2026-04-21)
Extension bumped to 1.0.2 (body-truncation signal unified across raw / detail / fallback paths).
### Features
* **Window lifecycle flags** — `--live` (or `OPENCLI_LIVE=1`) keeps the automation window open after a command finishes; `--focus` (or `OPENCLI_WINDOW_FOCUSED=1`) brings the window to the foreground. Works on any subcommand. ([#1122](https://github.com/jackwener/opencli/issues/1122))
* **Selector-first browser interactions** — `find` / `get` / `click` / `type` / `select` accept CSS selectors in addition to numeric refs; `--nth` disambiguates multiple matches. ([#1112](https://github.com/jackwener/opencli/issues/1112))
* **Agent-native browser payload** — structured `network` bodies with truncation signal, `get html --as json` with `--depth` / `--children-max` / `--text-max` budgets, new `browser extract` command for long-form content with resume cursor. ([#1104](https://github.com/jackwener/opencli/issues/1104))
* **`network --filter <fields>`** — filter captured requests by body-shape path segments for quick API discovery. ([#1103](https://github.com/jackwener/opencli/issues/1103))
* **`get html --as json`** — structured HTML tree output; no more silent truncation on raw `--as html`. ([#1102](https://github.com/jackwener/opencli/issues/1102))
* **`browser network` rewrite** — agent-native discovery with cache keys and shape preview. ([#1100](https://github.com/jackwener/opencli/issues/1100))
* **Compound form fields** — date / select / file controls surface a `compound` envelope with format, options, `accept`. Cascading stale-ref recovery + bbox 0.99 dedup for tagged elements. ([#1116](https://github.com/jackwener/opencli/issues/1116))
* **twitter `tweets`** — fetch a user's recent posts. ([#1098](https://github.com/jackwener/opencli/issues/1098))
* **bilibili `video`** — new video command. ([#1110](https://github.com/jackwener/opencli/issues/1110))
* **deepseek `--file`** — file upload support on `ask`. ([#1093](https://github.com/jackwener/opencli/issues/1093))
### Bug Fixes
* **twitter** — 5s timeout on `resolveTwitterQueryId` to prevent hang. ([#1106](https://github.com/jackwener/opencli/issues/1106))
* **youtube** — fall back to Videos tab when Home has no videos. ([#1109](https://github.com/jackwener/opencli/issues/1109))
* **jianyu** — keep accessible detail urls in search. ([#1099](https://github.com/jackwener/opencli/issues/1099))
* **jianyu** — block inaccessible detail links and verification pages. ([#918](https://github.com/jackwener/opencli/issues/918))
### Docs
* **opencli-browser skill** — restored and upgraded for selector-first workflow. ([#1119](https://github.com/jackwener/opencli/issues/1119))
* **Window lifecycle** — sync README + skill docs with `--live` / `--focus` behavior. ([#1125](https://github.com/jackwener/opencli/issues/1125))
### Extension (1.0.2)
* Unify body-truncation contract across raw / detail / fallback network paths; surface `body_truncated` / `body_full_size` / `body_truncation_reason`. ([#1104](https://github.com/jackwener/opencli/issues/1104))
## [1.7.5](https://github.com/jackwener/opencli/compare/v1.7.4...v1.7.5) (2026-04-20)
Extension bumped to 1.0.1 (multi-tab routing + cross-origin iframe).
### Features
* **DeepSeek adapter** — browser-based `ask` / `history` / `new` / `read` / `status` ([#1088](https://github.com/jackwener/opencli/issues/1088))
* **Eastmoney adapters** — 13 finance adapters as Phase A oracle: `quote`, `rank`, `kline`, `sectors`, `etf`, `holders`, `money-flow`, `northbound`, `longhu`, `kuaixun`, `convertible`, `index-board`, `announcement` ([#1091](https://github.com/jackwener/opencli/issues/1091))
* **Twitter GraphQL lists** — `list-tweets`, `list-add`, `list-remove` ([#1076](https://github.com/jackwener/opencli/issues/1076))
* **nowcoder adapter** — 牛客网 with 16 commands ([#1036](https://github.com/jackwener/opencli/issues/1036))
* **Chinese academic & policy adapters** — `baidu-scholar`, `google-scholar`, `wanfang`, `gov-law`, `gov-policy` ([#243](https://github.com/jackwener/opencli/issues/243))
* **Download saved path** — `web read` and `weixin download` now show saved file location ([#1042](https://github.com/jackwener/opencli/issues/1042))
* **Cross-origin iframe support** — CDP execution context for iframed content ([#1084](https://github.com/jackwener/opencli/issues/1084))
### Improvements
* **Multi-tab routing** — hardened target isolation and tab routing ([#1072](https://github.com/jackwener/opencli/issues/1072))
* **Skill consolidation** — 6 skills merged into 3 (`opencli-adapter-author`, `opencli-autofix`, `smart-search`); removed mechanical commands `explore` / `synthesize` / `generate` / `cascade` / `record` ([#1094](https://github.com/jackwener/opencli/issues/1094))
* **Browser docs rewrite** — docs reoriented for AI Agent use case ([#1080](https://github.com/jackwener/opencli/issues/1080))
* **antigravity serve** — configurable timeout + auto-reconnect ([#859](https://github.com/jackwener/opencli/issues/859), [#1063](https://github.com/jackwener/opencli/issues/1063))
* **Design debt cleanup** — deprecated APIs, arg validation, dead plugin code ([#1065](https://github.com/jackwener/opencli/issues/1065))
### Bug Fixes
* **xiaoyuzhou** — migrate from broken SSR to authenticated API ([#1059](https://github.com/jackwener/opencli/issues/1059)); accept `CONFIG_ERROR` in E2E guard ([#1066](https://github.com/jackwener/opencli/issues/1066))
* **xiaohongshu** — detect draft save success ([#1060](https://github.com/jackwener/opencli/issues/1060)); verify title input sticks on publish ([#1050](https://github.com/jackwener/opencli/issues/1050))
* **twitter** — repair lists scraping from detail pages ([#1053](https://github.com/jackwener/opencli/issues/1053))
* **zsxq** — separate content from title, remove title truncation ([#1079](https://github.com/jackwener/opencli/issues/1079))
* **extension** — per-workspace idle timeout for browser sessions ([#1064](https://github.com/jackwener/opencli/issues/1064))
### Revert
* Undo output renderer table-formatting patch ([#1085](https://github.com/jackwener/opencli/issues/1085), reverts [#1081](https://github.com/jackwener/opencli/issues/1081))
### Extension (1.0.1)
* Multi-tab routing support ([#1072](https://github.com/jackwener/opencli/issues/1072))
* Cross-origin iframe CDP contexts ([#1084](https://github.com/jackwener/opencli/issues/1084))
## [1.7.0](https://github.com/jackwener/opencli/compare/v1.6.1...v1.7.0) (2026-04-11)
This is a major release with significant internal architecture changes.
@@ -84,7 +212,7 @@ Adapter code, validation, and error handling have been modernized.
1. **Update Node.js** to v21 or later (v22 LTS recommended).
2. **Run `npm install -g @jackwener/opencli@latest`** — the preuninstall hook gracefully stops the old daemon; the first browser command after upgrade auto-restarts it.
3. **If you have custom `.ts` adapters** in `~/.opencli/clis/`, rename or compile them to `.js`. A warning will be printed on startup if stale `.ts` files are detected.
4. **If you have custom `.yaml` adapters**, convert them to JS using the `cli()` API (see `skills/opencli-explorer/references/adapter-templates.md`).
4. **If you have custom `.yaml` adapters**, convert them to JS using the `cli()` API (see `skills/opencli-adapter-author/references/adapter-template.md`).
5. **If you parse error output from stdout**, switch to stderr. Errors are now structured YAML envelopes with typed exit codes.
+1 -1
View File
@@ -102,7 +102,7 @@ cli({
});
```
Use `opencli explore <url>` to discover APIs and see [opencli-explorer skill](./skills/opencli-explorer/SKILL.md) if you need the full adapter workflow.
Install the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md) if you need the full adapter workflow — recon → API discovery → field decoding → `opencli browser verify`.
### Validate Your Adapter
+141 -72
View File
@@ -11,29 +11,22 @@
OpenCLI gives you one surface for three different kinds of automation:
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
- **Drive a live browser directly** with `opencli browser` when an AI agent needs to click, type, extract, or inspect a page in real time.
- **Generate new adapters** from real browser behavior with `explore`, `synthesize`, `generate`, and `cascade`.
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Notion.
## Why OpenCLI
---
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Browser Automation** — `browser` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
- **Website → CLI** — Turn any website into a deterministic CLI: 87+ pre-built adapters, or crystallize your own with `opencli record`.
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type, extract, screenshot — all through your logged-in Chrome session.
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `browser` controls the browser directly.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
- **Dynamic Loader** — Simply drop `.js` adapters into the `clis/` folder for auto-registration.
- **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, etc).
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
- **Broad coverage** — 87+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
---
@@ -41,7 +34,10 @@ It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other
### 1. Install OpenCLI
OpenCLI requires **Node.js >= 21**.
```bash
node --version
npm install -g @jackwener/opencli
```
@@ -49,7 +45,11 @@ npm install -g @jackwener/opencli
OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extension plus a small local daemon. The daemon auto-starts when needed.
1. Download the latest `opencli-extension.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
**Option A — Chrome Web Store (recommended):**
Install **OpenCLI** from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk).
**Option B — Manual install:**
1. Download the latest `opencli-extension-v{version}.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
2. Unzip it, open `chrome://extensions`, and enable **Developer mode**.
3. Click **Load unpacked** and select the unzipped folder.
@@ -59,7 +59,20 @@ OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extensi
opencli doctor
```
### 4. Run your first commands
### 4. Optional: name your Chrome profile
Each Chrome profile runs its own OpenCLI extension instance. If you use multiple Chrome profiles, list the connected profiles and assign local aliases:
```bash
opencli profile list
opencli profile rename <contextId> work
opencli profile use work
opencli --profile work browser state
```
With only one connected profile, OpenCLI uses it automatically. With multiple connected profiles and no default, OpenCLI asks you to choose instead of guessing.
### 5. Run your first commands
```bash
opencli list
@@ -73,17 +86,26 @@ Use OpenCLI directly when you want a reliable command instead of a live browser
- `opencli list` shows every registered command.
- `opencli <site> <command>` runs a built-in or generated adapter.
- `opencli register mycli` exposes a local CLI through the same discovery surface.
- `opencli external register mycli` exposes a local CLI through the same discovery surface.
- `opencli doctor` helps diagnose browser connectivity.
## Extending OpenCLI
If you want to add your own commands, start with the [Extending OpenCLI guide](./docs/guide/extending-opencli.md). README keeps this short; the guide covers the directory layout, source-control model, and install commands.
| Need | Recommended path |
|------|------------------|
| Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
| Wrap an existing local binary | `opencli external register <name>` |
## For AI Agents
Use two different entry points depending on the task:
OpenCLI's browser commands are designed to be used by AI Agents — not run manually. Install skills into your AI agent (Claude Code, Cursor, etc.), and the agent operates websites on your behalf using your logged-in Chrome session.
- [`skills/opencli-explorer/SKILL.md`](./skills/opencli-explorer/SKILL.md): the entry point for creating new adapters — supports both fully automated generation (`opencli generate <url>`) and manual exploration workflows.
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md): the low-level control surface for live browsing, debugging, and manual intervention.
Install the packaged skills with:
### Install skills
```bash
npx skills add jackwener/opencli
@@ -92,40 +114,68 @@ npx skills add jackwener/opencli
Or install only what you need:
```bash
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-explorer
npx skills add jackwener/opencli --skill opencli-oneshot
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
In practice:
### Which skill to use
- start with `opencli-explorer` when the agent needs a reusable command for a site (it covers both automated and manual flows)
- use `opencli-browser` when the agent needs to inspect or steer the page directly
| Skill | When to use | Example prompt to your AI agent |
|-------|------------|-------------------------------|
| **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-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" |
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, and `close`.
### How it works
Once `opencli-adapter-author` is installed, your AI agent can:
1. **Navigate** to any URL using your logged-in browser
2. **Read** page content via structured DOM snapshots (not screenshots)
3. **Interact** — click buttons, fill forms, select options, press keys
4. **Extract** data from the page or intercept network API responses
5. **Wait** for elements, text, or page transitions
The agent handles all the `opencli browser` commands internally — you just describe what you want done in natural language.
**Skill references:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — browser operation + adapter authoring, end-to-end
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — repair broken adapters
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — browser automation reference
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — capability search
Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
`opencli browser open <url>` and `opencli browser tab new [url]` both return a target ID. Use `opencli browser tab list` to inspect the target IDs of tabs that already exist, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted `opencli browser ...` commands.
## Core Concepts
### `browser`: live control
### `browser`: AI Agent browser control
Use `opencli browser` when the task is inherently interactive and the agent needs to operate the page directly.
`opencli browser` commands are the low-level primitives that AI Agents use to operate websites. You don't run these manually instead, install the `opencli-adapter-author` skill into your AI agent, describe what you want in natural language, and the agent handles the browser operations.
For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser open`, `state`, `click`, etc. under the hood.
### Built-in adapters: stable commands
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists and you want deterministic output.
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists. These are deterministic and work without browser — ideal for both humans and AI agents.
### `explore` / `synthesize` / `generate`: create new CLIs
### Writing a new adapter
Use these commands when the site you need is not covered yet:
When the site you need is not yet covered, use the `opencli-adapter-author` skill. It takes the agent end-to-end:
- `explore` inspects the page, network activity, and capability surface.
- `synthesize` turns exploration artifacts into evaluate-based JS adapters.
- `generate` runs the verified generation path and returns either a usable command or a structured explanation of why completion was blocked or needs human review.
### `cascade`: auth strategy discovery
Use `cascade` to probe fallback auth paths such as public endpoints, cookies, and custom headers before you commit to an adapter design.
1. Recon the site and classify its pattern (SPA / SSR / JSONP / Token / Streaming).
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
4. Decode response fields and design output columns.
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
### CLI Hub and desktop adapters
@@ -136,7 +186,8 @@ OpenCLI is not only for websites. It can also:
## Prerequisites
- **Node.js**: >= 21.0.0 (or **Bun** >= 1.0)
- **Node.js**: >= 21.0.0 (required for the standard npm install path)
- **Bun**: >= 1.0 (optional alternative runtime)
- **Chrome or Chromium** running and logged into the target site for browser-backed commands
> **Important**: Browser-backed commands reuse your Chrome/Chromium login session. If you get empty data or permission-like failures, first confirm the site is already open and authenticated in Chrome/Chromium.
@@ -146,18 +197,18 @@ OpenCLI is not only for websites. It can also:
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge |
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open automation windows in the foreground (useful for debugging) |
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open the automation container in the foreground (useful for debugging). The `--focus` flag sets this. |
| `OPENCLI_LIVE` | `false` | Set to `1` to keep the automation lease open after an adapter command finishes (useful for inspection). The `--live` flag sets this. |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | `120` | Seconds to wait for explore/record operations |
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
| `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) |
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
| `OPENCLI_DIAGNOSTIC` | `false` | Set to `1` to capture structured diagnostic context on failures |
| `OUTPUT` | — | Override output format: `json`, `yaml`, or `table` |
| `DEBUG` | — | Set to `opencli` for internal debug logging |
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation lease open until you run `opencli browser close` or the idle timeout expires.
## Update
```bash
@@ -170,10 +221,11 @@ npx skills add jackwener/opencli
Or refresh only the skills you actually use:
```bash
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-explorer
npx skills add jackwener/opencli --skill opencli-oneshot
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
## For Developers
@@ -198,24 +250,36 @@ To load the source Browser Bridge extension:
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
| **tieba** | `hot` `posts` `search` `read` |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **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` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **1688** | `search` `item` `assets` `download` `store` |
| **gitee** | `trending` `search` `user` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
| **uiverse** | `code` `preview` |
| **baidu-scholar** | `search` |
| **google-scholar** | `search` `cite` `profile` |
| **gov-law** | `search` `recent` |
| **gov-policy** | `search` `recent` |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` |
| **wanfang** | `search` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
87+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
100+ site surfaces in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast`, `podcast-episodes`, `episode`, `download`, and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## CLI Hub
@@ -227,14 +291,14 @@ OpenCLI acts as a universal hub for your existing command-line tools — unified
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker | `opencli docker ps` |
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
| **dingtalk** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dingtalk msg send --to user "hello"` |
| **wecom** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom msg send --to user "hello"` |
| **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"` |
| **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`:
```bash
opencli register mycli
opencli external register mycli
```
### Desktop App Adapters
@@ -246,7 +310,7 @@ Control Electron desktop apps directly from the terminal. Each adapter has its o
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
@@ -266,18 +330,24 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **douban** | Images | Poster / still image lists |
| **pixiv** | Images | Original-quality illustrations, multi-page |
| **1688** | Images, Videos | Downloads page-visible product media from item pages |
| **xiaoyuzhou** | Audio, Transcript | Downloads episode audio and transcript JSON/text with local credentials |
| **zhihu** | Articles (Markdown) | Exports with optional image download |
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
For video downloads, install `yt-dlp` first: `brew install yt-dlp`
```bash
opencli xiaohongshu download abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
opencli bilibili download BV1xxx --output ./bilibili
opencli twitter download elonmusk --limit 20 --output ./twitter
opencli 1688 download 841141931191 --output ./1688-downloads
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
```
`opencli xiaoyuzhou download` and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## Output Formats
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
@@ -306,8 +376,8 @@ opencli follows Unix `sysexits.h` conventions so it integrates naturally with sh
```bash
opencli spotify status || echo "exit $?" # 69 if browser not running
opencli github issues 2>/dev/null
[ $? -eq 77 ] && opencli github auth # auto-auth if not logged in
opencli gh issue list 2>/dev/null
[ $? -eq 77 ] && opencli gh auth login # auto-auth if not logged in
```
## Plugins
@@ -332,16 +402,15 @@ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
## For AI Agents (Developer Guide)
> **Quick mode**: To generate a single command for a specific page URL, see [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — just a URL + one-line goal, 4 steps done.
Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end:
> **Full mode**: Before writing any adapter code, read [opencli-explorer skill](./skills/opencli-explorer/SKILL.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
- Verify with `opencli browser verify <site>/<name>` before shipping.
```bash
opencli explore https://example.com --site mysite # Discover APIs + capabilities
opencli synthesize mysite # Generate JS adapters
opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
```
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
## Testing
@@ -349,10 +418,10 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
## Troubleshooting
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions` in Chrome or Chromium.
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
- **Node API errors** — Ensure Node.js >= 21. Some features require `node:util` styleText (stable in Node 21+).
- **Node API errors / missing `fetch` / startup crash on old Node** — OpenCLI requires **Node.js >= 21**. Run `node --version`, upgrade Node if needed, then retry.
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
## Star History
+136 -81
View File
@@ -10,26 +10,31 @@
OpenCLI 可以用同一套 CLI 做三类事情:
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [87+ 站点](#内置命令) 开箱即用。
- **直接驱动浏览器**:用 `opencli browser` 让 AI Agent 实时点击、输入、提取、截图、检查页面状态
- **把新网站生成成 CLI**通过 `explore``synthesize``generate``cascade` 从真实页面行为推导出新的适配器
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
- **让 AI Agent 操作任意网站**:在你的 AI AgentClaude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入、提取任意网页内容
- **把新网站成 CLI** `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker` 等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Notion 等 Electron 应用。
## 为什么是 OpenCLI
## 亮点
- **同一个心智模型**:网站、浏览器自动化、Electron 应用、本地 CLI 都走同一个入口
- **复用真实会话**:浏览器命令直接使用你已经登录 Chrome/Chromium,而不是重新造一套认证
- **输出稳定**:适配器命令返回固定结构,适合 shell、脚本、CI 和 AI Agent 工具调用
- **面向 AI Agent**`browser` 负责实时操作,`explore` 负责探索接口,`synthesize` 负责生成适配器,`cascade` 负责探测认证路径
- **运行成本低**:已有命令运行时不消耗模型 token
- **天然可扩展**:既能用内置能力,也能注册本地 CLI,或直接往 `clis/``.js` 适配器
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)
- **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 等)
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
## 快速开始
### 1. 安装 OpenCLI
OpenCLI 要求 **Node.js >= 21**
```bash
node --version
npm install -g @jackwener/opencli
```
@@ -37,7 +42,11 @@ npm install -g @jackwener/opencli
OpenCLI 通过轻量 Browser Bridge 扩展和本地微型 daemon 与 Chrome/Chromium 通信。daemon 会按需自动启动。
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
**方式 A — Chrome Web Store(推荐):**
在 [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) 安装 **OpenCLI** 扩展。
**方式 B — 手动安装:**
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension-v{version}.zip`
2. 解压后打开 `chrome://extensions`,启用 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的目录。
@@ -61,17 +70,26 @@ opencli bilibili hot --limit 5
- `opencli list` 查看当前所有命令
- `opencli <site> <command>` 调用内置或生成好的适配器
- `opencli register mycli` 把本地 CLI 接入同一发现入口
- `opencli external register mycli` 把本地 CLI 接入同一发现入口
- `opencli doctor` 处理浏览器连通性问题
## 扩展 OpenCLI
如果你想新增自己的命令,先看 [扩展 OpenCLI](./docs/zh/guide/extending-opencli.md)。README 只保留入口;目录结构、源码管理方式和安装命令放在文档里。
| 需求 | 推荐路径 |
|------|----------|
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
| 包装已有本机 binary | `opencli external register <name>` |
## 给 AI Agent
按任务类型,AI Agent 有两个不同入口:
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI AgentClaude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
- [`skills/opencli-explorer/SKILL.md`](./skills/opencli-explorer/SKILL.md):适配器创建入口,支持全自动生成(`opencli generate <url>`)和手动探索两种流程。
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md):底层控制入口,适合实时操作页面、debug 和人工介入。
安装全部 OpenCLI skills
### 安装 skill
```bash
npx skills add jackwener/opencli
@@ -80,40 +98,68 @@ npx skills add jackwener/opencli
或只装需要的 skill
```bash
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-explorer
npx skills add jackwener/opencli --skill opencli-oneshot
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
实际使用上:
### 选择哪个 skill
- 需要把某个站点收成可复用命令时,优先走 `opencli-explorer`(涵盖自动和手动两种路径)
- 需要直接检查页面、操作页面时,再走 `opencli-browser`
| Skill | 适用场景 | 你对 AI Agent 说的话 |
|-------|---------|-------------------|
| **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" |
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
| **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" |
`browser` 可用命令包括:`open``state``click``type``select``keys``wait``get``screenshot``scroll``back``eval``network``init``verify``close`
### 工作原理
安装 `opencli-adapter-author` skill 后,你的 AI Agent 可以:
1. **导航**到任意 URL,使用你的已登录浏览器
2. **读取**页面内容——通过结构化 DOM 快照(不是截图)
3. **交互**——点击按钮、填写表单、选择选项、按键
4. **提取**页面数据或拦截网络 API 响应
5. **等待**元素、文本或页面跳转
Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自然语言描述想做的事。
**Skill 参考文档:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 浏览器操作 + 适配器编写,全流程
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — 修复已有适配器
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 浏览器自动化参考
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — 能力搜索
`browser` 可用命令包括:`open``state``click``type``select``keys``wait``get``find``extract``frames``screenshot``scroll``back``eval``network``tab list``tab new``tab select``tab close``init``verify``close`
`opencli browser open <url>``opencli browser tab new [url]` 都会返回 target ID。`opencli browser tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为后续未指定 target 的 `opencli browser ...` 命令的默认目标。
## 核心概念
### `browser`实时操作
### `browser`AI Agent 的浏览器控制层
当任务本身就是交互式页面操作时,使用 `opencli browser` 直接驱动浏览器。
`opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作
比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser open``state``click` 等命令。
### 内置适配器:稳定命令
当某个站点能力已经存在时,优先使用 `opencli hackernews top``opencli reddit hot` 这类稳定命令,而不是重新走一遍浏览器操作
当某个站点能力已经存在时,优先使用 `opencli hackernews top``opencli reddit hot` 这类稳定命令。这些命令是确定性的,无需浏览器——人类和 AI Agent 都可以直接使用
### `explore` / `synthesize` / `generate`:生成新的 CLI
### 为新站点写适配器
当你需要的网站还没覆盖时:
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,它会把 Agent 带到闭环
- `explore` 负责观察页面、网络请求和能力边界
- `synthesize` 负责把探索结果转成 evaluate-based YAML 适配器
- `generate` 负责跑通 verified generation 主链路,最后要么给出可直接使用的命令,要么返回结构化的阻塞原因 / 人工介入结果
### `cascade`:认证策略探测
`cascade` 去判断某个能力应该优先走公开接口、Cookie 还是自定义 Header,而不是一开始就把适配器写死。
1. 侦察站点,分类 patternSPA / SSR / JSONP / Token / Streaming
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
4. 字段解码 + 设计输出列
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
### CLI 枢纽与桌面端适配器
@@ -124,7 +170,8 @@ OpenCLI 不只是网站 CLI,还可以:
## 前置要求
- **Node.js**: >= 21.0.0
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
- **Bun**: >= 1.0(可选替代运行时)
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
@@ -134,18 +181,17 @@ OpenCLI 不只是网站 CLI,还可以:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试) |
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试)`--focus` 标志会设置此变量 |
| `OPENCLI_LIVE` | `false` | 设为 `1` 时 adapter 命令执行完后保留 automation 窗口不关闭(适合检查页面)。`--live` 标志会设置此变量 |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | `120` | explore/record 操作超时(秒) |
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com` |
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
| `OUTPUT` | — | 覆盖输出格式:`json``yaml``table` |
| `DEBUG` | — | 设为 `opencli` 开启内部调试日志 |
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
`--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时。
## 更新
```bash
@@ -158,10 +204,9 @@ npx skills add jackwener/opencli
如果你只装了部分 skill,也可以只刷新自己在用的:
```bash
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-explorer
npx skills add jackwener/opencli --skill opencli-oneshot
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill smart-search
```
## 面向开发者
@@ -187,12 +232,12 @@ npm link
| 站点 | 命令 | 模式 |
|------|------|------|
| **twitter** | `trending` `search` `timeline` `lists` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
@@ -201,16 +246,23 @@ npm link
| **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` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
| **uiverse** | `code` `preview` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **baidu-scholar** | `search` | 公开 |
| **google-scholar** | `search` `cite` `profile` | 公开 |
| **gov-law** | `search` `recent` | 公开 |
| **gov-policy** | `search` `recent` | 公开 |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` | 公开 / 浏览器 |
| **wanfang** | `search` | 公开 |
| **xiaoyuzhou** | `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` `auth` | 本地凭证 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
| **bbc** | `news` | 公共 API |
@@ -232,7 +284,7 @@ npm link
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
| **chaoxing** | `assignments` `exams` | 浏览器 |
| **grok** | `ask` | 浏览器 |
| **grok** | `ask` `image` | 浏览器 |
| **hf** | `top` | 公开 |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
@@ -244,17 +296,18 @@ npm link
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | 浏览器 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` | 浏览器 |
| **1688** | `search` `item` `assets` `download` `store` | 浏览器 |
| **gitee** | `trending` `search` `user` | 公开 / 浏览器 |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` | 浏览器 |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | 浏览器 |
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
| **lobsters** | `hot` `newest` `active` `tag` `read` | 公开 |
| **medium** | `feed` `search` `user` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
| **substack** | `feed` `search` `publication` | 浏览器 |
@@ -265,7 +318,9 @@ npm link
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |
87+ 适配器**[→ 查看完整命令列表](./docs/adapters/index.md)**
100+ 站点能力**[→ 查看完整命令列表](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast``podcast-episodes``episode``download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
### 外部 CLI 枢纽
@@ -277,8 +332,8 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
| **dingtalk** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dingtalk msg send --to user "hello"` |
| **wecom** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom msg send --to user "hello"` |
| **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` |
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
@@ -300,7 +355,7 @@ opencli register mycli
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
@@ -319,6 +374,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
| **1688** | 图片、视频 | 下载商品页中可见的商品素材 |
| **小宇宙** | 音频、转录 | 使用本地凭证下载单集音频和转录 JSON / 文本 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
@@ -338,7 +394,8 @@ brew install yt-dlp
```bash
# 下载小红书笔记中的图片/视频
opencli xiaohongshu download abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
# 下载B站视频(需要 yt-dlp
opencli bilibili download BV1xxx --output ./bilibili
@@ -356,6 +413,12 @@ opencli douban download 30382501 --output ./douban
# 下载 1688 商品页中的图片 / 视频素材
opencli 1688 download 841141931191 --output ./1688-downloads
# 下载小宇宙单集音频
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
# 下载小宇宙单集转录
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
# 导出知乎文章为 Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
@@ -366,6 +429,8 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
`opencli xiaoyuzhou download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
## 输出格式
@@ -435,36 +500,26 @@ opencli plugin uninstall my-tool # 卸载
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
> **快速模式**:只想为某个页面快速生成一个命令?看 [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — 给一个 URL + 一句话描述,4 步搞定。
在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来:
> **完整模式**:在编写任何新代码前,先阅读 [opencli-explorer skill](./skills/opencli-explorer/SKILL.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
- 侦察站点,选定 patternSPA / SSR / JSONP / Token / Streaming
-`opencli browser network``eval`、interceptor 等找到目标 endpoint
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
- 交付前用 `opencli browser verify <site>/<name>` 验证
```bash
# 1. Deep Explore — 网络拦截 → 响应分析 → 能力推理 → 框架检测
opencli explore https://example.com --site mysite
# 2. Synthesize — 从探索成果物生成 evaluate-based TS 适配器
opencli synthesize mysite
# 3. Generate — 一键完成:探索 → 合成 → 注册
opencli generate https://example.com --goal "hot"
# 4. Strategy Cascade — 自动降级探测:PUBLIC → COOKIE → HEADER
opencli cascade https://api.example.com/data
```
探索结果输出到 `.opencli/explore/<site>/`
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
## 常见问题排查
- **"Extension not connected" 报错**
- 确保你当前的 Chrome 或 Chromium 已安装且**开启了** opencli Browser Bridge 扩展`chrome://extensions`检查)
- 确保你已从 [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) 安装 OpenCLI 扩展,且`chrome://extensions`**已启用**
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
- **返回空数据,或者报错 "Unauthorized"**
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
- 确保 Node.js 版本 `>= 21``node:util``styleText` 需要 Node 21+
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
- OpenCLI 要求 **Node.js >= 21**。先执行 `node --version`,如果版本过低先升级,再重试命令
- **Daemon 问题**
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
+2 -7
View File
@@ -208,7 +208,7 @@ it('producthunt me fails gracefully without login', async () => {
|---|---|---|
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome。
### Sharding
@@ -233,12 +233,7 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
```yaml
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
```
CI 通过 `./.github/actions/setup-chrome` 准备真实 Chrome,再直接执行测试。
---
+2 -2
View File
@@ -2,7 +2,7 @@
/**
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
*
* Spawns Claude Code with the opencli-browser skill. Claude Code
* Spawns Claude Code with the opencli-adapter-author skill. Claude Code
* completes the task using browse commands AND judges its own result.
*
* Task format: YAML with judge_context (multi-criteria, like Browser Use)
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESULTS_DIR = join(__dirname, 'results');
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-browser', 'SKILL.md');
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-adapter-author', 'SKILL.md');
// ── Types ──────────────────────────────────────────────────────────
+1 -1
View File
@@ -14,7 +14,7 @@ export const saveReliability: AutoResearchConfig = {
'src/cli.ts',
'src/discovery.ts',
'src/registry.ts',
'skills/opencli-browser/SKILL.md',
'skills/opencli-adapter-author/SKILL.md',
'autoresearch/save-tasks.json',
'autoresearch/save-adapters/*.ts',
],
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Preset: Skill E2E Quality
*
* Optimizes the opencli-browser SKILL.md against the Layer 2 LLM E2E test suite.
* Optimizes the opencli-adapter-author SKILL.md against the Layer 2 LLM E2E test suite.
* Metric: number of passing skill-tasks (out of 35).
*/
@@ -10,7 +10,7 @@ import type { AutoResearchConfig } from '../config.js';
export const skillQuality: AutoResearchConfig = {
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
scope: [
'skills/opencli-browser/SKILL.md',
'skills/opencli-adapter-author/SKILL.md',
],
metric: 'pass_count',
direction: 'higher',
+5021 -385
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
],
columns: ['rank', 'title', 'summary', 'date', 'url'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
const count = Math.min(kwargs.limit || 20, 50);
const resp = await fetch('https://www.36kr.com/feed', {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
+125
View File
@@ -0,0 +1,125 @@
/**
* 51job company jobs + basic info by encCoId.
*
* Navigates to `jobs.51job.com/all/co<encCoId>.html`. Each job card is an
* `<a sensorsdata="…">` whose attribute is a JSON blob with jobId, title,
* salary, area, year, degree — so parsing is just JSON, not DOM-text fragile.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { JOBS_ORIGIN, requirePage, navigateTo, parseCompanyJobCard } from './utils.js';
cli({
site: '51job',
name: 'company',
description: '51job 公司简介 + 在招职位(按 encCoId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'encCoId', type: 'string', required: true, positional: true, help: '加密公司 IDsearch 返回的 encCoId' },
{ name: 'limit', type: 'int', default: 20, help: '返回职位数(1-50' },
],
columns: [
'rank', 'jobId', 'title', 'salary', 'city', 'workYear', 'degree',
'funcType', 'issueDate', 'url',
'companyName', 'companyType', 'companySize', 'companyIndustry',
'companyIntro', 'companyUrl',
],
func: async (page, kwargs) => {
requirePage(page);
const encCoId = String(kwargs.encCoId ?? '').trim();
if (!encCoId) throw new CliError('INVALID_ARGUMENT', 'encCoId is required');
if (!/^[A-Za-z0-9_]+$/.test(encCoId)) {
throw new CliError('INVALID_ARGUMENT', `encCoId must be alphanumeric/underscore, got "${encCoId}"`);
}
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
const url = `${JOBS_ORIGIN}/all/co${encCoId}.html`;
await navigateTo(page, url, 2);
const script = `(() => {
const sel = s => document.querySelector(s)?.innerText?.trim() || '';
const bodyText = (document.body.innerText || '').slice(0, 400);
if (/公司不存在|页面不存在|账号状态异常/.test(bodyText)) {
return { error: 'NOT_FOUND', bodyText };
}
const companyName = sel('h1') || sel('.cname');
// Company introduction block
const introEl = document.querySelector('#companyIntroRef, .c-intro');
const companyIntro = introEl ? (introEl.innerText || '').trim() : '';
// Info sidebar (type / size / industry) — labels sit in .com-info dl or .coinfo
const sidebarText = sel('.ci-content, .company-info, .coinfo, .com-info');
const links = [...document.querySelectorAll('a[sensorsdata]')]
.filter(a => /\\/\\d{6,}\\.html/.test(a.href || ''))
.slice(0, 60)
.map(a => {
return {
href: a.href,
sensorsdata: a.getAttribute('sensorsdata') || '',
text: (a.innerText || '').trim(),
};
});
// Company meta is three inline spans under .c-info.ellipsis
// (title/size/industry) — extract them by position.
const cInfo = document.querySelector('.c-info.ellipsis');
const cInfoParts = cInfo
? [...cInfo.querySelectorAll('span')].map(s => (s.innerText || '').trim()).filter(Boolean)
: [];
return {
companyName,
companyIntro,
links,
cInfoParts,
sidebarText: sidebarText.slice(0, 400),
};
})()`;
const data = await page.evaluate(script);
if (data.error === 'NOT_FOUND') {
throw new CliError('NO_DATA', `Company ${encCoId} not found`);
}
if (!data.companyName) {
throw new CliError('NO_DATA', `Could not parse company page ${encCoId}; layout may have changed`);
}
const companyUrl = url;
const [companyType = '', companySize = '', companyIndustry = ''] = data.cInfoParts || [];
const seen = new Set();
const rows = [];
for (const link of data.links || []) {
const job = parseCompanyJobCard(link);
if (!job) continue;
if (seen.has(job.jobId)) continue;
seen.add(job.jobId);
rows.push({
rank: rows.length + 1,
...job,
companyName: data.companyName,
companyType,
companySize,
companyIndustry,
companyIntro: data.companyIntro || '',
companyUrl,
});
if (rows.length >= limit) break;
}
if (rows.length === 0) {
// Still return a sentinel row with the company info so caller isn't left with [].
return [{
rank: 0,
jobId: '',
title: '(no active jobs)',
salary: '', city: '', workYear: '', degree: '',
funcType: '', issueDate: '', url: '',
companyName: data.companyName,
companyType, companySize, companyIndustry,
companyIntro: data.companyIntro || '',
companyUrl,
}];
}
return rows;
},
});
+108
View File
@@ -0,0 +1,108 @@
/**
* 51job job detail by jobId.
*
* Navigates to `jobs.51job.com/x/<jobId>.html` (SSR page — the generic `/x/`
* area slug always resolves) and scrapes the structured blocks. No API
* surface returns the full detail page, so DOM scraping is the only path.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { JOBS_ORIGIN, requirePage, navigateTo } from './utils.js';
cli({
site: '51job',
name: 'detail',
description: '51job 职位详情(按 jobId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'jobId', type: 'string', required: true, positional: true, help: '职位 IDsearch 返回的 jobId' },
],
columns: [
'jobId', 'title', 'salary', 'location', 'workYear', 'degree',
'category', 'address', 'ageRequirement',
'description', 'welfare',
'company', 'companyType', 'companySize', 'companyIndustry',
'companyUrl', 'url',
],
func: async (page, kwargs) => {
requirePage(page);
const jobId = String(kwargs.jobId ?? '').trim();
if (!jobId) throw new CliError('INVALID_ARGUMENT', 'jobId is required');
if (!/^\d{6,12}$/.test(jobId)) throw new CliError('INVALID_ARGUMENT', `jobId must be a 6-12 digit number, got "${jobId}"`);
const url = `${JOBS_ORIGIN}/x/${jobId}.html`;
await navigateTo(page, url, 2);
const script = `(() => {
const sel = s => document.querySelector(s)?.innerText?.trim() || '';
const all = s => [...document.querySelectorAll(s)].map(e => e.innerText.trim()).filter(Boolean);
const finalUrl = window.location.href;
const bodyText = (document.body.innerText || '').slice(0, 400);
if (/职位已下线|该职位已删除|页面不存在/.test(bodyText)) {
return { error: 'EXPIRED', bodyText };
}
const companyA = document.querySelector('.cname a, .tCompany_sidebar .com_msg a');
const funcs = all('.bmsg .fp');
const pick = (prefix) => {
const row = funcs.find(f => f.startsWith(prefix));
return row ? row.slice(prefix.length).replace(/^[:\\s\\n]+/, '').trim() : '';
};
return {
finalUrl,
title: sel('h1') || sel('.cn .name'),
salary: sel('.cn strong') || sel('strong'),
meta: sel('.cn .msg.ltype') || sel('.msg.ltype'),
description: (() => {
const box = document.querySelector('.bmsg.job_msg') || document.querySelector('.job_msg');
if (!box) return '';
const clone = box.cloneNode(true);
clone.querySelectorAll('.fp, .mt10, script, style').forEach(n => n.remove());
return (clone.innerText || '').trim();
})(),
welfare: all('.t1 span, .jtag .t1 span'),
category: pick('职能类别'),
address: pick('上班地址'),
ageRequirement: pick('年龄要求'),
company: companyA?.innerText?.trim() || '',
companyUrl: companyA?.href || '',
companyTag: sel('.com_tag'),
};
})()`;
const data = await page.evaluate(script);
if (data.error === 'EXPIRED') {
throw new CliError('NO_DATA', `Job ${jobId} is offline or removed`);
}
if (!data.title) {
throw new CliError('NO_DATA', `Could not parse job detail for ${jobId}; page may have changed layout`);
}
// meta looks like "北京-丰台区 | 3年及以上 | 本科"
const [locRaw, workYear, degree] = (data.meta || '').split('|').map(s => s.trim());
// companyTag looks like "国企\n\n150-500人\n\n电子技术/半导体/集成电路"
const tagParts = (data.companyTag || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
return [{
jobId,
title: data.title,
salary: data.salary || '',
location: locRaw || '',
workYear: workYear || '',
degree: degree || '',
category: data.category || '',
address: data.address || '',
ageRequirement: data.ageRequirement || '',
description: data.description || '',
welfare: (data.welfare || []).join(','),
company: data.company || '',
companyType: tagParts[0] || '',
companySize: tagParts[1] || '',
companyIndustry: tagParts.slice(2).join(' / '),
companyUrl: data.companyUrl || '',
url: data.finalUrl || url,
}];
},
});
+55
View File
@@ -0,0 +1,55 @@
/**
* 51job hot / recommended feed.
*
* Same endpoint as `search`, but with empty keyword — 51job returns its
* own ranked recommendation list (up to ~999 for most regions).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import {
WE_ORIGIN, SEARCH_COLUMNS, SORT_CODES,
requirePage, navigateTo, pageFetchJson,
buildSearchUrl, mapJobItem, resolveCity, resolveCode,
} from './utils.js';
cli({
site: '51job',
name: 'hot',
description: '51job 推荐职位(按城市/行业/排序浏览)',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'area', type: 'string', default: '全国', help: '城市名或 6 位城市码(默认 "全国"' },
{ name: 'sort', type: 'string', default: '综合', help: '排序:综合 / 最新 / 薪资 / 距离' },
{ name: 'page', type: 'int', default: 1, help: '页码(1-based' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50' },
],
columns: SEARCH_COLUMNS,
func: async (page, kwargs) => {
requirePage(page);
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
const pageNum = Math.max(1, Number(kwargs.page) || 1);
const jobArea = resolveCity(kwargs.area);
const sortType = resolveCode(kwargs.sort, SORT_CODES, '0');
const currentUrl = await page.evaluate(`(() => window.location.href)()`);
if (!String(currentUrl).startsWith(WE_ORIGIN)) {
await navigateTo(page, `${WE_ORIGIN}/pc/search?searchType=2`, 2);
}
const url = buildSearchUrl({
keyword: '', jobArea, sortType,
pageNum, pageSize: Math.min(limit, 50),
});
const data = await pageFetchJson(page, url);
if (data.status !== '1' && data.status !== 1) {
throw new CliError('API_ERROR', `51job hot failed: ${data.message ?? 'unknown'}`);
}
const items = data?.resultbody?.job?.items ?? [];
if (items.length === 0) throw new CliError('NO_DATA', 'No recommended jobs returned');
return items.slice(0, limit).map((it, i) => mapJobItem(it, (pageNum - 1) * limit + i + 1));
},
});
+79
View File
@@ -0,0 +1,79 @@
/**
* 51job keyword search.
*
* Backed by `we.51job.com/api/job/search-pc`, which returns a job list with
* the full `jobDescribe` embedded. Needs the browser session because the
* Aliyun WAF in front of `we.51job.com` challenges bare fetches; the
* `pageFetchJson` helper runs inside the page so the WAF sees a real browser.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import {
WE_ORIGIN, SEARCH_COLUMNS,
SALARY_CODES, WORKYEAR_CODES, DEGREE_CODES,
COMPANY_TYPE_CODES, COMPANY_SIZE_CODES, SORT_CODES,
requirePage, navigateTo, pageFetchJson,
buildSearchUrl, mapJobItem, resolveCity, resolveCode,
} from './utils.js';
cli({
site: '51job',
name: 'search',
description: '51job 前程无忧关键词职位搜索',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'keyword', type: 'string', required: true, positional: true, help: '搜索关键词(岗位名 / 技能 / 公司)' },
{ name: 'area', type: 'string', default: '全国', help: '城市名或 6 位城市码(如 "杭州" / "080200" / "全国"' },
{ name: 'salary', type: 'string', default: '', help: '薪资区间(如 "10-15k" / "1-1.5万" / "20-30k"' },
{ name: 'experience', type: 'string', default: '', help: '工作年限(如 "应届" / "1-3年" / "3-5年" / "5-7年"' },
{ name: 'degree', type: 'string', default: '', help: '学历要求(如 "本科" / "大专" / "硕士"' },
{ name: 'companyType', type: 'string', default: '', help: '公司性质(如 "外资" / "国企" / "民营"' },
{ name: 'companySize', type: 'string', default: '', help: '公司规模(如 "50-150" / "1000-5000"' },
{ name: 'sort', type: 'string', default: '综合', help: '排序:综合 / 最新 / 薪资 / 距离' },
{ name: 'page', type: 'int', default: 1, help: '页码(1-based' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50' },
],
columns: SEARCH_COLUMNS,
func: async (page, kwargs) => {
requirePage(page);
const keyword = String(kwargs.keyword ?? '').trim();
if (!keyword) throw new CliError('INVALID_ARGUMENT', 'keyword is required');
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
const pageNum = Math.max(1, Number(kwargs.page) || 1);
const jobArea = resolveCity(kwargs.area);
const salary = resolveCode(kwargs.salary, SALARY_CODES);
const workYear = resolveCode(kwargs.experience, WORKYEAR_CODES);
const degree = resolveCode(kwargs.degree, DEGREE_CODES);
const companyType = resolveCode(kwargs.companyType, COMPANY_TYPE_CODES);
const companySize = resolveCode(kwargs.companySize, COMPANY_SIZE_CODES);
const sortType = resolveCode(kwargs.sort, SORT_CODES, '0');
// Establish WAF-clean origin. Reusing the same tab avoids the slider
// challenge fire every call.
const currentUrl = await page.evaluate(`(() => window.location.href)()`);
if (!String(currentUrl).startsWith(WE_ORIGIN)) {
await navigateTo(page, `${WE_ORIGIN}/pc/search?keyword=${encodeURIComponent(keyword)}&searchType=2`, 2);
}
const url = buildSearchUrl({
keyword, jobArea, salary, workYear, degree,
companyType, companySize, sortType,
pageNum, pageSize: Math.min(limit, 50),
});
const data = await pageFetchJson(page, url);
if (data.status !== '1' && data.status !== 1) {
throw new CliError('API_ERROR', `51job search failed: ${data.message ?? 'unknown'}`);
}
const items = data?.resultbody?.job?.items ?? [];
if (items.length === 0) {
throw new CliError('NO_DATA', `No jobs matched "${keyword}"`);
}
return items.slice(0, limit).map((it, i) => mapJobItem(it, (pageNum - 1) * limit + i + 1));
},
});
+302
View File
@@ -0,0 +1,302 @@
/**
* 51job shared utilities.
*
* Key design points:
* - we.51job.com is protected by Aliyun WAF — bare `curl` / Node-side fetch
* gets a slider CAPTCHA HTML page. Only browser-context fetch (page.evaluate)
* with the session's cookies survives the challenge.
* - `document.cookie` exposes the anti-bot cookies (`acw_sc__v2`, `ssxmod_itna`
* etc.) — no HttpOnly/login needed for public pages.
* - API (`we.51job.com/api/job/search-pc`) is same-origin when we've navigated
* to `https://we.51job.com/...`, so fetch inside page.evaluate works.
* - Detail / company pages live on `jobs.51job.com` and render data into the
* DOM (SSR), so adapters for those navigate and scrape.
*/
import { CliError } from '@jackwener/opencli/errors';
export const WE_ORIGIN = 'https://we.51job.com';
export const JOBS_ORIGIN = 'https://jobs.51job.com';
/**
* City name / alias → 6-digit jobArea code. `000000` is the national bucket.
* Covers the 40 largest cities the search UI surfaces. Unknown input passed
* as-is if it's already 6 digits; otherwise fall back to `000000` (all).
*/
export const CITY_CODES = {
'全国': '000000', 'all': '000000',
'北京': '010000', 'beijing': '010000',
'上海': '020000', 'shanghai': '020000',
'广州': '030200', 'guangzhou': '030200',
'深圳': '040000', 'shenzhen': '040000',
'武汉': '180200', 'wuhan': '180200',
'西安': '200200', "xi'an": '200200', 'xian': '200200',
'杭州': '080200', 'hangzhou': '080200',
'南京': '070200', 'nanjing': '070200',
'成都': '090200', 'chengdu': '090200',
'苏州': '070300', 'suzhou': '070300',
'重庆': '060000', 'chongqing': '060000',
'天津': '050000', 'tianjin': '050000',
'长沙': '190200', 'changsha': '190200',
'郑州': '170200', 'zhengzhou': '170200',
'青岛': '120300', 'qingdao': '120300',
'合肥': '150200', 'hefei': '150200',
'厦门': '110300', 'xiamen': '110300',
'无锡': '070400', 'wuxi': '070400',
'济南': '120200', 'jinan': '120200',
'佛山': '030700', 'foshan': '030700',
'东莞': '030800', 'dongguan': '030800',
'宁波': '080300', 'ningbo': '080300',
'福州': '110200', 'fuzhou': '110200',
'昆明': '250200', 'kunming': '250200',
'大连': '230300', 'dalian': '230300',
'沈阳': '230200', 'shenyang': '230200',
'哈尔滨': '220200', 'haerbin': '220200', 'harbin': '220200',
'石家庄': '160200', 'shijiazhuang': '160200',
'贵阳': '260200', 'guiyang': '260200',
'南宁': '100200', 'nanning': '100200',
'南昌': '130200', 'nanchang': '130200',
'长春': '240200', 'changchun': '240200',
'太原': '210200', 'taiyuan': '210200',
'兰州': '280200', 'lanzhou': '280200',
'乌鲁木齐': '310200', 'urumqi': '310200',
'海口': '270200', 'haikou': '270200',
'香港': '330000', 'hongkong': '330000', 'hk': '330000',
};
/** Salary bucket code (matches 51job's `salary` filter). */
export const SALARY_CODES = {
'不限': '',
'2千以下': '01', '2-3千': '02', '3-4.5千': '03',
'4.5-6千': '04', '6-8千': '05', '8k-1万': '06', '8-10k': '06',
'1-1.5万': '07', '10-15k': '07',
'1.5-2万': '08', '15-20k': '08',
'2-3万': '09', '20-30k': '09',
'3-5万': '10', '30-50k': '10',
'5万以上': '11', '50k以上': '11',
};
/** Work experience bucket. */
export const WORKYEAR_CODES = {
'不限': '',
'在校生': '01', '应届': '02', '1年以下': '03',
'1-3年': '04', '3-5年': '05', '5-7年': '06',
'7-10年': '07', '10年以上': '08',
};
/** Degree bucket. */
export const DEGREE_CODES = {
'不限': '',
'初中及以下': '01', '高中/中技/中专': '02', '高中': '02',
'大专': '03', '本科': '04', '硕士': '05', '博士': '06',
};
/** Company ownership type. */
export const COMPANY_TYPE_CODES = {
'不限': '',
'外资': '01', '欧美': '0101', '日韩': '0102',
'合资': '02', '国企': '03', '民营': '04',
'上市公司': '05', '创业公司': '06', '事业单位': '07',
'非营利': '08', '政府': '09',
};
/** Company headcount bucket. */
export const COMPANY_SIZE_CODES = {
'不限': '',
'少于50': '01', '50以下': '01',
'50-150': '02', '150-500': '03',
'500-1000': '04', '1000-5000': '05',
'5000-10000': '06', '10000以上': '07',
};
/** Sort strategy. */
export const SORT_CODES = {
'综合': '0', 'relevance': '0', 'default': '0',
'最新': '1', 'new': '1', 'newest': '1',
'薪资': '2', 'salary': '2', 'pay': '2',
'距离': '9', 'distance': '9',
};
export function resolveCity(input) {
if (!input) return '000000';
const s = String(input).trim();
if (!s || s === '全国' || s.toLowerCase() === 'all') return '000000';
if (/^\d{6}$/.test(s)) return s;
const key = s.toLowerCase();
if (CITY_CODES[s] !== undefined) return CITY_CODES[s];
if (CITY_CODES[key] !== undefined) return CITY_CODES[key];
for (const [name, code] of Object.entries(CITY_CODES)) {
if (typeof name === 'string' && name.includes(s)) return code;
}
throw new CliError('INVALID_ARGUMENT', `Unknown city/area "${s}"`, 'Use a supported city name like "杭州" or a 6-digit city code');
}
export function resolveCode(input, table, fallback = '') {
if (input === undefined || input === null || input === '') return fallback;
const s = String(input).trim();
if (table[s] !== undefined) return table[s];
const key = s.toLowerCase();
if (table[key] !== undefined) return table[key];
if (Object.values(table).includes(s)) return s;
for (const [k, v] of Object.entries(table)) {
if (typeof k === 'string' && k.includes(s)) return v;
}
return fallback;
}
export function requirePage(page) {
if (!page) throw new CliError('INTERNAL_ERROR', 'Browser page required (adapter must set browser: true)');
}
/**
* Navigate the page to a URL and give the SPA a moment to settle. Reuses
* existing session cookies — first call on a fresh browser may trigger the
* Aliyun WAF interstitial, which the headless Chromium solves automatically
* because the JS that sets `acw_sc__v2` runs in the page.
*/
export async function navigateTo(page, url, waitSeconds = 2) {
await page.goto(url);
await page.wait({ time: waitSeconds });
}
/**
* Browser-context fetch: execute `fetch(url, { credentials: 'include' })`
* inside the page so cookies apply and WAF sees a real browser. Returns
* parsed JSON; throws on network / parse / status failure.
*/
export async function pageFetchJson(page, url, opts = {}) {
const method = opts.method ?? 'GET';
const body = opts.body ?? null;
const timeout = opts.timeout ?? 15000;
const headers = opts.headers ?? {};
const script = `
async () => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ${timeout});
try {
const resp = await fetch(${JSON.stringify(url)}, {
method: ${JSON.stringify(method)},
credentials: 'include',
headers: ${JSON.stringify({ Accept: 'application/json', ...headers })},
${body !== null ? `body: ${JSON.stringify(body)},` : ''}
signal: ctrl.signal,
});
const text = await resp.text();
return { ok: resp.ok, status: resp.status, text };
} catch (e) {
return { ok: false, status: 0, text: '', error: String(e && e.message || e) };
} finally {
clearTimeout(timer);
}
}
`;
const res = await page.evaluate(script);
if (res.error) throw new CliError('HTTP_ERROR', `51job fetch failed: ${res.error}`);
if (!res.ok) throw new CliError('HTTP_ERROR', `51job HTTP ${res.status}`);
if (res.text.trim().startsWith('<')) {
throw new CliError('ANTI_BOT', '51job returned HTML (likely Aliyun WAF slider). Refresh browser session.');
}
try {
return JSON.parse(res.text);
} catch (e) {
throw new CliError('API_ERROR', `51job invalid JSON: ${res.text.slice(0, 200)}`);
}
}
/**
* Build the canonical search-pc URL. All optional filters default to empty
* (no constraint). `scene=7` + `source=1` match what the real SPA sends.
*/
export function buildSearchUrl(params) {
const qs = new URLSearchParams();
qs.set('api_key', '51job');
qs.set('timestamp', String(Date.now()));
qs.set('keyword', params.keyword ?? '');
qs.set('searchType', '2');
qs.set('function', params.function ?? '');
qs.set('industry', params.industry ?? '');
qs.set('jobArea', params.jobArea ?? '000000');
qs.set('jobArea2', params.jobArea2 ?? '');
qs.set('landmark', params.landmark ?? '');
qs.set('metro', params.metro ?? '');
qs.set('salary', params.salary ?? '');
qs.set('workYear', params.workYear ?? '');
qs.set('degree', params.degree ?? '');
qs.set('companyType', params.companyType ?? '');
qs.set('companySize', params.companySize ?? '');
qs.set('jobType', params.jobType ?? '');
qs.set('issueDate', params.issueDate ?? '');
qs.set('sortType', params.sortType ?? '0');
qs.set('pageNum', String(params.pageNum ?? 1));
qs.set('pageSize', String(params.pageSize ?? 20));
qs.set('source', '1');
qs.set('scene', '7');
return `${WE_ORIGIN}/api/job/search-pc?${qs.toString()}`;
}
/**
* Map a raw search-pc `resultbody.job.items[i]` into the canonical row shape
* we expose to the user. Kept here so `search` and `hot` stay aligned.
*/
export function mapJobItem(it, rank) {
const area = it.jobAreaLevelDetail || {};
return {
rank,
jobId: String(it.jobId ?? ''),
title: it.jobName ?? '',
salary: it.provideSalaryString ?? '',
salaryMin: Number(it.jobSalaryMin ?? 0) || 0,
salaryMax: Number(it.jobSalaryMax ?? 0) || 0,
city: area.cityString ?? it.jobAreaString ?? '',
district: area.districtString ?? '',
workYear: it.workYearString ?? '',
degree: it.degreeString ?? '',
tags: Array.isArray(it.jobTags) ? it.jobTags.join(',') : '',
company: it.companyName ?? '',
companyFull: it.fullCompanyName ?? '',
companyType: it.companyTypeString ?? '',
companySize: it.companySizeString ?? '',
industry: it.industryType1Str ?? '',
hr: it.hrName ? `${it.hrName}·${it.hrPosition ?? ''}` : '',
issueDate: it.issueDateString ?? '',
url: it.jobHref ?? '',
companyUrl: it.companyHref ?? '',
encCoId: it.encCoId ?? '',
};
}
export const SEARCH_COLUMNS = [
'rank', 'jobId', 'title', 'salary', 'salaryMin', 'salaryMax',
'city', 'district', 'workYear', 'degree', 'tags',
'company', 'companyFull', 'companyType', 'companySize', 'industry',
'hr', 'issueDate', 'url', 'companyUrl', 'encCoId',
];
/**
* Parse a 51job company-page `<a sensorsdata="...">` payload into a stable
* row fragment. Returns null when the attribute is absent or malformed.
*/
export function parseCompanyJobCard(raw) {
if (!raw || typeof raw !== 'object') return null;
const href = typeof raw.href === 'string' ? raw.href : '';
const sensorsdata = typeof raw.sensorsdata === 'string' ? raw.sensorsdata : '';
if (!href || !sensorsdata) return null;
let data;
try {
data = JSON.parse(sensorsdata);
} catch {
return null;
}
if (!data || !data.jobId) return null;
return {
jobId: String(data.jobId),
title: data.jobTitle || '',
salary: data.jobSalary || '',
city: data.jobArea || '',
workYear: data.jobYear || '',
degree: data.jobDegree || '',
funcType: data.funcType || '',
issueDate: data.jobTime || '',
url: href,
};
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest';
import { CliError } from '@jackwener/opencli/errors';
import { parseCompanyJobCard, pageFetchJson, resolveCity } from './utils.js';
describe('51job resolveCity', () => {
it('maps known city names and explicit national scope', () => {
expect(resolveCity('杭州')).toBe('080200');
expect(resolveCity('all')).toBe('000000');
expect(resolveCity('000000')).toBe('000000');
});
it('rejects unknown non-empty inputs instead of silently widening to 全国', () => {
expect(() => resolveCity('杭州z')).toThrowError(CliError);
expect(() => resolveCity('杭州z')).toThrow(/Unknown city\/area/);
});
});
describe('51job pageFetchJson', () => {
it('detects WAF challenge HTML and throws ANTI_BOT', async () => {
const page = {
evaluate: vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: '<html><title>slider</title></html>',
}),
};
await expect(pageFetchJson(page, 'https://we.51job.com/api/job/search-pc')).rejects.toMatchObject({
code: 'ANTI_BOT',
});
});
});
describe('51job parseCompanyJobCard', () => {
it('parses sensorsdata JSON into a stable row fragment', () => {
const row = parseCompanyJobCard({
href: 'https://jobs.51job.com/shanghai/123456789.html',
sensorsdata: JSON.stringify({
jobId: '123456789',
jobTitle: 'Senior Engineer',
jobSalary: '20-30K',
jobArea: '上海',
jobYear: '3-5年',
jobDegree: '本科',
funcType: '后端开发',
jobTime: '04-22',
}),
});
expect(row).toEqual({
jobId: '123456789',
title: 'Senior Engineer',
salary: '20-30K',
city: '上海',
workYear: '3-5年',
degree: '本科',
funcType: '后端开发',
issueDate: '04-22',
url: 'https://jobs.51job.com/shanghai/123456789.html',
});
});
it('returns null on malformed sensorsdata', () => {
expect(parseCompanyJobCard({
href: 'https://jobs.51job.com/shanghai/123456789.html',
sensorsdata: '{bad json}',
})).toBeNull();
});
});
+37 -6
View File
@@ -1,6 +1,6 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
function normalizeDiscussionPayload(payload) {
const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
const asin = extractAsin(payload.href ?? '') ?? null;
@@ -28,10 +28,16 @@ function normalizeDiscussionPayload(payload) {
})),
};
}
async function readDiscussionPayload(page, input, limit) {
const url = buildDiscussionUrl(input);
const state = await gotoAndReadState(page, url, 2500, 'discussion');
assertUsableState(state, 'discussion');
function hasDiscussionSummary(payload) {
return Boolean(cleanText(payload.average_rating_text) || cleanText(payload.total_review_count_text));
}
function isSignInState(state) {
const href = cleanText(state.href).toLowerCase();
const title = cleanText(state.title).toLowerCase();
return href.includes('/ap/signin')
|| title.includes('amazon sign-in');
}
async function readCurrentDiscussionPayload(page, limit) {
return await page.evaluate(`
(() => ({
href: window.location.href,
@@ -53,6 +59,29 @@ async function readDiscussionPayload(page, input, limit) {
}))()
`);
}
async function readDiscussionPayload(page, input, limit) {
const reviewUrl = buildDiscussionUrl(input);
const reviewState = await gotoAndReadState(page, reviewUrl, 2500, 'discussion');
assertUsableState(reviewState, 'discussion');
const reviewPayload = await readCurrentDiscussionPayload(page, limit);
if (hasDiscussionSummary(reviewPayload)) {
return reviewPayload;
}
const productUrl = buildProductUrl(input);
const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
assertUsableState(productState, 'discussion');
if (isSignInState(reviewState) && isSignInState(productState)) {
throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
}
const productPayload = await readCurrentDiscussionPayload(page, limit);
if (hasDiscussionSummary(productPayload)) {
return productPayload;
}
if (isSignInState(reviewState)) {
throw new CommandExecutionError('amazon review page redirected to sign-in and product page fallback did not expose review summary', 'Open the product page in Chrome, verify reviews are visible, and retry.');
}
return reviewPayload;
}
cli({
site: 'amazon',
name: 'discussion',
@@ -88,4 +117,6 @@ cli({
});
export const __test__ = {
normalizeDiscussionPayload,
hasDiscussionSummary,
isSignInState,
};
+147 -32
View File
@@ -1,36 +1,151 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import { __test__ } from './discussion.js';
import './discussion.js';
function createPageMock(evaluateResults) {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
}
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate,
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
describe('amazon discussion normalization', () => {
it('normalizes review summary and sample reviews', () => {
const result = __test__.normalizeDiscussionPayload({
href: 'https://www.amazon.com/product-reviews/B0FJS72893',
average_rating_text: '3.9 out of 5',
total_review_count_text: '27 global ratings',
qa_links: [],
review_samples: [
{
title: '5.0 out of 5 stars Great value and quality',
rating_text: '5.0 out of 5 stars',
author: 'GTreader2',
date_text: 'Reviewed in the United States on February 21, 2026',
body: 'Small but mighty.',
verified: true,
},
],
});
expect(result.asin).toBe('B0FJS72893');
expect(result.average_rating_value).toBe(3.9);
expect(result.total_review_count).toBe(27);
expect(result.review_samples).toEqual([
{
title: 'Great value and quality',
rating_text: '5.0 out of 5 stars',
rating_value: 5,
author: 'GTreader2',
date_text: 'Reviewed in the United States on February 21, 2026',
body: 'Small but mighty.',
verified_purchase: true,
},
]);
it('normalizes review summary and sample reviews', () => {
const result = __test__.normalizeDiscussionPayload({
href: 'https://www.amazon.com/product-reviews/B0FJS72893',
average_rating_text: '3.9 out of 5',
total_review_count_text: '27 global ratings',
qa_links: [],
review_samples: [
{
title: '5.0 out of 5 stars Great value and quality',
rating_text: '5.0 out of 5 stars',
author: 'GTreader2',
date_text: 'Reviewed in the United States on February 21, 2026',
body: 'Small but mighty.',
verified: true,
},
],
});
expect(result.asin).toBe('B0FJS72893');
expect(result.average_rating_value).toBe(3.9);
expect(result.total_review_count).toBe(27);
expect(result.review_samples).toEqual([
{
title: 'Great value and quality',
rating_text: '5.0 out of 5 stars',
rating_value: 5,
author: 'GTreader2',
date_text: 'Reviewed in the United States on February 21, 2026',
body: 'Small but mighty.',
verified_purchase: true,
},
]);
});
it('falls back to the product page when the review page redirects to sign-in', async () => {
const command = getRegistry().get('amazon/discussion');
const page = createPageMock([
{
href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
title: 'Amazon Sign-In',
body_text: 'Sign in Create account',
},
{
href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
average_rating_text: '',
total_review_count_text: '',
review_samples: [],
},
{
href: 'https://www.amazon.com/dp/B09HKN2ZRT',
title: 'Amazon.com: Example product',
body_text: 'Hello, zejia-wu Reviews',
},
{
href: 'https://www.amazon.com/dp/B09HKN2ZRT',
average_rating_text: '4.4 out of 5',
total_review_count_text: '349 global ratings',
review_samples: [
{
title: '5.0 out of 5 stars Perfect for the office',
rating_text: '5.0 out of 5 stars',
author: 'Ken',
date_text: 'Reviewed in the United States on March 19, 2026',
body: 'Good for the office, no complaints.',
verified: true,
},
],
},
]);
const result = await command.func(page, { input: 'B09HKN2ZRT', limit: 1 });
expect(page.goto.mock.calls.map((call) => call[0])).toEqual([
'https://www.amazon.com/product-reviews/B09HKN2ZRT',
'https://www.amazon.com/dp/B09HKN2ZRT',
]);
expect(result).toEqual([
expect.objectContaining({
asin: 'B09HKN2ZRT',
discussion_url: 'https://www.amazon.com/dp/B09HKN2ZRT',
average_rating_value: 4.4,
total_review_count: 349,
}),
]);
});
it('throws AuthRequiredError when both review and product pages are gated', async () => {
const command = getRegistry().get('amazon/discussion');
const authState = {
href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
title: 'Amazon Sign-In',
body_text: 'Sign in Create account',
};
const page = createPageMock([
authState,
{
href: authState.href,
average_rating_text: '',
total_review_count_text: '',
review_samples: [],
},
authState,
]);
await expect(command.func(page, { input: 'B09HKN2ZRT', limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError);
});
it('does not treat a public product page with sign-in copy as a gated page', () => {
expect(__test__.isSignInState({
href: 'https://www.amazon.com/dp/B09HKN2ZRT',
title: 'Amazon.com: Example product',
body_text: 'Hello, sign in Account & Lists Create account',
})).toBe(false);
});
});
+71 -25
View File
@@ -54,6 +54,20 @@ function jsonResponse(res, status, data) {
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function parseTimeoutValue(val, label, fallback) {
if (val === undefined) {
return fallback;
}
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`);
return fallback;
}
return parsed;
}
function parseEnvTimeout(envVar, fallback) {
return parseTimeoutValue(process.env[envVar], envVar, fallback);
}
// ─── DOM helpers ─────────────────────────────────────────────────────
/**
* Click the 'New Conversation' button to reset context.
@@ -267,41 +281,65 @@ async function waitForReply(page, beforeText, opts = {}) {
let lastText = beforeText;
let stableCount = 0;
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
let reconnectCount = 0;
while (Date.now() < deadline) {
const generating = await isGenerating(page);
const currentText = await getConversationText(page);
const textChanged = currentText !== beforeText && currentText.length > 0;
if (generating) {
hasStartedGenerating = true;
stableCount = 0; // Reset stability while generating
}
else {
if (hasStartedGenerating) {
// It actively generated and now it stopped -> DONE
// Provide a small buffer to let React render the final message fully
await sleep(500);
return;
try {
const generating = await isGenerating(page);
const currentText = await getConversationText(page);
const textChanged = currentText !== beforeText && currentText.length > 0;
if (generating) {
hasStartedGenerating = true;
stableCount = 0; // Reset stability while generating
}
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
if (textChanged) {
if (currentText === lastText) {
stableCount++;
if (stableCount >= stableThreshold) {
return; // Text has been stable for 2 seconds -> DONE
else {
if (hasStartedGenerating) {
// It actively generated and now it stopped -> DONE
// Provide a small buffer to let React render the final message fully
await sleep(500);
return page;
}
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
if (textChanged) {
if (currentText === lastText) {
stableCount++;
if (stableCount >= stableThreshold) {
return page; // Text has been stable for 2 seconds -> DONE
}
}
else {
stableCount = 0;
lastText = currentText;
}
}
else {
}
}
catch (err) {
const msg = err.message || String(err);
const isSessionLoss = /closed|lost|not open|websocket/i.test(msg);
if (opts.reconnect && isSessionLoss && reconnectCount < 2) {
reconnectCount++;
console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`);
try {
page = await opts.reconnect();
// Reset stability tracking after reconnect
stableCount = 0;
lastText = currentText;
lastText = beforeText;
continue;
}
catch (reconnectErr) {
console.error(`[serve] Reconnection failed: ${reconnectErr.message}`);
throw err; // Throw original error if reconnection itself fails
}
}
throw err;
}
await sleep(pollInterval);
}
throw new Error('Timeout waiting for Antigravity reply');
throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
}
// ─── Request Handlers ────────────────────────────────────────────────
async function handleMessages(body, page, bridge) {
async function handleMessages(body, page, opts = {}) {
const { bridge, timeout, reconnect } = opts;
// Extract the last user message
const userMessages = body.messages.filter(m => m.role === 'user');
if (userMessages.length === 0) {
@@ -328,7 +366,7 @@ async function handleMessages(body, page, bridge) {
await sendMessage(page, userText, bridge);
// Poll for reply (change detection)
console.error('[serve] Waiting for reply...');
await waitForReply(page, beforeText);
page = await waitForReply(page, beforeText, { timeout, reconnect });
// Extract the actual reply text precisely from the DOM
const replyText = await getLastAssistantReply(page, userText);
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
@@ -349,6 +387,10 @@ async function handleMessages(body, page, bridge) {
// ─── Server ──────────────────────────────────────────────────────────
export async function startServe(opts = {}) {
const port = opts.port ?? 8082;
const envTimeoutSeconds = parseEnvTimeout('OPENCLI_ANTIGRAVITY_TIMEOUT', 120);
const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds);
const effectiveTimeout = effectiveTimeoutSeconds * 1000;
console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`);
// Lazy CDP connection — connect when first request comes in
let cdp = null;
let page = null;
@@ -462,7 +504,11 @@ export async function startServe(opts = {}) {
}
// Lazy connect on first request
const activePage = await ensureConnected();
const response = await handleMessages(body, activePage, cdp ?? undefined);
const response = await handleMessages(body, activePage, {
bridge: cdp,
timeout: effectiveTimeout,
reconnect: ensureConnected,
});
jsonResponse(res, 200, response);
}
finally {
+4 -4
View File
@@ -24,7 +24,7 @@ describe('apple-podcasts search command', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await cmd.func(null, {
const result = await cmd.func({
query: 'machine learning',
keyword: 'sports',
limit: 5,
@@ -60,7 +60,7 @@ describe('apple-podcasts top command', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
await cmd.func(null, { country: 'US', limit: 1 });
await cmd.func({ country: 'US', limit: 1 });
const [, options] = fetchMock.mock.calls[0] ?? [];
expect(options).toBeDefined();
expect(options.signal).toBeDefined();
@@ -81,7 +81,7 @@ describe('apple-podcasts top command', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await cmd.func(null, { country: 'US', limit: 2 });
const result = await cmd.func({ country: 'US', limit: 2 });
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
signal: expect.any(Object),
}));
@@ -94,6 +94,6 @@ describe('apple-podcasts top command', () => {
const cmd = getRegistry().get('apple-podcasts/top');
expect(cmd?.func).toBeTypeOf('function');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
await expect(cmd.func(null, { country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
await expect(cmd.func({ country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
});
});
+1 -1
View File
@@ -12,7 +12,7 @@ cli({
{ name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
],
columns: ['title', 'duration', 'date'],
func: async (_page, args) => {
func: async (args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 200));
// results[0] is the podcast itself; the rest are episodes
const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
+1 -1
View File
@@ -12,7 +12,7 @@ cli({
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
],
columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
func: async (_page, args) => {
func: async (args) => {
const term = encodeURIComponent(args.query);
const limit = Math.max(1, Math.min(Number(args.limit), 25));
const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
+1 -1
View File
@@ -14,7 +14,7 @@ cli({
{ name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
],
columns: ['rank', 'title', 'author', 'id'],
func: async (_page, args) => {
func: async (args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 100));
const country = String(args.country || 'us').trim().toLowerCase();
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { normalizeArxivCategory, normalizeArxivLimit, parseEntries } from './utils.js';
import './paper.js';
import './search.js';
import './recent.js';
const SAMPLE_ENTRY_XML = `<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"
xmlns:arxiv="http://arxiv.org/schemas/atom"
xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>http://arxiv.org/abs/1706.03762v7</id>
<title>Attention Is All You Need &amp; Friends</title>
<updated>2023-08-02T00:41:18Z</updated>
<link href="https://arxiv.org/abs/1706.03762v7" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/1706.03762v7" rel="related" type="application/pdf" title="pdf"/>
<summary>The dominant sequence transduction models are based on complex recurrent or convolutional neural networks. We propose a new simple network architecture, the Transformer, based solely on attention.</summary>
<category term="cs.CL" scheme="http://arxiv.org/schemas/atom"/>
<category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
<published>2017-06-12T17:57:34Z</published>
<arxiv:comment>15 pages, 5 figures</arxiv:comment>
<arxiv:primary_category term="cs.CL"/>
<author><name>Ashish Vaswani</name></author>
<author><name>Noam Shazeer</name></author>
<author><name>Niki Parmar</name></author>
<author><name>Jakob Uszkoreit</name></author>
<author><name>Llion Jones</name></author>
<author><name>Aidan N. Gomez</name></author>
<author><name>Lukasz Kaiser</name></author>
<author><name>Illia Polosukhin</name></author>
</entry>
</feed>`;
describe('arxiv adapter', () => {
it('registers paper, search and recent commands with the expected columns', () => {
const paper = getRegistry().get('arxiv/paper');
const search = getRegistry().get('arxiv/search');
const recent = getRegistry().get('arxiv/recent');
expect(paper).toBeDefined();
expect(search).toBeDefined();
expect(recent).toBeDefined();
expect(paper.columns).toEqual([
'id', 'title', 'authors', 'published', 'updated',
'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url',
]);
expect(search.columns).toEqual([
'id', 'title', 'authors', 'published', 'primary_category', 'url',
]);
expect(recent.columns).toEqual([
'id', 'title', 'authors', 'published', 'primary_category', 'url',
]);
});
it('parseEntries returns full abstract, all authors, pdf, primary category and comment', () => {
const [entry] = parseEntries(SAMPLE_ENTRY_XML);
expect(entry.id).toBe('1706.03762');
expect(entry.title).toBe('Attention Is All You Need & Friends');
// All 8 authors must be present — earlier impl truncated to 3.
expect(entry.authors.split(', ')).toHaveLength(8);
expect(entry.authors).toContain('Ashish Vaswani');
expect(entry.authors).toContain('Illia Polosukhin');
// Full abstract — earlier impl truncated at 200 chars.
expect(entry.abstract.length).toBeGreaterThan(140);
expect(entry.abstract.endsWith('...')).toBe(false);
expect(entry.abstract).toContain('attention');
expect(entry.published).toBe('2017-06-12');
expect(entry.updated).toBe('2023-08-02');
expect(entry.primary_category).toBe('cs.CL');
expect(entry.categories).toBe('cs.CL, cs.LG');
expect(entry.comment).toBe('15 pages, 5 figures');
expect(entry.pdf).toBe('https://arxiv.org/pdf/1706.03762v7');
expect(entry.url).toBe('https://arxiv.org/abs/1706.03762');
});
it('parseEntries returns an empty list for feeds with no entries', () => {
expect(parseEntries('<feed></feed>')).toEqual([]);
});
it('recent rejects malformed category strings', async () => {
const recent = getRegistry().get('arxiv/recent');
await expect(recent.func({ category: 'not a category', limit: 5 })).rejects.toMatchObject({
code: 'ARGUMENT',
});
await expect(recent.func({ category: '', limit: 5 })).rejects.toMatchObject({
code: 'ARGUMENT',
});
});
it('category validation accepts real arXiv archive and subcategory forms', () => {
expect(normalizeArxivCategory('cs.CL')).toBe('cs.CL');
expect(normalizeArxivCategory('math')).toBe('math');
expect(normalizeArxivCategory('physics.comp-ph')).toBe('physics.comp-ph');
expect(normalizeArxivCategory('physics.data-an')).toBe('physics.data-an');
expect(normalizeArxivCategory('cond-mat.soft')).toBe('cond-mat.soft');
expect(normalizeArxivCategory('q-bio.NC')).toBe('q-bio.NC');
expect(() => normalizeArxivCategory('not a category')).toThrow('Invalid arXiv category');
expect(() => normalizeArxivCategory('cs/CL')).toThrow('Invalid arXiv category');
expect(() => normalizeArxivCategory('')).toThrow('Invalid arXiv category');
});
it('limit validation rejects non-positive, non-integer and over-cap values', () => {
expect(normalizeArxivLimit(10, 5, 25)).toBe(10);
expect(normalizeArxivLimit(undefined, 5, 25)).toBe(5);
expect(() => normalizeArxivLimit(0, 5, 25)).toThrow('positive integer');
expect(() => normalizeArxivLimit(1.5, 5, 25)).toThrow('positive integer');
expect(() => normalizeArxivLimit(26, 5, 25)).toThrow('<= 25');
});
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { arxivFetch, parseEntries } from './utils.js';
cli({
site: 'arxiv',
@@ -10,12 +10,12 @@ cli({
args: [
{ name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
],
columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
func: async (_page, args) => {
columns: ['id', 'title', 'authors', 'published', 'updated', 'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url'],
func: async (args) => {
const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
const entries = parseEntries(xml);
if (!entries.length)
throw new CliError('NOT_FOUND', `Paper ${args.id} not found`, 'Check the arXiv ID format, e.g. 1706.03762');
throw new EmptyResultError('arxiv paper', `Paper ${args.id} was not found. Check the arXiv ID format, e.g. 1706.03762`);
return entries;
},
});
+32
View File
@@ -0,0 +1,32 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { arxivFetch, normalizeArxivCategory, normalizeArxivLimit, parseEntries } from './utils.js';
cli({
site: 'arxiv',
name: 'recent',
description: 'List recent arXiv submissions in a category',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'category', positional: true, required: true, help: 'arXiv category (e.g. cs.CL, cs.LG, math.PR, q-bio.NC)' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 50)' },
],
columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
func: async (args) => {
const category = normalizeArxivCategory(args.category);
const limit = normalizeArxivLimit(args.limit, 10, 50);
const query = encodeURIComponent(`cat:${category}`);
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=submittedDate&sortOrder=descending`);
const entries = parseEntries(xml);
if (!entries.length)
throw new EmptyResultError('arxiv', `No recent papers in ${category}. Check the category name.`);
return entries.map(e => ({
id: e.id,
title: e.title,
authors: e.authors,
published: e.published,
primary_category: e.primary_category,
url: e.url,
}));
},
});
+19 -8
View File
@@ -1,6 +1,6 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { arxivFetch, parseEntries } from './utils.js';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { arxivFetch, normalizeArxivLimit, parseEntries } from './utils.js';
cli({
site: 'arxiv',
name: 'search',
@@ -11,14 +11,25 @@ cli({
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
],
columns: ['id', 'title', 'authors', 'published', 'url'],
func: async (_page, args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 25));
const query = encodeURIComponent(`all:${args.query}`);
columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
func: async (args) => {
const queryText = String(args.query || '').trim();
if (!queryText) {
throw new ArgumentError('arxiv search query cannot be empty');
}
const limit = normalizeArxivLimit(args.limit, 10, 25);
const query = encodeURIComponent(`all:${queryText}`);
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
const entries = parseEntries(xml);
if (!entries.length)
throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published, url: e.url }));
throw new EmptyResultError('arxiv', 'No papers found. Try a different keyword.');
return entries.map(e => ({
id: e.id,
title: e.title,
authors: e.authors,
published: e.published,
primary_category: e.primary_category,
url: e.url,
}));
},
});
+68 -5
View File
@@ -4,15 +4,44 @@
* arXiv exposes a public Atom/XML API — no key required.
* https://info.arxiv.org/help/api/index.html
*/
import { CliError } from '@jackwener/opencli/errors';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
const ARXIV_CATEGORY_PATTERN = /^[a-z]+(?:-[a-z]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)?$/;
export async function arxivFetch(params) {
const resp = await fetch(`${ARXIV_BASE}?${params}`);
if (!resp.ok) {
throw new CliError('FETCH_ERROR', `arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
throw new CommandExecutionError(`arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
}
return resp.text();
}
export function normalizeArxivLimit(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError(`arxiv ${label} must be a positive integer`);
}
if (limit > maxValue) {
throw new ArgumentError(`arxiv ${label} must be <= ${maxValue}`);
}
return limit;
}
export function normalizeArxivCategory(value) {
const category = String(value || '').trim();
if (!ARXIV_CATEGORY_PATTERN.test(category)) {
throw new ArgumentError(`Invalid arXiv category "${value}". Examples: cs.CL, cs.LG, math.PR, q-bio.NC, physics.comp-ph`);
}
return category;
}
/** Decode the small set of XML entities arXiv emits in text fields. */
function decodeEntities(s) {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'");
}
/** Extract the text content of the first matching XML tag. */
function extract(xml, tag) {
const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
@@ -27,6 +56,34 @@ function extractAll(xml, tag) {
results.push(m[1].trim());
return results;
}
/** Extract the value of a named attribute from the first matching tag (open or self-closing). */
function extractAttr(xml, tag, attr) {
const m = xml.match(new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`));
return m ? m[1] : '';
}
/** Extract all values of a named attribute across repeated tags. */
function extractAllAttr(xml, tag, attr) {
const re = new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`, 'g');
const out = [];
let m;
while ((m = re.exec(xml)) !== null)
out.push(m[1]);
return out;
}
/** Find the href of the first <link> tag matching a given rel. */
function findLinkHref(xml, rel) {
const re = /<link\b([^>]*)\/?>/g;
let m;
while ((m = re.exec(xml)) !== null) {
const attrs = m[1];
if (new RegExp(`\\brel="${rel}"`).test(attrs)) {
const h = attrs.match(/\bhref="([^"]*)"/);
if (h)
return h[1];
}
}
return '';
}
/** Parse Atom XML feed into structured entries. */
export function parseEntries(xml) {
const entryRe = /<entry>([\s\S]*?)<\/entry>/g;
@@ -36,12 +93,18 @@ export function parseEntries(xml) {
const e = m[1];
const rawId = extract(e, 'id');
const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, '');
const pdf = findLinkHref(e, 'related') || `https://arxiv.org/pdf/${arxivId}`;
entries.push({
id: arxivId,
title: extract(e, 'title').replace(/\s+/g, ' '),
authors: extractAll(e, 'name').slice(0, 3).join(', '),
abstract: (() => { const s = extract(e, 'summary').replace(/\s+/g, ' '); return s.length > 200 ? s.slice(0, 200) + '...' : s; })(),
title: decodeEntities(extract(e, 'title').replace(/\s+/g, ' ')),
authors: decodeEntities(extractAll(e, 'name').join(', ')),
abstract: decodeEntities(extract(e, 'summary').replace(/\s+/g, ' ')),
published: extract(e, 'published').slice(0, 10),
updated: extract(e, 'updated').slice(0, 10),
primary_category: extractAttr(e, 'arxiv:primary_category', 'term'),
categories: extractAllAttr(e, 'category', 'term').join(', '),
comment: decodeEntities(extract(e, 'arxiv:comment').replace(/\s+/g, ' ')),
pdf,
url: `https://arxiv.org/abs/${arxivId}`,
});
}
+87
View File
@@ -0,0 +1,87 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
cli({
site: 'baidu-scholar',
name: 'search',
description: '百度学术搜索',
domain: 'xueshu.baidu.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
],
columns: ['rank', 'title', 'authors', 'journal', 'year', 'cited', 'url'],
navigateBefore: false,
func: async (page, kwargs) => {
const limit = clampInt(kwargs.limit, 10, 1, 20);
const query = requireNonEmptyQuery(kwargs.query);
await page.goto(`https://xueshu.baidu.com/s?wd=${encodeURIComponent(query)}&pn=0&tn=SE_baiduxueshu_c1gjeupa`);
await page.wait(5);
const data = await page.evaluate(`
(async () => {
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
for (let i = 0; i < 20; i++) {
if (document.querySelectorAll('.result').length > 0) break;
await new Promise(r => setTimeout(r, 500));
}
const results = [];
for (const el of document.querySelectorAll('.result')) {
const titleEl = el.querySelector('h3 a, .paper-title a, .t a');
const title = normalize(titleEl?.textContent);
if (!title) continue;
let url = titleEl?.getAttribute('href') || '';
if (url && !url.startsWith('http')) url = 'https://xueshu.baidu.com' + url;
const infoEl = el.querySelector('.paper-info');
const infoText = normalize(infoEl?.textContent);
const spans = infoEl ? Array.from(infoEl.querySelectorAll('span')) : [];
let journal = '';
let year = '';
let cited = '0';
const authorParts = [];
for (const span of spans) {
const text = normalize(span.textContent);
if (!text || text === '' || text === ',') continue;
if (text.startsWith('《') || text.startsWith('〈')) {
journal = text.replace(/[《》〈〉]/g, '');
continue;
}
if (/^被引量[:]/.test(text)) {
cited = text.match(/(\\d+)/)?.[1] || '0';
continue;
}
if (/^-\\s*(\\d{4})/.test(text) || /^\\d{4}年?$/.test(text)) {
year = text.match(/(\\d{4})/)?.[1] || '';
continue;
}
if (!journal && !/^被引/.test(text) && !text.startsWith('-')) {
authorParts.push(text);
}
}
if (!year) year = infoText.match(/(19|20)\\d{2}/)?.[0] || '';
if (!cited || cited === '0') cited = infoText.match(/被引量[:]\\s*(\\d+)/)?.[1] || '0';
results.push({
rank: results.length + 1,
title,
authors: authorParts.join(', ').slice(0, 80),
journal,
year,
cited,
url,
});
if (results.length >= ${limit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
},
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './search.js';
describe('baidu-scholar search command', () => {
const command = getRegistry().get('baidu-scholar/search');
it('registers as a public browser command', () => {
expect(command).toBeDefined();
expect(command.site).toBe('baidu-scholar');
expect(command.strategy).toBe('public');
expect(command.browser).toBe(true);
});
it('rejects empty queries before browser navigation', async () => {
const page = { goto: vi.fn() };
await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
name: 'ArgumentError',
code: 'ARGUMENT',
});
expect(page.goto).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
/**
* band mentions — Show Band notifications where you were @mentioned.
@@ -52,7 +52,7 @@ cli({
await page.wait(0.5);
}
if (!bellReady) {
throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
}
// Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
// getInterceptedRequests() clears the array on each call, so captures are accumulated
@@ -80,7 +80,7 @@ cli({
return true;
}`);
if (!bellClicked) {
throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
}
const requests = await waitForOneCapture();
// Find the get_news response (has result_data.news); get_news_count responses do not.
+1 -1
View File
@@ -12,7 +12,7 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' },
],
columns: ['rank', 'title', 'description', 'url'],
func: async (page, kwargs) => {
func: async (kwargs) => {
const count = Math.min(kwargs.limit || 20, 50);
const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml');
if (!resp.ok)
+18 -13
View File
@@ -3,27 +3,32 @@ import { apiGet, payloadData, getSelfUid } from './utils.js';
cli({
site: 'bilibili',
name: 'favorite',
description: '我的默认收藏夹',
description: '我的收藏夹',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'fid', type: 'int', required: false, help: 'Favorite folder ID (defaults to first folder)' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
],
columns: ['rank', 'title', 'author', 'plays', 'url'],
func: async (page, kwargs) => {
const { limit = 20, page: pageNum = 1 } = kwargs;
// Get current user's UID
const uid = await getSelfUid(page);
// Get default favorite folder ID
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
params: { up_mid: uid },
signed: true,
});
const folders = payloadData(foldersPayload)?.list ?? [];
if (!folders.length)
return [];
const fid = folders[0].id;
const { fid: favoriteId, limit = 20, page: pageNum = 1 } = kwargs;
let fid;
if (favoriteId) {
fid = Number(favoriteId);
} else {
// Fall back to the default (first) favorite folder
const uid = await getSelfUid(page);
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
params: { up_mid: uid },
signed: true,
});
const folders = payloadData(foldersPayload)?.list ?? [];
if (!folders.length)
return [];
fid = folders[0].id;
}
// Fetch favorite items
const payload = await apiGet(page, '/x/v3/fav/resource/list', {
params: { media_id: fid, pn: pageNum, ps: Math.min(Number(limit), 40) },
+5 -1
View File
@@ -7,7 +7,7 @@ cli({
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' },
],
columns: ['rank', 'title', 'author', 'play', 'danmaku'],
columns: ['rank', 'title', 'author', 'play', 'danmaku', 'bvid', 'url'],
pipeline: [
{ navigate: 'https://www.bilibili.com' },
{ evaluate: `(async () => {
@@ -20,6 +20,8 @@ cli({
author: item.owner?.name,
play: item.stat?.view,
danmaku: item.stat?.danmaku,
bvid: item.bvid,
url: item.bvid ? 'https://www.bilibili.com/video/' + item.bvid : '',
}));
})()
` },
@@ -29,6 +31,8 @@ cli({
author: '${{ item.author }}',
play: '${{ item.play }}',
danmaku: '${{ item.danmaku }}',
bvid: '${{ item.bvid }}',
url: '${{ item.url }}',
} },
{ limit: '${{ args.limit }}' },
],
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './hot.js';
describe('bilibili hot adapter', () => {
const command = getRegistry().get('bilibili/hot');
it('registers bvid and url columns in the public hot-list shape', () => {
expect(command?.columns).toEqual(['rank', 'title', 'author', 'play', 'danmaku', 'bvid', 'url']);
expect(command?.pipeline?.[1]?.evaluate).toContain('bvid: item.bvid');
expect(command?.pipeline?.[1]?.evaluate).toContain("'https://www.bilibili.com/video/' + item.bvid");
expect(command?.pipeline?.[2]?.map).toMatchObject({
bvid: '${{ item.bvid }}',
url: '${{ item.url }}',
});
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
cli({
site: 'bilibili',
@@ -23,7 +23,7 @@ cli({
return state?.videoData?.cid;
})()`);
if (!cid) {
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
+68
View File
@@ -0,0 +1,68 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { apiGet, resolveBvid } from './utils.js';
cli({
site: 'bilibili',
name: 'video',
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
],
columns: ['field', 'value'],
func: async (page, kwargs) => {
if (!page) {
throw new CommandExecutionError('Browser session required for bilibili video');
}
// Resolve BV ID from three advertised input forms:
// 1. Bare "BV..." id
// 2. Full bilibili.com/video/<BV>... URL (with or without query string / www / m.)
// 3. b23.tv short link (delegated to resolveBvid)
// resolveBvid() alone handles (1) and (3) but not (2), so we pre-extract
// from bilibili URLs before falling through.
const input = String(kwargs.bvid ?? '').trim();
const bilibiliUrlMatch = input.match(/bilibili\.com\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
const bvid = bilibiliUrlMatch ? bilibiliUrlMatch[1] : await resolveBvid(input);
// Navigate to video page first so subsequent api call shares a primed session.
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
const payload = await apiGet(page, '/x/web-interface/view', {
params: { bvid },
});
if (payload.code !== 0) {
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
}
const d = payload.data || {};
const stat = d.stat || {};
const owner = d.owner || {};
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
const dur = d.duration || 0;
const mm = Math.floor(dur / 60);
const ss = dur % 60;
return [
{ field: 'bvid', value: d.bvid ?? '' },
{ field: 'aid', value: String(d.aid ?? '') },
{ field: 'title', value: d.title ?? '' },
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
{ field: 'category', value: d.tname_v2 || d.tname || '' },
{ field: 'publish_time', value: pubDate },
{ field: 'duration', value: dur ? `${mm}m${ss}s (${dur}s)` : '' },
{ field: 'view', value: String(stat.view ?? '') },
{ field: 'danmaku', value: String(stat.danmaku ?? '') },
{ field: 'reply', value: String(stat.reply ?? '') },
{ field: 'like', value: String(stat.like ?? '') },
{ field: 'coin', value: String(stat.coin ?? '') },
{ field: 'favorite', value: String(stat.favorite ?? '') },
{ field: 'share', value: String(stat.share ?? '') },
{ field: 'parts', value: String(d.videos ?? 1) },
{ field: 'thumbnail', value: d.pic ?? '' },
{ field: 'description', value: d.desc ?? '' },
];
},
});
+132
View File
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CommandExecutionError } from '@jackwener/opencli/errors';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiGet: mockApiGet,
}));
import { getRegistry } from '@jackwener/opencli/registry';
import './video.js';
describe('bilibili video', () => {
const command = getRegistry().get('bilibili/video');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
};
beforeEach(() => {
mockApiGet.mockReset();
page.goto.mockClear();
page.evaluate.mockReset();
});
it('returns a field/value table of video metadata on success', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: {
bvid: 'BV1xx411c7mD',
aid: 12345678,
title: '三层结构笔记法',
tname: '教程',
pubdate: 1775053078, // 2026-04-01 14:17:58 UTC
duration: 434,
videos: 1,
pic: 'https://i1.hdslb.com/some.jpg',
desc: 'Obsidian 教程',
owner: { mid: 507578555, name: 'IOI科技' },
stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
},
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
// Every row has { field, value }
expect(Array.isArray(rows)).toBe(true);
for (const row of rows) {
expect(row).toHaveProperty('field');
expect(row).toHaveProperty('value');
}
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(byField.bvid).toBe('BV1xx411c7mD');
expect(byField.title).toBe('三层结构笔记法');
expect(byField.author).toBe('IOI科技 (mid: 507578555)');
expect(byField.duration).toBe('7m14s (434s)');
expect(byField.view).toBe('6128');
expect(byField.like).toBe('162');
// Navigation primes the session
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
// API called without signing
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
});
it('throws CommandExecutionError when bilibili view API returns non-zero code', async () => {
mockApiGet.mockResolvedValueOnce({
code: -404,
message: '啥都木有',
data: null,
});
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /啥都木有|-404/.test(err.message),
);
});
it('extracts BV ID from full bilibili.com URL input', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
});
await command.func(page, { bvid: 'https://www.bilibili.com/video/BV1xx411c7mD/' });
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
});
it('extracts BV ID from bilibili URL with trailing query string', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '' },
});
await command.func(page, {
bvid: 'https://www.bilibili.com/video/BV1Je9EBnEha/?spm_id_from=333.1007&vd_source=abc',
});
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1Je9EBnEha' } });
});
it('extracts BV ID from m.bilibili.com mobile URL', async () => {
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
});
await command.func(page, { bvid: 'https://m.bilibili.com/video/BV1xx411c7mD' });
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
});
it('returns full description without truncation or whitespace collapse', async () => {
const longDesc = '第一行描述\n\n第二段,有多个空格 和换行\n\n' + 'x'.repeat(500);
mockApiGet.mockResolvedValueOnce({
code: 0,
data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc },
});
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
// JSON/YAML consumers must receive the complete description verbatim,
// including original whitespace and length > 200 chars.
expect(byField.description).toBe(longDesc);
expect(byField.description.length).toBeGreaterThan(200);
});
});
+3 -4
View File
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'depth',
description: 'Order book bid prices for a trading pair',
description: 'Order book bid and ask prices for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
browser: false,
@@ -11,11 +11,10 @@ cli({
{ name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
{ name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' },
],
columns: ['rank', 'bid_price', 'bid_qty'],
columns: ['rank', 'bid_price', 'bid_qty', 'ask_price', 'ask_qty'],
pipeline: [
{ fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
{ select: 'bids' },
{ map: { rank: '${{ index + 1 }}', bid_price: '${{ item.0 }}', bid_qty: '${{ item.1 }}' } },
{ map: { select: 'bids', rank: '${{ index + 1 }}', bid_price: '${{ item[0] }}', bid_qty: '${{ item[1] }}', ask_price: '${{ root.asks[index]?.[0] ?? "" }}', ask_qty: '${{ root.asks[index]?.[1] ?? "" }}' } },
{ limit: '${{ args.limit }}' },
],
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('economics', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('industries', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('main', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('markets', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('opinions', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('politics', kwargs.limit ?? 1);
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
],
columns: ['title', 'summary', 'link', 'mediaLinks'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
return fetchBloombergFeed('tech', kwargs.limit ?? 1);
},
});
+49 -8
View File
@@ -2,6 +2,7 @@
* BOSS直聘 job search — browser cookie API.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
/** City name → BOSS Zhipin city code mapping */
const CITY_CODES = {
@@ -22,8 +23,16 @@ const CITY_CODES = {
'香港': '101320100',
};
const EXP_MAP = {
'不限': '0', '在校/应届': '108', '应届': '108', '1年以内': '101',
'1-3年': '102', '3-5年': '103', '5-10年': '104', '10年以上': '105',
'不限': '0',
'在校/应届': '108',
'在校生': '108', '在校': '108',
'应届生': '102', '应届': '102',
'经验不限': '101',
'1年以内': '103',
'1-3年': '104',
'3-5年': '105',
'5-10年': '106',
'10年以上': '107',
};
const DEGREE_MAP = {
'不限': '0', '初中及以下': '209', '中专/中技': '208', '高中': '206',
@@ -38,6 +47,10 @@ const INDUSTRY_MAP = {
'人工智能': '100901', '大数据': '100902', '金融': '100101',
'教育培训': '100200', '医疗健康': '100300',
};
const JOB_TYPE_MAP = {
'不限': '0', '全职': '1901', '实习': '1902', '兼职': '1903',
};
const JOB_TYPE_CODES = new Set(Object.values(JOB_TYPE_MAP));
function resolveCity(input) {
if (!input)
return '101010100';
@@ -62,35 +75,54 @@ function resolveMap(input, map) {
}
return input;
}
function resolveJobType(input) {
if (!input)
return '';
if (JOB_TYPE_MAP[input] !== undefined)
return JOB_TYPE_MAP[input];
if (JOB_TYPE_CODES.has(input))
return input;
throw new ArgumentError(`Invalid jobType: ${input}`, 'Use one of: 全职, 兼职, 实习, 不限');
}
function formatBossOnline(value) {
if (value === true)
return 'Y';
if (value === false)
return 'N';
return '';
}
cli({
site: 'boss',
name: 'search',
description: 'BOSS直聘搜索职位',
description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
browser: true,
args: [
{ name: 'query', required: true, positional: true, help: 'Search keyword (e.g. AI agent, 前端)' },
{ name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
{ name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
{ name: 'experience', default: '', help: 'Experience: 应届/1年以内/1-3年/3-5年/5-10年/10年以上' },
{ name: 'experience', default: '', help: 'Experience: 在校生(实习)/应届生(校招)/经验不限/1年以内/1-3年/3-5年/5-10年/10年以上' },
{ name: 'degree', default: '', help: 'Degree: 大专/本科/硕士/博士' },
{ name: 'salary', default: '', help: 'Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上' },
{ name: 'industry', default: '', help: 'Industry code or name (e.g. 100020, 互联网)' },
{ name: 'jobType', default: '', help: 'Job type: 全职/兼职/实习(不传=不限,混合校招与实习)' },
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
],
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'bossOnline', 'security_id', 'url'],
func: async (page, kwargs) => {
requirePage(page);
const query = String(kwargs.query ?? '').trim();
const cityCode = resolveCity(kwargs.city);
verbose('Navigating to set referrer context...');
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(kwargs.query)}&city=${cityCode}`);
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
await new Promise(r => setTimeout(r, 1000));
const expVal = resolveMap(kwargs.experience, EXP_MAP);
const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
const jobTypeVal = resolveJobType(kwargs.jobType);
const limit = kwargs.limit || 15;
let currentPage = kwargs.page || 1;
let allJobs = [];
@@ -101,7 +133,7 @@ cli({
}
const qs = new URLSearchParams({
scene: '1',
query: kwargs.query,
query,
city: cityCode,
page: String(currentPage),
pageSize: '15',
@@ -114,6 +146,8 @@ cli({
qs.set('salary', salaryVal);
if (industryVal)
qs.set('industry', industryVal);
if (jobTypeVal)
qs.set('jobType', jobTypeVal);
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
const data = await bossFetch(page, targetUrl);
@@ -135,6 +169,7 @@ cli({
degree: j.jobDegree,
skills: (j.skills || []).join(','),
boss: j.bossName + ' · ' + j.bossTitle,
bossOnline: formatBossOnline(j.bossOnline),
security_id: j.securityId || '',
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
});
@@ -153,3 +188,9 @@ cli({
return allJobs;
},
});
export const __test__ = {
EXP_MAP,
resolveMap,
resolveJobType,
formatBossOnline,
};
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { __test__ } from './search.js';
import './search.js';
function createPageMock(response) {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(response),
};
}
describe('boss search', () => {
const command = getRegistry().get('boss/search');
it('keeps legacy 在校/应届 experience input compatible', () => {
expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
});
it('fails fast on invalid jobType values', async () => {
expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
});
it('accepts supported jobType labels and raw codes', () => {
expect(__test__.resolveJobType('全职')).toBe('1901');
expect(__test__.resolveJobType('实习')).toBe('1902');
expect(__test__.resolveJobType('兼职')).toBe('1903');
expect(__test__.resolveJobType('1902')).toBe('1902');
});
it('keeps empty query empty and sends jobType filter to the API', async () => {
const page = createPageMock({
code: 0,
zpData: {
hasMore: false,
jobList: [
{
encryptJobId: 'abc',
securityId: 'sec',
jobName: '前端开发实习生',
salaryDesc: '150-200/天',
brandName: 'OpenCLI',
cityName: '北京',
areaDistrict: '海淀区',
businessDistrict: '',
jobExperience: '在校/应届',
jobDegree: '本科',
skills: ['JavaScript'],
bossName: '张三',
bossTitle: '技术负责人',
bossOnline: false,
},
],
},
});
const rows = await command.func(page, {
query: undefined,
city: '北京',
jobType: '实习',
limit: 1,
page: 1,
});
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
const fetchScript = page.evaluate.mock.calls.at(-1)[0];
expect(fetchScript).toContain('query=');
expect(fetchScript).not.toContain('query=undefined');
expect(fetchScript).toContain('jobType=1902');
expect(rows[0]).toMatchObject({
name: '前端开发实习生',
bossOnline: 'N',
});
});
});
+3 -3
View File
@@ -6,7 +6,7 @@
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, } from './utils.js';
import { EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
import { EmptyResultError, selectorError } from '@jackwener/opencli/errors';
cli({
site: 'boss',
name: 'send',
@@ -30,12 +30,12 @@ cli({
const friendName = friend.name || '候选人';
const clicked = await clickCandidateInList(page, numericUid);
if (!clicked) {
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
throw selectorError('聊天列表中的用户', '请确认聊天列表中有此人');
}
await page.wait({ time: 2 });
const sent = await typeAndSendMessage(page, kwargs.text);
if (!sent) {
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
}
await page.wait({ time: 1 });
return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
+2 -2
View File
@@ -214,10 +214,10 @@ export async function typeAndSendMessage(page, text) {
return true;
}
/**
* Verbose log helper — prints when OPENCLI_VERBOSE or DEBUG=opencli is set.
* Verbose log helper — prints when OPENCLI_VERBOSE is set.
*/
export function verbose(msg) {
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] ${msg}`);
}
}
@@ -1,9 +1,9 @@
import { execSync, spawnSync } from 'node:child_process';
import { execSync } from 'node:child_process';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ConfigError } from '@jackwener/opencli/errors';
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating, sendPrompt } from './ax.js';
export const askCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'ask',
description: 'Send a prompt and wait for the AI response (send + wait + read)',
domain: 'localhost',
@@ -15,7 +15,7 @@ export const askCommand = cli({
{ name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
],
columns: ['Role', 'Text'],
func: async (page, kwargs) => {
func: async (kwargs) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
@@ -27,26 +27,10 @@ export const askCommand = cli({
activateChatGPT();
selectModel(model);
}
// Backup clipboard
let clipBackup = '';
try {
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
}
catch { }
const messagesBefore = getVisibleChatMessages();
// Send the message
spawnSync('pbcopy', { input: text });
activateChatGPT();
const cmd = "osascript " +
"-e 'tell application \"System Events\"' " +
"-e 'keystroke \"v\" using command down' " +
"-e 'delay 0.2' " +
"-e 'keystroke return' " +
"-e 'end tell'";
execSync(cmd);
// Restore clipboard after the prompt is sent.
if (clipBackup)
spawnSync('pbcopy', { input: clipBackup });
sendPrompt(text);
// Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears),
// then read the final response text.
const pollInterval = 2;
+140 -4
View File
@@ -60,6 +60,125 @@ for list in lists {
let data = try! JSONSerialization.data(withJSONObject: best, options: [])
print(String(data: data, encoding: .utf8)!)
`;
const AX_SEND_SCRIPT = `
import Cocoa
import ApplicationServices
func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
return value as AnyObject?
}
func s(_ el: AXUIElement, _ name: String) -> String? {
if let v = attr(el, name) as? String { return v }
return nil
}
func isEnabled(_ el: AXUIElement) -> Bool {
(attr(el, kAXEnabledAttribute as String) as? Bool) ?? true
}
func children(_ el: AXUIElement) -> [AXUIElement] {
(attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
}
func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) {
guard depth < 25 else { return }
let role = s(el, kAXRoleAttribute as String) ?? ""
if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) {
out.append(el)
}
for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) }
}
func isInput(_ el: AXUIElement) -> Bool {
let role = s(el, kAXRoleAttribute as String) ?? ""
return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String
}
func focusedInput(_ axApp: AXUIElement) -> AXUIElement? {
guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else {
return nil
}
return isInput(focused) && isEnabled(focused) ? focused : nil
}
func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? {
guard depth < 25 else { return nil }
let role = s(el, kAXRoleAttribute as String) ?? ""
let desc = s(el, kAXDescriptionAttribute as String) ?? ""
if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el }
for c in children(el) {
if let found = findByDescriptions(c, targets, depth: depth + 1) { return found }
}
return nil
}
func press(_ el: AXUIElement) {
AXUIElementPerformAction(el, kAXPressAction as CFString)
}
let args = CommandLine.arguments
guard args.count > 1 else {
fputs("Missing prompt text\\n", stderr)
exit(1)
}
let text = args[1]
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
fputs("ChatGPT not running\\n", stderr)
exit(1)
}
let axApp = AXUIElementCreateApplication(app.processIdentifier)
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
fputs("No focused ChatGPT window\\n", stderr)
exit(1)
}
var inputs: [AXUIElement] = []
collectEditableInputs(win, into: &inputs)
guard let input = focusedInput(axApp) ?? inputs.last else {
fputs("Could not find editable input area\\n", stderr)
exit(1)
}
guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else {
fputs("Failed to set input value\\n", stderr)
exit(1)
}
Thread.sleep(forTimeInterval: 0.2)
guard s(input, kAXValueAttribute as String) == text else {
fputs("Failed to verify input value after AX set\\n", stderr)
exit(1)
}
guard let sendButton = findByDescriptions(win, ["发送", "傳送", "Send"]) else {
fputs("Could not find send button\\n", stderr)
exit(1)
}
press(sendButton)
var submitted = false
for _ in 0..<15 {
Thread.sleep(forTimeInterval: 0.1)
if s(input, kAXValueAttribute as String) != text {
submitted = true
break
}
}
guard submitted else {
fputs("Prompt did not leave input after pressing send\\n", stderr)
exit(1)
}
print("Sent")
`;
const AX_MODEL_SCRIPT = `
import Cocoa
import ApplicationServices
@@ -121,11 +240,15 @@ let args = CommandLine.arguments
let target = args.count > 1 ? args[1] : ""
let needsLegacy = args.count > 2 && args[2] == "legacy"
// Step 1: Click the "Options" button to open the popover
guard let optionsBtn = findByDesc(win, "Options") else {
// Step 1: Click the "Options" button to open the popover (support English, Simplified and Traditional Chinese UI)
var optionsBtn: AXUIElement? = nil
if let btn = findByDesc(win, "Options") { optionsBtn = btn }
else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
else if let btn = findByDesc(win, "選項") { optionsBtn = btn }
guard let options = optionsBtn else {
fputs("Could not find Options button\\n", stderr); exit(1)
}
press(optionsBtn)
press(options)
Thread.sleep(forTimeInterval: 0.8)
// Step 2: Find the popover that appeared, search ONLY within it
@@ -189,7 +312,8 @@ let axApp = AXUIElementCreateApplication(app.processIdentifier)
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
print("false"); exit(0)
}
print(hasButton(win, desc: "Stop generating") ? "true" : "false")
let targets = ["Stop generating", "停止生成"]
print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
`;
const MODEL_MAP = {
'auto': { desc: 'Auto' },
@@ -218,6 +342,13 @@ export function selectModel(model) {
}).trim();
return output;
}
export function sendPrompt(text) {
return execFileSync('swift', ['-', text], {
input: AX_SEND_SCRIPT,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
}).trim();
}
export function isGenerating() {
try {
const output = execFileSync('swift', ['-'], {
@@ -247,3 +378,8 @@ export function getVisibleChatMessages() {
.map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
.filter((item) => item.length > 0);
}
export const __test__ = {
AX_SEND_SCRIPT,
AX_MODEL_SCRIPT,
AX_GENERATING_SCRIPT,
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './ax.js';
describe('chatgpt-app AX send script', () => {
it('prefers the focused composer before falling back to the last editable input', () => {
expect(__test__.AX_SEND_SCRIPT).toContain('kAXFocusedUIElementAttribute');
});
it('fails fast when the AX set does not round-trip into the composer value', () => {
expect(__test__.AX_SEND_SCRIPT).toContain('Failed to verify input value after AX set');
});
it('does not report success until the prompt leaves the composer after send', () => {
expect(__test__.AX_SEND_SCRIPT).toContain('Prompt did not leave input after pressing send');
});
it('supports english, zh-CN, and zh-TW send button labels', () => {
expect(__test__.AX_SEND_SCRIPT).toContain('["发送", "傳送", "Send"]');
});
});
describe('chatgpt-app AX model script', () => {
it('supports english, zh-CN, and zh-TW options button labels', () => {
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "Options")');
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "选项")');
expect(__test__.AX_MODEL_SCRIPT).toContain('findByDesc(win, "選項")');
});
});
describe('chatgpt-app generating detection', () => {
it('supports both english and zh-CN stop-generating labels', () => {
expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating');
expect(__test__.AX_GENERATING_SCRIPT).toContain('停止生成');
});
});
@@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { ConfigError } from '@jackwener/opencli/errors';
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
export const modelCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'model',
description: 'Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)',
domain: 'localhost',
@@ -12,7 +12,7 @@ export const modelCommand = cli({
{ name: 'model', required: true, positional: true, help: 'Model to switch to', choices: MODEL_CHOICES },
],
columns: ['Status', 'Model'],
func: async (page, kwargs) => {
func: async (kwargs) => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS');
}
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
export const newCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'new',
description: 'Open a new chat in ChatGPT Desktop App',
domain: 'localhost',
@@ -10,7 +10,7 @@ export const newCommand = cli({
browser: false,
args: [],
columns: ['Status'],
func: async (page) => {
func: async () => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
import { getVisibleChatMessages } from './ax.js';
export const readCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'read',
description: 'Read the last visible message from the focused ChatGPT Desktop window',
domain: 'localhost',
@@ -11,7 +11,7 @@ export const readCommand = cli({
browser: false,
args: [],
columns: ['Role', 'Text'],
func: async (page) => {
func: async () => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
@@ -1,9 +1,8 @@
import { execSync, spawnSync } from 'node:child_process';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { getErrorMessage } from '@jackwener/opencli/errors';
import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
import { activateChatGPT, selectModel, MODEL_CHOICES, sendPrompt } from './ax.js';
export const sendCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'send',
description: 'Send a message to the active ChatGPT Desktop App window',
domain: 'localhost',
@@ -14,7 +13,7 @@ export const sendCommand = cli({
{ name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES },
],
columns: ['Status'],
func: async (page, kwargs) => {
func: async (kwargs) => {
const text = kwargs.text;
const model = kwargs.model;
try {
@@ -23,26 +22,8 @@ export const sendCommand = cli({
activateChatGPT();
selectModel(model);
}
// Backup current clipboard content
let clipBackup = '';
try {
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
}
catch { /* clipboard may be empty */ }
// Copy text to clipboard
spawnSync('pbcopy', { input: text });
activateChatGPT();
const cmd = "osascript " +
"-e 'tell application \"System Events\"' " +
"-e 'keystroke \"v\" using command down' " +
"-e 'delay 0.2' " +
"-e 'keystroke return' " +
"-e 'end tell'";
execSync(cmd);
// Restore original clipboard content
if (clipBackup) {
spawnSync('pbcopy', { input: clipBackup });
}
sendPrompt(text);
return [{ Status: 'Success' }];
}
catch (err) {
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, ConfigError } from '@jackwener/opencli/errors';
export const statusCommand = cli({
site: 'chatgpt',
site: 'chatgpt-app',
name: 'status',
description: 'Check if ChatGPT Desktop App is running natively on macOS',
domain: 'localhost',
@@ -10,7 +10,7 @@ export const statusCommand = cli({
browser: false,
args: [],
columns: ['Status'],
func: async (page) => {
func: async () => {
if (process.platform !== 'darwin') {
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
}
@@ -1,7 +1,9 @@
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { saveBase64ToFile } from '@jackwener/opencli/utils';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
const CHATGPT_DOMAIN = 'chatgpt.com';
@@ -24,13 +26,29 @@ function displayPath(filePath) {
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
}
export function resolveOutputDir(value) {
const raw = String(value || '').trim();
if (!raw) return path.join(os.homedir(), 'Pictures', 'chatgpt');
if (raw === '~') return os.homedir();
if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2));
return path.resolve(raw);
}
export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync) {
let candidate = path.join(dir, `${baseName}${ext}`);
for (let index = 1; existsSync(candidate); index += 1) {
candidate = path.join(dir, `${baseName}_${index}${ext}`);
}
return candidate;
}
async function currentChatGPTLink(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
return typeof url === 'string' && url ? url : 'https://chatgpt.com';
}
export const imageCommand = cli({
site: 'chatgptweb',
site: 'chatgpt',
name: 'image',
description: 'Generate images with ChatGPT web and save them locally',
domain: CHATGPT_DOMAIN,
@@ -41,13 +59,13 @@ export const imageCommand = cli({
timeoutSeconds: 240,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
{ name: 'op', default: path.join(os.homedir(), 'Pictures', 'chatgpt'), help: 'Output directory' },
{ name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
],
columns: ['status', 'file', 'link'],
func: async (page, kwargs) => {
const prompt = kwargs.prompt;
const outputDir = kwargs.op || path.join(os.homedir(), 'Pictures', 'chatgpt');
const outputDir = resolveOutputDir(kwargs.op);
const skipDownloadRaw = kwargs.sd;
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
const timeout = 120;
@@ -63,12 +81,23 @@ export const imageCommand = cli({
return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }];
}
// Wait for response and images
const urls = await waitForChatGPTImages(page, beforeUrls, timeout);
const link = await currentChatGPTLink(page);
// ChatGPT briefly navigates to /c/{id} after sending, then may
// redirect back to the home page. Poll until we capture the /c/ URL.
let convUrl = '';
for (let ci = 0; ci < 10; ci++) {
const url = await currentChatGPTLink(page);
if (url.includes('/c/')) { convUrl = url; break; }
await page.wait(2);
}
if (!convUrl) {
convUrl = await currentChatGPTLink(page);
}
const urls = await waitForChatGPTImages(page, beforeUrls, timeout, convUrl);
const link = convUrl;
if (!urls.length) {
return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
throw new EmptyResultError('chatgpt image', `No generated images were detected before timeout. Open ${link} and verify whether ChatGPT finished generating the image.`);
}
if (skipDownload) {
@@ -78,7 +107,7 @@ export const imageCommand = cli({
// Export and save images
const assets = await getChatGPTImageAssets(page, urls);
if (!assets.length) {
return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
throw new CommandExecutionError('Failed to export generated ChatGPT image assets', `Open ${link} and verify the generated images are visible, then retry.`);
}
const stamp = Date.now();
@@ -88,7 +117,7 @@ export const imageCommand = cli({
const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
const suffix = assets.length > 1 ? `_${index + 1}` : '';
const ext = extFromMime(asset.mimeType);
const filePath = path.join(outputDir, `chatgpt_${stamp}${suffix}${ext}`);
const filePath = nextAvailablePath(outputDir, `chatgpt_${stamp}${suffix}`, ext);
await saveBase64ToFile(base64, filePath);
results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
}
+92
View File
@@ -0,0 +1,92 @@
import * as os from 'node:os';
import * as path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getChatGPTVisibleImageUrls: vi.fn(),
sendChatGPTMessage: vi.fn(),
waitForChatGPTImages: vi.fn(),
getChatGPTImageAssets: vi.fn(),
saveBase64ToFile: vi.fn(),
}));
vi.mock('./utils.js', () => ({
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
sendChatGPTMessage: mocks.sendChatGPTMessage,
waitForChatGPTImages: mocks.waitForChatGPTImages,
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
}));
vi.mock('@jackwener/opencli/utils', () => ({
saveBase64ToFile: mocks.saveBase64ToFile,
}));
const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
function createPage() {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'),
};
}
beforeEach(() => {
vi.restoreAllMocks();
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
url: 'https://images.example/generated.png',
dataUrl: 'data:image/png;base64,aGVsbG8=',
mimeType: 'image/png',
}]);
mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined);
});
describe('chatgpt image output paths', () => {
it('expands the default and explicit home-relative output directories', () => {
expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt'));
expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images'));
expect(resolveOutputDir('~')).toBe(os.homedir());
});
it('generates a non-overwriting file path when a timestamp collision exists', () => {
const dir = '/tmp/chatgpt';
const taken = new Set([
path.join(dir, 'chatgpt_123.png'),
path.join(dir, 'chatgpt_123_1.png'),
]);
expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
});
});
describe('chatgpt image failure contracts', () => {
it('fails fast when image generation detection finds no new images', async () => {
mocks.waitForChatGPTImages.mockResolvedValue([]);
await expect(imageCommand.func(createPage(), {
prompt: 'cat',
op: '',
sd: false,
})).rejects.toMatchObject({
code: 'EMPTY_RESULT',
message: expect.stringContaining('chatgpt image returned no data'),
hint: expect.stringContaining('No generated images were detected'),
});
});
it('fails fast when generated image assets cannot be exported', async () => {
mocks.getChatGPTImageAssets.mockResolvedValue([]);
await expect(imageCommand.func(createPage(), {
prompt: 'cat',
op: '',
sd: false,
})).rejects.toMatchObject({
code: 'COMMAND_EXEC',
message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
});
});
});
@@ -7,11 +7,21 @@ export const CHATGPT_DOMAIN = 'chatgpt.com';
export const CHATGPT_URL = 'https://chatgpt.com';
// Selectors
const COMPOSER_SELECTOR = '[aria-label="Chat with ChatGPT"]';
const COMPOSER_SELECTORS = [
'[aria-label="Chat with ChatGPT"]',
'[placeholder="Ask anything"]',
'#prompt-textarea',
];
const SEND_BTN_SELECTOR = 'button[aria-label="Send prompt"]';
function isSameChatGPTConversation(currentUrl, expectedUrl) {
if (!currentUrl || !expectedUrl) return false;
return currentUrl === expectedUrl
|| currentUrl.startsWith(`${expectedUrl}?`)
|| currentUrl.startsWith(`${expectedUrl}#`);
}
function buildComposerLocatorScript() {
const selectorsJson = JSON.stringify([COMPOSER_SELECTOR]);
const markerAttr = 'data-opencli-chatgpt-composer';
return `
const isVisible = (el) => {
@@ -33,7 +43,7 @@ function buildComposerLocatorScript() {
const marked = document.querySelector('[' + markerAttr + '="1"]');
if (marked instanceof HTMLElement && isVisible(marked)) return marked;
for (const selector of ${JSON.stringify([COMPOSER_SELECTOR])}) {
for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) {
const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c));
if (node instanceof HTMLElement) {
node.setAttribute(markerAttr, '1');
@@ -89,7 +99,9 @@ export async function sendChatGPTMessage(page, text) {
// Fallback: use execCommand
await page.evaluate(`
(() => {
const composer = document.querySelector('[aria-label="Chat with ChatGPT"]');
var composer = null;
var sels = ${JSON.stringify(COMPOSER_SELECTORS)};
for (var si = 0; si < sels.length; si++) { composer = document.querySelector(sels[si]); if (composer) break; }
if (!composer) return;
composer.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
@@ -181,7 +193,7 @@ export async function getChatGPTVisibleImageUrls(page) {
/**
* Wait for new images to appear after sending a prompt.
*/
export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, convUrl) {
const beforeSet = new Set(beforeUrls);
const pollIntervalSeconds = 3;
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
@@ -191,10 +203,26 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
for (let i = 0; i < maxPolls; i++) {
await page.wait(i === 0 ? 3 : pollIntervalSeconds);
// Check if still generating
let currentUrl = '';
if (convUrl && convUrl.includes('/c/')) {
currentUrl = await page.evaluate('window.location.href').catch(() => '');
if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
await page.goto(convUrl);
await page.wait(3);
}
}
const generating = await isGenerating(page);
if (generating) continue;
if (convUrl && convUrl.includes('/c/') && i > 0 && i % 5 === 0) {
const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl);
if (onConversation) {
await page.goto(convUrl);
await page.wait(3);
}
}
const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url));
if (urls.length === 0) continue;
@@ -214,6 +242,11 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
return lastUrls;
}
export const __test__ = {
COMPOSER_SELECTORS,
isSameChatGPTConversation,
};
/**
* Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs.
*/
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest';
import { __test__, waitForChatGPTImages } from './utils.js';
function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
let generatingIndex = 0;
let imageIndex = 0;
return {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn((script) => {
if (script === 'window.location.href') return Promise.resolve(location);
if (script.includes('Stop generating') || script.includes('Thinking')) {
const value = generating[Math.min(generatingIndex, generating.length - 1)] ?? false;
generatingIndex += 1;
return Promise.resolve(value);
}
if (script.includes("document.querySelectorAll('img')")) {
const value = imageUrls[Math.min(imageIndex, imageUrls.length - 1)] ?? [];
imageIndex += 1;
return Promise.resolve(value);
}
return Promise.resolve(undefined);
}),
};
}
describe('chatgpt image wait contract', () => {
it('does not periodically reload the conversation while generation is still active', async () => {
const convUrl = 'https://chatgpt.com/c/demo';
const page = createPageMock({
location: convUrl,
generating: [true, true, true, true, true, true],
});
await expect(waitForChatGPTImages(page, [], 18, convUrl)).resolves.toEqual([]);
expect(page.goto).not.toHaveBeenCalled();
});
it('jumps back to the captured conversation when the page drifts away', async () => {
const convUrl = 'https://chatgpt.com/c/demo';
const page = createPageMock({
location: 'https://chatgpt.com/',
generating: [false],
imageUrls: [['https://cdn.openai.com/generated/demo.png']],
});
await expect(waitForChatGPTImages(page, [], 3, convUrl)).resolves.toEqual([
'https://cdn.openai.com/generated/demo.png',
]);
expect(page.goto).toHaveBeenCalledWith(convUrl);
});
it('treats query and hash variants as the same conversation', () => {
expect(__test__.isSameChatGPTConversation(
'https://chatgpt.com/c/demo?model=gpt-image-1',
'https://chatgpt.com/c/demo',
)).toBe(true);
expect(__test__.isSameChatGPTConversation(
'https://chatgpt.com/c/other',
'https://chatgpt.com/c/demo',
)).toBe(false);
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const askCommand = cli({
site: 'chatwise',
name: 'ask',
@@ -43,7 +43,7 @@ export const askCommand = cli({
})(${JSON.stringify(text)})
`);
if (!injected)
throw new SelectorError('ChatWise input element');
throw selectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
// Poll for response
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const modelCommand = cli({
site: 'chatwise',
name: 'model',
@@ -58,7 +58,7 @@ export const modelCommand = cli({
})(${JSON.stringify(desiredModel)})
`);
if (!opened)
throw new SelectorError('ChatWise model selector');
throw selectorError('ChatWise model selector');
await page.wait(0.5);
// Find and click the target model in the dropdown
const found = await page.evaluate(`
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const sendCommand = cli({
site: 'chatwise',
name: 'send',
@@ -36,7 +36,7 @@ export const sendCommand = cli({
})(${JSON.stringify(text)})
`);
if (!injected)
throw new SelectorError('ChatWise input element');
throw selectorError('ChatWise input element');
await page.wait(0.5);
await page.pressKey('Enter');
return [
+128
View File
@@ -0,0 +1,128 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import {
CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, selectModel, setAdaptiveThinking,
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
ensureClaudeComposer, requireNonEmptyPrompt, requirePositiveInt,
} from './utils.js';
export const askCommand = cli({
site: 'claude',
name: 'ask',
description: 'Send a prompt to Claude and get the response',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
timeoutSeconds: 180,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
{ name: 'model', default: 'sonnet', choices: ['sonnet', 'opus', 'haiku'], help: 'Model to use: sonnet, opus, or haiku' },
{ name: 'think', type: 'boolean', default: false, help: 'Enable Adaptive thinking' },
{ name: 'file', help: 'Attach a file (image, PDF, text) with the prompt' },
],
columns: ['response'],
func: async (page, kwargs) => {
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude ask');
const timeoutSeconds = requirePositiveInt(
Number(kwargs.timeout ?? 120),
'claude ask --timeout',
'Example: opencli claude ask "hello" --timeout 120',
);
const timeoutMs = timeoutSeconds * 1000;
const wantThink = parseBoolFlag(kwargs.think);
if (parseBoolFlag(kwargs.new)) {
await page.goto(CLAUDE_URL);
await page.wait(3);
} else {
const navigated = await ensureOnClaude(page);
if (navigated) {
// Workspace was recycled; try to resume the most recent
// conversation instead of starting a new one.
await page.evaluate(`(() => {
var link = document.querySelector('a[href*="/chat/"]');
if (link) link.click();
})()`);
await page.wait(2);
}
}
await page.wait(2);
await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.'));
// Model selector is only available on the new-chat page, not inside
// an existing conversation. Skip it when we resumed a prior thread.
const currentUrl = await page.evaluate('window.location.href') || '';
const inConversation = currentUrl.includes('/chat/');
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
const wantModel = kwargs.model || 'sonnet';
if (inConversation && modelExplicit) {
throw new ArgumentError(
`Cannot switch to ${wantModel} model inside an existing conversation.`,
'Re-run with --new to start a fresh chat before selecting a model.',
);
}
if (!inConversation) {
const modelResult = await withRetry(() => selectModel(page, wantModel));
if (!modelResult?.ok) {
if (modelResult?.upgrade) {
throw new ArgumentError(
`${wantModel} model requires a paid Claude plan.`,
'Pick --model sonnet or --model haiku, or upgrade your account.',
);
}
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
}
if (modelResult?.toggled) await page.wait(0.5);
}
const thinkResult = await withRetry(() => setAdaptiveThinking(page, wantThink));
if (!thinkResult?.ok && wantThink) {
throw new CommandExecutionError('Could not enable Adaptive thinking');
}
if (thinkResult?.toggled) await page.wait(0.5);
if (kwargs.file) {
const baseline = await withRetry(() => getBubbleCount(page));
try {
const fileResult = await sendWithFile(page, kwargs.file, prompt);
if (fileResult && !fileResult.ok) {
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
}
} catch (err) {
// SPA navigates after send; "Promise was collected" means send succeeded
if (!String(err?.message || err).includes('Promise was collected')) throw err;
}
await page.wait(3);
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
if (!result) {
throw new EmptyResultError(
'claude ask',
`No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`,
);
}
return [{ response: result }];
}
const baseline = await withRetry(() => getBubbleCount(page));
const sendResult = await withRetry(() => sendMessage(page, prompt));
if (!sendResult?.ok) {
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
}
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
if (!result) {
throw new EmptyResultError(
'claude ask',
`No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`,
);
}
return [{ response: result }];
},
});
+338
View File
@@ -0,0 +1,338 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const {
mockEnsureOnClaude,
mockEnsureClaudeComposer,
mockSelectModel,
mockSetAdaptiveThinking,
mockSendMessage,
mockSendWithFile,
mockGetBubbleCount,
mockWaitForResponse,
mockParseBoolFlag,
mockRequireNonEmptyPrompt,
mockRequirePositiveInt,
mockWithRetry,
} = vi.hoisted(() => ({
mockEnsureOnClaude: vi.fn(),
mockEnsureClaudeComposer: vi.fn(),
mockSelectModel: vi.fn(),
mockSetAdaptiveThinking: vi.fn(),
mockSendMessage: vi.fn(),
mockSendWithFile: vi.fn(),
mockGetBubbleCount: vi.fn(),
mockWaitForResponse: vi.fn(),
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
mockRequirePositiveInt: vi.fn((v) => Number(v)),
mockWithRetry: vi.fn(async (fn) => fn()),
}));
vi.mock('./utils.js', () => ({
CLAUDE_DOMAIN: 'claude.ai',
CLAUDE_URL: 'https://claude.ai/new',
ensureOnClaude: mockEnsureOnClaude,
ensureClaudeComposer: mockEnsureClaudeComposer,
selectModel: mockSelectModel,
setAdaptiveThinking: mockSetAdaptiveThinking,
sendMessage: mockSendMessage,
sendWithFile: mockSendWithFile,
getBubbleCount: mockGetBubbleCount,
waitForResponse: mockWaitForResponse,
parseBoolFlag: mockParseBoolFlag,
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
requirePositiveInt: mockRequirePositiveInt,
withRetry: mockWithRetry,
}));
import { askCommand } from './ask.js';
describe('claude ask basic flow', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
};
beforeEach(() => {
vi.clearAllMocks();
page.evaluate.mockResolvedValue('https://claude.ai/new');
mockEnsureOnClaude.mockResolvedValue(false);
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
mockSendMessage.mockResolvedValue({ ok: true });
mockSendWithFile.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(0);
mockWaitForResponse.mockResolvedValue('hello there');
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
mockRequirePositiveInt.mockImplementation((v) => Number(v));
});
it('returns the assistant response on a fresh chat', async () => {
const rows = await askCommand.func(page, {
prompt: 'hi',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
});
expect(rows).toEqual([{ response: 'hello there' }]);
expect(mockSendMessage).toHaveBeenCalledWith(page, 'hi');
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 0, 'hi', 120000);
});
it('navigates to /new when --new is set', async () => {
await askCommand.func(page, {
prompt: 'hi',
timeout: 120,
new: true,
model: 'sonnet',
think: false,
});
expect(page.goto).toHaveBeenCalledWith('https://claude.ai/new');
expect(mockEnsureOnClaude).not.toHaveBeenCalled();
});
it('throws EmptyResultError when waitForResponse yields nothing', async () => {
mockWaitForResponse.mockResolvedValue(null);
await expect(askCommand.func(page, {
prompt: 'hi',
timeout: 60,
new: false,
model: 'sonnet',
think: false,
})).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when send fails', async () => {
mockSendMessage.mockResolvedValue({ ok: false, reason: 'composer not found' });
await expect(askCommand.func(page, {
prompt: 'hi',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
})).rejects.toThrow(/composer not found/);
});
});
describe('claude ask --model handling', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockEnsureOnClaude.mockResolvedValue(false);
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
mockSendMessage.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(0);
mockWaitForResponse.mockResolvedValue('reply');
});
it('rejects --model opus on free tier with usage-error guidance', async () => {
page.evaluate.mockResolvedValue('https://claude.ai/new');
mockSelectModel.mockResolvedValue({ ok: false, upgrade: true });
await expect(askCommand.func(page, {
prompt: 'hi',
timeout: 120,
new: false,
model: 'opus',
think: false,
})).rejects.toMatchObject(new ArgumentError(
'opus model requires a paid Claude plan.',
'Pick --model sonnet or --model haiku, or upgrade your account.',
));
});
it('skips model selection inside an existing conversation', async () => {
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
const rows = await askCommand.func(page, {
prompt: 'continue',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
});
expect(rows).toEqual([{ response: 'reply' }]);
expect(mockSelectModel).not.toHaveBeenCalled();
});
it('fails fast when --model is explicit inside an existing conversation', async () => {
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
await expect(askCommand.func(page, {
prompt: 'continue',
timeout: 120,
new: false,
model: 'opus',
think: false,
__opencliOptionSources: { model: 'cli' },
})).rejects.toMatchObject(new ArgumentError(
'Cannot switch to opus model inside an existing conversation.',
'Re-run with --new to start a fresh chat before selecting a model.',
));
expect(mockSelectModel).not.toHaveBeenCalled();
});
});
describe('claude ask --think', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
};
beforeEach(() => {
vi.clearAllMocks();
mockEnsureOnClaude.mockResolvedValue(false);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
mockSendMessage.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(0);
mockWaitForResponse.mockResolvedValue('reply');
});
it('toggles Adaptive thinking when --think is set', async () => {
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: true });
await askCommand.func(page, {
prompt: 'reason carefully',
timeout: 120,
new: false,
model: 'sonnet',
think: true,
});
expect(mockSetAdaptiveThinking).toHaveBeenCalledWith(page, true);
});
it('throws when --think requested but toggle fails', async () => {
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
await expect(askCommand.func(page, {
prompt: 'reason carefully',
timeout: 120,
new: false,
model: 'sonnet',
think: true,
})).rejects.toThrow(/Adaptive thinking/);
});
it('does not throw when --think is false and toggle returns ok=false', async () => {
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
await expect(askCommand.func(page, {
prompt: 'hi',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
})).resolves.toEqual([{ response: 'reply' }]);
});
it('fails fast when prompt validation rejects an empty prompt', async () => {
mockRequireNonEmptyPrompt.mockImplementation(() => {
throw new ArgumentError('claude ask prompt cannot be empty');
});
await expect(askCommand.func(page, {
prompt: '',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
})).rejects.toThrow(ArgumentError);
});
it('fails fast when timeout validation rejects a non-positive value', async () => {
mockRequirePositiveInt.mockImplementation(() => {
throw new ArgumentError('claude ask --timeout must be a positive integer');
});
await expect(askCommand.func(page, {
prompt: 'hi',
timeout: 0,
new: false,
model: 'sonnet',
think: false,
})).rejects.toThrow(ArgumentError);
});
});
describe('claude ask --file', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
};
beforeEach(() => {
vi.clearAllMocks();
mockEnsureOnClaude.mockResolvedValue(false);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
mockSendWithFile.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(3);
mockWaitForResponse.mockResolvedValue('the image shows a cat');
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
mockRequirePositiveInt.mockImplementation((v) => Number(v));
});
it('routes through sendWithFile and captures baseline before sending', async () => {
const rows = await askCommand.func(page, {
prompt: 'describe this',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
file: '/tmp/cat.png',
});
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
expect(mockSendWithFile).toHaveBeenCalledWith(page, '/tmp/cat.png', 'describe this');
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 3, 'describe this', 120000);
});
it('surfaces file upload failure as CommandExecutionError', async () => {
mockSendWithFile.mockResolvedValue({ ok: false, reason: 'file preview did not appear' });
await expect(askCommand.func(page, {
prompt: 'describe this',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
file: '/tmp/cat.png',
})).rejects.toThrow(/file preview did not appear/);
});
it('absorbs "Promise was collected" SPA navigation error after send', async () => {
mockSendWithFile.mockRejectedValue(new Error('Promise was collected'));
const rows = await askCommand.func(page, {
prompt: 'describe this',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
file: '/tmp/cat.png',
});
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
const {
mockEnsureOnClaude,
mockEnsureClaudeComposer,
mockEnsureClaudeLogin,
mockSendMessage,
mockParseBoolFlag,
mockRequireNonEmptyPrompt,
mockGetVisibleMessages,
mockGetConversationList,
mockRequirePositiveInt,
mockRequireConversationId,
mockWithRetry,
} = vi.hoisted(() => ({
mockEnsureOnClaude: vi.fn(),
mockEnsureClaudeComposer: vi.fn(),
mockEnsureClaudeLogin: vi.fn(),
mockSendMessage: vi.fn(),
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
mockGetVisibleMessages: vi.fn(),
mockGetConversationList: vi.fn(),
mockRequirePositiveInt: vi.fn((v) => Number(v)),
mockRequireConversationId: vi.fn((v) => String(v ?? '').trim()),
mockWithRetry: vi.fn(async (fn) => fn()),
}));
vi.mock('./utils.js', () => ({
CLAUDE_DOMAIN: 'claude.ai',
CLAUDE_URL: 'https://claude.ai/new',
ensureOnClaude: mockEnsureOnClaude,
ensureClaudeComposer: mockEnsureClaudeComposer,
ensureClaudeLogin: mockEnsureClaudeLogin,
sendMessage: mockSendMessage,
parseBoolFlag: mockParseBoolFlag,
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
getVisibleMessages: mockGetVisibleMessages,
getConversationList: mockGetConversationList,
requirePositiveInt: mockRequirePositiveInt,
requireConversationId: mockRequireConversationId,
withRetry: mockWithRetry,
}));
import { sendCommand } from './send.js';
import { newCommand } from './new.js';
import { readCommand } from './read.js';
import { historyCommand } from './history.js';
import { detailCommand } from './detail.js';
describe('claude command-level fail-fast contracts', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
vi.clearAllMocks();
mockEnsureOnClaude.mockResolvedValue(false);
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
mockEnsureClaudeLogin.mockResolvedValue({ isLoggedIn: true });
mockSendMessage.mockResolvedValue({ ok: true });
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
mockGetVisibleMessages.mockResolvedValue([{ Index: 0, Role: 'assistant', Text: 'hi' }]);
mockGetConversationList.mockResolvedValue([{ Index: 1, Id: 'abc', Title: 'Hi', Url: 'https://claude.ai/chat/abc' }]);
mockRequirePositiveInt.mockImplementation((v) => Number(v));
mockRequireConversationId.mockImplementation((v) => String(v ?? '').trim());
});
it('send rejects empty prompt via ArgumentError', async () => {
mockRequireNonEmptyPrompt.mockImplementation(() => {
throw new ArgumentError('claude send prompt cannot be empty');
});
await expect(sendCommand.func(page, { prompt: '', new: false })).rejects.toThrow(ArgumentError);
});
it('send surfaces auth failure from composer readiness', async () => {
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude send requires a logged-in Claude session.'));
await expect(sendCommand.func(page, { prompt: 'hi', new: false })).rejects.toThrow(AuthRequiredError);
});
it('new no longer false-succeeds on login wall', async () => {
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude new requires a logged-in Claude session with a visible composer.'));
await expect(newCommand.func(page)).rejects.toThrow(AuthRequiredError);
});
it('read throws EmptyResultError instead of a placeholder row', async () => {
mockGetVisibleMessages.mockResolvedValue([]);
await expect(readCommand.func(page)).rejects.toThrow(EmptyResultError);
});
it('history rejects invalid --limit values instead of silently coercing them', async () => {
mockRequirePositiveInt.mockImplementation(() => {
throw new ArgumentError('claude history --limit must be a positive integer');
});
await expect(historyCommand.func(page, { limit: 0 })).rejects.toThrow(ArgumentError);
});
it('history throws EmptyResultError on an empty /recents page', async () => {
mockGetConversationList.mockResolvedValue([]);
await expect(historyCommand.func(page, { limit: 20 })).rejects.toThrow(EmptyResultError);
});
it('detail rejects a missing conversation id', async () => {
mockRequireConversationId.mockImplementation(() => {
throw new ArgumentError('claude detail requires a conversation id');
});
await expect(detailCommand.func(page, { id: '' })).rejects.toThrow(ArgumentError);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { CLAUDE_DOMAIN, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
export const detailCommand = cli({
site: 'claude',
name: 'detail',
description: 'Open a Claude conversation by ID and read its messages',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/<id>)' },
],
columns: ['Index', 'Role', 'Text'],
func: async (page, kwargs) => {
const id = requireConversationId(kwargs.id);
await page.goto(`https://claude.ai/chat/${id}`);
await page.wait(4);
await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.');
const messages = await getVisibleMessages(page);
if (messages.length > 0) return messages;
throw new EmptyResultError('claude detail', `No visible Claude messages were found for conversation ${id}.`);
},
});
+31
View File
@@ -0,0 +1,31 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { CLAUDE_DOMAIN, getConversationList, ensureClaudeLogin, requirePositiveInt } from './utils.js';
export const historyCommand = cli({
site: 'claude',
name: 'history',
description: 'List conversation history from Claude /recents',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
],
columns: ['Index', 'Id', 'Title', 'Url'],
func: async (page, kwargs) => {
const limit = requirePositiveInt(
Number(kwargs.limit ?? 20),
'claude history --limit',
'Example: opencli claude history --limit 20',
);
const conversations = await getConversationList(page);
await ensureClaudeLogin(page, 'Claude history requires a logged-in Claude session.');
if (conversations.length === 0) {
throw new EmptyResultError('claude history', 'No Claude conversation history was visible on /recents.');
}
return conversations.slice(0, limit);
},
});
+21
View File
@@ -0,0 +1,21 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureClaudeComposer } from './utils.js';
export const newCommand = cli({
site: 'claude',
name: 'new',
description: 'Start a new conversation in Claude',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Status'],
func: async (page) => {
await page.goto(CLAUDE_URL);
await page.wait(2);
await ensureClaudeComposer(page, 'Claude new requires a logged-in Claude session with a visible composer.');
return [{ Status: 'New chat started' }];
},
});
+24
View File
@@ -0,0 +1,24 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { CLAUDE_DOMAIN, ensureOnClaude, getVisibleMessages, ensureClaudeLogin } from './utils.js';
export const readCommand = cli({
site: 'claude',
name: 'read',
description: 'Read the current Claude conversation',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Index', 'Role', 'Text'],
func: async (page) => {
await ensureOnClaude(page);
await page.wait(3);
await ensureClaudeLogin(page, 'Claude read requires a logged-in Claude session.');
const messages = await getVisibleMessages(page);
if (messages.length > 0) return messages;
throw new EmptyResultError('claude read', 'No visible Claude messages were found in the current conversation.');
},
});
+41
View File
@@ -0,0 +1,41 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js';
export const sendCommand = cli({
site: 'claude',
name: 'send',
description: 'Send a prompt to Claude without waiting for the response',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
],
columns: ['Status', 'SubmittedBy', 'InjectedText'],
func: async (page, kwargs) => {
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude send');
if (parseBoolFlag(kwargs.new)) {
await page.goto(CLAUDE_URL);
await page.wait(3);
} else {
await ensureOnClaude(page);
await page.wait(2);
}
await withRetry(() => ensureClaudeComposer(page, 'Claude send requires a visible composer on the current page.'));
const sendResult = await withRetry(() => sendMessage(page, prompt));
if (!sendResult?.ok) {
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
}
return [{
Status: 'Success',
SubmittedBy: sendResult.method || 'send-button',
InjectedText: prompt,
}];
},
});
+24
View File
@@ -0,0 +1,24 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CLAUDE_DOMAIN, ensureOnClaude, getPageState } from './utils.js';
export const statusCommand = cli({
site: 'claude',
name: 'status',
description: 'Check Claude page availability and login state',
domain: CLAUDE_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Status', 'Login', 'Url'],
func: async (page) => {
await ensureOnClaude(page);
const state = await getPageState(page);
return [{
Status: state.hasComposer ? 'Connected' : 'Page not ready',
Login: state.isLoggedIn ? 'Yes' : 'No',
Url: state.url,
}];
},
});
+440
View File
@@ -0,0 +1,440 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
export const CLAUDE_DOMAIN = 'claude.ai';
export const CLAUDE_URL = 'https://claude.ai/new';
export const COMPOSER_SELECTOR = '[data-testid="chat-input"]';
export const MESSAGE_SELECTOR = '.font-claude-response';
export const MODEL_DROPDOWN_SELECTOR = '[data-testid="model-selector-dropdown"]';
const MODEL_DISPLAY_NAMES = {
sonnet: 'Sonnet 4.6',
opus: 'Opus 4.7',
haiku: 'Haiku 4.5',
};
export async function isOnClaude(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string' || !url) return false;
try {
const h = new URL(url).hostname;
return h === CLAUDE_DOMAIN || h.endsWith(`.${CLAUDE_DOMAIN}`);
} catch {
return false;
}
}
export async function ensureOnClaude(page) {
if (await isOnClaude(page)) return false;
await page.goto(CLAUDE_URL);
await page.wait(3);
return true;
}
export async function getPageState(page) {
return page.evaluate(`(() => {
var composer = document.querySelector('${COMPOSER_SELECTOR}');
var userMenu = document.querySelector('[data-testid="user-menu-button"]');
return {
url: window.location.href,
title: document.title,
hasComposer: !!composer,
isLoggedIn: !!userMenu,
};
})()`);
}
export async function ensureClaudeLogin(page, message = 'Claude requires a logged-in browser session.') {
const state = await getPageState(page);
if (!state.isLoggedIn) {
throw new AuthRequiredError(CLAUDE_DOMAIN, message);
}
return state;
}
export async function ensureClaudeComposer(page, message = 'Claude composer is not available on the current page.') {
const state = await ensureClaudeLogin(page, message);
if (!state.hasComposer) {
throw new CommandExecutionError(message);
}
return state;
}
export function requireNonEmptyPrompt(prompt, commandName) {
const text = String(prompt ?? '').trim();
if (!text) {
throw new ArgumentError(
`${commandName} prompt cannot be empty`,
`Example: opencli ${commandName} "hello"`,
);
}
return text;
}
export function requirePositiveInt(value, flagLabel, hint) {
if (!Number.isInteger(value) || value < 1) {
throw new ArgumentError(`${flagLabel} must be a positive integer`, hint);
}
return value;
}
export function requireConversationId(value) {
const id = String(value ?? '').trim();
if (!id) {
throw new ArgumentError(
'claude detail requires a conversation id',
'Example: opencli claude detail 123e4567-e89b-12d3-a456-426614174000',
);
}
return id;
}
export async function getVisibleMessages(page) {
const result = await page.evaluate(`(() => {
var nodes = document.querySelectorAll('[data-testid="user-message"], ${MESSAGE_SELECTOR}');
var rows = [];
Array.from(nodes).forEach(function(el) {
var isUser = el.getAttribute('data-testid') === 'user-message';
var raw = (el.innerText || '').trim();
if (!isUser) {
var parts = raw.split(/\\n\\n+/);
while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
raw = parts.join('\\n\\n').trim();
}
if (raw) rows.push({ role: isUser ? 'user' : 'assistant', text: raw });
});
return rows;
})()`);
if (!Array.isArray(result)) return [];
return result.map(function(r, i) { return { Index: i, Role: r.role, Text: r.text }; });
}
export async function getConversationList(page) {
if (!(await isOnClaude(page)) || !(await page.evaluate('window.location.href') || '').includes('/recents')) {
await page.goto('https://claude.ai/recents');
await page.wait(3);
}
const items = await page.evaluate(`(() => {
var links = Array.from(document.querySelectorAll('a[href*="/chat/"]'));
return links.map(function(link, i) {
var href = link.getAttribute('href') || '';
var idMatch = href.match(/\\/chat\\/([a-f0-9-]+)/);
return {
Index: i + 1,
Id: idMatch ? idMatch[1] : href,
Title: (link.innerText || '').trim().split('\\n')[0].trim() || '(untitled)',
Url: href.startsWith('http') ? href : ('https://claude.ai' + href),
};
});
})()`);
return Array.isArray(items) ? items : [];
}
export async function selectModel(page, modelName) {
const display = MODEL_DISPLAY_NAMES[String(modelName).toLowerCase()];
if (!display) return { ok: false };
const opened = await page.evaluate(`(() => {
var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
if (!trigger) return { ok: false };
var label = trigger.getAttribute('aria-label') || '';
if (label.indexOf(${JSON.stringify(display)}) >= 0) {
return { ok: true, toggled: false };
}
trigger.click();
return { ok: true, opened: true };
})()`);
if (!opened?.ok) return opened;
if (!opened.opened) return opened;
await page.wait(0.6);
return page.evaluate(`(() => {
var items = Array.from(document.querySelectorAll('div[role="menuitemradio"]'));
var target = items.find(function(el) { return (el.innerText || '').indexOf(${JSON.stringify(display)}) >= 0; });
if (!target) return { ok: false };
// Free-tier locked options carry an inline "Upgrade" button next to the label.
var upgrade = target.querySelector('button');
if (upgrade && (upgrade.innerText || '').toLowerCase().indexOf('upgrade') >= 0) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return { ok: false, upgrade: true };
}
var alreadySelected = target.getAttribute('aria-checked') === 'true';
if (!alreadySelected) target.click();
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return { ok: true, toggled: !alreadySelected };
})()`);
}
export async function setAdaptiveThinking(page, enabled) {
const opened = await page.evaluate(`(() => {
var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
if (!trigger) return { ok: false };
trigger.click();
return { ok: true };
})()`);
if (!opened?.ok) return { ok: false };
await page.wait(0.6);
return page.evaluate(`(() => {
var items = Array.from(document.querySelectorAll('div[role="menuitem"]'));
var target = items.find(function(el) { return (el.innerText || '').indexOf('Adaptive thinking') >= 0; });
if (!target) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return { ok: false };
}
var isActive = target.getAttribute('aria-checked') === 'true';
if (${enabled} !== isActive) target.click();
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return { ok: true, toggled: ${enabled} !== isActive };
})()`);
}
export async function sendMessage(page, prompt) {
const promptJson = JSON.stringify(prompt);
const composerReady = await page.evaluate(`(() => {
var box = document.querySelector('${COMPOSER_SELECTOR}');
if (!box) return false;
box.focus();
// ProseMirror editors hold content in nested <p>; clear via Range/delete
// rather than .value or textContent, which the editor won't notice.
var sel = window.getSelection();
sel.removeAllRanges();
var range = document.createRange();
range.selectNodeContents(box);
sel.addRange(range);
document.execCommand('delete', false);
return true;
})()`);
if (!composerReady) return { ok: false, reason: 'composer not found' };
let typedNatively = false;
if (page.nativeType) {
try {
await page.nativeType(prompt);
typedNatively = true;
} catch (err) {
const msg = String(err?.message || err);
if (!msg.includes('Unknown action') && !msg.includes('not supported')) throw err;
}
}
if (!typedNatively) {
await page.evaluate(`(() => {
var box = document.querySelector('${COMPOSER_SELECTOR}');
if (!box) return;
box.focus();
document.execCommand('insertText', false, ${promptJson});
})()`);
}
await page.wait(1.2);
return page.evaluate(`(() => {
var ariaCandidates = [
'button[aria-label="Send Message"]',
'button[aria-label="Send message"]',
'button[aria-label="Send"]',
'button[aria-label*="Send"]',
];
for (var i = 0; i < ariaCandidates.length; i++) {
var btn = document.querySelector(ariaCandidates[i]);
if (btn && !btn.disabled) { btn.click(); return { ok: true }; }
}
// Fallback: rightmost enabled button with an svg in the composer container.
var box = document.querySelector('${COMPOSER_SELECTOR}');
if (box) {
var c = box.parentElement;
for (var hop = 0; hop < 6 && c; hop++) {
var btns = Array.from(c.querySelectorAll('button')).filter(function(b) { return !b.disabled && b.querySelector('svg'); });
if (btns.length) { btns[btns.length - 1].click(); return { ok: true, method: 'fallback' }; }
c = c.parentElement;
}
}
var box2 = document.querySelector('${COMPOSER_SELECTOR}');
if (box2) {
box2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
return { ok: true, method: 'enter' };
}
return { ok: false, reason: 'send button not found' };
})()`);
}
export async function getBubbleCount(page) {
const count = await page.evaluate(`(() => {
return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
})()`);
return count || 0;
}
export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
const startTime = Date.now();
let lastText = '';
let stableCount = 0;
while (Date.now() - startTime < timeoutMs) {
await page.wait(3);
let result;
try {
result = await page.evaluate(`(() => {
var bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
// Adaptive thinking renders "Thought process" labels at the top
// of the response (often duplicated for the expand/collapse widget).
// Strip them so the row value is the actual answer text.
var texts = Array.from(bubbles).map(function(b) {
var raw = (b.innerText || '').trim();
// Drop leading paragraphs that are widget labels:
// "Thought process" / "Thought for Xs" — Adaptive thinking expand widget
// "View uploaded image" / "View attachment" — file thumbnail label
// These render twice (collapsed + expanded) and are followed by a blank line.
var parts = raw.split(/\\n\\n+/);
while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
return parts.join('\\n\\n').trim();
}).filter(Boolean);
return {
count: texts.length,
last: texts[texts.length - 1] || '',
streaming: !!document.querySelector('[data-is-streaming="true"]'),
};
})()`);
} catch {
continue;
}
if (!result) continue;
const candidate = result.last;
if (!candidate || candidate === prompt.trim()) continue;
if (result.count <= baselineCount) continue;
if (result.streaming) {
lastText = candidate;
stableCount = 0;
continue;
}
if (candidate === lastText) {
stableCount++;
if (stableCount >= 3) return candidate;
} else {
stableCount = 0;
lastText = candidate;
}
}
return lastText || null;
}
async function waitForFilePreview(page, fileName) {
for (let attempt = 0; attempt < 12; attempt++) {
await page.wait(1);
const ready = await page.evaluate(`(() => {
// Claude renders attachments as data-testid="file-thumbnail" cards with
// a sibling Remove button. Either signal indicates the file took.
if (document.querySelector('[data-testid="file-thumbnail"]')) return true;
var removeBtn = Array.from(document.querySelectorAll('button'))
.find(function(b) { return (b.getAttribute('aria-label') || '') === 'Remove'; });
return !!removeBtn;
})()`);
if (ready) return true;
}
return false;
}
export async function sendWithFile(page, filePath, prompt) {
const fs = await import('node:fs');
const path = await import('node:path');
const absPath = path.default.resolve(filePath);
if (!fs.default.existsSync(absPath)) {
return { ok: false, reason: `File not found: ${absPath}` };
}
const stats = fs.default.statSync(absPath);
if (stats.size > 30 * 1024 * 1024) {
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 30 MB` };
}
const fileName = path.default.basename(absPath);
let uploaded = false;
if (page.setFileInput) {
try {
// Upload via CDP so the file content does not cross the daemon body
// limit, then trigger React's controlled onChange manually because
// CDP assigns .files without firing the synthetic event React listens for.
await page.setFileInput([absPath], 'input[data-testid="file-upload"]');
const fired = await page.evaluate(`(() => {
var inp = document.querySelector('input[data-testid="file-upload"]');
if (!inp) return { ok: false, reason: 'file input not found' };
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
if (propsKey && typeof inp[propsKey].onChange === 'function') {
inp[propsKey].onChange({ target: { files: inp.files } });
return { ok: true, via: 'react' };
}
inp.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true, via: 'native' };
})()`);
if (!fired?.ok) return fired;
uploaded = true;
} catch (err) {
const msg = String(err?.message || err);
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
throw err;
}
}
}
if (!uploaded) {
const content = fs.default.readFileSync(absPath);
const base64 = content.toString('base64');
const fallbackResult = await page.evaluate(`(async () => {
var binary = atob('${base64}');
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
var file = new File([bytes], ${JSON.stringify(fileName)});
var dt = new DataTransfer();
dt.items.add(file);
var inp = document.querySelector('input[data-testid="file-upload"]');
if (!inp) return { ok: false, reason: 'file input not found' };
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
return { ok: false, reason: 'React onChange not found' };
}
inp.files = dt.files;
inp[propsKey].onChange({ target: { files: inp.files } });
return { ok: true };
})()`);
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
}
const ready = await waitForFilePreview(page, fileName);
if (!ready) return { ok: false, reason: 'file preview did not appear' };
return sendMessage(page, prompt);
}
// Retries on CDP "Promise was collected" errors caused by Claude SPA route changes.
export async function withRetry(fn, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
const msg = String(err?.message || err);
if (i < retries && msg.includes('Promise was collected')) {
await new Promise(r => setTimeout(r, 2000));
continue;
}
throw err;
}
}
}
export function parseBoolFlag(value) {
if (typeof value === 'boolean') return value;
return String(value ?? '').trim().toLowerCase() === 'true';
}
+148
View File
@@ -0,0 +1,148 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError } from '@jackwener/opencli/errors';
import { parseBoolFlag, sendWithFile, selectModel, requireConversationId, requireNonEmptyPrompt, requirePositiveInt } from './utils.js';
describe('claude parseBoolFlag', () => {
it('returns booleans unchanged', () => {
expect(parseBoolFlag(true)).toBe(true);
expect(parseBoolFlag(false)).toBe(false);
});
it('treats only "true" string (case-insensitive) as true', () => {
expect(parseBoolFlag('true')).toBe(true);
expect(parseBoolFlag('TRUE')).toBe(true);
expect(parseBoolFlag('1')).toBe(false);
expect(parseBoolFlag('yes')).toBe(false);
expect(parseBoolFlag('')).toBe(false);
expect(parseBoolFlag(null)).toBe(false);
expect(parseBoolFlag(undefined)).toBe(false);
});
});
describe('claude argument helpers', () => {
it('rejects blank prompts', () => {
expect(() => requireNonEmptyPrompt(' ', 'claude ask')).toThrow(ArgumentError);
});
it('rejects non-positive integers for numeric flags', () => {
expect(() => requirePositiveInt(0, 'claude ask --timeout')).toThrow(ArgumentError);
expect(() => requirePositiveInt(-1, 'claude history --limit')).toThrow(ArgumentError);
});
it('rejects missing conversation ids', () => {
expect(() => requireConversationId(' ')).toThrow(ArgumentError);
});
});
describe('claude sendWithFile', () => {
const tempDirs = [];
afterEach(() => {
vi.restoreAllMocks();
while (tempDirs.length) {
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
it('prefers page.setFileInput, then sends after preview appears', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'cat.png');
fs.writeFileSync(filePath, 'fake');
const page = {
nativeType: vi.fn().mockResolvedValue(undefined),
setFileInput: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: true, via: 'react' }) // React onChange fired after setFileInput
.mockResolvedValueOnce(true) // waitForFilePreview hit
.mockResolvedValueOnce(true) // composer ready
.mockResolvedValueOnce({ ok: true }), // send button click
};
const result = await sendWithFile(page, filePath, 'describe this');
expect(result).toEqual({ ok: true });
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[data-testid="file-upload"]');
expect(page.nativeType).toHaveBeenCalledWith('describe this');
});
it('returns file-not-found when path does not exist', async () => {
const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
const result = await sendWithFile(page, '/no/such/file.png', 'hi');
expect(result.ok).toBe(false);
expect(result.reason).toContain('File not found');
});
it('rejects oversized files before any upload attempt', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'big.bin');
fs.writeFileSync(filePath, Buffer.alloc(31 * 1024 * 1024));
const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
const result = await sendWithFile(page, filePath, 'hi');
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/too large/);
expect(page.setFileInput).not.toHaveBeenCalled();
});
});
describe('claude selectModel', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('rejects unknown model keys without touching the page', async () => {
const page = { evaluate: vi.fn() };
const result = await selectModel(page, 'gpt5');
expect(result).toEqual({ ok: false });
expect(page.evaluate).not.toHaveBeenCalled();
});
it('returns toggled=false when the dropdown already shows the requested model', async () => {
const page = {
evaluate: vi.fn().mockResolvedValueOnce({ ok: true, toggled: false }),
wait: vi.fn(),
};
const result = await selectModel(page, 'sonnet');
expect(result).toEqual({ ok: true, toggled: false });
expect(page.wait).not.toHaveBeenCalled();
});
it('opens the dropdown and clicks the matching radio', async () => {
const page = {
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: true, opened: true })
.mockResolvedValueOnce({ ok: true, toggled: true }),
wait: vi.fn().mockResolvedValue(undefined),
};
const result = await selectModel(page, 'haiku');
expect(result).toEqual({ ok: true, toggled: true });
expect(page.evaluate).toHaveBeenCalledTimes(2);
});
it('flags upgrade-required when picking a paid model on free tier', async () => {
const page = {
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: true, opened: true })
.mockResolvedValueOnce({ ok: false, upgrade: true }),
wait: vi.fn().mockResolvedValue(undefined),
};
const result = await selectModel(page, 'opus');
expect(result).toEqual({ ok: false, upgrade: true });
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const askCommand = cli({
site: 'codex',
name: 'ask',
@@ -34,7 +34,7 @@ export const askCommand = cli({
})(${JSON.stringify(text)})
`);
if (!injected)
throw new SelectorError('Codex input element');
throw selectorError('Codex input element');
await page.wait(0.5);
await page.pressKey('Enter');
// Poll for new content
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const sendCommand = cli({
site: 'codex',
name: 'send',
@@ -28,7 +28,7 @@ export const sendCommand = cli({
})(${JSON.stringify(textToInsert)})
`);
if (!injected)
throw new SelectorError('Codex Composer input element');
throw selectorError('Codex Composer input element');
// Wait for the UI to register the input
await page.wait(0.5);
// Simulate Enter key to submit
+1 -1
View File
@@ -34,7 +34,7 @@ cli({
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
],
columns: ['rank', 'name', 'type', 'score', 'price', 'url'],
func: async (_page, kwargs) => {
func: async (kwargs) => {
const query = String(kwargs.query || '').trim();
if (!query) {
throw new ArgumentError('Search keyword cannot be empty');
+4 -4
View File
@@ -25,7 +25,7 @@ describe('ctrip search', () => {
],
},
}), { status: 200 })));
const result = await command.func(null, { query: '苏州', limit: 3 });
const result = await command.func({ query: '苏州', limit: 3 });
expect(result).toEqual([
{
rank: 1,
@@ -46,11 +46,11 @@ describe('ctrip search', () => {
]);
});
it('rejects empty queries', async () => {
await expect(command.func(null, { query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
await expect(command.func({ query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
});
it('surfaces fetch failures as CliError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 503 })));
await expect(command.func(null, { query: '苏州', limit: 3 })).rejects.toMatchObject({
await expect(command.func({ query: '苏州', limit: 3 })).rejects.toMatchObject({
code: 'FETCH_ERROR',
message: 'ctrip search failed with status 503',
});
@@ -59,6 +59,6 @@ describe('ctrip search', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
Response: { searchResults: [] },
}), { status: 200 })));
await expect(command.func(null, { query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
await expect(command.func({ query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const askCommand = cli({
site: 'cursor',
name: 'ask',
@@ -28,7 +28,7 @@ export const askCommand = cli({
return true;
})(${JSON.stringify(text)})`);
if (!injected)
throw new SelectorError('Cursor input element');
throw selectorError('Cursor input element');
await page.wait(0.5);
await page.pressKey('Enter');
// Poll until a new assistant message appears or timeout
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const composerCommand = cli({
site: 'cursor',
name: 'composer',
@@ -27,7 +27,7 @@ export const composerCommand = cli({
return true;
})(${JSON.stringify(textToInsert)})`);
if (!typed) {
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
throw selectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
}
await page.wait(0.5);
await page.pressKey('Enter');
+2 -2
View File
@@ -1,5 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { SelectorError } from '@jackwener/opencli/errors';
import { selectorError } from '@jackwener/opencli/errors';
export const sendCommand = cli({
site: 'cursor',
name: 'send',
@@ -24,7 +24,7 @@ export const sendCommand = cli({
return true;
})(${JSON.stringify(textToInsert)})`);
if (!injected) {
throw new SelectorError('Cursor Composer input element');
throw selectorError('Cursor Composer input element');
}
// Submit the command. In Cursor, Enter usually submits the chat.
await page.wait(0.5);
+139
View File
@@ -0,0 +1,139 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
import {
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
} from './utils.js';
export const askCommand = cli({
site: 'deepseek',
name: 'ask',
description: 'Send a prompt to DeepSeek and get the response',
domain: DEEPSEEK_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
timeoutSeconds: 180,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
{ name: 'model', default: 'instant', choices: ['instant', 'expert', 'vision'], help: 'Model to use: instant, expert, or vision' },
{ name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' },
{ name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
{ name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
],
// columns omitted: derived from row keys so non-think output shows only 'response'
func: async (page, kwargs) => {
const prompt = kwargs.prompt;
const timeoutMs = (kwargs.timeout || 120) * 1000;
const wantThink = parseBoolFlag(kwargs.think);
const wantSearch = parseBoolFlag(kwargs.search);
if (parseBoolFlag(kwargs.new)) {
await page.goto(DEEPSEEK_URL);
await page.wait(3);
} else {
const navigated = await ensureOnDeepSeek(page);
if (navigated) {
// Workspace was recycled; try to resume the most recent
// conversation instead of starting a new one.
await page.evaluate(`(() => {
var link = document.querySelector('a[href*="/a/chat/s/"]');
if (link) link.click();
})()`);
await page.wait(2);
}
}
await page.wait(2);
// Model selector is only available on the new-chat page, not inside
// an existing conversation. Skip it when we resumed a prior thread.
const currentUrl = await page.evaluate('window.location.href') || '';
const inConversation = currentUrl.includes('/a/chat/s/');
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
const wantModel = kwargs.model || 'instant';
if (inConversation && modelExplicit) {
throw new CliError(
'ARGUMENT',
`Cannot switch to ${wantModel} model inside an existing conversation.`,
'Re-run with --new to start a fresh chat before selecting a model.',
EXIT_CODES.USAGE_ERROR,
);
}
if (!inConversation) {
const modelResult = await withRetry(() => selectModel(page, wantModel));
if (!modelResult?.ok) {
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
}
if (modelResult?.toggled) await page.wait(0.5);
}
const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
if (!thinkResult?.ok && wantThink) {
throw new CommandExecutionError('Could not enable DeepThink');
}
if (wantModel === 'vision' && wantSearch) {
throw new CliError(
'ARGUMENT',
'DeepSeek vision mode does not support --search.',
'Run without --search, or use --model instant/expert for web search.',
EXIT_CODES.USAGE_ERROR,
);
}
// Vision mode does not have the search toggle.
let searchResult;
if (wantModel !== 'vision') {
searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
if (!searchResult?.ok && wantSearch) {
throw new CommandExecutionError('Could not enable Search');
}
}
if (thinkResult?.toggled || searchResult?.toggled) await page.wait(0.5);
if (kwargs.file) {
const baseline = await withRetry(() => getBubbleCount(page));
try {
const fileResult = await sendWithFile(page, kwargs.file, prompt);
if (fileResult && !fileResult.ok) {
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
}
} catch (err) {
// SPA navigates after send; "Promise was collected" means send succeeded
if (!String(err?.message || err).includes('Promise was collected')) throw err;
}
await page.wait(3);
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
if (!result) {
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
}
if (wantThink && typeof result === 'object' && result.response !== undefined) {
return [result];
}
return [{ response: result }];
}
const baseline = await withRetry(() => getBubbleCount(page));
const sendResult = await withRetry(() => sendMessage(page, prompt));
if (!sendResult?.ok) {
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
}
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
if (!result) {
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
}
if (wantThink && typeof result === 'object' && result.response !== undefined) {
return [result];
}
return [{ response: result }];
},
});
+312
View File
@@ -0,0 +1,312 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
const {
mockEnsureOnDeepSeek,
mockSelectModel,
mockSetFeature,
mockSendMessage,
mockSendWithFile,
mockGetBubbleCount,
mockWaitForResponse,
mockParseBoolFlag,
mockWithRetry,
} = vi.hoisted(() => ({
mockEnsureOnDeepSeek: vi.fn(),
mockSelectModel: vi.fn(),
mockSetFeature: vi.fn(),
mockSendMessage: vi.fn(),
mockSendWithFile: vi.fn(),
mockGetBubbleCount: vi.fn(),
mockWaitForResponse: vi.fn(),
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
mockWithRetry: vi.fn(async (fn) => fn()),
}));
vi.mock('./utils.js', () => ({
DEEPSEEK_DOMAIN: 'chat.deepseek.com',
DEEPSEEK_URL: 'https://chat.deepseek.com/',
ensureOnDeepSeek: mockEnsureOnDeepSeek,
selectModel: mockSelectModel,
setFeature: mockSetFeature,
sendMessage: mockSendMessage,
sendWithFile: mockSendWithFile,
getBubbleCount: mockGetBubbleCount,
waitForResponse: mockWaitForResponse,
parseBoolFlag: mockParseBoolFlag,
withRetry: mockWithRetry,
}));
import { askCommand } from './ask.js';
describe('deepseek ask --file', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
};
beforeEach(() => {
vi.clearAllMocks();
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
mockEnsureOnDeepSeek.mockResolvedValue(false);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
mockSendWithFile.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(7);
mockWaitForResponse.mockResolvedValue('new reply');
});
it('captures the existing baseline before sending a file prompt', async () => {
const rows = await askCommand.func(page, {
prompt: 'summarize this',
timeout: 120,
file: './report.pdf',
new: false,
model: 'instant',
think: false,
search: false,
});
expect(rows).toEqual([{ response: 'new reply' }]);
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000, false);
});
it('still fails when explicit instant model selection cannot be verified', async () => {
mockSelectModel.mockResolvedValue({ ok: false });
await expect(askCommand.func(page, {
prompt: 'summarize this',
timeout: 120,
new: false,
model: 'instant',
think: false,
search: false,
})).rejects.toThrow(new CommandExecutionError('Could not switch to instant model'));
});
});
describe('deepseek ask --think', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
};
beforeEach(() => {
vi.clearAllMocks();
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
mockEnsureOnDeepSeek.mockResolvedValue(false);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
mockSendMessage.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(5);
});
it('returns separate thinking and response fields when --think is enabled', async () => {
mockWaitForResponse.mockResolvedValue({
response: 'The answer is 42.',
thinking: 'Let me analyze this...',
thinking_time: '2.5',
});
const rows = await askCommand.func(page, {
prompt: 'what is the answer?',
timeout: 120,
new: false,
model: 'instant',
think: true,
search: false,
});
expect(rows).toEqual([{
response: 'The answer is 42.',
thinking: 'Let me analyze this...',
thinking_time: '2.5',
}]);
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, true);
});
it('returns plain response when --think is disabled', async () => {
mockWaitForResponse.mockResolvedValue('The answer is 42.');
const rows = await askCommand.func(page, {
prompt: 'what is the answer?',
timeout: 120,
new: false,
model: 'instant',
think: false,
search: false,
});
expect(rows).toEqual([{ response: 'The answer is 42.' }]);
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, false);
});
it('does not declare static columns (derived from row keys)', () => {
// columns should be undefined so the renderer infers from row keys,
// avoiding empty trailing columns on non-think output.
expect(askCommand.columns).toBeUndefined();
});
it('non-think rows only contain response key', async () => {
mockWaitForResponse.mockResolvedValue('Plain answer.');
const rows = await askCommand.func(page, {
prompt: 'hello',
timeout: 120,
new: false,
model: 'instant',
think: false,
search: false,
});
// Row keys drive rendered columns; no thinking/thinking_time present.
expect(Object.keys(rows[0])).toEqual(['response']);
});
});
describe('deepseek ask conversation resume', () => {
const page = {
wait: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
mockSendMessage.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(2);
mockWaitForResponse.mockResolvedValue('follow-up reply');
});
it('resumes the most recent conversation and skips model selection', async () => {
mockEnsureOnDeepSeek.mockResolvedValue(true);
// first evaluate: sidebar resume click (returns undefined)
page.evaluate.mockResolvedValueOnce(undefined);
// second evaluate: URL check (now inside a conversation)
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123');
const rows = await askCommand.func(page, {
prompt: 'follow up',
timeout: 120,
new: false,
model: 'instant',
think: false,
search: false,
});
expect(rows).toEqual([{ response: 'follow-up reply' }]);
expect(mockSelectModel).not.toHaveBeenCalled();
expect(mockSendMessage).toHaveBeenCalled();
});
it('skips model selection when already inside an existing conversation', async () => {
mockEnsureOnDeepSeek.mockResolvedValue(false);
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
const rows = await askCommand.func(page, {
prompt: 'continue',
timeout: 120,
new: false,
model: 'expert',
think: false,
search: false,
});
expect(rows).toEqual([{ response: 'follow-up reply' }]);
expect(mockSelectModel).not.toHaveBeenCalled();
});
it('fails fast when --model is explicitly requested inside an existing conversation', async () => {
mockEnsureOnDeepSeek.mockResolvedValue(false);
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
await expect(askCommand.func(page, {
prompt: 'continue',
timeout: 120,
new: false,
model: 'expert',
think: false,
search: false,
__opencliOptionSources: { model: 'cli' },
})).rejects.toMatchObject(new CliError(
'ARGUMENT',
'Cannot switch to expert model inside an existing conversation.',
'Re-run with --new to start a fresh chat before selecting a model.',
EXIT_CODES.USAGE_ERROR,
));
expect(mockSelectModel).not.toHaveBeenCalled();
});
it('still selects model when no conversation to resume', async () => {
mockEnsureOnDeepSeek.mockResolvedValue(true);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
// first evaluate: sidebar resume click (no link found)
page.evaluate.mockResolvedValueOnce(undefined);
// second evaluate: URL check (still on root page)
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/');
const rows = await askCommand.func(page, {
prompt: 'hello',
timeout: 120,
new: false,
model: 'instant',
think: false,
search: false,
});
expect(rows).toEqual([{ response: 'follow-up reply' }]);
expect(mockSelectModel).toHaveBeenCalled();
});
it('skips search toggle in vision mode when search is not requested', async () => {
mockEnsureOnDeepSeek.mockResolvedValue(false);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
mockSendMessage.mockResolvedValue({ ok: true });
mockGetBubbleCount.mockResolvedValue(0);
mockWaitForResponse.mockResolvedValue('vision reply');
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
const rows = await askCommand.func(page, {
prompt: 'describe',
timeout: 120,
new: false,
model: 'vision',
think: false,
search: false,
});
expect(rows).toEqual([{ response: 'vision reply' }]);
expect(mockSetFeature).toHaveBeenCalledTimes(1);
expect(mockSetFeature).toHaveBeenCalledWith(expect.anything(), 'DeepThink', false);
});
it('fails fast instead of silently ignoring --search in vision mode', async () => {
mockEnsureOnDeepSeek.mockResolvedValue(false);
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
await expect(askCommand.func(page, {
prompt: 'describe',
timeout: 120,
new: false,
model: 'vision',
think: false,
search: true,
})).rejects.toMatchObject(new CliError(
'ARGUMENT',
'DeepSeek vision mode does not support --search.',
'Run without --search, or use --model instant/expert for web search.',
EXIT_CODES.USAGE_ERROR,
));
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockSendWithFile).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { DEEPSEEK_DOMAIN, getConversationList } from './utils.js';
export const historyCommand = cli({
site: 'deepseek',
name: 'history',
description: 'List conversation history from DeepSeek sidebar',
domain: DEEPSEEK_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
],
columns: ['Index', 'Title', 'Url'],
func: async (page, kwargs) => {
const limit = Math.max(1, kwargs.limit || 20);
const conversations = await getConversationList(page);
if (conversations.length === 0) {
return [{ Index: 0, Title: 'No conversation history found.', Url: '' }];
}
return conversations.slice(0, limit);
},
});
+20
View File
@@ -0,0 +1,20 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { DEEPSEEK_DOMAIN, DEEPSEEK_URL } from './utils.js';
export const newCommand = cli({
site: 'deepseek',
name: 'new',
description: 'Start a new conversation in DeepSeek',
domain: DEEPSEEK_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Status'],
func: async (page) => {
await page.goto(DEEPSEEK_URL);
await page.wait(2);
return [{ Status: 'New chat started' }];
},
});
+22
View File
@@ -0,0 +1,22 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getVisibleMessages } from './utils.js';
export const readCommand = cli({
site: 'deepseek',
name: 'read',
description: 'Read the current DeepSeek conversation',
domain: DEEPSEEK_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Role', 'Text'],
func: async (page) => {
await ensureOnDeepSeek(page);
await page.wait(5);
const messages = await getVisibleMessages(page);
if (messages.length > 0) return messages;
return [{ Role: 'system', Text: 'No visible messages found.' }];
},
});
+24
View File
@@ -0,0 +1,24 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getPageState } from './utils.js';
export const statusCommand = cli({
site: 'deepseek',
name: 'status',
description: 'Check DeepSeek page availability and login state',
domain: DEEPSEEK_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Status', 'Login', 'Url'],
func: async (page) => {
await ensureOnDeepSeek(page);
const state = await getPageState(page);
return [{
Status: state.hasTextarea ? 'Connected' : 'Page not ready',
Login: state.isLoggedIn ? 'Yes' : 'No',
Url: state.url,
}];
},
});
+414
View File
@@ -0,0 +1,414 @@
export const DEEPSEEK_DOMAIN = 'chat.deepseek.com';
export const DEEPSEEK_URL = 'https://chat.deepseek.com/';
export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]';
export const MESSAGE_SELECTOR = '.ds-message';
export async function isOnDeepSeek(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string' || !url) return false;
try {
const h = new URL(url).hostname;
return h === 'deepseek.com' || h.endsWith('.deepseek.com');
} catch {
return false;
}
}
export async function ensureOnDeepSeek(page) {
if (await isOnDeepSeek(page)) return false;
await page.goto(DEEPSEEK_URL);
await page.wait(3);
return true;
}
export async function getPageState(page) {
return page.evaluate(`(() => {
const url = window.location.href;
const title = document.title;
const textarea = document.querySelector('${TEXTAREA_SELECTOR}');
const avatar = document.querySelector('img[src*="user-avatar"]');
return {
url,
title,
hasTextarea: !!textarea,
isLoggedIn: !!avatar,
};
})()`);
}
export async function selectModel(page, modelName) {
return page.evaluate(`(() => {
var radios = document.querySelectorAll('div[role="radio"]');
if (radios.length === 0) return { ok: false };
var name = '${modelName}'.toLowerCase();
var index = name === 'instant' ? 0 : name === 'expert' ? 1 : name === 'vision' ? 2 : -1;
if (index < 0 || index >= radios.length) return { ok: false };
var target = radios[index];
var alreadySelected = target.getAttribute('aria-checked') === 'true';
if (!alreadySelected) target.click();
return { ok: true, toggled: !alreadySelected };
})()`);
}
export async function setFeature(page, featureName, enabled) {
// Match by position: DeepThink is the first toggle, Search is the second
var index = featureName === 'DeepThink' ? 0 : 1;
return page.evaluate(`(() => {
var toggles = Array.from(document.querySelectorAll('.ds-toggle-button'));
var btn = toggles[${index}];
if (!btn) return { ok: false };
var isActive = btn.classList.contains('ds-toggle-button--selected');
if (${enabled} !== isActive) btn.click();
return { ok: true, toggled: ${enabled} !== isActive };
})()`);
}
export async function sendMessage(page, prompt) {
const promptJson = JSON.stringify(prompt);
return page.evaluate(`(async () => {
const box = document.querySelector('${TEXTAREA_SELECTOR}');
if (!box) return { ok: false, reason: 'textarea not found' };
box.focus();
box.value = '';
document.execCommand('selectAll');
document.execCommand('insertText', false, ${promptJson});
await new Promise(r => setTimeout(r, 800));
// Find the send button: last non-toggle button in the textarea's container
var container = box.parentElement;
while (container && !container.querySelector('div[role="button"]')) {
container = container.parentElement;
}
if (container) {
var btns = container.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
var sendBtn = btns[btns.length - 1];
if (sendBtn && sendBtn.getAttribute('aria-disabled') === 'false'
&& sendBtn.querySelectorAll('svg').length > 0) {
sendBtn.click();
return { ok: true };
}
}
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
return { ok: true, method: 'enter' };
})()`);
}
export async function getBubbleCount(page) {
const count = await page.evaluate(`(() => {
return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
})()`);
return count || 0;
}
// Parse thinking response using text as a fallback when DOM-level extraction
// is not available. Does NOT split on \n\n — that heuristic silently corrupts
// multi-paragraph thinking or multi-paragraph answers. Instead, everything
// after the header is treated as thinking content, and `response` stays empty
// until the caller provides a DOM-separated answer.
export function parseThinkingResponse(rawText) {
if (!rawText) return null;
// Match thinking header patterns: "Thought for X seconds" or "已思考(用时 X 秒)"
const thinkHeaderMatch = rawText.match(/^(Thought for ([\d.]+) seconds?|已思考(用时 ([\d.]+) 秒))\s*/);
if (!thinkHeaderMatch) {
// No thinking section found, return plain response
return { response: rawText, thinking: null, thinking_time: null };
}
const thinkingTime = thinkHeaderMatch[2] || thinkHeaderMatch[3];
const afterHeader = rawText.slice(thinkHeaderMatch[0].length);
// Treat everything after the header as thinking. The response will be
// populated by the DOM-level extraction in waitForResponse().
return {
response: '',
thinking: afterHeader.trim(),
thinking_time: thinkingTime,
};
}
export async function waitForResponse(page, baselineCount, prompt, timeoutMs, parseThinking = false) {
const startTime = Date.now();
let lastText = '';
let stableCount = 0;
while (Date.now() - startTime < timeoutMs) {
await page.wait(3);
let result;
try {
result = await page.evaluate(`(() => {
const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
var last = texts[texts.length - 1] || '';
// DOM-level thinking/response separation.
// DeepSeek renders thinking in a collapsible container with a
// distinct class (e.g. .ds-markdown--think or similar) and the
// final answer in the main .ds-markdown region. By querying
// these separately we avoid any text-heuristic split.
var thinkEl = null, answerEl = null, thinkTime = null;
if (${parseThinking} && bubbles.length > 0) {
var lastBubble = bubbles[bubbles.length - 1];
// Thinking container — DeepSeek uses various class names;
// try common selectors.
thinkEl = lastBubble.querySelector('.ds-markdown--think')
|| lastBubble.querySelector('[class*="think"]');
// Final answer container — the main markdown block that is
// NOT the thinking section.
var markdownEls = lastBubble.querySelectorAll('.ds-markdown');
for (var i = 0; i < markdownEls.length; i++) {
if (markdownEls[i] !== thinkEl
&& !(thinkEl && thinkEl.contains(markdownEls[i]))
&& !markdownEls[i].classList.contains('ds-markdown--think')) {
answerEl = markdownEls[i];
}
}
// Thinking time from the toggle/header element
var timeEl = lastBubble.querySelector('[class*="think"] ~ *')
|| lastBubble.querySelector('.ds-thinking-header');
if (!timeEl) {
// Fallback: parse from raw text header
var m = last.match(/^(?:Thought for ([\\d.]+) seconds?|已思考(用时 ([\\d.]+) 秒))/);
if (m) thinkTime = m[1] || m[2];
} else {
var tm = (timeEl.textContent || '').match(/([\\d.]+)/);
if (tm) thinkTime = tm[1];
}
}
return {
count: texts.length,
last: last,
// DOM-separated fields (null when not available)
thinkText: thinkEl ? (thinkEl.innerText || '').trim() : null,
answerText: answerEl ? (answerEl.innerText || '').trim() : null,
thinkTime: thinkTime,
};
})()`);
} catch {
continue;
}
if (!result) continue;
const candidate = result.last;
if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
if (candidate === lastText) {
stableCount++;
if (stableCount >= 3) {
if (parseThinking) {
// Prefer DOM-level separation
if (result.thinkText != null || result.answerText != null) {
return {
thinking: result.thinkText || '',
response: result.answerText || '',
thinking_time: result.thinkTime || null,
};
}
// Fallback to text-header parsing (no \n\n split)
return parseThinkingResponse(candidate);
}
return candidate;
}
} else {
stableCount = 0;
}
lastText = candidate;
}
}
if (parseThinking && lastText) {
return parseThinkingResponse(lastText);
}
return lastText || null;
}
export async function getVisibleMessages(page) {
const result = await page.evaluate(`(() => {
const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}');
return Array.from(msgs).map(m => {
// User messages carry an extra hash-class alongside ds-message
const isUser = m.className.split(/\\s+/).length > 2;
return {
Role: isUser ? 'user' : 'assistant',
Text: (m.innerText || '').trim(),
};
}).filter(m => m.Text);
})()`);
return Array.isArray(result) ? result : [];
}
export async function getConversationList(page) {
await ensureOnDeepSeek(page);
// Expand sidebar if collapsed
await page.evaluate(`(() => {
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) {
const btn = document.querySelector('div[tabindex="0"][role="button"]');
if (btn) btn.click();
}
})()`);
for (let attempt = 0; attempt < 5; attempt++) {
await page.wait(2);
const items = await page.evaluate(`(() => {
const items = [];
const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
links.forEach((link, i) => {
const title = (link.innerText || '').trim().split('\\n')[0].trim();
const href = link.getAttribute('href') || '';
const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
items.push({
Index: i + 1,
Id: idMatch ? idMatch[1] : href,
Title: title || '(untitled)',
Url: 'https://chat.deepseek.com' + href,
});
});
return items;
})()`);
if (Array.isArray(items) && items.length > 0) return items;
}
return [];
}
async function waitForFilePreview(page, fileName) {
for (let attempt = 0; attempt < 8; attempt++) {
await page.wait(2);
const ready = await page.evaluate(`(() => {
var name = ${JSON.stringify(fileName)};
var hasFileName = Array.from(document.querySelectorAll('div'))
.some(function(el) { return el.children.length === 0 && (el.textContent || '').trim() === name; });
if (hasFileName) return true;
// Vision mode shows an image thumbnail, not filename text. Require
// a preview-like node here; send-button readiness is checked later.
var box = document.querySelector('${TEXTAREA_SELECTOR}');
if (!box) return false;
var c = box.parentElement;
while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
if (!c) return false;
return !!c.querySelector('img[src], canvas, video, [style*="background-image"], [class*="preview"], [class*="upload"]');
})()`);
if (ready) return true;
}
return false;
}
export async function sendWithFile(page, filePath, prompt) {
const fs = await import('node:fs');
const path = await import('node:path');
const absPath = path.default.resolve(filePath);
if (!fs.default.existsSync(absPath)) {
return { ok: false, reason: `File not found: ${absPath}` };
}
const stats = fs.default.statSync(absPath);
if (stats.size > 100 * 1024 * 1024) {
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 100 MB` };
}
const fileName = path.default.basename(absPath);
// Collapse sidebar to keep DOM simple for send button matching
await page.evaluate(`(() => {
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length > 0) {
const btn = document.querySelector('div[tabindex="0"][role="button"]');
if (btn) btn.click();
}
})()`);
await page.wait(0.5);
let uploaded = false;
if (page.setFileInput) {
try {
await page.setFileInput([absPath], 'input[type="file"]');
uploaded = true;
} catch (err) {
const msg = String(err?.message || err);
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
throw err;
}
}
}
if (!uploaded) {
const content = fs.default.readFileSync(absPath);
const base64 = content.toString('base64');
const fallbackResult = await page.evaluate(`(async () => {
var binary = atob('${base64}');
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
var file = new File([bytes], ${JSON.stringify(fileName)});
var dt = new DataTransfer();
dt.items.add(file);
var inp = document.querySelector('input[type="file"]');
if (!inp) return { ok: false, reason: 'file input not found' };
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
return { ok: false, reason: 'React onChange not found' };
}
inp.files = dt.files;
// Use inp.files, not dt.files; assignment transfers ownership
inp[propsKey].onChange({ target: { files: inp.files } });
return { ok: true };
})()`);
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
}
const ready = await waitForFilePreview(page, fileName);
if (!ready) return { ok: false, reason: 'file preview did not appear' };
// File preview appears immediately but send button stays disabled until
// the server upload finishes. Wait for it.
let sendEnabled = false;
for (let tick = 0; tick < 15; tick++) {
const enabled = await page.evaluate(`(() => {
var box = document.querySelector('${TEXTAREA_SELECTOR}');
if (!box) return false;
var c = box.parentElement;
while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
if (!c) return false;
var btns = c.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
var last = btns[btns.length - 1];
return !!(last && last.getAttribute('aria-disabled') === 'false');
})()`);
if (enabled) {
sendEnabled = true;
break;
}
await page.wait(1);
}
if (!sendEnabled) {
return { ok: false, reason: 'send button did not enable after upload' };
}
return sendMessage(page, prompt);
}
// Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
export async function withRetry(fn, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
const msg = String(err?.message || err);
if (i < retries && msg.includes('Promise was collected')) {
await new Promise(r => setTimeout(r, 2000));
continue;
}
throw err;
}
}
}
export function parseBoolFlag(value) {
if (typeof value === 'boolean') return value;
return String(value ?? '').trim().toLowerCase() === 'true';
}
+264
View File
@@ -0,0 +1,264 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { selectModel, sendWithFile, parseThinkingResponse } from './utils.js';
describe('deepseek parseThinkingResponse', () => {
it('returns plain response when no thinking header is present', () => {
const rawText = 'This is a regular response without thinking.';
const result = parseThinkingResponse(rawText);
expect(result).toEqual({
response: rawText,
thinking: null,
thinking_time: null,
});
});
it('parses English thinking header — all content after header is thinking', () => {
const rawText = 'Thought for 3.5 seconds\n\nLet me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.';
const result = parseThinkingResponse(rawText);
// Text-level parser no longer splits on \n\n; everything after header is thinking.
// DOM-level extraction in waitForResponse() handles the actual separation.
expect(result).toEqual({
response: '',
thinking: 'Let me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.',
thinking_time: '3.5',
});
});
it('parses Chinese thinking header — all content after header is thinking', () => {
const rawText = '已思考(用时 2.3 秒)\n\n让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。';
const result = parseThinkingResponse(rawText);
expect(result).toEqual({
response: '',
thinking: '让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。',
thinking_time: '2.3',
});
});
it('multi-paragraph thinking without final answer is not corrupted', () => {
const rawText = 'Thought for 1.2 seconds\n\nFirst paragraph.\n\nSecond paragraph.';
const result = parseThinkingResponse(rawText);
// Both paragraphs must stay in thinking; response is empty.
expect(result).toEqual({
response: '',
thinking: 'First paragraph.\n\nSecond paragraph.',
thinking_time: '1.2',
});
});
it('multi-paragraph final answer is not split by text parser', () => {
const rawText = 'Thought for 3 seconds\n\nreasoning\n\nAnswer para 1.\n\nAnswer para 2.';
const result = parseThinkingResponse(rawText);
// Text parser treats everything as thinking; DOM handles separation.
expect(result).toEqual({
response: '',
thinking: 'reasoning\n\nAnswer para 1.\n\nAnswer para 2.',
thinking_time: '3',
});
});
it('handles thinking without final response', () => {
const rawText = 'Thought for 1.2 seconds\n\nThinking process here...';
const result = parseThinkingResponse(rawText);
expect(result).toEqual({
response: '',
thinking: 'Thinking process here...',
thinking_time: '1.2',
});
});
it('returns null for empty input', () => {
const result = parseThinkingResponse('');
expect(result).toBeNull();
});
it('returns null for null input', () => {
const result = parseThinkingResponse(null);
expect(result).toBeNull();
});
});
describe('deepseek sendWithFile', () => {
const tempDirs = [];
afterEach(() => {
vi.restoreAllMocks();
while (tempDirs.length) {
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
it('prefers page.setFileInput over base64-in-evaluate when supported', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'report.txt');
fs.writeFileSync(filePath, 'hello');
const page = {
setFileInput: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(undefined) // sidebar collapse
.mockResolvedValueOnce(true) // waitForFilePreview
.mockResolvedValueOnce(true) // send button enabled check
.mockResolvedValueOnce({ ok: true }), // sendMessage
};
const result = await sendWithFile(page, filePath, 'summarize this');
expect(result).toEqual({ ok: true }); expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
});
it('fails closed when upload preview appears but send button never enables', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'report.txt');
fs.writeFileSync(filePath, 'hello');
const page = {
setFileInput: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(undefined) // sidebar collapse
.mockResolvedValueOnce(true) // waitForFilePreview
.mockResolvedValue(false), // send button never enables
};
const result = await sendWithFile(page, filePath, 'summarize this');
expect(result).toEqual({ ok: false, reason: 'send button did not enable after upload' });
expect(page.evaluate).toHaveBeenCalledTimes(17);
});
});
describe('deepseek selectModel', () => {
afterEach(() => {
vi.restoreAllMocks();
delete global.document;
});
it('fails expert selection when only one radio is present', async () => {
const instantRadio = {
getAttribute: vi.fn(() => 'true'),
click: vi.fn(),
};
global.document = {
querySelectorAll: vi.fn(() => [instantRadio]),
};
const page = {
evaluate: vi.fn(async (script) => eval(script)),
};
const result = await selectModel(page, 'expert');
expect(result).toEqual({ ok: false });
expect(instantRadio.click).not.toHaveBeenCalled();
});
it('selects the correct radio for each model', async () => {
const radios = [0, 1, 2].map(() => ({
getAttribute: vi.fn(() => 'false'),
click: vi.fn(),
}));
global.document = {
querySelectorAll: vi.fn(() => radios),
};
const page = {
evaluate: vi.fn(async (script) => eval(script)),
};
await selectModel(page, 'instant');
expect(radios[0].click).toHaveBeenCalled();
expect(radios[1].click).not.toHaveBeenCalled();
expect(radios[2].click).not.toHaveBeenCalled();
radios.forEach(r => r.click.mockClear());
await selectModel(page, 'expert');
expect(radios[1].click).toHaveBeenCalled();
radios.forEach(r => r.click.mockClear());
await selectModel(page, 'vision');
expect(radios[2].click).toHaveBeenCalled();
});
it('rejects unknown model names', async () => {
const radios = [0, 1, 2].map(() => ({
getAttribute: vi.fn(() => 'false'),
click: vi.fn(),
}));
global.document = {
querySelectorAll: vi.fn(() => radios),
};
const page = {
evaluate: vi.fn(async (script) => eval(script)),
};
const result = await selectModel(page, 'turbo');
expect(result).toEqual({ ok: false });
});
});
describe('deepseek sendWithFile Not allowed fallback', () => {
const tempDirs = [];
afterEach(() => {
vi.restoreAllMocks();
while (tempDirs.length) {
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
it('falls back to DataTransfer when setFileInput throws Not allowed', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'image.png');
fs.writeFileSync(filePath, 'fake-png');
const page = {
setFileInput: vi.fn().mockRejectedValue(new Error('Not allowed')),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(undefined) // sidebar collapse
.mockResolvedValueOnce({ ok: true }) // DataTransfer fallback
.mockResolvedValueOnce(true) // waitForFilePreview
.mockResolvedValueOnce(true) // send button enabled
.mockResolvedValueOnce({ ok: true }),// sendMessage
};
const result = await sendWithFile(page, filePath, 'describe');
expect(page.setFileInput).toHaveBeenCalled();
expect(page.evaluate).toHaveBeenCalledTimes(5);
expect(result).toEqual({ ok: true });
});
it('does not treat send-button enablement alone as image upload proof', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'image.png');
fs.writeFileSync(filePath, 'fake-png');
const page = {
setFileInput: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(undefined) // sidebar collapse
.mockResolvedValue(false), // no filename / thumbnail preview
};
const result = await sendWithFile(page, filePath, 'describe');
expect(result).toEqual({ ok: false, reason: 'file preview did not appear' });
expect(page.evaluate.mock.calls[1][0]).toContain('img[src], canvas, video');
expect(page.evaluate.mock.calls[1][0]).not.toContain("aria-disabled') === 'false'");
});
});
+236
View File
@@ -0,0 +1,236 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './top.js';
import './tag.js';
import './user.js';
import './read.js';
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('devto listing adapters surface id + reading_time + published_at', () => {
it('devto/top has the agent-native column shape and pipeline mapping', () => {
const cmd = getRegistry().get('devto/top');
expect(cmd?.columns).toEqual([
'rank', 'id', 'title', 'author', 'reactions', 'comments',
'reading_time', 'published_at', 'tags', 'url',
]);
const mapStep = cmd?.pipeline?.find((step) => step.map);
expect(mapStep?.map).toMatchObject({
id: '${{ item.id }}',
reading_time: '${{ item.reading_time_minutes }}',
published_at: '${{ item.published_at }}',
url: '${{ item.url }}',
});
});
it('devto/tag has the agent-native column shape and pipeline mapping', () => {
const cmd = getRegistry().get('devto/tag');
expect(cmd?.columns).toEqual([
'rank', 'id', 'title', 'author', 'reactions', 'comments',
'reading_time', 'published_at', 'tags', 'url',
]);
const mapStep = cmd?.pipeline?.find((step) => step.map);
expect(mapStep?.map).toMatchObject({
id: '${{ item.id }}',
reading_time: '${{ item.reading_time_minutes }}',
published_at: '${{ item.published_at }}',
});
});
it('devto/user has the agent-native column shape (no author column, since user-specific)', () => {
const cmd = getRegistry().get('devto/user');
expect(cmd?.columns).toEqual([
'rank', 'id', 'title', 'reactions', 'comments',
'reading_time', 'published_at', 'tags', 'url',
]);
const mapStep = cmd?.pipeline?.find((step) => step.map);
expect(mapStep?.map).toMatchObject({
id: '${{ item.id }}',
reading_time: '${{ item.reading_time_minutes }}',
published_at: '${{ item.published_at }}',
});
});
});
describe('devto/read adapter', () => {
const cmd = getRegistry().get('devto/read');
it('registers the article-detail row shape', () => {
expect(cmd?.columns).toEqual([
'id', 'title', 'author', 'reactions', 'reading_time',
'tags', 'published_at', 'body', 'url',
]);
});
it('takes a positional id plus a tunable max-length', () => {
const argNames = (cmd?.args || []).map((a) => a.name);
expect(argNames).toEqual(['id', 'max-length']);
const idArg = cmd?.args?.find((a) => a.name === 'id');
expect(idArg?.required).toBe(true);
expect(idArg?.positional).toBe(true);
});
it('uses the public dev.to JSON endpoint (no browser, public strategy)', () => {
expect(cmd?.browser).toBe(false);
expect(cmd?.strategy).toBe('public');
});
it('fails fast with ArgumentError for non-numeric id before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: 'not-a-number', 'max-length': 20000 }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('fails fast with ArgumentError for max-length below 100 before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: '12345', 'max-length': 50 }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('accepts numeric max-length strings on the direct func path', async () => {
const article = {
id: 1,
title: 't',
user: { username: 'u' },
public_reactions_count: 0,
reading_time_minutes: 1,
tag_list: [],
published_at: '',
body_markdown: 'x'.repeat(150),
url: 'https://dev.to/u/t-1',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
const rows = await cmd.func({ id: '1', 'max-length': '100' });
expect(rows[0].body).toBe('x'.repeat(100) + '\n\n... [truncated]');
});
it('fails fast with ArgumentError for invalid max-length strings before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: '12345', 'max-length': 'abc' }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('fails fast with EmptyResultError on 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Not found', { status: 404 })));
await expect(cmd.func({ id: '99999999', 'max-length': 20000 }))
.rejects.toThrow(EmptyResultError);
});
it('fails fast with CommandExecutionError on non-404 HTTP failures', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Server error', { status: 500 })));
await expect(cmd.func({ id: '12345', 'max-length': 20000 }))
.rejects.toThrow(CommandExecutionError);
});
it('fails fast with CommandExecutionError on invalid JSON responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 })));
await expect(cmd.func({ id: '12345', 'max-length': 20000 }))
.rejects.toThrow(CommandExecutionError);
});
it('fails fast when the full article body is missing instead of returning a summary', async () => {
const article = {
id: 1,
title: 't',
user: { username: 'u' },
public_reactions_count: 0,
reading_time_minutes: 1,
tag_list: [],
published_at: '',
description: 'summary only',
url: 'https://dev.to/u/t-1',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
await expect(cmd.func({ id: '1', 'max-length': 20000 }))
.rejects.toThrow(CommandExecutionError);
});
it('returns a single article row with body_markdown extracted', async () => {
// Real /api/articles/<id> returns tag_list as a comma string and tags as an array.
const article = {
id: 3605688,
title: 'How to do thing X in Rust',
user: { username: 'jdoe' },
public_reactions_count: 42,
reading_time_minutes: 7,
tag_list: 'rust, webdev',
tags: ['rust', 'webdev'],
published_at: '2026-05-01T00:00:00Z',
body_markdown: '# Hello\n\nThis is the article body.',
url: 'https://dev.to/jdoe/how-to-do-thing-x-in-rust-1234',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
const rows = await cmd.func({ id: '3605688', 'max-length': 20000 });
expect(rows).toEqual([
{
id: 3605688,
title: 'How to do thing X in Rust',
author: 'jdoe',
reactions: 42,
reading_time: 7,
tags: 'rust, webdev',
published_at: '2026-05-01T00:00:00Z',
body: '# Hello\n\nThis is the article body.',
url: 'https://dev.to/jdoe/how-to-do-thing-x-in-rust-1234',
},
]);
});
it('handles the alternate shape where tag_list is an array (defensive)', async () => {
const article = {
id: 1,
title: 't',
user: { username: 'u' },
public_reactions_count: 0,
reading_time_minutes: 1,
tag_list: ['javascript', 'webdev'],
published_at: '',
body_markdown: 'body',
url: 'https://dev.to/u/t-1',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
const rows = await cmd.func({ id: '1', 'max-length': 20000 });
expect(rows[0].tags).toBe('javascript, webdev');
});
it('truncates body when over max-length and appends a marker', async () => {
const longBody = 'x'.repeat(500);
const article = {
id: 1,
title: 't',
user: { username: 'u' },
public_reactions_count: 0,
reading_time_minutes: 1,
tag_list: [],
published_at: '',
body_markdown: longBody,
url: 'https://dev.to/u/t-1',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(article), { status: 200 })));
const rows = await cmd.func({ id: '1', 'max-length': 100 });
expect(rows[0].body).toBe('x'.repeat(100) + '\n\n... [truncated]');
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* DEV.to article reader.
*
* Public API: https://dev.to/api/articles/<id>
* Returns the full article including `body_markdown` (and `body_html`).
*
* The DEV.to API does not currently expose article comments — this reader
* therefore emits one row with the article body. If/when comments become
* available we can extend to a POST + L0/L1 shape like `hackernews read`.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const DEVTO_ARTICLE_BASE = 'https://dev.to/api/articles';
async function fetchArticle(id) {
let res;
try {
res = await fetch(`${DEVTO_ARTICLE_BASE}/${id}`);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new CommandExecutionError(`DEV.to API request failed for article ${id}`, detail);
}
if (res.status === 404) {
throw new EmptyResultError(`devto/${id}`, 'Article not found');
}
if (!res.ok) {
throw new CommandExecutionError(`DEV.to API HTTP ${res.status} for article ${id}`, 'Check the article id');
}
try {
return await res.json();
} catch {
throw new CommandExecutionError(`DEV.to API returned invalid JSON for article ${id}`, 'Retry later or open the article URL directly');
}
}
function requireMinInt(value, min, label) {
const number = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(number) || number < min) {
throw new ArgumentError(`${label} must be an integer >= ${min}`);
}
return number;
}
function requireArticleBody(article, id) {
if (typeof article.body_markdown === 'string' && article.body_markdown.trim()) {
return article.body_markdown;
}
throw new CommandExecutionError(
`DEV.to article ${id} did not include body_markdown`,
'DEV.to API response shape may have changed. Open the article URL directly or retry later.',
);
}
cli({
site: 'devto',
name: 'read',
description: 'Read a DEV.to article body by id',
domain: 'dev.to',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', required: true, positional: true, help: 'DEV.to article id (numeric, e.g. 3605688)' },
{ name: 'max-length', type: 'int', default: 20000, help: 'Max characters of body to return (min 100)' },
],
columns: ['id', 'title', 'author', 'reactions', 'reading_time', 'tags', 'published_at', 'body', 'url'],
func: async (args) => {
const id = String(args.id || '').trim();
if (!/^\d+$/.test(id)) {
throw new ArgumentError(`Invalid DEV.to article id: ${args.id}`, 'Pass a numeric id like 3605688');
}
const maxLength = requireMinInt(args['max-length'] ?? 20000, 100, 'devto read --max-length');
const article = await fetchArticle(id);
if (!article || !article.id) {
throw new EmptyResultError(`devto/${id}`, 'Article not found');
}
const body = requireArticleBody(article, id);
const truncated = body.length > maxLength
? body.slice(0, maxLength) + '\n\n... [truncated]'
: body;
// The single-article endpoint returns `tag_list` as a comma-separated
// string and `tags` as an array — the opposite of the listing endpoints.
// Normalize either shape into a single comma-separated string.
const tagsRaw = article.tag_list ?? article.tags ?? '';
const tags = Array.isArray(tagsRaw) ? tagsRaw.join(', ') : String(tagsRaw);
return [{
id: article.id,
title: article.title || '',
author: article.user?.username || '[deleted]',
reactions: article.public_reactions_count ?? 0,
reading_time: article.reading_time_minutes ?? 0,
tags,
published_at: article.published_at || '',
body: truncated,
url: article.url || '',
}];
},
});
+4 -1
View File
@@ -15,15 +15,18 @@ cli({
},
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles' },
],
columns: ['rank', 'title', 'author', 'reactions', 'comments', 'tags'],
columns: ['rank', 'id', 'title', 'author', 'reactions', 'comments', 'reading_time', 'published_at', 'tags', 'url'],
pipeline: [
{ fetch: { url: 'https://dev.to/api/articles?tag=${{ args.tag }}&per_page=${{ args.limit }}' } },
{ map: {
rank: '${{ index + 1 }}',
id: '${{ item.id }}',
title: '${{ item.title }}',
author: '${{ item.user.username }}',
reactions: '${{ item.public_reactions_count }}',
comments: '${{ item.comments_count }}',
reading_time: '${{ item.reading_time_minutes }}',
published_at: '${{ item.published_at }}',
tags: `\${{ item.tag_list | join(', ') }}`,
url: '${{ item.url }}',
} },

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