Compare commits

...

53 Commits

Author SHA1 Message Date
jackwener da42b6f137 fix(dianping/shop): correct in-browser name and reviews extraction
The merged adapter had two silent in-browser bugs that the mocked-evaluate
unit tests don't catch — only live verify against www.dianping.com surfaces
them:

1. Shop name returned `undefined`. The fallback parsed `document.title` with
   an ASCII-bracket split (`/[\\[\\]]/`) but dianping wraps the name in
   full-width brackets `【芈重山老火锅(五道口店)】...`. Switch to a `【...】`
   regex so the title fallback actually fires.

2. Reviews returned `5` instead of `21241`. The headText was whitespace-
   collapsed to `★★★★★4.821241条...`, fusing the rating and review digits;
   a head-wide `/\d+条/` then captured `4.821241` and rounded to `5`. Read
   the dedicated `.reviews / .review-num` element ("21241条") instead, with
   a `.review-title` "评价(<n>)" fallback.
2026-05-04 23:04:55 +08:00
jakevin 0f806e9473 feat(dianping): browser adapter — search + shop on www.dianping.com (#1309)
* feat(dianping): browser adapter — search + shop on www.dianping.com

Adds two browser-mode adapters for the dianping (大众点评) PC site:

- `dianping search "<keyword>" --city <name|id> --limit <n>`: keyword
  shop/restaurant search. Returns rank, shop_id, name, rating, reviews,
  price, cuisine, district, url. shop_id round-trips into `dianping shop`.
- `dianping shop <shop_id>` (alias `detail`): shop detail sheet
  (field/value rows: name, rating, breakdown 口味/环境/服务/食材, reviews,
  price, rank, hours, address, subway, features, url).

Both use Strategy.COOKIE on www.dianping.com (the PC site renders search
SSR and does not require JS hydration). m.dianping.com is intentionally
crippled for non-mobile UAs, so it's not used.

Auth detection (utils.detectAuthOrEmpty) inspects both response text and
final URL for the Meituan Yoda captcha redirect (verify.meituan.com) and
the dianping login redirect; raises AuthRequiredError with the captcha
URL embedded so the user can clear it manually in the same profile.

Listing↔detail id pairing: search.shop_id → shop.<id>. Adds 'shop' to
DETAIL_NAMES in scripts/check-listing-id-pairing.mjs so the convention
gate scans this site (35 sites / 78 listings now covered).

* fix(dianping): harden browser failure classification

* fix(dianping): fail on partial missing shop ids
2026-05-04 23:00:38 +08:00
jakevin 73dc1295e7 feat(cli): add convention audit command
Adds opencli convention-audit for batch convention scanning, with structured output, strict mode, docs, and startup isolation from local user/plugin discovery.
2026-05-04 22:52:42 +08:00
jakevin f482a6b2b1 feat(youtube/xiaohongshu/xiaoe): surface dropped ids/url on listings (sweep) (#1305)
* feat(youtube/xiaohongshu/xiaoe): surface dropped ids/url on listings (sweep)

Round 8 same silent-column-drop class as #1300/#1301/#1302 — row already
emits the id/url field but `columns` array forgot to project it, so table
view drops it and agent loses the chain into detail commands.

- youtube/feed: rename row.videoId → video_id (snake_case convention),
  add to columns. youtube/video accepts both URL and id, so url-based
  round-trip already worked, but exposing the canonical id removes the
  url-parse step for chained calls.
- xiaohongshu/feed: pipeline map already extracts `id` from the homefeed
  payload, columns now lists it.
- xiaoe/catalog: pipeline map already projects `url`, columns now lists
  it. xiaoe/detail takes a positional url, so this completes the
  round-trip explicitly.

Also fixes one camelCase column violation on youtube/feed (videoId vs the
project's snake_case convention as in twitter `is_retweet`/`created_at`,
douban `subject_id`/`photo_id`, hupu `thread_title`).

CI gate `check:listing-id-pairing` ✓ (34 sites, 77 listings, 10 exempt).
typecheck clean. 114 tests pass for youtube + xiaohongshu.

* fix(youtube): keep feed continuation ids after rename
2026-05-04 22:03:52 +08:00
jakevin 0d37f48626 feat(cli): add agent-native structured help (#1304) 2026-05-04 21:55:09 +08:00
jakevin edf3c07d66 add cases/ directory for collecting user use cases (#1303)
Users can submit PRs adding individual .md files — one per case,
no merge conflicts.
2026-05-04 21:26:10 +08:00
jakevin ac94b75879 feat(1688/hupu/douban/linux-do): surface dropped ids on listings (#1302)
Round 7 — silent-drop sweep. Continues the listing→detail id-pairing
work from #1297. Each row was already extracting these ids/urls
internally; only the `columns` projection was missing, so they showed
up in `-f json` but never on the table view.

| Adapter            | Added columns                       |
|--------------------|-------------------------------------|
| `1688 search`      | `item_url`, `member_id`             |
| `hupu mentions`    | `tid`, `pid`, `url`                 |
| `douban photos`    | `photo_id`, `subject_id`            |
| `linux-do tags`    | `slug`                              |

Round-trip wins:
- `1688 search` → `1688 item <item_url>` (item_url is the canonical
  detail.1688.com URL); `1688 search` → `1688 store <member_id>`
- `hupu mentions` → `hupu detail <tid>` (and `pid` for the deep link)
- `douban photos` → tied back to the parent movie via `subject_id`
- `linux-do tags` → `linux-do feed --tag <slug>` (slug is the URL form)

No logic change — only the column array. JSON output unchanged.
Tests: 45/45 pass for the four affected sites.
2026-05-04 21:22:23 +08:00
jakevin ae9ad4aeec feat(twitter): surface tweet id on bookmarks/likes/tweets listings (#1301)
Round 6 — silent-drop audit follow-up. Sibling twitter listings have
been inconsistent about the canonical tweet `id` (rest_id):

- timeline      ✓ exposes id
- search        ✓ exposes id
- list-tweets   ✓ exposes id
- notifications ✓ exposes id
- bookmarks     ✗ extracts but drops it from columns
- likes         ✗ extracts but drops it from columns
- tweets        ✗ extracts but drops it from columns

The `id` is already in the row object — only the `columns` projection
was missing. With the listing↔detail id-pairing CI gate from #1297 now
on main, surfacing `id` makes round-trip into `twitter thread <id>` /
`twitter delete <id>` / `twitter like <id>` work from the table view too
(previously only via `-f json`).

Other field-presentation drift (`name`, `created_at`, `retweets`)
aligned with sibling adapters where those values are already emitted.

Tests: tweets.test.js asserts `toEqual` on the columns array — updated
that assertion. Other twitter tests use `toMatchObject` and pass
unchanged. 81/81 in `clis/twitter/`.
2026-05-04 21:22:10 +08:00
jakevin 2b9af38db4 feat(pixiv): surface user_id + url on listings, url on user/illusts (#1300)
While auditing instagram/facebook/pixiv coverage gaps, found that pixiv
listings already extract `user_id` and construct `url` per row but drop
both fields from the table view (`columns` doesn't list them). The data
is in the row object — only the column projection was missing.

Per the listing↔detail id pairing convention (#1297), surface them so:
- `user_id` round-trips from `ranking` / `search` → `user` / `illusts`
- `url` is the canonical share link for every illust / user record

Changes:
- `ranking`: + user_id, + url
- `search`:  + user_id, + url
- `illusts`: + url (user_id is the arg, no need to repeat per row)
- `user`:    + url

No behavior change beyond the table view — JSON output already had these
fields, so existing scripts that consume `-f json` keep working.
2026-05-04 21:09:01 +08:00
jakevin 545f91a2d2 feat(dblp): public bibliography adapter — search + paper (#1299)
* feat(dblp): public bibliography adapter — search + paper

Wraps the dblp.org public API:
- `dblp search <query>` → /search/publ/api JSON, projected into one row per hit
- `dblp paper <key>` → /rec/<key>.xml, parsed into a one-row record

Why dblp on top of arxiv/openreview: dblp is the largest, oldest CS
bibliography (7M+ entries) and the only one of the three that consistently
indexes pre-arXiv literature, journal articles, books, and theses. The
canonical record key (e.g. `conf/nips/VaswaniSPUJGKP17`) round-trips
cleanly between the two commands per the listing↔detail convention.

Implementation notes:
- No deps beyond the registry — XML parsed with conservative regexes,
  same approach as the arxiv adapter.
- Polite User-Agent per dblp's API guidance; HTTP 429 mapped to a
  CommandExecutionError with a "lower --limit" hint.
- Author homonym suffixes (`"Smith 0001"`) trimmed for clean output.
- 39 unit tests cover validators, XML extraction, both commands.

* fix(dblp): fail fast on API status envelopes
2026-05-04 21:04:50 +08:00
jakevin 0a85e73aa5 feat(convention): listing↔detail id pairing rule + CI gate (#1297)
* feat(convention): listing↔detail id pairing rule + CI gate

Adds a hard convention: when a site exposes both a listing-class command
(search / hot / top / recent / ...) and a detail-class command (read /
paper / article / view / ...), every listing row MUST surface an id-shaped
column whose value round-trips into the detail command. Without that, an
agent has no way to follow up on a listing row except re-searching by
title or scraping URLs out of band — both of which break the agent-native
contract.

What's in this PR

- docs/conventions/listing-detail-id-pairing.md — full rule, examples
  table, why-it-matters, what counts as id-shaped, exemption taxonomy,
  how to add an id column to a listing.
- scripts/check-listing-id-pairing.mjs — validator that reads
  cli-manifest.json, classifies each entry as listing / detail / other,
  and fails when a listing on a site that also has a read-detail command
  is missing an id-shaped column. Exemption allowlist records WHY each
  pair is exempt so future maintainers know what to verify.
- npm run check:listing-id-pairing — strict-mode wrapper.
- CI: new step in build job runs the validator after the manifest
  freshness check on Linux.
- docs/developer/ts-adapter.md — cross-link from the adapter authoring
  guide.
- docs/.vitepress/config.mts — sidebar entries for the new conventions
  section.

Fixes brought to zero violations

- 1688/search: add offer_id (already extracted, just surfaced)
- bluesky/user: add uri (AT URI round-trips into bluesky/thread)
- tieba/search: add id + url (thread_id already extracted)
- tieba/hot: add url (rows are topics, not threads — url is the
  best-effort round-trip handle, doc'd as such)

Exemptions (intentional, doc'd in EXEMPT map with rationale)

- nowcoder/hot, bluesky/trending, twitter/trending — listing rows are
  topic strings, not posts.
- lesswrong/user, reddit/user — rows are profile-attribute key/value
  pairs, addressed by the username arg.
- discord-app/search — desktop UI session, message ids not extractable.
- notion/search — Strategy.UI Quick Find, page ids not exposed in DOM.

Validator output after this PR: 32 sites scanned, 75 listings checked,
7 exempted, 0 violations.

* fix(convention): tighten listing id gate

* fix(convention): close url-derived id loophole
2026-05-04 20:54:14 +08:00
jakevin 29b4869efd feat(indeed): add search and job adapters (US site) (#1298)
* feat(indeed): add `search` and `job` adapters (US site)

Adds an Indeed adapter that fills the US job-search gap (alongside
existing 51job / boss-zhipin / linkedin coverage). Both commands run
through a real browser session because Indeed sits behind Cloudflare
and answers bare HTTP fetches with `403` + `cf-mitigated: challenge`.

## Commands

- `indeed search <query>` — keyword job search
  - args: `query`, `--location`, `--fromage`, `--sort`, `--start`, `--limit`
  - columns: `rank, id, title, company, location, salary, tags, url`
- `indeed job <jk>` (alias `detail`, `view`) — full job posting
  - args: `id` (positional, the 16-char hex `jk` from `search`)
  - columns: `id, title, company, location, salary, job_type, description, url`

## Listing↔detail id pairing

`search.id` is the Indeed `jk` (job key, 16-char lowercase hex). It feeds
directly into `indeed job <jk>`. Conforms to the listing↔detail id
pairing convention proposed in #1297.

## CF challenge handling

The adapter polls the result selectors for up to 15s after navigation,
giving the browser time to clear the Cloudflare interstitial. If the
challenge is still up after the wait, the adapter throws a
`CommandExecutionError` with a hint pointing the user at the connected
browser to clear it once. Subsequent calls reuse the warmed cookies via
`Strategy.COOKIE`, mirroring the v2ex / boss / linkedin patterns.

## Validation

`utils.js` keeps argument validation pure and unit-testable:

- `requireJobKey` rejects anything that isn't a 16-char lowercase hex
- `requireFromage` only accepts `1` / `3` / `7` / `14` (Indeed's enum)
- `requireSort` only accepts `relevance` / `date`
- `requireBoundedInt(limit, default=15, max=25)` — Indeed serves at most
  one page (10 jobs/page); ArgumentError on out-of-range, no silent
  clamping, per the typed-error feedback in #1289.

## Tests

18 unit tests in `clis/indeed/indeed.test.js` cover registration,
validators, URL builders, and DOM-card normalizers. Browser-driven
verification stays out of CI by design (CF challenge is interactive).

## Docs

- `docs/adapters/browser/indeed.md` — full adapter doc with prerequisite
  CF-challenge notes and listing↔detail id pairing callout.
- Sidebar entry + adapter index row.

* fix(indeed): tighten timeout fail-fast and runtime tests

* fix(indeed): align readiness with search parser
2026-05-04 20:52:16 +08:00
jakevin eea9ff8bfe feat(cli): add command access metadata (#1296) 2026-05-04 19:47:08 +08:00
jakevin 328140966e feat(openreview): public adapter — search/venue/paper/reviews (#1294)
* feat(openreview): add public adapter — search/venue/paper/reviews

OpenReview is the open peer-review platform used by ICLR / TMLR / COLM
and ML workshops. Its v2 API exposes everyone-readable submissions,
reviews, and decisions without auth, so all four commands run with
`browser: false`.

Commands:
- `openreview search <query>` — full-text search
- `openreview venue <venue>` — list submissions; accepts either a venue
  display name (matched against `content.venue`, e.g. "ICLR 2024 oral")
  or a full invitation id (e.g. "ICLR.cc/2025/Conference/-/Submission")
  via `/-/` heuristic; supports offset pagination
- `openreview paper <id>` — single-paper detail with full abstract
- `openreview reviews <forum>` — paper + threaded reviews/decisions/
  comments, ordered chronologically with paper lifted to row 0;
  classifies notes via invitation tail (REVIEW / DECISION / REBUTTAL /
  COMMENT / META_REVIEW / WITHDRAWAL); per-row truncation via
  `--max-length` (min 200)

Listing IDs round-trip into `paper`/`reviews`. PDF URLs normalized to
absolute `https://openreview.net/pdf/...`. `pdate` falls back to
`cdate` when missing, formatted as `YYYY-MM-DD`.

All limits/offsets/ids fail-fast with typed errors (`ArgumentError`,
`EmptyResultError`, `CommandExecutionError`) — no silent clamping, no
empty-array fallbacks. fetch + json + non-2xx + 404 are wrapped so
network/API failures never look like empty results.

Tests: 23 unit tests covering the column contract, content extraction,
date/PDF normalization, invitation-vs-venue dispatch, error paths
(network/JSON/HTTP), pagination offset accounting, and the reviews
classifier + section joiner + truncation.

Live-verified against api2.openreview.net for search ("diffusion
model"), venue ("ICLR 2024 oral"), paper (KS8mIvetg2), and reviews on
that paper's full thread.

* fix(openreview): tighten error and review typing

* fix(openreview): stabilize review contracts
2026-05-04 19:23:55 +08:00
jakevin ed0b2acc82 docs(stackoverflow): clarify read fetches answers up to --answers-limit (not 'all') (#1295)
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:20:43 +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
802 changed files with 17471 additions and 1738 deletions
+8
View File
@@ -50,6 +50,14 @@ jobs:
exit 1
fi
# Guard: listing-class commands must surface an id-shaped column so their
# rows round-trip into the site's detail-class command. Without this, an
# agent has to re-search by title or scrape URLs to follow up on a row.
# See docs/conventions/listing-detail-id-pairing.md.
- name: Check listing↔detail id pairing
if: runner.os == 'Linux'
run: npm run check:listing-id-pairing
# ── 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).
+12 -1
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,7 +51,7 @@ 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-v${EXT_VERSION}.zip .
+2
View File
@@ -4,6 +4,8 @@
### 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.
+24 -8
View File
@@ -21,7 +21,7 @@ It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other
- **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: 90+ pre-built adapters, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification.
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, etc).
@@ -34,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
```
@@ -86,6 +89,18 @@ Use OpenCLI directly when you want a reliable command instead of a live browser
- `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
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.
@@ -159,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
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 init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
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
@@ -171,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.
@@ -189,7 +205,6 @@ OpenCLI is not only for websites. It can also:
| `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 |
| `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.
@@ -245,6 +260,7 @@ To load the source Browser Bridge extension:
| **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` |
@@ -261,7 +277,7 @@ To load the source Browser Bridge extension:
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
90+ 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`.
@@ -391,10 +407,10 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
- Decode response fields, design columns, scaffold with `opencli browser init`.
- 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.
Adapters you write outside the repo 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.
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
@@ -405,7 +421,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
- **"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
+28 -11
View File
@@ -10,7 +10,7 @@
OpenCLI 可以用同一套 CLI 做三类事情:
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [90+ 站点](#内置命令) 开箱即用。
- **直接使用现成适配器**: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` 一条龙。
@@ -20,7 +20,7 @@ OpenCLI 可以用同一套 CLI 做三类事情:
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)。
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入、提取、截图——全部通过你的已登录 Chrome 会话完成。
- **网站 → CLI** — 把任何网站变成确定性 CLI:90+ 内置适配器,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
- **网站 → CLI** — 把任何网站变成确定性 CLI:100+ 站点能力已注册,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian 等)。
@@ -31,7 +31,10 @@ OpenCLI 可以用同一套 CLI 做三类事情:
### 1. 安装 OpenCLI
OpenCLI 要求 **Node.js >= 21**
```bash
node --version
npm install -g @jackwener/opencli
```
@@ -67,9 +70,21 @@ 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
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI AgentClaude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
@@ -143,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
4. 字段解码 + 设计输出列
5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
### CLI 枢纽与桌面端适配器
@@ -155,7 +170,8 @@ OpenCLI 不只是网站 CLI,还可以:
## 前置要求
- **Node.js**: >= 21.0.0
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
- **Bun**: >= 1.0(可选替代运行时)
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
@@ -172,7 +188,6 @@ OpenCLI 不只是网站 CLI,还可以:
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com` |
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
`--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时。
@@ -256,6 +271,7 @@ npm link
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **openreview** | `search` `venue` `paper` `reviews` | 公开 |
| **paperreview** | `submit` `review` `feedback` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
@@ -285,13 +301,14 @@ npm link
| **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` | 浏览器 |
@@ -302,7 +319,7 @@ npm link
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |
90+ 适配器**[→ 查看完整命令列表](./docs/adapters/index.md)**
100+ 站点能力**[→ 查看完整命令列表](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast``podcast-episodes``episode``download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
@@ -489,7 +506,7 @@ opencli plugin uninstall my-tool # 卸载
- 侦察站点,选定 patternSPA / SSR / JSONP / Token / Streaming
-`opencli browser network``eval`、interceptor 等找到目标 endpoint
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
- 字段解码、设计 columns、`opencli browser init` 生成骨架
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
- 交付前用 `opencli browser verify <site>/<name>` 验证
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
@@ -502,8 +519,8 @@ opencli plugin uninstall my-tool # 卸载
- 其他 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`
+9
View File
@@ -0,0 +1,9 @@
# Use Cases
Real-world examples of how people use OpenCLI.
## Contributing
Want to share your use case? Submit a PR that adds a new `.md` file to this directory.
Each file is one use case — describe what you wanted to do, which commands you used, and the result.
+1742 -25
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -183,6 +183,7 @@ export async function extractAssetsForInput(page, input) {
cli({
site: '1688',
name: 'assets',
access: 'read',
description: '列出 1688 商品页可提取的图片/视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -42,6 +42,7 @@ function toDownloadItems(offerId, assets) {
cli({
site: '1688',
name: 'download',
access: 'read',
description: '批量下载 1688 商品页可提取的图片和视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -156,6 +156,7 @@ async function readItemPayload(page, itemUrl) {
cli({
site: '1688',
name: 'item',
access: 'read',
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+2 -1
View File
@@ -275,6 +275,7 @@ async function collectSearchRows(page, query, limit) {
cli({
site: '1688',
name: 'search',
access: 'read',
description: '1688 商品搜索(结果候选、卖家链接、价格/MOQ/销量文本)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
@@ -293,7 +294,7 @@ cli({
help: `结果数量上限(默认 ${SEARCH_LIMIT_DEFAULT},最大 ${SEARCH_LIMIT_MAX}`,
},
],
columns: ['rank', 'title', 'price_text', 'moq_text', 'seller_name', 'location'],
columns: ['rank', 'offer_id', 'title', 'item_url', 'price_text', 'moq_text', 'seller_name', 'member_id', 'location'],
func: async (page, kwargs) => {
const query = String(kwargs.query ?? '');
const limit = parseSearchLimit(kwargs.limit);
+1
View File
@@ -167,6 +167,7 @@ function hasAnyEvidence(storePayload, contactPayload, seed) {
cli({
site: '1688',
name: 'store',
access: 'read',
description: '1688 店铺/供应商公开信息(联系方式、主营、入驻年限、公开服务信号)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -13,6 +13,7 @@ function parseArticleId(input) {
cli({
site: '36kr',
name: 'article',
access: 'read',
description: '获取36氪文章正文内容',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
+1
View File
@@ -26,6 +26,7 @@ function buildHotListUrl(listType, date = new Date()) {
cli({
site: '36kr',
name: 'hot',
access: 'read',
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -5,6 +5,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: '36kr',
name: 'news',
access: 'read',
description: 'Latest tech/startup news from 36kr (36氪)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -8,6 +8,7 @@ import { CliError } from '@jackwener/opencli/errors';
cli({
site: '36kr',
name: 'search',
access: 'read',
description: '搜索36氪文章',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -13,6 +13,7 @@ import { JOBS_ORIGIN, requirePage, navigateTo, parseCompanyJobCard } from './uti
cli({
site: '51job',
name: 'company',
access: 'read',
description: '51job 公司简介 + 在招职位(按 encCoId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -13,6 +13,7 @@ import { JOBS_ORIGIN, requirePage, navigateTo } from './utils.js';
cli({
site: '51job',
name: 'detail',
access: 'read',
description: '51job 职位详情(按 jobId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -16,6 +16,7 @@ import {
cli({
site: '51job',
name: 'hot',
access: 'read',
description: '51job 推荐职位(按城市/行业/排序浏览)',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -20,6 +20,7 @@ import {
cli({
site: '51job',
name: 'search',
access: 'read',
description: '51job 前程无忧关键词职位搜索',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -2,6 +2,7 @@ import { cli } from '@jackwener/opencli/registry';
import { createRankingCliOptions } from './rankings.js';
cli(createRankingCliOptions({
commandName: 'bestsellers',
access: 'read',
listType: 'bestsellers',
description: 'Amazon Best Sellers pages for category candidate discovery',
}));
+1
View File
@@ -85,6 +85,7 @@ async function readDiscussionPayload(page, input, limit) {
cli({
site: 'amazon',
name: 'discussion',
access: 'read',
description: 'Amazon review summary and sample customer discussion from product review pages',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -2,6 +2,7 @@ import { cli } from '@jackwener/opencli/registry';
import { createRankingCliOptions } from './rankings.js';
cli(createRankingCliOptions({
commandName: 'movers-shakers',
access: 'read',
listType: 'movers_shakers',
description: 'Amazon Movers & Shakers pages for short-term growth signals',
}));
+1
View File
@@ -2,6 +2,7 @@ import { cli } from '@jackwener/opencli/registry';
import { createRankingCliOptions } from './rankings.js';
cli(createRankingCliOptions({
commandName: 'new-releases',
access: 'read',
listType: 'new_releases',
description: 'Amazon New Releases pages for early momentum discovery',
}));
+1
View File
@@ -106,6 +106,7 @@ async function readOfferPayload(page, input) {
cli({
site: 'amazon',
name: 'offer',
access: 'read',
description: 'Amazon seller, buy box, and fulfillment facts from the product page',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -65,6 +65,7 @@ async function readProductPayload(page, input) {
cli({
site: 'amazon',
name: 'product',
access: 'read',
description: 'Amazon product page facts for candidate validation',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -142,6 +142,7 @@ export function createRankingCliOptions(definition) {
return {
site: 'amazon',
name: definition.commandName,
access: definition.access ?? 'read',
description: definition.description,
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -49,6 +49,7 @@ async function readSearchPayload(page, query) {
cli({
site: 'amazon',
name: 'search',
access: 'read',
description: 'Amazon search results for product discovery and coarse filtering',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
export const dumpCommand = cli({
site: 'antigravity',
name: 'dump',
access: 'read',
description: 'Dump the DOM to help AI understand the UI',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const extractCodeCommand = cli({
site: 'antigravity',
name: 'extract-code',
access: 'read',
description: 'Extract multi-line code blocks from the current Antigravity conversation',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const modelCommand = cli({
site: 'antigravity',
name: 'model',
access: 'read',
description: 'Switch the active LLM model in Antigravity',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const newCommand = cli({
site: 'antigravity',
name: 'new',
access: 'read',
description: 'Start a new conversation / clear context in Antigravity',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const readCommand = cli({
site: 'antigravity',
name: 'read',
access: 'read',
description: 'Read the latest chat messages from Antigravity AI',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const sendCommand = cli({
site: 'antigravity',
name: 'send',
access: 'write',
description: 'Send a message to Antigravity AI via the internal Lexical editor',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const statusCommand = cli({
site: 'antigravity',
name: 'status',
access: 'read',
description: 'Check Antigravity CDP connection and get current page state',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
export const watchCommand = cli({
site: 'antigravity',
name: 'watch',
access: 'read',
description: 'Stream new chat messages from Antigravity in real-time',
domain: 'localhost',
strategy: Strategy.UI,
+1
View File
@@ -4,6 +4,7 @@ import { itunesFetch, formatDuration, formatDate } from './utils.js';
cli({
site: 'apple-podcasts',
name: 'episodes',
access: 'read',
description: 'List recent episodes of an Apple Podcast (use ID from search)',
strategy: Strategy.PUBLIC,
browser: false,
+1
View File
@@ -4,6 +4,7 @@ import { itunesFetch } from './utils.js';
cli({
site: 'apple-podcasts',
name: 'search',
access: 'read',
description: 'Search Apple Podcasts',
strategy: Strategy.PUBLIC,
browser: false,
+1
View File
@@ -6,6 +6,7 @@ const CHARTS_TIMEOUT_MS = 15_000;
cli({
site: 'apple-podcasts',
name: 'top',
access: 'read',
description: 'Top podcasts chart on Apple Podcasts',
strategy: Strategy.PUBLIC,
browser: false,
+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 -3
View File
@@ -1,21 +1,22 @@
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',
name: 'paper',
access: 'read',
description: 'Get arXiv paper details by ID',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
],
columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
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;
},
});
+33
View File
@@ -0,0 +1,33 @@
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',
access: 'read',
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 -7
View File
@@ -1,9 +1,10 @@
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',
access: 'read',
description: 'Search arXiv papers',
strategy: Strategy.PUBLIC,
browser: false,
@@ -11,14 +12,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'],
columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
func: async (args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 25));
const query = encodeURIComponent(`all:${args.query}`);
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}`,
});
}
+1
View File
@@ -4,6 +4,7 @@ import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
cli({
site: 'baidu-scholar',
name: 'search',
access: 'read',
description: '百度学术搜索',
domain: 'xueshu.baidu.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -13,6 +13,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'band',
name: 'bands',
access: 'read',
description: 'List all Bands you belong to',
domain: 'www.band.us',
strategy: Strategy.COOKIE,
+4 -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.
@@ -12,6 +12,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'band',
name: 'mentions',
access: 'read',
description: 'Show Band notifications where you are @mentioned',
domain: 'www.band.us',
strategy: Strategy.INTERCEPT,
@@ -52,7 +53,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 +81,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
View File
@@ -18,6 +18,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'band',
name: 'post',
access: 'read',
description: 'Export full content of a post including comments',
domain: 'www.band.us',
strategy: Strategy.COOKIE,
+1
View File
@@ -10,6 +10,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'band',
name: 'posts',
access: 'read',
description: 'List posts from a Band',
domain: 'www.band.us',
strategy: Strategy.COOKIE,
+1
View File
@@ -7,6 +7,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'barchart',
name: 'flow',
access: 'read',
description: 'Barchart unusual options activity / options flow',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -7,6 +7,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'barchart',
name: 'greeks',
access: 'read',
description: 'Barchart options greeks overview (IV, delta, gamma, theta, vega)',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -6,6 +6,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'barchart',
name: 'options',
access: 'read',
description: 'Barchart options chain with greeks, IV, volume, and open interest',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -7,6 +7,7 @@ import { CommandExecutionError } from '@jackwener/opencli/errors';
cli({
site: 'barchart',
name: 'quote',
access: 'read',
description: 'Barchart stock quote with price, volume, and key metrics',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -5,6 +5,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bbc',
name: 'news',
access: 'read',
description: 'BBC News headlines (RSS)',
domain: 'www.bbc.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -7,6 +7,7 @@ import { apiGet, resolveBvid } from './utils.js';
cli({
site: 'bilibili',
name: 'comments',
access: 'read',
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -14,6 +14,7 @@ import { resolveBvid } from './utils.js';
cli({
site: 'bilibili',
name: 'download',
access: 'read',
description: '下载B站视频(需要 yt-dlp',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -3,6 +3,7 @@ import { apiGet } from './utils.js';
cli({
site: 'bilibili',
name: 'dynamic',
access: 'read',
description: 'Get Bilibili user dynamic feed',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -3,6 +3,7 @@ import { apiGet, payloadData, getSelfUid } from './utils.js';
cli({
site: 'bilibili',
name: 'favorite',
access: 'write',
description: '我的收藏夹',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+2
View File
@@ -65,6 +65,7 @@ function parseItem(item) {
cli({
site: 'bilibili',
name: 'feed',
access: 'read',
description: '动态时间线(不传 uid 查关注时间线,传 uid 查指定用户动态)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
@@ -134,6 +135,7 @@ cli({
cli({
site: 'bilibili',
name: 'feed-detail',
access: 'read',
description: '查看 Bilibili 动态详情(支持充电专属内容)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -4,6 +4,7 @@ import { fetchJson, getSelfUid, resolveUid } from './utils.js';
cli({
site: 'bilibili',
name: 'following',
access: 'read',
description: '获取 Bilibili 用户的关注列表',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -3,6 +3,7 @@ import { apiGet, payloadData } from './utils.js';
cli({
site: 'bilibili',
name: 'history',
access: 'read',
description: '我的观看历史',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+6 -1
View File
@@ -2,12 +2,13 @@ import { cli } from '@jackwener/opencli/registry';
cli({
site: 'bilibili',
name: 'hot',
access: 'read',
description: 'B站热门视频',
domain: 'www.bilibili.com',
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 +21,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 +32,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 }}',
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { apiGet, getSelfUid } from './utils.js';
cli({
site: 'bilibili', name: 'me', description: 'My Bilibili profile info', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
site: 'bilibili', name: 'me', access: 'read', description: 'My Bilibili profile info', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
args: [],
columns: ['name', 'uid', 'level', 'coins', 'followers', 'following'],
func: async (page) => {
+1
View File
@@ -3,6 +3,7 @@ import { apiGet } from './utils.js';
cli({
site: 'bilibili',
name: 'ranking',
access: 'read',
description: 'Get Bilibili video ranking board',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1 -1
View File
@@ -1,7 +1,7 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { apiGet, stripHtml } from './utils.js';
cli({
site: 'bilibili', name: 'search', description: 'Search Bilibili videos or users', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
site: 'bilibili', name: 'search', access: 'read', description: 'Search Bilibili videos or users', domain: 'www.bilibili.com', strategy: Strategy.COOKIE,
args: [
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
{ name: 'type', default: 'video', help: 'video or user' },
+3 -2
View File
@@ -1,9 +1,10 @@
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',
name: 'subtitle',
access: 'read',
description: '获取 Bilibili 视频的字幕',
strategy: Strategy.COOKIE,
args: [
@@ -23,7 +24,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
+1
View File
@@ -3,6 +3,7 @@ import { apiGet, payloadData, resolveUid } from './utils.js';
cli({
site: 'bilibili',
name: 'user-videos',
access: 'read',
description: '查看指定用户的投稿视频',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -5,6 +5,7 @@ import { apiGet, resolveBvid } from './utils.js';
cli({
site: 'bilibili',
name: 'video',
access: 'read',
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
strategy: Strategy.COOKIE,
args: [
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'asks',
access: 'read',
description: 'Order book ask prices for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'depth',
access: 'read',
description: 'Order book bid and ask prices for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'gainers',
access: 'read',
description: 'Top gaining trading pairs by 24h price change',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'klines',
access: 'read',
description: 'Candlestick/kline data for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'losers',
access: 'read',
description: 'Top losing trading pairs by 24h price change',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'pairs',
access: 'read',
description: 'List active trading pairs on Binance',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'price',
access: 'read',
description: 'Quick price check for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'prices',
access: 'read',
description: 'Latest prices for all trading pairs',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'ticker',
access: 'read',
description: '24h ticker statistics for top trading pairs by volume',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'top',
access: 'read',
description: 'Top trading pairs by 24h volume on Binance',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'binance',
name: 'trades',
access: 'read',
description: 'Recent trades for a trading pair',
domain: 'data-api.binance.vision',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'businessweek',
access: 'read',
description: 'Bloomberg Businessweek top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'economics',
access: 'read',
description: 'Bloomberg Economics top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { BLOOMBERG_FEEDS } from './utils.js';
cli({
site: 'bloomberg',
name: 'feeds',
access: 'read',
description: 'List the Bloomberg RSS feed aliases used by the adapter',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'industries',
access: 'read',
description: 'Bloomberg Industries top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'main',
access: 'read',
description: 'Bloomberg homepage top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'markets',
access: 'read',
description: 'Bloomberg Markets top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -4,6 +4,7 @@ import { extractStoryMediaLinks, renderStoryBody, validateBloombergLink, } from
cli({
site: 'bloomberg',
name: 'news',
access: 'read',
description: 'Read a Bloomberg story/article page and return title, full content, and media links',
domain: 'www.bloomberg.com',
strategy: Strategy.COOKIE,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'opinions',
access: 'read',
description: 'Bloomberg Opinion top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'politics',
access: 'read',
description: 'Bloomberg Politics top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -3,6 +3,7 @@ import { fetchBloombergFeed } from './utils.js';
cli({
site: 'bloomberg',
name: 'tech',
access: 'read',
description: 'Bloomberg Tech top stories (RSS)',
domain: 'feeds.bloomberg.com',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'feeds',
access: 'read',
description: 'Popular Bluesky feed generators',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'followers',
access: 'read',
description: 'List followers of a Bluesky user',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'following',
access: 'read',
description: 'List accounts a Bluesky user is following',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'profile',
access: 'read',
description: 'Get Bluesky user profile info',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'search',
access: 'read',
description: 'Search Bluesky users',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'starter-packs',
access: 'read',
description: 'Get starter packs created by a Bluesky user',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'thread',
access: 'read',
description: 'Get a Bluesky post thread with replies',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'trending',
access: 'read',
description: 'Trending topics on Bluesky',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
+3 -1
View File
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'bluesky',
name: 'user',
access: 'read',
description: 'Get recent posts from a Bluesky user',
domain: 'public.api.bsky.app',
strategy: Strategy.PUBLIC,
@@ -15,7 +16,7 @@ cli({
},
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
],
columns: ['rank', 'text', 'likes', 'reposts', 'replies'],
columns: ['rank', 'uri', 'text', 'likes', 'reposts', 'replies'],
pipeline: [
{ fetch: {
url: 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }}',
@@ -23,6 +24,7 @@ cli({
{ select: 'feed' },
{ map: {
rank: '${{ index + 1 }}',
uri: '${{ item.post.uri }}',
text: '${{ item.post.record.text }}',
likes: '${{ item.post.likeCount }}',
reposts: '${{ item.post.repostCount }}',
+1
View File
@@ -6,6 +6,7 @@ import { requirePage, navigateToChat, fetchRecommendList, clickCandidateInList,
cli({
site: 'boss',
name: 'batchgreet',
access: 'write',
description: 'BOSS直聘批量向推荐候选人发送招呼',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,

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