Compare commits

...

489 Commits

Author SHA1 Message Date
jakevin 9ccc896585 chore(release): 1.7.21 (#1571)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-05-14 19:36:41 +08:00
jakevin 1a69f40a80 fix(social): use ephemeral adapter site sessions (#1569) 2026-05-14 19:22:42 +08:00
J.Chen 300607f692 fix(facebook/feed): add fallback extraction for empty article nodes (#1538)
* fix(facebook/feed): add fallback extraction for empty article nodes

Add fallback extraction for Facebook feed posts when [role=article] nodes exist but contain empty text. Includes diagnostic errors, content/author cleanup, nested-container dedupe, and an evaluate-script syntax regression test.

* fix(facebook): bound feed fallback extraction

* fix(facebook): keep feed fallback available after chrome articles

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 18:42:50 +08:00
J.Chen 42b5a4e68d feat(boss): support job-seeker chatlist and chatmsg (#1539)
* feat(boss): support job-seeker chatlist and chatmsg

* fix(boss): type chat-side failure boundaries

* fix(boss): guard malformed chat API payloads

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 18:41:02 +08:00
jakevin bccd275d66 test(extension): cover adapter group tiebreaker (#1566) 2026-05-14 18:04:07 +08:00
胡大头 edfa5f0da3 feat: add DuckDuckGo, Brave, and Yahoo web search adapters (#1546)
* feat: add DuckDuckGo, Brave, and Yahoo web search adapters

Add three new search engine adapters with browser-based DOM extraction:

- duckduckgo/search: Search DuckDuckGo via html.duckduckgo.com
  Supports region, time filters, and XHR-based pagination (--offset)
- duckduckgo/suggest: Search suggestion autocomplete (no browser needed)
- brave/search: Search Brave Search via search.brave.com
  Supports GET-based pagination (--offset)
- yahoo/search: Search Yahoo (Bing-powered) via search.yahoo.com
  Supports GET-based pagination (--page)

All search adapters use Strategy.PUBLIC with browser:true, navigating
the target site and extracting results via page.evaluate() DOM queries.
Includes full test coverage (16 tests).

* fix: use clampInt from shared utils and add adapter docs

- Replace Math.max/Math.min patterns with clampInt() from _shared/common.js
  to pass the typed-error-lint gate (4 silent-clamp violations resolved)
- Add adapter documentation for duckduckgo, brave, and yahoo to fix
  the doc-coverage CI check
- Regenerate cli-manifest.json and typed-error-lint-baseline.json

* fix: avoid silent-column-drop overlap in brave/yahoo extractors

Change buildExtractorJs to return arrays instead of objects whose keys
matched columns. This prevents silent-column-drop audit false positives
as per opencli-adapter-author conventions.

* fix(search): tighten browser search adapters

* chore(search): drop baseline churn

* fix(duckduckgo): execute search extractor safely

* fix(yahoo): reject unsafe redirect targets

---------

Co-authored-by: huzekang <huzekang@opencode.ai>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 17:54:34 +08:00
J.Chen 16b02bcc58 fix(extension): reuse existing adapter tab group (#1541)
* fix(extension): reuse existing adapter tab group

* fix(extension): choose best existing adapter group

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 17:15:39 +08:00
Iris Chen 5af2ff1d6c fix(xiaohongshu,rednote): unwrap page.evaluate envelope in search adapter (#1561)
* fix(xiaohongshu,rednote): unwrap page.evaluate envelope in search adapter

`page.evaluate(...)` returns a `{ session, data }` envelope rather than
the raw IIFE return value, but the search adapters were calling
`Array.isArray(payload)` directly on the envelope. `Array.isArray` is
always false on the envelope, so every search result was silently
dropped — status=success, exit 0, empty array, no error.

The rednote adapter had this same bug; both share `buildSearchExtractJs`
from `xiaohongshu/search.js`.

Introduces `unwrapEvaluateResult(payload)` as a shared helper in
`clis/xiaohongshu/search.js` (re-exported via the existing import line
from `rednote/search.js`). The helper is a defensive ternary: it
unwraps when payload looks like an envelope with an array `.data`,
otherwise it passes the value through unchanged. This keeps the change
back-compat with bridge versions that return the raw value, and
preserves the existing `Array.isArray(payload)` typecheck at each call
site.

Verified manually against `opencli xiaohongshu search "补墙洞"` (a query
known to return 20+ results in a logged-in browser tab): previously
`[]`, now returns the expected ranked rows with all declared columns
(`rank, title, author, likes, published_at, url`) populated.

Adds 5 unit tests for `unwrapEvaluateResult` covering raw array passthrough,
envelope unwrap, non-envelope object passthrough, null/undefined safety,
and the "data is not an array" guard. The existing 19 search tests in
`clis/xiaohongshu/search.test.js` still pass — the unwrap is invisible
to the existing mocks which already return raw arrays.

* fix(xhs): unwrap search evaluate envelopes

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 17:05:58 +08:00
jakevin 9c25bc7009 fix(ci): add Windows native binding lock entries (#1563) 2026-05-14 16:45:00 +08:00
jakevin 8c88a3cbf3 chore(release): 1.7.20 (#1562)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-05-14 16:33:19 +08:00
jakevin 9c4f4a3d30 fix(cli): show external CLI package aliases (#1560) 2026-05-14 16:28:18 +08:00
jakevin 29c135b656 refactor(notion): replace built-in CDP adapter with external ntn CLI (#1559)
* refactor(notion): replace built-in CDP adapter with external ntn CLI

Notion has shipped an official CLI at https://ntn.dev. It uses the
public Notion API (blocks / databases / properties / comments) instead
of reverse-engineering the Desktop UI, so it survives Notion app
updates and exposes a wider command surface than the in-tree adapter
could.

Changes:
  - `src/external-clis.yaml` — register `ntn` as first-class external CLI
    (binary `ntn`, homepage ntn.dev, install via the shell-pipe script
    on mac/linux)
  - `clis/notion/` — entire directory removed (8 commands: status /
    search / read / new / write / sidebar / favorites / export)
  - `docs/adapters/desktop/notion.md` — removed
  - `docs/.vitepress/config.mts` — drop nav entry
  - `docs/adapters/index.md` — drop adapter row
  - `README.md` / `README.zh-CN.md` — drop notion from feature lines,
    drop adapter table row, add `ntn` to CLI hub examples
  - `docs/index.md` / `docs/zh/index.md` / `docs/guide/getting-started.md`
    — drop notion from electron-control feature copy
  - `skills/opencli-usage/SKILL.md` — drop notion from electron list
  - `cli-manifest.json` — rebuilt with --allow-removals=8

Migration for users:
  `curl -fsSL https://ntn.dev | bash`  (or `opencli external install ntn`)
  Then use `opencli ntn <command>` in place of `opencli notion <command>`.

Rationale: the in-tree adapter was reverse-engineered against Notion
Desktop CDP and shipped only 8 commands. The official CLI gives users
the full Notion API surface and reduces our maintenance burden to zero.
Same pattern as gh / obsidian / lark-cli / tg-cli / discord-cli / wx-cli.

Verification:
  - `npx tsc --noEmit` clean
  - `npx vitest run --project unit` → 1091/1 skipped
  - `npm run build` (with --allow-removals=8) — manifest 809 entries
  - grep notion in user-facing docs (README / docs / skills) — only
    descriptive mentions remain in non-blocking places (comparison /
    site-recon / electron how-to / design doc), no broken adapter
    references

* fix(notion): align ntn external migration

* docs(notion): clarify ntn manual install
2026-05-14 16:12:17 +08:00
jakevin 7edf53783f fix(daemon): report unknown browser command results (#1558) 2026-05-14 14:30:13 +08:00
jakevin af7b94152f feat(twitter): add extractMedia parity to bookmarks + bookmark-folder (#1555)
Mirrors PR #1464 (list-tweets) and the timeline/search/tweets/likes/thread
family: spread `...extractMedia(legacy)` into the row and surface
`has_media` + `media_urls` columns. Pure parity, no behavior change for
existing callers — media keys do not collide with the original columns.

- bookmarks.js: import `extractMedia` from ./shared.js, spread into
  extractBookmarkTweet row, append columns, export __test__.
- bookmark-folder.js: same change on extractFolderTweet, export
  extractFolderTweet via __test__.
- bookmarks.test.js (new): baseline + photo + video + entities-only
  fallback + dedup + envelope + empty-envelope (8 tests).
- bookmark-folder.test.js: update existing baseline expectation with
  has_media/media_urls, add 3 new media tests (photo / mp4 / no-media).
- cli-manifest.json: regenerated; only the two `columns` entries change.

Reverse-validated: tests fail when extractMedia spread is removed.

Audits unchanged: typed-error-lint 189/189, silent-column-drop 102/103
(pre-existing main resolution noted but not consumed here).
2026-05-14 14:24:31 +08:00
Ocean 6b26aedd56 feat(twitter/list-tweets): include media via extractMedia (parity with timeline/search) (#1464)
* feat(twitter/list-tweets): include media via extractMedia (parity with timeline/search)

list-tweets was the only X recall path that dropped media. timeline.js and
search.js both call extractMedia(legacy) and emit has_media/media_urls;
list-tweets returned only text fields, so downstream consumers (e.g.
ml-scout's rate UI) couldn't render image/video thumbnails on tweets pulled
from a list timeline.

Changes:
- Import extractMedia from ./shared.js
- Spread extractMedia(legacy) into extractTimelineTweet return
- Add has_media, media_urls to columns array (--format columns parity)
- Update unit test to assert the new shape; add coverage for photo and
  video extraction

* chore(manifest): rebuild cli-manifest.json for list-tweets media columns

---------

Co-authored-by: ml-scout <ml-scout@anthropic.com>
2026-05-14 14:01:34 +08:00
jakevin 68b18cdbcd fix(extension): coalesce daemon websocket connects (#1554) 2026-05-14 13:57:38 +08:00
J.Chen cddc84776c docs(browser): clarify named session lifecycle (#1542)
* docs(browser): clarify named session lifecycle

* docs(browser): clarify owned versus bound sessions

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 13:38:41 +08:00
J.Chen 4f5fcd9acb fix(extension): keep active daemon websocket
Keep stale Browser Bridge WebSocket events from clobbering the active daemon connection.\n\nCo-authored-by: Jeff Chen <jeff@adtiming.com>
2026-05-14 13:38:05 +08:00
jakevin 40b2f75098 feat(external)!: drop -cli suffix from tg/discord/wx subcommand names (#1544)
The opencli external-CLI name is the user-typed subcommand; the binary is
what gets executed. The convention everywhere else (`gh`, `docker`,
`obsidian`, `vercel`, `dws`) is `name == binary`. Three entries violated
the convention: `tg-cli` / `discord-cli` / `wx-cli` registered an
opencli name with a `-cli` suffix that does NOT exist on the binary,
forcing the awkward double-prefix `opencli discord-cli dc` instead of
`opencli discord dc`.

The README's example column already showed the desired form
(`opencli tg search`, `opencli discord recent`, `opencli wx search`) —
only the yaml registration was out of sync.

Renames in `src/external-clis.yaml`:

* `name: tg-cli`     → `name: tg`       (binary: `tg`)
* `name: discord-cli`→ `name: discord`  (binary: `discord`)
* `name: wx-cli`     → `name: wx`       (binary: `wx`)

The `binary`, `homepage`, and `install` fields are unchanged — the
underlying packages (`kabi-tg-cli`, `kabi-discord-cli`, `@jackwener/wx-cli`)
keep their published names.

Other entries left as-is: `lark-cli`, `wecom-cli`, and `dws` already have
`name == binary` (their actual binaries are `lark-cli`, `wecom-cli`, `dws`).

BREAKING CHANGE: `opencli tg-cli ...`, `opencli discord-cli ...`,
`opencli wx-cli ...` no longer resolve. Use `opencli tg ...`,
`opencli discord ...`, `opencli wx ...` instead. The feature is recent
(shipped 2026-05) so impact is expected to be minimal.
2026-05-14 04:13:39 +08:00
jakevin feab24f76c chore(release): 1.7.19 (#1543)
E2E Headed Chrome / e2e-headed (macos-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-05-14 02:33:56 +08:00
jakevin 8ef7e903b8 feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug (#1531)
* feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug

Primary: make `opencli twitter tweets` default to the logged-in user
when no username is given, so agents can pull their own posts without
needing to know their own handle. Mirrors the existing self-detection
pattern in twitter/profile and twitter/likes (AppTabBar_Profile_Link
probe on /home, then UserByScreenName lookup). Description + help
string now mention the default so agents discover it.

Consistency pass — profile/likes/following/followers: the
self-detection in these four siblings was silently broken because
page.evaluate() primitive returns come back through the CDP bridge
wrapped as `{session: 'site:twitter', data: '/<handle>'}` (same
envelope root cause as #1525). They called `.replace()` directly on
the envelope object → TypeError surfaced as AUTH_REQUIRED 'Could not
detect logged-in user', even for logged-in users. Wrap each probe
with unwrapBrowserResult so the bare href string survives. Also:
- Add an explicit page.goto('/home') + page.wait(primaryColumn)
  before the probe in likes/following so the AppTabBar sidebar is
  guaranteed rendered (framework pre-nav lands on bare x.com without
  the sidebar mounted).
- following.js: switch its probe from the function-literal form
  `() => {...}` to a template-string. Confirmed live: function-literal
  silently drops primitive returns entirely — bridge returns
  `{session}` with no `data` field at all, while template-string
  returns `{session, data}` as expected.

Out of scope (pre-existing, flagged as follow-up): likes/following
have additional downstream evaluate paths (userId/GraphQL fetch) that
still drop or envelope their results; they return [] or
'Could not find user' even after this PR. Same daemon-side bug class
as #1525.

Live-verified:
  opencli twitter tweets --limit 2     → own tweets (@jakevin7)
  opencli twitter profile              → own profile

Tests 227/227, audits typed-error-lint 189 + silent-column-drop 103
unchanged, manifest stable at 816 entries.

* fix(twitter): validate self-detected handles

* fix(twitter): unwrap downstream self evaluate results
2026-05-13 22:51:48 +08:00
ppop123 7c5bafd49b fix(twitter): repair list-add / list-tweets / lists / following after 2026-05 changes (#1503)
* fix(twitter): unwrap page.evaluate primitive returns in lists/list-tweets/following

The opencli >=1.7.x browser bridge wraps page.evaluate's primitive return
values as { session, data: <value> }. Adapters that destructure .data
inline (e.g. data.queryId, data.viewer) keep working because the wrapper
spreads object-typed responses to the top level, but ones that consume
the return value as a bare string broke:

- twitter list-tweets: the dynamically resolved queryId (a string) became
  {session, data:"..."}. Interpolating that into the GraphQL URL produced
  /i/api/graphql/[object Object]/ListLatestTweetsTimeline, giving "HTTP
  400: queryId may have expired".
- twitter lists: same on ListsManagementPageTimeline queryId.
- twitter following: same shape bug on the href read from the profile
  link, producing "TypeError: href.replace is not a function" when no
  --user is given.

Add a small unwrap() helper at each call site so primitive returns are
extracted from the wrapper before use. Object-typed GraphQL responses
are left as-is since they rely on spread semantics.

* fix(twitter): rewrite list-add to use ListAddMember GraphQL mutation

In 2026-05 X replaced the "Add/remove from Lists" modal dialog with a
full-page route (/i/lists/add_member). The previous UI flow no longer
works:

  Save button not found in dialog (X expected text Save/Done).
  Dialog structure may have changed.

The mutation that the dialog used to fire (ListAddMember) is still the
right primitive — and the surrounding adapter already calls X GraphQL
APIs directly to resolve userId and verify member_count. Drop the UI
flow entirely and call ListAddMember directly via fetch in the page
context.

Wins:
- Works again on current X UI (verified 2026-05-12 on x.com).
- ~10x faster: no goto-profile + click-caret + scroll-dialog round trips.
- One less moving piece — no dependency on Chrome extension's nativeClick
  for this command.

Implementation notes:
- LIST_ADD_MEMBER_QUERY_ID is a 2026-05 fallback; resolveTwitterQueryId
  does live lookup from the loaded client-web bundle, matching the
  pattern already used elsewhere in the twitter clis.
- X's ListAddMember response routinely contains a non-fatal partial
  decode error on default_banner_media_results (code 214, Validation /
  BadRequestError) alongside a fully populated data.list. We treat the
  call as failed only when data.list / member_count is missing, and
  ignore decode-flavored errors confined to banner fields.
- Same opencli >=1.7.x { session, data } primitive-wrap behavior that
  the previous commit addressed applies here: userId from the
  UserByScreenName call needs unwrap before being interpolated into
  the mutation body, otherwise X parses "[object Object]" as user_id
  and returns "strconv.ParseInt ... invalid syntax".

Verified flows:
- noop (already a member) → status: noop, member_count unchanged.
- new add (e.g. @AnthropicAI on a fresh list) → status: success,
  member_count incremented.

Trade-off: rejection signals (e.g. X declining to add @deepseek_ai)
look indistinguishable from noop at the response level, since X returns
HTTP 200 with member_count unchanged. Documented in the success message.

* fix(twitter): integrate list media and harden list-add

---------

Co-authored-by: wangyan <wy@wang-yan-Air.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 22:35:57 +08:00
jakevin f66996a148 fix(twitter): raise cursor pagination guard
Fix hidden pagination ceilings across Twitter cursor-pagination adapters.\n\nCo-authored-by: Mingming Lou <1109198+lmmsoft@users.noreply.github.com>
2026-05-13 22:14:05 +08:00
jakevin 4fac911425 feat(zhihu): add answer-detail to fetch a single answer's full content (#1528)
* feat(zhihu): add answer-detail to fetch a single answer's full content

The existing `zhihu answer` adapter is a write (post an answer); the
listing `zhihu question` truncates each answer's body to 200 chars.
There was no way to fetch one specific answer's full content by id.

New read adapter `zhihu answer-detail`:

- Accepts a bare numeric answer id, a typed target `answer:<qid>:<aid>`,
  or a full Zhihu answer URL (the form you paste from a browser).
- Calls `/api/v4/answers/<aid>?include=content,voteup_count,...,question`
  inside the cookie-bearing page context (Strategy.COOKIE).
- Returns a single row with id / author / votes / comments /
  question_id / question_title / url / created_at / updated_at /
  content. The content column is the full stripped answer body by
  default — no silent truncation. `--max-content N` is an opt-in user
  cap (mirroring the wikipedia `page` flag), and `--max-content 0`
  (the default) means "no cap, full content".

Important precision note: Zhihu answer ids since 2024 routinely
exceed `Number.MAX_SAFE_INTEGER` (the test fixture uses the real id
`1937205528846655537`). `data.id` is round-tripped through browser
`JSON.parse` and would round to `1937205528846655500`, so the adapter
deliberately ignores `data.id` for the canonical row id and anchors
it to the already-validated input string instead. A regression test
locks this contract in by mocking `data.id = 0` and asserting the row
still carries the parsed input id.

Typed errors: bad input → INVALID_INPUT; 401/403 → AuthRequiredError;
other HTTP / null → FETCH_ERROR. No silent fallbacks, no sentinel
strings.

Live-verified against the example URL — fetched 5547 votes / 165
comments / 1937205528846655537-end-to-end. 16 unit tests, audits
unchanged (typed-error-lint 189/189, silent-column-drop 103/103),
manifest 816→817.

* fix(zhihu): tighten answer-detail contracts
2026-05-13 21:47:37 +08:00
xcd_git b52da639a3 fix(google-scholar/search): wrap evaluate return to fix serialization (#1525)
* fix(google-scholar/search): wrap evaluate return to fix serialization

Same issue as google/search: page.evaluate() serializes JS arrays as
plain objects across the CDP boundary, causing Array.isArray() to
return false. The adapter silently returned [] instead of results.

Also replace fixed page.wait(3) with selector-based wait for
.gs_r.gs_or.gs_scl with a 3s fallback.

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

* fix(google-scholar): type search evaluate payload

* chore: rerun google scholar search checks

---------

Co-authored-by: cxiao <chuda.xiao@wuerzburg-dynamics.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 19:07:39 +08:00
Joseph赛博阿隆 b1dca04ddd fix(xiaohongshu): parseLikes should handle 2.1w / 1.5万 / 1.2k shortforms (#1504)
* fix(xiaohongshu): parseLikes should handle 2.1w / 1.5万 / 1.2k shortforms

Xiaohongshu renders top-popular comment like-counts as shortened
strings like '2.1w' / '1.1万' / '1.2k' once they exceed ~10 000.
The previous parseLikes only matched bare digits via /^\d+$/ and
silently returned 0 for any shortform, which inverted the sort
order: the highest-liked comments (often 10k+) ranked last while
mid-tier comments with plain numeric counts (e.g. 7569) appeared
on top.

Repro on any popular xiaohongshu thread (>10 000 likes on a top
comment): with --format json the most-upvoted parent rows show
"likes": 0.

This patch keeps the original fast path for plain integers and
adds a single regex for the well-known shortform suffixes:

  - w / 万 -> *10000
  - k / 千 -> *1000
  - trailing '+' tolerated (e.g. '999+')
  - unknown shapes still fall back to 0 (no behavior change)

Note: parseLikes runs inside the IIFE injected via page.evaluate(),
so the existing comments.test.js mock harness (which stubs
evaluate's return value directly) does not exercise it. A future
refactor that exports parseLikes for direct testing would be a
separate change.

Affects both top-level comments and 楼中楼 sub-replies (same
helper).

* fix(xiaohongshu): parse comment like shortforms safely

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:47:20 +08:00
jakevin f481585ba1 chore: drop util.styleText to support Node v20+ (#1524)
* chore: drop util.styleText to support Node v20+

util.styleText was added in Node v21.7.0 / v20.12.0. v21.0.0-v21.6.x and
v20.0.0-v20.11.x throw `SyntaxError: ... styleText` at startup because the
import resolves before any user code runs (a real user reported this on
v21.2.0).

OpenCLI is primarily agent-facing — terminal colors are noise to consumers,
and the [OK] / [WARN] / [FAIL] / ℹ / ⚠ / ✖ markers we already write carry
the semantic info that colors only repeated. Strip styleText entirely from
logger / output / doctor / tui / update-check / cli / download/progress /
commands/daemon and clean up the resulting awkward `${'literal'}` template
fragments. engines.node now reads ">=20.0.0".

This removes the Node-version coupling that A/B fixes would only have
papered over.

* fix(runtime): truly support Node v20+ by aligning guard + undici

Follow-up to the styleText removal: declaring engines.node >=20.0.0 is
not enough on its own. Two coupled barriers remained:

- src/runtime-detect.ts: MIN_SUPPORTED_NODE_MAJOR = 21 explicitly
  rejected v20 at startup
- undici@^8.0.2 declares engines.node >=22.19.0; Node 20/21 crash on
  webidl.util.markAsUncloneable before any user code runs

Lower the guard to 20 and downgrade undici to ^6.25.0 (engines >=18.17,
retains Agent / EnvHttpProxyAgent / fetch / Dispatcher). Smoke-tested
--help / doctor / list on Node v20.0.0, v21.2.0, v22.22.2. 213/213
targeted unit tests pass.
2026-05-13 18:33:21 +08:00
lenovobenben 723f2b9147 feat(zhihu): paginate question answers and recommendations (#1517)
* feat(zhihu): paginate question answers and recommendations

* fix(zhihu): drop Math.min limit clamp and 'unknown' sentinel

Two audit-driven fixes on top of feat/zhihu-pagination-recommend:

1. question.js: replace `Math.min(answerLimit, 20)` with a named
   constant `ZHIHU_PAGE_SIZE = 20`. The Zhihu API caps `limit` at 20
   per request anyway, and the pagination loop already trims to the
   user-requested `answerLimit` via `answers.length >= answerLimit`,
   so the Math.min silent-clamp was both unnecessary and tripped the
   silent-clamp audit. Updates the existing unit test to expect the
   API-max page size in the fetch URL with an explanatory comment.

2. recommend.js: rebuild the dedup key without the `'unknown'`
   sentinel. The old form `\`\${target.type || 'unknown'}:\${target.id}\``
   collapsed distinct typed items into the same bucket whenever
   `target.type` was missing, and tripped the silent-sentinel audit.
   New form: prefer `type:targetId`, fall back to `__feed:item.id`,
   and when neither id is available keep the row but skip dedup
   (surfacing potentially-duplicate items beats silently dropping
   them).

Audits unchanged (typed-error-lint 189/189, silent-column-drop
103/103). All 88 zhihu tests pass.

---------

Co-authored-by: lihaidong <lihaidong@kingsoft.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:28:44 +08:00
Benjamin Liu 2babed84e9 fix(xiaohongshu+rednote/search): fall back to href-based note cards when section.note-item class is dropped (#1506) (#1507)
Issue #1506 reports `opencli xiaohongshu search` returning `[]` even though
the page visibly has results. Trace evidence: xhs ships a render variant
where each note card is a bare `<section>` (no `note-item` class), so
the three `section.note-item` selectors in this file all match zero
elements.

Three call sites in the shared search IIFEs now use the same defensive
selector strategy: try the legacy `section.note-item` class first, then
fall back to any `<section>` that wraps a `/search_result/...` or
`/explore/...` link. The change is in the xiaohongshu file so the
rednote adapter (which imports `buildSearchExtractJs` and
`buildScrollUntilJs` from here) picks it up automatically.

Extraction-side title selector also gets a fallback: when no
`.title` / `.note-title` element matches, read the first `<span>`
inside the search-result link, which is where the bare-section render
puts the caption per the trace.

## Verification

`npx vitest run --project adapter clis/xiaohongshu/`: 105/105 green
(existing test suite unchanged, passes on both legacy and fallback paths).

Live verify on rednote (same code path, account-safe):

```
$ opencli rednote search "美食" --limit 3 -f json
[ {rank:1, title:"在朋友家吃过一次..."}, {rank:2, title:"我的15💰晚餐..."}, {rank:3, title:"干净饮食🫛..."} ]
```

Legacy `section.note-item` path is exercised here (rednote still renders
the class) and returns identical row shape to before the fix, confirming
no regression on the working path.

Live verify on xiaohongshu cannot be performed here (no logged-in xhs
session on the test machine; xhs account-ban risk per the project's
operational guidance). The fix is structural: the new `<section>` shape
the issue reporter traced is reachable through the fallback, and the
existing test fixture keeps the legacy path green.

`npx tsc --noEmit` clean. `npm run build` 815 manifest entries unchanged
shape. `silent-column-drop` / `typed-error-lint` baselines unchanged.

Closes #1506
Refs #1500
2026-05-13 18:24:55 +08:00
陈家名 a6ca53c7cf fix: clamp download progress percentages (#1520)
* fix: clamp download progress percentages

* test(download): cover unknown progress total

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:24:26 +08:00
jakevin c3912d8e5c feat(reddit/read): --expand-more via /api/morechildren + 7-kind typed errors (#1492)
* feat(reddit/read): add --expand-more via /api/morechildren + 7-kind discriminated union

PR B of the rdt-cli parity follow-up (after PR #1491, see #1481 thread).
Closes the second-largest gap: Reddit's "[+N more replies]" stubs were
opaque markers in the comment tree. With --expand-more, the adapter
follows them by POST-ing the t1 ids to /api/morechildren.json, then
re-threads the returned things back into the tree by parent_id before
walking it.

New args:

- `--expand-more` (bool, default false) — turn on stub expansion.
- `--expand-rounds <N>` (int, default 2, range [1, 5]) — Reddit returns
  fresh "more" stubs at the expansion depth boundary, so up to N rounds
  are run. Strictly validated via `parseExpandRounds` — out-of-range
  raises ArgumentError BEFORE `page.goto`, no silent clamp.

Boy-Scout: the in-browser script now returns a 7-kind discriminated
union instead of a flat row array (matching the PR #1428 / #1491
sediment). Each kind maps 1:1 to a typed error on the Node side:

  - `inaccessible` → EmptyResultError
      401/403/404 on /comments/<id>.json (post-specific access, not
      session-level auth — applies the PR #1491 review-side sediment
      "inaccessible-resource vs session-auth").
  - `auth`         → AuthRequiredError
      401/403 on /api/morechildren (expand-write endpoints often demand
      a logged-in session even when the read endpoint is anonymous).
  - `http`         → CommandExecutionError
  - `malformed`    → CommandExecutionError
      200 with unexpected envelope shape — schema drift, not empty.
  - `parser-drift` → CommandExecutionError
      tree had t1 entries but the walker produced no rows (PR #1491
      review-side sediment "post-construction 0 rows + pre-walk
      non-empty = parser drift, not legitimate empty").
  - `expand-failed`→ CommandExecutionError
      /api/morechildren returned a non-empty json.errors array.
  - `ok`           → returns rows[].

Intermediate keys (kind / detail / httpStatus / where / rows /
expandMeta) deliberately avoid the declared columns (type / author /
score / text) per the PR #1329 silent-column-drop sediment.

Tests:
  clis/reddit/read.test.js — 11 tests
    - Adapter shape (browser / siteSession / columns / args)
    - --expand-more / --expand-rounds present with correct types/defaults
    - parseExpandRounds default / range / non-integer rejection
    - Pre-navigation validation (bad --expand-rounds doesn't reach goto)
    - kind=ok happy path (POST + L0 rows)
    - 6-kind error → typed error mapping
    - Unknown envelope shape → CommandExecutionError
    - Evaluate script embeds expandMore/expandRounds/sort/limit literals
    - Evaluate script contains /api/morechildren POST scaffolding
    - Evaluate script never names declared columns as intermediate keys

Full reddit suite 48/48; full project 3402/3402.

Audits: typed-error-lint 189/189 (0 new), silent-column-drop 103/103
(0 new). Manifest 815 → 815 (existing read entry gets 2 new args).

Existing --limit / --depth / --replies / --max-length keep their
original Math.max-style behaviour (grandfathered in the baseline);
only the new --expand-rounds flag fails fast per the typed-errors
standard.

Refs: https://github.com/jackwener/rdt-cli (browse.read --expand-more)

* fix(reddit): preserve expanded comment tree order

* fix(reddit): fail on partial morechildren expansion
2026-05-13 18:13:11 +08:00
darthjaja 67599ea67c fix(twitter): repair search and tweets readback (#1512)
* fix(twitter): repair search and tweets readback

* fix(twitter): prefer baked operation features when bundle parse returns empty

The bundle parser in resolveTwitterOperationMetadata locates the queryId via
`queryId:"..."` inside a ~2500-char snippet around the operationName marker,
then independently extracts `featureSwitches:[...]` and `fieldToggles:[...]`
via separate regexes. When minification rearranges the snippet (or the
snippet window truncates before the array), either regex can miss while
queryId still resolves; keysToFlags(undefined) then returns {}.

sanitizeTwitterOperationMetadata previously accepted any object as
features / fieldToggles, including {}. Twitter's GraphQL endpoint rejects
SearchTimeline / UserTweets requests with empty features (HTTP 400),
surfacing a misleading "queryId may have expired" error — the queryId is
fresh; only the feature flags are missing.

Guard against this by deferring to the baked fallback whenever the resolved
map is empty. Adds a JSDOM-free unit test that, reverse-validated, fails on
the un-fixed code with the exact silent-fallback shape.

Refs PR #1512

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:11:41 +08:00
darthjaja f321a6096d fix(twitter): make reply submission robust (#1511) 2026-05-13 18:10:19 +08:00
xcd_git 59ebf551f0 fix(google/search): wrap evaluate return value in object to fix serialization (#1523)
page.evaluate() serializes JS arrays as plain objects, causing
Array.isArray() to return false and the adapter to throw NOT_FOUND
even when results exist. Wrap the return value in {items: results}
and extract via wrapper.items to avoid the type check issue.

Co-authored-by: cxiao <chuda.xiao@wuerzburg-dynamics.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 18:05:03 +08:00
jakevin 04a57029b3 ci(adapter-test): gate adapter-test off pull_request trigger (#1522)
Per @WAWQAQ direction (DM): trim PR-time CI to fast-feedback only.
adapter-test (~30-60s) is the next-largest PR wait after e2e-headed
(which #1521 just removed). Adapter authors typically run focused tests
locally before pushing (`npm run test:adapter`); CI duplication adds
queue latency without catching new classes of bugs.

PR-time CI surface now:
  - typecheck / unit (~1 min)
  - lint gates (typed-error / silent-column-drop)
  - build × 3 platforms

Adapter test guards (still strict):
  - push to main / dev
  - nightly cron
  - workflow_dispatch (manual when an adapter-heavy PR really wants the
    signal before merge)

Same gate as smoke-test (`if: github.event_name == 'push' || schedule
|| workflow_dispatch`) for consistency.
2026-05-13 17:55:23 +08:00
jakevin fd438c2109 ci(e2e): drop e2e-headed from pull_request trigger (#1521)
Per-PR e2e-headed Chrome was the dominant PR-time wait (~10-15 min on
two platforms) and on fork PRs blocks behind maintainer approval, while
the actually-blocking failures it caught in the last 30 days were all
e2e-test migrations missed by the authoring PR (#1461 / #1505 workspace
->session) rather than real regressions the unit/typecheck tier missed.

PR feedback path is now:
  - typecheck / unit / lint / adapter / build  ← `pull_request` (ci.yml)
  - extension typecheck / build                ← `pull_request` (build-extension.yml)
  - docs build                                  ← `pull_request` (doc-check.yml)
  - security audit                              ← `pull_request` (security.yml)

E2E-headed Chrome guards:
  - push to main / dev (watched paths)
  - push v* tag (release)
  - nightly cron 08:00 UTC (added: catches Chrome version drift / flake
    drift even when no commits touch watched paths)
  - workflow_dispatch (manual when a PR really wants e2e signal)

smoke-test was already gated on `schedule || workflow_dispatch` only
(ci.yml), so no change needed there.
2026-05-13 17:45:41 +08:00
Xiaohan Li 1eac8e0776 fix(browser): drop session injection from extension exec results (#1518)
`pageScopedResult()` in extension/src/background.ts was spreading the
lease's session into the result `data` for every page-scoped command. For
the `exec` action — which routes user JavaScript through page.evaluate()
— this contaminated arbitrary user-JS returns:

* Array / primitive returns came back as `{ session, data: <value> }`
  envelopes. Adapters that did `Array.isArray(result)` got `false` and
  treated the page as having no rows. Visible repro:
  `opencli google search ...` and `opencli xiaohongshu search ...` —
  Chrome rendered results correctly but adapters extracted an empty array
  (reported in #1518 from the Browser Bridge v1.0.12 envelope).
* Plain-object returns had an extra `session` key spliced in, silently
  overwriting any user `session` field with the lease's value.

Fix in the extension layer instead of compensating client-side:
`pageScopedResult` now returns `{ id, ok, data, page }` — the same form
it had before #1461 added the workspace→session refactor. Client-side
unwrapping is no longer needed and the original PR #1518 `Page.evaluate`
heuristic is dropped (it only covered the array path and would have
missed the plain-object path).

Two adapter improvements kept from the original PR:

* `clis/google/search.js` — wait for `#rso a h3` (with a 5s timeout)
  before extracting. On Chrome 148 / Linux Wayland the DOM can settle
  before SERP anchors are populated, so the existing fixed `wait 2`
  could return empty even with the envelope fix.
* `clis/xiaohongshu/search.js` — extract initially visible cards before
  scrolling, then merge post-scroll rows by URL. Xiaohongshu's
  virtualized masonry can evict the initial note cards from the DOM
  after scroll, causing extraction to return [] even though the
  browser had rendered results correctly.

Extension version bumped to 1.0.14.

Repro environment (from #1518):

* OpenCLI 1.7.18
* Browser Bridge extension 1.0.12 → 1.0.14
* Chrome 148.0.7778.96
* Linux Wayland, Node 22.22.1

Tests: extension/src/background.test.ts navigate same-url assertion
updated to no longer expect `session` in `data`. Three Page.evaluate
unwrap test cases removed.
2026-05-13 17:45:07 +08:00
Benjamin Liu 6af4db2ab5 fix(xueqiu/kline,earnings-date): format dates in Asia/Shanghai instead of UTC (#1498)
* fix(xueqiu/kline,earnings-date): format dates in Asia/Shanghai instead of UTC (#1465)

`xueqiu/kline` and `xueqiu/earnings-date` formatted bar timestamps with
`new Date(ts).toISOString().split('T')[0]`. That string is the UTC
calendar date, always one day earlier than the date xueqiu shows in its
UI (which is Beijing-aligned for every market). Issue #1465 reports
"5月10日跑的,5月8号的k线没有" because the May 8 China trading-day bar
was labeled 2026-05-07. Same off-by-one was present in `earnings-date.js`.

Routes both call sites through a new `formatChinaDate(ts)` helper in
`clis/xueqiu/utils.js` built on `toLocaleDateString('en-CA', { timeZone:
'Asia/Shanghai' })`. Verified live against SZ300136 and AAPL: both now
match the dates shown on xueqiu.com.

Tests: `clis/xueqiu/utils.test.js` (new) pins the Asia/Shanghai semantic
with 4 cases (China midnight, late-evening, 16:00 UTC day boundary, and
nullish input). `npx vitest run --project adapter clis/xueqiu/` 49/49,
`npx tsc --noEmit` clean, `npm run build` 815 entries unchanged shape.

Closes #1465

* fix(xueqiu): stabilize China date formatting

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 17:40:28 +08:00
jakevin 5127211ec1 refactor(extension): remove lease key session backdoor (#1510) 2026-05-12 22:43:30 +08:00
jakevin 587750cad3 refactor(env): remove OPENCLI_KEEP_TAB (#1509)
`OPENCLI_KEEP_TAB` was a debugging shortcut, not a config dimension. It
let users override `--keep-tab` globally via the shell environment,
which contradicts the per-command lifecycle model: `siteSession:'persistent'`
already pins persistent site tabs as a hard adapter-metadata constraint,
and `--keep-tab true|false` covers the ad-hoc override case. The env
just leaked process state across every browser command in the shell.

Changes:
  - src/execution.ts: `resolveKeepTab()` drops the
    `normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)`
    fallback. `--keep-tab` is now the single user override.
  - src/execution.test.ts: two regression tests rewritten to use the
    `executeCommand(cmd, {}, false, { keepTab: 'true' })` signature
    instead of the env. Logic and assertions unchanged.
  - README.md / README.zh-CN.md / skills/opencli-usage/SKILL.md:
    drop the env table row. `--keep-tab` documentation stays.
  - CHANGELOG.md: BREAKING entry under Unreleased.

Note: the 1.7.15 CHANGELOG entry still references the env historically;
that's intentional, historical entries are not retroactively edited.

Verification:
  - npx tsc --noEmit pass
  - npx vitest run --project unit --project extension → 1144/1145 pass
    (1 unrelated skip)
  - typed-error-lint baseline 189
  - silent-column-drop baseline 103
2026-05-12 22:02:17 +08:00
jakevin a77e05847f feat(browser): add function form page evaluate (#1508) 2026-05-12 21:48:44 +08:00
jakevin 0e168d570e refactor(browser): replace --session flag with <sessionname> positional (#1505)
* refactor(browser): replace --session flag with <sessionname> positional

The `--session <name>` flag was semantically required but syntactically
optional, which is an anti-pattern. Required + flag is a contradiction:
flag form implies "optional", required is a runtime patch on top. Session
is OpenCLI's "operation target" identifier — the natural form for that is
a positional argument, like `docker exec <container> <cmd>` or
`git checkout <branch>`.

New surface:

  opencli browser <sessionname> open https://x.com
  opencli browser <sessionname> click 12
  opencli browser <sessionname> bind
  opencli browser <sessionname> unbind

Commander 14 cannot natively combine a parent positional with subcommand
dispatch — the parent's positional is shadowed by subcommand matching. To
bridge that, main.ts now pre-processes argv: when the token after `browser`
is non-flag and not a known subcommand name, it is treated as the
sessionname and rewritten to the internal `--session <name>` flag form
before commander parses it. Help text on the `browser` command is
overridden via `.usage('<sessionname> <command> [options]')` so users see
the positional form.

Reserved subcommand names (33) are listed in cli-argv-preprocess.ts and
tested for parity with cli.ts subcommand registrations. If a future
subcommand is added, the test fails loudly.

Synced surfaces:
  - README.md / README.zh-CN.md — all examples
  - docs/guide/browser-bridge.md (+ zh)
  - skills/opencli-browser/SKILL.md (bind/unbind, examples, table)
  - skills/opencli-usage/SKILL.md
  - tests/e2e/browser-tabs.test.ts
  - CHANGELOG.md (Unreleased BREAKING)

The internal `--session` flag and the unit tests calling
`program.parseAsync(['...', 'browser', '--session', 'foo', ...])` are
preserved as a stable internal API: tests bypass main.ts pre-processing
and exercise commander directly. The pre-processor has its own targeted
test file (cli-argv-preprocess.test.ts, 10 tests, all green).

Verification:
  - npx tsc --noEmit — pass
  - npx vitest run --project unit — 1073/1074 pass (1 unrelated skip)
  - npx vitest run --project extension — 61/61 pass
  - npm run check:typed-error-lint — baseline 189
  - npm run check:silent-column-drop — baseline 103

* fix(cli-argv): only rewrite when `browser` is the root command

The preprocessor was looping through every argv slot and would mis-rewrite
occurrences of the literal word `browser` deeper in argv (e.g. `opencli
adapter init browser/x` or arg values containing `browser`).

Now the preprocessor walks past leading root flags + their values to
identify the root command token, and only acts when that token is
`browser`. The full set of root value-consuming flags
(`ROOT_VALUE_FLAGS`) is documented inline and kept in sync with the
`program.option()` calls in cli.ts.

Adds regression tests:
  - `opencli adapter init browser x` not rewritten
  - URL/path values containing `browser` not rewritten
  - `list browser state` (different root command) not rewritten
  - `--profile work browser foo state` correctly identifies `foo` as
    sessionname (not as --profile's value)
  - `--profile=work` long-form-with-equals consumes one slot only
  - boolean flags (`-v`) don't consume the next value

12/12 preprocessor tests pass.

* fix(cli-argv): hide --session flag, fail-fast on retired form, rename to <session>

Three blockers in #1505 review:

1. `--session` flag was still visible in `opencli browser --help` and could
   be used as a public entrance, contradicting "positional only" UX.
   Fix: switch from `.requiredOption()` to `.addOption(new Option(...).hideHelp())`.
   The flag is preserved as an internal API for the daemon protocol and direct
   `program.parseAsync` callers (tests), but is no longer documented or
   surfaced in structured help.

2. `opencli browser --session foo state` still succeeded. Now the argv
   preprocessor throws `BrowserSessionArgvError` when root `browser` is
   followed by `--session`, and main.ts catches it and exits with a
   user-facing usage error pointing to the positional form.

3. Missing-session error message exposed the internal flag:
   `required option '--session <name>' not specified`. Now `getBrowserSession()`
   in the action body throws `<session> is a required positional argument:
   opencli browser <session> <command>`, and commander no longer guards the
   hidden option.

Also (per @WAWQAQ) rename placeholder `<sessionname>` -> `<session>` everywhere
user-facing — shorter, matches CLI convention. The help text "<session> is a
required positional: pass the name of the browser session..." carries the
"name" semantics in description, not in the placeholder itself.

Sync surfaces:
  - src/cli.ts — usage line, addOption with hideHelp, descriptions
  - src/cli-argv-preprocess.ts — throw on --session form
  - src/cli-argv-preprocess.test.ts — refusal test for old form
  - src/cli.test.ts — assertions updated for hidden option + new error path
  - src/help.ts — read `_usage` private field to respect `.usage()` override
    (commander's `.usage()` getter returns auto-generated form if not set,
    which would otherwise pollute every namespace's usage string)
  - src/main.ts — catch BrowserSessionArgvError, stderr + exit
  - README.md / README.zh-CN.md
  - docs/guide/browser-bridge.md / docs/zh/guide/browser-bridge.md
  - skills/opencli-browser/SKILL.md / skills/opencli-usage/SKILL.md
  - CHANGELOG.md

Manual smoke tests (against built dist):
  - `opencli browser --help` shows `Usage: opencli browser <session> <command> [options]`
  - `opencli browser --help` Options block does NOT show `--session`
  - `opencli browser --session foo state` → friendly error, no commander stacktrace
  - `opencli browser state` → `<session> is a required positional argument: opencli browser <session> <command>`
  - `opencli browser foo state` → parses correctly

* fix: inject <session> into subcommand help paths and drop stale sessions ref

Two follow-up blockers from #1505 review:

1. Subcommand help and structured help still rendered the command path
   without the parent's positional. `opencli browser foo state --help`
   showed `Usage: opencli browser state [options]`, which would lead
   users (and agents reading structured help) to think
   `opencli browser state` was a valid invocation. Now:

   - `commanderPath()` injects an ancestor's leading-positional placeholder
     (extracted from its `.usage()` override) between the ancestor's name
     and the next path segment when building paths upward.
   - `commandPathFromRoot()` strips placeholder segments (e.g. `<session>`)
     from the relative `name` field so agents can still address subcommands
     by their leaf name; placeholders remain in the `command` / `usage`
     display paths.
   - `program.configureHelp({ commandUsage: ... })` is applied recursively
     to every descendant of `browser`, because commander does NOT inherit
     `configureHelp` into subcommands.

   Result:
     opencli browser <session> click --help
     -> Usage: opencli browser <session> click [target] [options]

   Daemon, plugin, adapter, profile namespaces (no `.usage()` override)
   are unaffected.

2. `skills/opencli-browser/SKILL.md` still referenced
   `opencli browser sessions`, which was removed in #1470. Replaced the
   sentence with the underlying invariant ("Bound sessions have no
   OpenCLI idle-close timer; the binding lasts until `unbind`, tab close,
   window close, or daemon restart") without mentioning the deleted
   command.

Tests:
  - cli.test.ts: structured help expectations updated to include
    `<session>` in command/usage paths (3 tests)
  - cli-argv-preprocess.test.ts: 12 tests still green
  - 1136/1137 unit+extension green (1 unrelated skip)
  - typed-error-lint baseline 189
  - silent-column-drop baseline 103
2026-05-12 20:44:29 +08:00
jakevin fa9b38cd92 feat(reddit): add whoami, home, subreddit-info read commands (#1491)
* feat(reddit): add whoami, home, subreddit-info read commands

Closes gap against jackwener/rdt-cli — three commands the existing 17 reddit
adapters were missing:

- `reddit whoami` — show the currently logged-in identity (fields:
  Username, ID, Post / Comment / Total Karma, Account Created, Gold, Mod,
  Verified Email, Has Mail, Inbox Count). Probes `/api/me.json` with
  two-pronged auth detection (401/403 OR `data.name` missing on 200 —
  Reddit returns 200 with an empty body for stale anon sessions, see PR
  #1428).

- `reddit home` — personalized Best feed (`/best.json`). Distinct from
  the public `frontpage`/`r/all` command: enforces login via the same
  two-pronged auth check rather than silently degrading to the
  unauthenticated default feed. `--limit` accepts [1, 100] — out-of-range
  raises `ArgumentError` before navigation, no silent clamp.

- `reddit subreddit-info` — subreddit metadata (Name, Title, Subscribers,
  Active Now, NSFW, Type, Description, Created, URL) from
  `/r/<X>/about.json`. Banned / private / quarantined / 404 subreddits
  raise `EmptyResultError` so the output table never holds a silent
  sentinel row.

All three use Strategy.COOKIE + siteSession:'persistent' matching the
existing reddit adapters, validate args upfront before `page.goto`, and
use the 5-kind discriminated-union pattern (kind: auth/http/missing/
exception/ok) from PR #1428 to map page.evaluate results to typed errors
on the Node side. Intermediate object keys deliberately avoid the
declared columns (`field`/`value`/`rank`/etc.) per the silent-column-drop
audit sediment from PR #1329.

Tests: 28 new (whoami 6, home 9, subreddit-info 13); full reddit suite
38/38. Audits: typed-error-lint 189/189 (0 new), silent-column-drop
103/103 (0 new). Manifest 812 → 815.

Refs: https://github.com/jackwener/rdt-cli

* fix(reddit): tighten new read command failure contracts

* fix(reddit): treat inaccessible subreddit info as empty
2026-05-12 04:10:44 +08:00
jakevin 93bc374437 chore(scripts): auto-refresh dist/ before build-manifest (#1490)
* chore(scripts): auto-refresh dist/ before build-manifest

`build-manifest.ts` is invoked via tsx so its own imports go to TS source,
but the adapter `.js` files it loads import `@jackwener/opencli/registry`
through package exports, which resolves to `dist/src/registry-api.js`.

When `dist/` is stale relative to `src/` (e.g. a contributor edits
`src/registry.ts` and runs only `npm run build-manifest` instead of the
full `npm run build`), the stale dist drops fields like `siteSession`
from the rebuilt manifest. CI catches the resulting diff via the
"cli-manifest.json is up-to-date" gate, but locally it surfaces as
mysterious unrelated diff lines for adapter files the contributor never
touched.

Add an npm pre-script that runs `tsc --build` (incremental, ~0.6s when
warm) so `npm run build-manifest` is safe to use directly. `npm run build`
is unchanged — it still does the full `clean-dist + tsc + copy-yaml +
build-manifest` sequence, and `prebuild-manifest` will be a no-op there
since TS is already compiled by the time it runs.

Verified:
- `rm -rf dist && npm run build-manifest` now restores dist via the
  pre-hook and produces a 0-line diff against committed manifest
- `npm run build` still produces the same clean output

* fix(scripts): force manifest dist refresh

* fix(scripts): avoid duplicate manifest compile
2026-05-12 04:00:24 +08:00
jakevin eb59b7444d feat(ctrip): add hotel-search + flight browser-mode commands (#1481) (#1489)
* feat(ctrip): add hotel-search + flight browser-mode commands

Closes #1481.

Two new browser-mode commands on top of the existing public `search` /
`hotel-suggest` pair:

- `ctrip hotel-search <city> --checkin --checkout [--limit]` reads
  `window.__NEXT_DATA__.props.pageProps.initListData.hotelList` on
  `hotels.ctrip.com/hotels/list`. SSR-rendered first page ships ~13
  entries; the server ignores `&pageSize=N` so limit caps at 30 with
  default 10. AuthRequiredError surfaces when Ctrip redirects to the
  captcha gate.

- `ctrip flight <from> <to> --date [--limit]` searches one-way flights on
  `flights.ctrip.com/online/list/oneway-…`. The post-load XHR is not
  currently captured by the daemon network buffer (per the known
  daemon_capture_pipeline_bug_2026_05_07 in agent memory), so rows are
  pulled from `.flight-list > span > div` cards via a position-anchored
  innerText parser. A generic `buildScrollUntilJs(selector, target)`
  helper mirrors the PR #1487 xiaohongshu scroll-until pattern with the
  selector parameterised. Round-trip + airline filters are out of scope
  for v1.

All argument validation (IATA / ISO date / city ID / limit range) fires
upfront before any `page.goto`, per the PR #1387 boundary standard. No
silent clamps, no sentinel rows: rows missing required fields are
dropped, and end-state checks raise `ArgumentError` /
`AuthRequiredError` / `EmptyResultError` as appropriate. The new
`mapHotelRow` / `pickHotelMapCoords` / `buildFlightExtractJs` /
`buildScrollUntilJs` helpers live in `clis/ctrip/utils.js` alongside the
existing suggest helpers.

Docs at `docs/adapters/browser/ctrip.md` now distinguish the public
suggest commands from the browser-mode commands and document each
command's columns + caveats.

Verified:
- 61/61 vitest tests in `clis/ctrip/ctrip.test.js` (including JSDOM
  exercises of `buildFlightExtractJs` and full `mapHotelRow` shape parity)
- `check:typed-error-lint` 189/189 (0 new)
- `check:silent-column-drop` 103/103 (0 new)
- `build-manifest` clean — 812 entries total (was 810)

* fix(ctrip): harden browser search failure contracts

* fix(ctrip): tighten browser empty-vs-parser failures
2026-05-12 03:43:32 +08:00
jakevin 43d0722264 docs(skill/adapter-author): aria-label / placeholder / title are locale-dependent (#1474) (#1488)
* docs(skill/adapter-author): warn aria-label / placeholder / title is locale-dependent

aria-label changes with the browser's UI language (chrome://settings/languages).
A button labelled `aria-label="Submit"` in English Chrome becomes
`aria-label="提交"` in Chinese Chrome, so CSS selectors hardcoded to one
locale silently match zero elements — `notEmpty` / `types` never fire because
the adapter just returns 0 rows.

First-principles framing in adapter-template:
  - Split DOM attributes into "locale-stable identifiers" (id / class /
    data-testid / data-* / role) vs "locale-dependent text" (aria-label /
    title / placeholder / alt / textContent)
  - Primary selectors must use locale-stable identifiers; locale-dependent
    text is a last-resort tiebreaker
  - When a site (e.g. ChatGPT web) only exposes aria-label, link the existing
    `clis/chatgpt/utils.js` fallback-list pattern (en + zh-CN + stable
    fallback at the front)

Explicitly document why we are NOT building a `find --i18n "zh:提交"` flag
(over-engineering: same indirection as a fallback list plus a translation
dictionary to maintain) and why we are NOT locking Chrome's locale at launch
(opencli doesn't launch Chrome — it connects to the user's running browser
via CDP, so forcing en-US would break users who intentionally run Chinese UI).

Adds pitfall #11 to success-rate-pitfalls.md for the agent-facing checklist.

Closes #1474

* docs(skill): tighten locale selector guidance
2026-05-12 03:13:53 +08:00
jakevin 7df9b80dea fix(xiaohongshu+rednote): scroll until enough rows for --limit > 13 (#1471) (#1487)
* fix(xiaohongshu+rednote): scroll until enough rows are rendered instead of fixed 2x autoScroll

Both search adapters previously called `page.autoScroll({ times: 2 })` which
hard-capped extraction at ~13 notes (xiaohongshu lazy-loads ~5-7 notes per
scroll round) regardless of `--limit`. Reported in #1471: `--limit 40` still
only returned 13 results.

Replace with a dynamic `buildScrollUntilJs(targetCount, maxScrolls=15)`
helper that:
  - counts visible `section.note-item` rows (excluding `.query-note-item`
    related-search rows)
  - breaks early when count >= target
  - breaks early after 2 consecutive scrolls add no new rows (DOM plateaued,
    feed exhausted)
  - hard caps at 15 iterations to bound runtime

Exported from xiaohongshu and reused by rednote (same DOM shape) instead of
duplicating the IIFE.

Fixes #1471

* fix(xiaohongshu): tighten search scroll boundary
2026-05-12 02:59:15 +08:00
jakevin 23e1161ffd chore(release): 1.7.18 (#1486)
Release / release (push) Has been cancelled
2026-05-12 02:50:24 +08:00
jakevin dccf9d00e9 fix(doctor): pass session to connectivity probe (#1485)
* fix(doctor): pass session to connectivity probe

* fix(doctor): isolate probe session name

* fix(cli): mark browser session as required
2026-05-12 02:49:21 +08:00
jakevin b476d2364f fix(doubao/ask): restore Assistant detection after 2026-05 DOM refactor (#1484)
* fix(doubao/ask): restore Assistant turn detection after 2026-05 DOM refactor

Doubao reworked message-item wrappers and dropped all `receive-message` /
`bg-g-receive-msg-bubble` markers from assistant turns. The legacy 6
`itemSelectors` (`item-kDun2N`, `union_message`, `message-block-container`,
`data-message-id`, `bg-g-send-msg-bubble`, `bg-g-receive-msg-bubble`) match 0
elements on the new DOM, so `getTurnsScript` returned [] and `getDoubaoTurns`
fell through to the whole-page transcript scraper. Assistant text came back as
sidebar labels + history titles + adjacent conversation snippets concatenated
with the real reply — silent SELECTOR failure (no thrown error).

Two minimal changes in `clis/doubao/utils.js` `getTurnsScript`:

1. `itemSelectors`: prepend `[class*="inner-item-"]` and `[class*="top-item-"]`
   — the new 2026-05 wrappers. Outer wins via existing ancestor-keep dedup
   below, so we get one root per turn (not one per nested chunk).
2. `getRole`: add a third fallback branch — if the root matches
   `inner-item-*` / `top-item-*`, contains `.flow-markdown-body`, and has NO
   `bg-g-send-msg-bubble` marker (User detection still works), treat it as
   Assistant. `.flow-markdown-body` is already in `messageTextSelectors`, so
   text extraction kicks in unchanged.

Test added asserting both new wrappers and the `.flow-markdown-body` assistant
fallback are present in the generated script.

Fixes #1478

* test(doubao): cover refactored assistant turns
2026-05-12 02:46:53 +08:00
Kagura 6d84009ee8 fix(youtube): request srv3 format for caption URLs (#1420) (#1422)
* fix(youtube): request srv3 format for caption URLs (#1420)

YouTube may return empty responses when caption URLs lack an explicit format
parameter. This adds fmt=srv3 (standard YouTube XML caption format) to the
caption URL when no fmt parameter is already present, with a fallback to the
original URL if srv3 also returns empty.

Also adds HTTP status checking before reading the response body, preventing
silent failures on non-200 responses.

Fixes #1420

* fix(youtube): preserve caption fetch failures

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-12 02:44:21 +08:00
Gaurav Saxena 150551be8c feat(reddit): add reply command for replying to comments (#1428)
* feat(reddit): add reply command for replying to comments

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

* fix(reddit/reply): replace silent-sentinel rows with typed errors

reply.js originally mirror-copied comment.js's failure pattern: returning
[{ status: 'failed', message: 'HTTP 403' }] on auth/HTTP/Reddit errors and
relying on the caller to inspect the row instead of throwing. That's the
'silent-sentinel' anti-pattern from typed-errors.md — failures should
surface as typed errors so an agent can actually branch on them.

Round 21 lesson (f) — "grandfathered-not-exempt + helper-refactor boundary
is new" — applies: comment.js / upvote.js / save.js can stay grandfathered,
but a brand-new file does not inherit that exemption.

Changes:
- Throw AuthRequiredError when /api/me.json or /api/comment returns 401/403,
  or when /api/me.json returns 200 but data.name is missing (stale anon
  session — empty modhash alone isn't a strong enough signal).
- Throw CommandExecutionError for non-2xx HTTP and for non-empty
  data.json.errors (e.g. RATELIMIT, NO_TEXT, TOO_OLD).
- Drop the over-defensive `if (!page) throw ...` — registry guarantees a
  page object when browser:true.
- Intermediate result object uses `kind` discriminator + `detail` /
  `httpStatus` / `where` keys that don't overlap with columns
  ['status','message'], so the silent-column-drop audit stays quiet
  (per PR #1329 sediment).

Verified:
- npx tsc --noEmit clean
- node scripts/check-typed-error-lint.mjs → 189/189, 0 new
- node scripts/check-silent-column-drop.mjs → 103/103, 0 new
- npx vitest run clis/reddit src/convention-audit → 11/11 pass
- node ./dist/src/main.js validate → 0 errors

Success path is unchanged: still returns
[{ status: 'success', message: 'Reply posted on t1_<id>' }].

* fix(reddit): harden reply command contract

* fix(reddit): reject suffixed reply urls

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-12 02:33:09 +08:00
Benjamin Liu 64ac362a40 feat(rednote): add rednote.com adapter mirroring xiaohongshu read commands (#1136) (#1475)
* feat(rednote): add rednote.com adapter mirroring xiaohongshu read commands (#1136)

Implements rednote.com support as discussed in issue #1136. The mainland
xiaohongshu adapter stays in place; international users redirected to
www.rednote.com now have a CLI without a copy-pasted adapter.

Issue #1136 documents that xiaohongshu and rednote share DOM selectors,
URL paths, API paths, response schema, cookies, and the xsec_token auth
mechanism. The only material differences:

  Layer            xiaohongshu                rednote
  Web host         www.xiaohongshu.com        www.rednote.com
  API host         edith.xiaohongshu.com      webapi.rednote.com
  Security host    fe-static.xhscdn.com       as.rednote.com
  Cookie root      .xiaohongshu.com           .rednote.com
  Search gate      Inline text                Full-screen modal + text

## Architecture (minimal)

`clis/xiaohongshu/*` keep all selector / regex / extraction logic. Each
command file is touched minimally to export the IIFE or pipeline so the
sibling adapter can reuse it:

  search.js          + export const buildSearchExtractJs(webHost)
                     + export const command = cli({...})
  note.js            + export const NOTE_EXTRACT_JS
                     + export const command = cli({...})
  comments.js        + export function buildCommentsExtractJs(withReplies)
                     + export parseCommentLimit
                     + export const command = cli({...})
  download.js        + export function buildDownloadExtractJs(noteId)
                       (CDN allowlist now includes rednote alongside xhscdn)
                     + export const command = cli({...})
  user.js            + export const USER_SNAPSHOT_JS
                     + export const command = cli({...})
  feed.js            + export function buildFeedPipeline(webHost)
                     + export const command = cli({...})
  notifications.js   + export function buildNotificationsPipeline(webHost)
                     + export const command = cli({...})
  note-helpers.js    buildNoteUrl now accepts `cookieRoot` + `signedUrlHint`
                     options (defaults preserved so xhs callers and tests
                     are unchanged)
  user-helpers.js    buildXhsNoteUrl / extractXhsUserNotes accept an
                     optional `webHost` argument (default xhs)

The `export const command = cli({...})` pattern matches twitter/lists.js
and clis/discord-app/*; without it the build-manifest scanner attributes
xhs's command to whichever rednote sibling triggered the transitive
import first.

## clis/rednote/ — thin shims

Each rednote command file imports the relevant builder / constant from
its xiaohongshu sibling and calls `cli()` with the rednote host triple.
No selectors, regexes, or extraction logic are duplicated.

  search.js          imports buildSearchExtractJs + noteIdToDate
                     declares its own WAIT_FOR_CONTENT_JS (modal + text
                     login-gate variants — the one xhs behaviour that
                     genuinely differs)
  note.js            imports NOTE_EXTRACT_JS + buildNoteUrl + parseNoteId
  comments.js        imports buildCommentsExtractJs + parseCommentLimit
                     + buildNoteUrl + parseNoteId
  download.js        imports buildDownloadExtractJs + buildNoteUrl + parseNoteId
  user.js            imports USER_SNAPSHOT_JS + extractXhsUserNotes
                     + normalizeXhsUserId

## Scope (initial)

Ships the five commands verified live against the user's logged-in
rednote.com session: search / note / comments / user / download.

`feed` and `notifications` are intentionally left out. Both rely on
intercepting the xiaohongshu Pinia store at the `homefeed` / `you`
capture pattern; live verification on rednote returns `tap → dict
(error)` for the feed step, so shipping them would surface a broken
contract. The mainland xiaohongshu commands continue to work. Adding
the rednote-side feed / notifications is straightforward follow-up
work once someone with rednote access maps the network surface.

Creator-center commands (publish, creator-*) have no rednote
counterpart and stay xiaohongshu-only, per the reporter's note in #1136.

## Verification

  - clis/xiaohongshu/ + clis/rednote/: 103/103 tests green
  - npx tsc --noEmit: clean
  - npm run build: 807 manifest entries (xhs 13 + rednote 5 + everything
    else preserved)
  - silent-column-drop / typed-error-lint: 103 / 189 baseline entries,
    no new violations
  - Live verify against the user's rednote.com session:
      rednote search "travel" --limit 1 → real note row
      rednote note <signed-url>         → 7 field/value rows
      rednote comments <signed-url> --limit 3 → 3 top-level rows
      rednote user 5b21f6564eacab3b38f05c39 --limit 2 → 2 profile notes
    Spaced 15–30s between runs per the xhs/rednote rate-limit guidance;
    no write commands invoked. Regression check: xiaohongshu/feed on
    the existing mainland session still returns the standard 6-field
    rows after the refactor.

Closes #1136

* fix(rednote): tighten adapter failure boundaries

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-12 02:32:25 +08:00
jakevin c1af68b909 chore(release): 1.7.17 (#1483)
Release / release (push) Has been cancelled
2026-05-12 02:24:16 +08:00
E2ern1ty b262d8ffd5 feat(chatgpt): support local image uploads (#1476)
* feat(chatgpt): support local image uploads

* chore: refresh cli manifest

* fix(chatgpt): harden image upload flow

* fix(chatgpt): validate image uploads before navigation

* fix(chatgpt): keep send fallback click in sync

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 17:52:16 +08:00
jakevin 987d9cba48 refactor(doctor): drop --no-live and --sessions flags + dead protocol (#1470)
Doctor's job is browser-bridge health diagnosis. The `--no-live` flag
let users skip the connectivity probe (= the core diagnostic), and
`--sessions` listed automation sessions (a separate concern not part of
health). Both flags accreted features that violated the command's
first-principles purpose.

Cleanup chain (removing dead code surfaced by the flag removal):
- `--no-live` / `--sessions` flags removed from `opencli doctor`
- `DoctorOptions.live` / `DoctorOptions.sessions` removed
- `DoctorReport.sessions` removed
- `[SKIP] Connectivity` render branch removed (always-live now)
- `listSessions()` removed (only consumer was doctor)
- `'sessions'` action removed from daemon-client protocol type
- `BrowserSessionInfo` type removed (no remaining consumers)
- extension `handleSessions` action handler removed (1.0.12)
- extension test "reports sessions per session" removed
- `OPENCLI_BROWSER_IDLE_TIMEOUT` test rewired to 'cookies' action

Verification:
- root typecheck + extension typecheck pass
- doctor.test.ts 17/17 pass
- extension/background.test.ts 49/49 pass
- typed-error-lint 189/189 baseline
- silent-column-drop 103/103 baseline
- build + extension build green
2026-05-11 13:10:06 +08:00
jakevin 467fdd0b62 refactor(adapter): rename site browser reuse to persistent sessions (#1462) 2026-05-11 04:56:34 +08:00
jakevin 9c06e84c89 refactor(browser): replace workspaces with sessions (#1461) 2026-05-11 04:26:51 +08:00
jakevin b56bebdd7a chore(release): 1.7.16 (#1460)
Release / release (push) Has been cancelled
2026-05-11 03:27:06 +08:00
jakevin 1d2e606498 perf(chatgpt): replace fixed-sleep waits with selector-based readiness (D3) (#1456)
Continues the wait→event sweep started in #1449 (deepseek) / #1452 (claude). Same
3-bucket classification across the chatgpt adapter:

CONVERT (5)
- utils.js ensureOnChatGPT/startNewChat: 2s settle → wait({selector: composer, 8s})
- utils.js getConversationList: openSidebar 1.5s + fallback goto 2.5s → selector
- detail.js: post-/c/<id> goto 2s → wait({selector: message bubble, 10s})

DELETE (6)
- ask/send/read.js: standalone 2s settle after ensureOnChatGPT/startNewChat
  (those helpers now wait for composer internally — settle is redundant)
- utils.js sendChatGPTMessage: 0.5s post-closeBtn + 1.5s pre-composer-focus
- utils.js getConversationList: 2s settle after ensureOnChatGPT (helper waits
  for composer; we re-check sidebar selector independently)

KEEP (6)
- utils.js sendChatGPTMessage: ProseMirror React debounce ticks
- utils.js waitForChatGPTResponse: streaming response polling cadence

Verification:
- npx vitest run clis/chatgpt → 20/20 (4 files)
- Full vitest → 3379 pass / 1 skip (2 errors are the unrelated daemon EADDRINUSE
  flake also seen on #1449/#1452/#1454)
- tsc --noEmit clean / typed-error-lint 189 / silent-column-drop 103 unchanged
- npm run build → 802 manifest entries

Diff: +50 / -13 across 5 files (ask.js, detail.js, read.js, send.js, utils.js).
No typed-error harmonization needed — chatgpt's existing helpers already gate
through ensureChatGPTLogin (AuthRequiredError) and ensureChatGPTComposer
(CommandExecutionError) correctly.
2026-05-11 03:22:39 +08:00
jakevin 864af48b0b docs(readme): list tg-cli, discord-cli, wx-cli in External CLI sections (#1459)
Follow-up to feat #1458 (registering tg-cli/discord-cli/wx-cli in
src/external-clis.yaml) — README and README.zh-CN had not been updated
to reflect the new entries.

Updates four spots in each README:
- intro paragraph that names example external CLIs
- "CLI Hub" highlight bullet
- "OpenCLI is not only for websites" bullet list
- the External CLI table itself
2026-05-11 03:19:53 +08:00
jakevin d0127e188a feat(external): register tg-cli, discord-cli, wx-cli (#1458)
Add three local-first messaging CLIs to the External CLI registry so
agents can discover and install them via `opencli external install`:

- `tg-cli` (binary `tg`) — Telegram local sync/search/export via MTProto
- `discord-cli` (binary `discord`) — Discord local sync/search/export
- `wx-cli` (binary `wx`) — WeChat local data CLI

Refresh the External CLI list in skills/opencli-usage/SKILL.md so the
agent-facing skill names stay in sync.
2026-05-11 03:19:27 +08:00
jakevin cd93910fdd feat(help): structured help for daemon/plugin/adapter/profile namespaces (#1407)
Extends A0 (PR #1404) by dogfooding `installCommanderNamespaceStructuredHelp`
on the four remaining built-in Commander namespaces:

- `opencli daemon --help -f yaml|json`
- `opencli plugin --help -f yaml|json`
- `opencli adapter --help -f yaml|json`
- `opencli profile --help -f yaml|json`

Each emits the same payload shape as `browser`: namespace metadata, every
leaf command's positionals + command_options + description + usage,
namespace_options (empty for these), and program-level global_options.
Agents can fetch every leaf's contract in a single call — no per-leaf
`--help` follow-ups.

Each namespace snapshots its original description at declaration time
because `applyRootSubcommandSummaries(program)` later overwrites
`.description()` with a child-name listing; without the snapshot,
structured help would surface `"restart, status, stop"` instead of
`"Manage the opencli daemon"`. Tests lock the snapshot semantics for
`adapter` explicitly.

Tests: 138/138 (4 new — one per namespace, covering description
preservation, leaf names, positionals, command_options).
Typecheck + build clean.
2026-05-11 02:50:09 +08:00
jakevin 64c67c331e chore(extension): rename adapter tab group (#1457)
* chore(extension): rename adapter tab group

* test(extension): update adapter group wording
2026-05-11 02:12:37 +08:00
jakevin 6d87142821 perf(reddit): opt 13 browser adapters into shared site-tab lease (#1455)
* perf(reddit): opt 13 browser adapters into shared site-tab lease

Adds `browserSession: { reuse: 'site' }` to every reddit adapter that
already runs `browser: true` on `domain: 'reddit.com'`. Same metadata-only
follow-up to the twitter sweep merged in #1454 — the framework's
`shouldRunPreNav` short-circuit (src/execution.ts:190) skips the redundant
domain-root pre-nav when a sibling adapter already has the tab on
reddit.com, and idle-bound tabs are reused under the `site:reddit` bucket
until expiry.

Scope (13 files, all on `domain: 'reddit.com'` + `Strategy.COOKIE`):
- read (9): frontpage / popular / saved / search / subreddit / upvoted /
  user / user-comments / user-posts
- write (4): comment / save / subscribe / upvote

Excluded:
- `hot.js` (no browser:true — public Reddit JSON API, no tab)
- `read.js` (Strategy.COOKIE but no browser:true — non-browser pipeline)

No logic changes; only metadata + manifest regeneration.

Verification:
- npm run check:typed-error-lint → 189/189 unchanged
- npm run check:silent-column-drop → 103/103 unchanged
- npm run test:adapter → 264/264 passed (2146 tests)
- npx vitest run --project unit → 72/72 passed (unrelated EADDRINUSE
  flake on daemon.test.ts port 19825, also seen on #1454/#1452)
- tsc --noEmit clean

* fix(reddit): include read in site browser session reuse
2026-05-11 02:12:21 +08:00
Ethon 357dec5969 fix(xiaohongshu): fallback to base64 upload when CDP setFileInput returns 'Not allowed' (#1374)
The uploadImages function catches errors from page.setFileInput and only
falls back to the legacy base64 DataTransfer method when the message
contains 'Unknown action' or 'not supported'. However, Chrome can also
return 'Not allowed' (code -32000), which was not handled — causing the
publish command to fail instead of using the fallback.

Add 'Not allowed' to the fallback condition so image upload works even
when CDP file injection is blocked by Chrome's security policy.

Co-authored-by: together <together@togetherdeMac-mini.local>
2026-05-11 01:56:19 +08:00
Benjamin Liu 674f0e1105 feat(openreview): add author command for ID-explicit publication lookup (#1365)
* feat(openreview): add author command for ID-explicit publication lookup

Closes the missing leaf in the openreview adapter. Among the public-strategy
academic adapters, dblp and arxiv both already ship an `author` command for
ID-explicit publication lookup; openreview only had `search` (full-text),
`paper` (detail by note id), `reviews` (thread by forum id) and `venue`
(listing by invitation / venue text). There was no way to ask "give me every
submission this author put on OpenReview, newest first."

`openreview author <profile>`:
  - takes a canonical profile id (`~First_LastN`); validated by
    `requireProfileId` so a dblp PID or a bare name fails before any
    network call,
  - hits `/notes?content.authorids=~<id>&limit=<n>&sort=cdate:desc`,
  - returns rank-ordered rows with the same shape as `openreview search`
    (id / title / authors / venue / pdate / url),
  - throws `EmptyResultError` when the profile has no public submissions
    instead of returning an empty list,
  - inherits the typed-error envelope from `openreviewFetch` so network
    failure, non-200, malformed JSON, and in-band error envelopes all
    surface as `CommandExecutionError`.

Tests: 6 new `it` blocks plus 1 updated registration test in
`clis/openreview/openreview.test.js`.

  - `requireProfileId` (1 block, 9 assertions): accepts canonical
    `~First_LastN`, `~Bo_Liu17`, and a multi-segment middle-name id;
    rejects empty, whitespace, missing tilde, missing trailing number,
    embedded space, and a dblp-style PID.
  - 5 author runtime cases covering pre-network ArgumentError, empty
    result, non-200, fetch network error, and the happy path with a
    request-shape assertion (`content.authorids` filter + `cdate:desc`
    sort).
  - Registration test extended to expect five commands and lock the new
    `columns` contract.

Manifest auto-regenerated to register the new command.

Live-verified end to end against `~Yoshua_Bengio1`: the most recent ICLR
2026 workshop submissions return with the expected fields. A malformed
profile is rejected before any HTTP call. A nonexistent profile yields
`EMPTY_RESULT`.

* fix(openreview): accept real profile id slugs

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 01:53:46 +08:00
UtoPiaCD 2034e90337 fix(chatgpt): use locale-stable send button selector (#1354)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 01:53:08 +08:00
jakevin a77d9c930a perf(claude): replace fixed-sleep waits with selector-based readiness (#1452)
Convert 9 of 18 page.wait(N) calls in clis/claude/ from fixed-duration
sleeps to event-based readiness checks (page.wait({ selector, timeout }),
backed by MutationObserver). Mirrors the deepseek D1 template (PR #1449).

* Page-ready waits (5 converted): utils.js:29 (ensureOnClaude composer),
  utils.js:114 (getConversationList recents links), new.js:19 (composer),
  detail.js:24 (.font-claude-response message bubble), send.js:26 (composer).
  Each resolves as soon as the selector matches, swallowing the timeout so
  downstream typed-error helpers (ensureClaudeLogin / ensureClaudeComposer
  / EmptyResultError) still surface the right error when the selector
  never mounts (login redirect, empty conversation, etc).

* Dropdown waits (2 converted): utils.js:150 (selectModel post-trigger),
  utils.js:178 (setAdaptiveThinking post-trigger). Wait for menuitemradio
  / menuitem to mount instead of a fixed 0.6 s sleep.

* Resume conversation wait (1 converted): ask.js:51 — wait for the resumed
  message bubble (MESSAGE_SELECTOR) instead of a fixed 2 s sleep.

* Settle/redundant waits removed (6): ask.js:55 standalone settle (next
  ensureClaudeComposer queries composer presence directly via getPageState);
  ask.js:83 / ask.js:90 post-toggle settles (next CDP eval flushes React
  state between roundtrips); ask.js:103 pre-waitForResponse settle (the
  polling loop's first 3 s tick already covers this); read.js:20 post-
  ensureOnClaude sleep (ensureOnClaude now waits for the composer selector
  itself); send.js:29 post-ensureOnClaude sleep (same).

Three remaining page.wait(N) calls are kept: utils.js:231 post-input
1.2 s React debounce inside sendMessage (the ProseMirror editor needs a
debounce window before the send button enables; reducing this risks
silent send-button-disabled drops), and the 3 s / 1 s polling ticks in
waitForResponse / waitForFilePreview (already polling patterns, out of
scope for D-track wait→event sweep).

Targeted tests: clis/claude + src/browser 389/389 pass; tsc clean;
build clean; typed-error 189/189 baseline (no new); silent-column-drop
103/103 baseline (no new).

D2 in the LLM-adapter wait→event sweep started by deepseek (D1, #1449).
2026-05-11 01:52:41 +08:00
jakevin cb64192f06 perf(deepseek): replace fixed-sleep waits with selector-based readiness (#1449)
Convert 10 of 18 `page.wait(N)` calls in clis/deepseek/ from fixed-duration
sleeps to event-based readiness checks (`page.wait({ selector, timeout })`,
backed by MutationObserver):

* Page-ready waits (5): utils.js:46, ask.js:38/52, detail.js:28, new.js:19
  now wait for the composer textarea (TEXTAREA_SELECTOR) or message bubble
  (MESSAGE_SELECTOR) to mount before continuing. Resolves as soon as the
  selector matches instead of always sleeping the full duration.
* Settle/redundant waits removed (5): ask.js:56 standalone settle (already
  covered by upstream selector waits); ask.js:79/105 post-toggle settles
  (next CDP eval gives React time to flush aria-checked updates); ask.js:118
  pre-waitForResponse settle (the polling loop's first 3 s tick already
  covers this); read.js:19 post-ensureOnDeepSeek sleep (ensureOnDeepSeek
  now waits for the textarea selector itself).
* `new.js` now throws CommandExecutionError when the composer fails to
  mount within 8 s instead of silently returning "New chat started" on a
  half-loaded or logged-out page.

Eight remaining `page.wait(N)` calls are kept: in-loop polling ticks in
waitForResponse / pickResumeUrl / getConversationList / waitForFilePreview
/ send-button-enable polling (these are already polling patterns and
out of scope for D1), and the native-input flush + textarea-mount poll in
send.js.

Targeted tests: clis/deepseek 49/49, src/browser 355/355 pass; build,
typecheck, typed-error and silent-column-drop audits clean.

Proof template for the LLM-adapter wait-cleanup follow-ups.
2026-05-11 01:52:19 +08:00
jakevin 833c1c872f perf(twitter): enable browserSession reuse:site on 17 read-only adapters (PR B) (#1454)
Read-only Twitter/X adapters now declare `browserSession: { reuse: 'site' }`,
matching the LLM-site adapters (claude/gemini/yuanbao/etc.) and unblocking
the perf wins WAWQAQ called out for the 35s→9s/3.4s thread.js progression
(#OpenCLI:3889b5cf):

- Tab lease shared across calls under `site:twitter` until idle expiry, so
  the second-and-later command pays no cold-start tab cost.
- Framework's domain-root pre-nav (`https://x.com`) is skipped on subsequent
  calls when the reused tab is already on x.com (`shouldRunPreNav` →
  `isDomainRootPreNav` + `urlMatchesDomain` short-circuit at
  `src/execution.ts:190`).

Files (17 read-only adapters):
- Strategy.COOKIE × 13: article, bookmark-folder, bookmark-folders,
  bookmarks, download, following, likes, list-tweets, lists, profile,
  thread, timeline, trending, tweets
- Strategy.UI × 1: followers
- Strategy.INTERCEPT × 2: notifications, search

Insertion point in each file: after `browser: true,` (or after `strategy:`
in download.js which omits the explicit `browser:` field), matching the
convention used by yuanbao/read.js, claude/read.js, etc.

Manifest regenerated (cli-manifest.json: +85/-17 — 17 entries gain the
`browserSession: { reuse: "site" }` block).

Verification:
- npx tsc --noEmit clean
- npx vitest run clis/twitter → 218/218 pass (25 files)
- npx vitest run src/convention-audit.test.ts → 8/8 pass
- typed-error-lint baseline 189/189 (no new violations)
- silent-column-drop baseline 103/103 (no new violations)

Scope notes (intentionally NOT in this PR):
- Write adapters (post/reply/quote/like/retweet/bookmark/follow/list-add/
  list-remove/delete/hide-reply/block/accept/follow) are kept as one-shot
  by default — `reuse: 'site'` for write paths is a separate decision
  about action idempotency under tab reuse.
- The thread.js / timeline.js comments still say "Cookie context
  auto-established by framework pre-nav"; the deeper truth (CDP
  `getCookies({url})` is origin-independent) was a framing nit on PR C
  (#1451) — left as a doc-only follow-up to keep this PR's diff focused
  on the perf gain.

Refs: #OpenCLI:3889b5cf (WAWQAQ msg=fa209a2c, msg=35c90460, msg=838128ef
"你们继续做啊… 后面还有那么多其他的东西呢")
2026-05-11 01:51:59 +08:00
jakevin a92f382c2d refactor(browser): split interactive and automation windows 2026-05-11 01:48:18 +08:00
jakevin ff7d741a4c perf(twitter): drop redundant goto+wait — framework auto pre-navs (PR C) (#1451)
* perf(twitter): drop redundant goto+wait — framework auto pre-navs (PR C)

Twelve twitter read adapters did `await page.goto('https://x.com'); await
page.wait(2~3)` purely to establish cookie context for the subsequent
`document.cookie` read. After PR #1450 hoisted those reads to
`page.getCookies({url})` (which queries the CDP cookie store directly,
no navigation needed), the explicit goto+wait became dead.

The framework already pre-navigates to `https://${domain}` for any
adapter declaring `Strategy.COOKIE + domain` (`src/registry.ts:191`),
so the cookie store is populated before `func` runs. The 2-3s
`page.wait` was the slowest part of the redundant call.

Files (all read-only, all ct0/cookie-only):
- bookmark-folder / bookmark-folders / bookmarks
- following / likes / list-add / list-remove / list-tweets / lists
- thread / timeline / tweets

Out of scope (kept as-is): goto calls that navigate to a *specific*
URL needed for content/SPA shell — `trending` (`/explore/tabs/trending`),
`notifications` (`/home`), `article` (`/i/article/{id}`), `profile`
(`/${username}`), and `list-add` line 133 (`/${username}` for UI ops).

Verification:
- npx tsc --noEmit ✓
- npx vitest run clis/twitter → 216/216 ✓
- typed-error-lint 189/189, 0 new ✓
- silent-column-drop 103/103, 0 new ✓

* fix(twitter): keep list UI root navigation
2026-05-11 01:24:07 +08:00
jakevin 60dbbd4baa perf(adapters): hoist cookie reads to page.getCookies (Tier 1, 25 files) (#1450)
* perf: replace document.cookie reads with page.getCookies({domain}) (Tier 1 cookie API sweep)

Prior pattern in 25 adapter files round-tripped through `page.evaluate(\`document.cookie.split…\`)` to extract a single cookie value (CSRF token, session ID, etc.). CDP's `page.getCookies({domain})` reads the cookie store directly with zero JS-execution overhead.

Files touched (sites: twitter / linkedin / maimai / youtube):

- twitter (15): thread, timeline, list-add, bookmark-folders, following, list-tweets, bookmarks, list-remove, tweets, bookmark-folder, likes, lists, trending — direct 4-line replacement (cookie was outside `page.evaluate`); article, profile — hoisted ct0 read OUT of `page.evaluate` and threw `AuthRequiredError` upfront so unreachable in-evaluate auth branches got cleaned up too.

- linkedin/search.js — JSESSIONID was read inside the per-batch fetch loop's `page.evaluate`; hoisted once before the loop and pass `csrf` value into the template via `JSON.stringify`.

- maimai/search-talents.js — csrftoken cookie hoisted via getCookies; meta-tag fallback preserved inside `page.evaluate` (reached only when no cookie). Also converted the `page.evaluate(async (body) => …, body)` Playwright-style call to OpenCLI's template-string form so the helper actually runs.

- youtube — `SAPISID_HASH_FN` (used by like / unlike / subscribe / unsubscribe) reworked: sapisid is now passed in as a parameter; new `readYoutubeSapisid(page)` helper reads it via CDP. The HMAC-SHA1 compute still happens browser-side (Web Crypto), only the cookie read is hoisted.

Tests updated where mocks specifically referenced `document.cookie` (twitter following / bookmark-folder / bookmark-folders) to mock `getCookies` instead.

Verification:
- `npx tsc --noEmit` clean
- `npx vitest run clis/twitter clis/linkedin clis/youtube` → 264/264 pass
- typed-error-lint 189/189 (no new violations)
- silent-column-drop 103/103 (no new violations)

Scope notes (not in this PR):
- `goto + wait` redundancy and `browserSession: { reuse: 'site' }` rollout are scoped to follow-up PRs B and C per the #OpenCLI:3889b5cf thread plan.
- `document.cookie.match(...)` patterns (instagram 8 / xiaoe / qwen / hupu / tiktok / 1point3acres — ~13 files) are outside the original \`document.cookie.split\` audit scope and will follow as a Tier 1 expansion sweep.

* fix(adapters): read auth cookies by url scope
2026-05-11 01:06:48 +08:00
jakevin 8f0958a295 chore(release): 1.7.15 (#1448)
Release / release (push) Has been cancelled
- bump opencli to 1.7.15 (was 1.7.14)
- extension stays at 1.0.9 (already bumped during the release cycle)
- finalize CHANGELOG: move Unreleased to 1.7.15 with date

Major release: Browser Agent Runtime project (Phase 0/1/2) — alignment
with vercel-labs/agent-browser model. CDP-primary input, AX snapshot/refs
with stale recovery, semantic locators across all primitives, full form
toolbelt (hover/focus/dblclick/check/uncheck/upload/drag/wait-download),
annotated screenshots, and same-origin iframe AX routing.
2026-05-10 23:12:43 +08:00
jakevin aa6696f6ce feat(browser): add annotated screenshot refs (#1433) 2026-05-10 22:15:56 +08:00
jakevin accdd970a4 test(browser): add real Chrome AX smoke (#1445)
* test(browser): add real Chrome AX smoke

* fix(browser): attach cross-origin frame targets directly

* fix(browser): resolve frame target by URL

* test(browser): include frame target URL in AX smoke

* fix(browser): discover iframe targets before routing

* fix(browser): resolve iframe targets through CDP

* fix(browser): auto-attach iframe targets for routing

* test(browser): make cross-origin AX smoke a capability probe

* docs(browser): mark cross-origin AX as best-effort

* ci(browser): keep AX smoke out of normal e2e sweep
2026-05-10 21:07:54 +08:00
jakevin 1364a11ab2 feat(browser): add semantic locators to input actions
Add semantic locator flags to browser type/fill/select while preserving explicit target syntax.
2026-05-10 19:54:55 +08:00
jakevin 3b44f901eb fix(browser): enable AX in cross-origin frame targets
Enable the Accessibility domain inside cross-origin frame target sessions before AX tree fetches and stale ref recovery.
2026-05-10 19:45:56 +08:00
jakevin 19976723c1 feat(browser): route AX refs through cross-origin frames
Route AX snapshot and AX ref click CDP calls through attachable cross-origin frame targets. Bump Browser Bridge extension to 1.0.9 for frame target routing.
2026-05-10 17:28:09 +08:00
jakevin 65903a09ff feat(browser): wait for downloads (#1441) 2026-05-10 17:15:31 +08:00
jakevin bfe7116e82 feat(browser): extend semantic locators to actions (#1440) 2026-05-10 17:01:18 +08:00
jakevin 4e4bef6474 feat(browser): add drag command (#1439) 2026-05-10 16:54:11 +08:00
jakevin 98fcce7bd3 feat(browser): add upload command (#1438) 2026-05-10 16:50:40 +08:00
jakevin 6e1c56e1e6 feat(browser): add check and uncheck (#1437) 2026-05-10 16:32:29 +08:00
jakevin b69b2e384d feat(browser): add hover focus and dblclick (#1435) 2026-05-10 16:18:19 +08:00
jakevin 76b34b7e87 feat(browser): add semantic locator flags (#1434)
* feat(browser): add semantic locator flags

* fix(browser): report semantic read match totals
2026-05-10 16:05:02 +08:00
jakevin 19130ab1af fix(e2e): match fake daemon version to running CLI (#1432)
PR #1399 added auto-restart of stale daemons in BrowserBridge
(daemonVersion ≠ PKG_VERSION → restart). The browser-tabs e2e fake
daemon hard-coded `daemonVersion: 'test'`, so every test reported as
stale and the bridge tried to /shutdown the fake daemon — which has no
shutdown endpoint — causing all 4 tests in the file to exit with code 1.

This has been the failing signal in `e2e-headed (ubuntu-latest)` and
`e2e-headed (macos-latest)` on every main push since #1399.

Read PKG_VERSION from package.json once at module load and feed that to
the fake /status response. The fake daemon now matches the running CLI
so the stale-daemon path is not triggered.

Verification:
- npx tsc --noEmit clean
- npm run build clean
- npx vitest run --project e2e tests/e2e/browser-tabs.test.ts → 4/4 pass
2026-05-10 15:26:52 +08:00
Henry 85ea18c93b feat(dianping): resolve unknown cities live from www.dianping.com (#1429)
* feat(dianping): resolve unknown cities live from www.dianping.com

The static CITY_ID map in clis/dianping/utils.js only covers ~20 cities,
so passing --city 汕头 (or any other Chinese name / pinyin slug not on
that list) fails with ArgumentError. Adding the missing cityIds by hand
doesn't scale to dianping's full city list and silently goes stale when
the site renumbers cities.

This change adds an async resolver that falls back to dianping.com when
the static map misses:

  - Numeric input → pass through unchanged.
  - Static map hit → fast path, no network (utils.CITY_ID untouched).
  - Pinyin slug (e.g. "shantou") → goto /<slug>, parse cityId out of
    any /search/keyword/{id}/ link rendered on the per-city landing page.
  - Chinese name (e.g. "汕头") → goto /citylist, walk anchors to build a
    Chinese-name → pinyin map, then resolve the slug as above.

Resolved (input → cityId) pairs are memoized per-process so repeat
searches skip both navigations.

Implemented as a new module (clis/dianping/cityResolver.js) so utils.js
stays minimal and the existing synchronous resolveCityId / CITY_ID API
keeps working for direct callers and tests.

Tested:
  - Unit tests cover null/numeric/static fast paths, pinyin fallback +
    cache, Chinese-name fallback via /citylist + cache for both forms,
    rejection of garbage input, rejection of Chinese names not on
    /citylist, and CommandExecutionError when the per-city page lacks
    a /search/keyword/{id}/ link.
  - JSDOM tests cover the pure DOM extractors (buildCitylistMap and
    extractCityIdFromPage) against curated HTML fixtures.
  - npm test: 3196 passed, 1 skipped (no new failures).
  - npx tsc --noEmit: clean.
  - opencli validate: 0 errors.

* fix(dianping): require city resolver links to be authoritative

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-10 14:57:44 +08:00
Kagura 962842cd59 fix(douyin): handle empty response body in browserFetch (#1408)
* fix(douyin): handle empty response body in browserFetch (#1405)

browserFetch calls res.json() directly, which throws SyntaxError when
the API returns an empty body (content-length: 0). This happens when
the Douyin hashtag search endpoint returns HTTP 200 with no content.

Fix: read response as text first, return null for empty bodies, then
throw a descriptive CommandExecutionError at the caller level.

Fixes #1405

* fix(douyin): wrap browser fetch parse failures

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-10 14:53:55 +08:00
jakevin 6f200cc744 fix(browser): enable accessibility before AX snapshots (#1417) 2026-05-09 03:12:27 +08:00
jakevin 70981ef06a docs(browser): document AX validation workflow (#1416) 2026-05-08 19:44:38 +08:00
jakevin 3f8b88cf64 feat(browser): compare observation source metrics (#1415) 2026-05-08 19:40:47 +08:00
jakevin d6e3971c79 feat(browser): route AX refs through same-origin frames (#1414) 2026-05-08 19:35:51 +08:00
jakevin e99cbd4a7e feat(browser): add opt-in AX refs (#1413) 2026-05-08 19:20:21 +08:00
jakevin 136e5888ce fix(browser): drive click through CDP mouse events (#1412) 2026-05-08 19:06:03 +08:00
jakevin 53516f7511 docs(browser): design agent runtime roadmap (#1411)
* docs(browser): design agent runtime roadmap

* docs(browser): tighten runtime MVP criteria
2026-05-08 18:35:37 +08:00
jakevin 4475d4efe3 feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs (#1406)
Round 21 follow-up to #1400 (P0 write-action symmetry, merged `644d4517`). 5 features + help docs unified into one PR per WAWQAQ "全部合成一个 PR" directive.

## Scope

- **P1** (`cf10c098`): `twitter search` `--from / --has / --exclude / --product` filters, mapping to X `from:` / `filter:` / `-filter:` / `f=` operators; legacy `--filter top|live` preserved (--product win on conflict)
- **P2** (`a484a69a`): new `twitter bookmark-folders` + `bookmark-folder <id>`; X Premium GraphQL `bookmarkFoldersSlice` + `BookmarkFolderTimeline`; queryId 三层 fallback (placeholder.json → client-web bundle → pinned constants)
- **P3** (`f209f914`): `--top-by-engagement N` to 7 tweet-shaped read commands (search/timeline/likes/bookmarks/list-tweets/tweets/thread); single helper in `utils.js`; formula `likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5`; **N=0 reference equality no-op** → existing 157 twitter tests 0 churn
- **P4** (`89283fa0`): `TWITTER_BEARER_TOKEN` + composer image helpers extracted to `utils.js` (12 GraphQL adapter dedup); reply hardening; quote adds `--image`
- **P5** (`a3d10a48`): sibling article-scope helper extracted to `shared.js` (9 write commands reuse, dedup with #1400 P0 invariant)
- **docs** (`2a358d80`): help-doc precision (positional-omitted defaults + download/bookmarks/notifications/timeline/lists description thicken; concurrent #1401/#1403 wording preserved)

47 files / +2594/-470. Tests **96 → 216 (+120)**, manifest 798 → 801 (+3), typed-error-lint 190 → 189 (resolved 1 grandfathered sentinel).

## Iteration history (3 review fix commits on top of 6 author commits)

- `7f93779b` — codex-mini1 lead fix1: 3 blocker bundle (P5 host invariant + P2 safe-id + sentinel removal + P1 fallback fail-fast)
- `2a29ecc6` — codex-mini1 lead fix2: P3 help formula consistency (doc/help text matches actual `log10(views+1)×0.5`)
- `df4dcd76` — codex-mini1 lead fix3 (F-P-1 aux catch): P2 `bookmark-folder --limit` upfront validation (`Number(kwargs.limit ?? 20)` + reject non-positive/non-integer + regression `0/negative/fractional/NaN` + `page.goto` zero-call assert)

## 4 progressive blockers caught (codex-mini1 lead 3 rounds + F-P-1 aux 1 round)

1. **P5 host invariant gap** (lead): article-scope helper preserved exact `/status/<id>` path but ignored link host → off-domain `https://evil.com/alice/status/<target>` would satisfy `__twHasLinkToTarget`. Fixed: `https` + X/Twitter host or subdomain + exact `/status/<id>` or `/i/status/<id>` path; query/hash allowed; off-domain/host-suffix/non-https/path-suffix/substring-id rejected; JSDOM positive + 5 negative anchors.

2. **P2 listing→detail round-trip + sentinel** (lead): `bookmark-folders` accepted opaque IDs but `bookmark-folder <id>` only accepted numeric → round-trip broken; new `author: 'unknown'` sentinel created fabricated author URL. Fixed: `[A-Za-z0-9_-]+` opaque safe-id (rejects `/`, `?`, `%`, spaces) + `resolveTwitterQueryId()` sanitization for queryId resolution; sentinel removed → empty author + canonical `/i/status/<id>` URL.

3. **P1 fallback silent tab miss** (lead): pushState fail → fallback typing into search box, `clickProductTabIfNeeded()` silent return on tab not found → user `--product photos` silently degraded to Top results. Fixed: throw `CommandExecutionError` when requested `--product` tab cannot be selected + invalid `--from` / `--limit` upfront pre-nav reject + double-direction tests.

4. **P2 limit silent normalize** (aux): `const limit = kwargs.limit || 20` → `--limit 0` silent → 20; negative/non-integer pre-IO unchecked. Fixed: `Number(kwargs.limit ?? 20)` + require positive integer before `page.goto` + regression covers `0/negative/fractional/NaN` + `page.goto` zero-call.

## Cultural sediment (Round 21 audit checklist 7 rules / 6 dimensions)

This PR **immediately validated 4 of 7 rules** in review pipeline:
- (b) silent-clamp class — P1 fallback silent tab miss (silent semantic-downgrade) + P2 `|| 20` silent normalize
- (e) ID exact-not-substring — P5 host invariant (was only path-exact, not host-exact)
- (f) grandfathered-not-exempt — P5 helper-refactor boundary lost host invariant + P2 new adapter inherited grandfathered `'unknown'` sentinel
- (g) fallback-must-have-success-criterion — P1 fallback path missing post-condition assertion

7 rules / 6 dimensions:
- (a) cross-grep sibling URL pattern — structural
- (b) silent-clamp class — failure mode (input)
- (c) broad querySelector → article-scoping — scope
- (d) missing-validation early reject — boundary
- (e) ID exact-not-substring — identity
- (f) grandfathered-not-exempt (corollary: applies to new file + new helper-refactor boundary; not original-file line-edit) — time-axis
- (g) fallback-must-have-success-criterion (sub-rule g': fallback unit test must include post-condition assertion, not just "doesn't throw") — failure mode (output)

**Cross-PR validation 4-chain on meta-anchor "Structural exactness for identity matching"**:
- #1391 URL layer (`isFacebookAuthRedirectPath`: top-level anchor + `\.php` + `(/|$)` segment edge)
- #1392 URL parser layer (`parseGrokSessionId`: bare UUID exact / URL host-exact-or-subdomain + path-exact)
- #1400 DOM layer (article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex / segment-array exact)
- #1406 P5 helper-refactor boundary (full URL invariant in shared helper: host+path re-anchored after extraction)
- Common invariant: boundary-lock structural shape; **fuzzy match is silent-failure 温床**; lesson lifecycle = surface-shift not add-and-forget.

**Audit framework self-discipline**: each rule must have grep-able detection signal, otherwise rule degenerates to mantra. Framework is "7 rules + sub-instance pattern in new surface", not frozen 7 rules.

**Round 17 race-mitigation 第 9 连续 race-free execution**: standard alternation cadence (#1400 A 组 → #1406 B 组), lead final + aux final + `@pr-monitor squash?` trigger, pr-monitor proactive ack + serial squash, lead silent on closeout.

## Validation gates (final head `df4dcd76`)

Local: Twitter adapter tests `25 files / 216 tests`, focused P1/P2/P3/P5 tests `99/99`, `node --check` touched runtime, `npx tsc --noEmit`, `npm run build`, manifest 801 entries, typed-error-lint `189/189`, silent-column-drop `103/103`, doc-coverage `140/140`, docs:build clean, listing-id advisory `13` unchanged (wikipedia/trending residual non-Twitter), `git diff --check` clean.

GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`.

Reviewers:
- Lead: @codex-mini1 (3 fix rounds, all caught proactively + amend P3 help consistency)
- Aux: @First-principles-1 (better-solution triangulation on P2 queryId 三层 fallback + P5 invariant + P3 N=0 reference no-op + caught P2 limit silent normalize)
- Author: @opencli-user (5-feature scope + 7-rule sediment co-author + corollary contributor)
2026-05-08 02:36:39 +08:00
jakevin 34f793ff5c feat(help): add browser structured help (#1404) 2026-05-08 02:15:59 +08:00
jakevin 407b559a83 feat(help): hard-gate empty positional help text + fix 18 offenders (#1403)
Why
- `opencli twitter followers --help` rendered:
    Arguments:
      user
  with a blank trailing column. Both humans and agents could not
  recover the parameter's purpose without reading source. WAWQAQ
  surfaced this directly: "没有说明当后面的 followers [user] [options]
  如果都没填的时候,获取的是什么?"
- This is metadata completeness, not stylistic taste. Failing closed
  is the only way to keep the help surface trustworthy as adapters
  land.

What
- src/build-manifest.ts: add `findManifestMetadataIssues()` that flags
  any positional with empty / whitespace-only / missing `help`. Wired
  into `main()` after the import-failures gate; build aborts non-zero
  with a per-arg report (`site/cmd positional "name" (sourceFile)`).
- src/build-manifest.test.ts: cover the gate (positives + negatives,
  scoped strictly to positionals — named flags are intentionally
  out-of-scope).
- 18 adapter offenders (16 required + 2 optional) get explicit help
  text:
    twitter: followers/following/list-add/list-remove/list-tweets/
             search/thread
    reddit:  search/subreddit/user/user-comments/user-posts
    douyin:  stats/update
    bilibili: subtitle
    jike:    search
  Optional positionals (`twitter followers/following [user]`) now
  document the omit semantics — fetches the currently logged-in
  account.
- CHANGELOG: document the build gate and the offender list.

Out of scope (planned follow-ups)
- Semantic-quality advisory: optional positional help should also
  contain `default / omit / current / logged-in / required unless …`
  keywords. That belongs to the planned Arg metadata v2 work
  (`when_omitted / when_present / value_format` 3-field schema).
- Named-flag `help` quality. Named flags carry the flag name itself
  in help, so a missing `help` is not as opaque; if we want to gate
  those too, do it as a separate, intentional decision.

Validation
- `npm run build`            → 799 entries, clean.
- `npm run typecheck`        → clean.
- `npx vitest run --project unit --project adapter` → 257 + 4 files,
  all green (build-manifest 13 tests, manifest gate added).
- Smoke: temporarily reverted `followers.js` help to empty → build
  aborts with the exact `twitter/followers positional "user" (...)`
  line; restored, build is clean again.
- `npm run check:silent-column-drop` and `check:typed-error-lint`
  baselines unchanged.
2026-05-08 01:55:19 +08:00
jakevin dc7b88d45b chore(release): 1.7.14 (#1402)
Release / release (push) Has been cancelled
- bump opencli to 1.7.14 (was 1.7.13)
- extension stays at 1.0.6 (no extension changes since v1.7.13)
- finalize CHANGELOG with the three landed PRs:
  * #1399 daemon restart on stale ready state for npm -g upgrade
  * #1400 twitter write-action symmetry (unlike/retweet/unretweet/quote)
  * #1401 agent-friendly adapter help (drop globally-shared option noise)
2026-05-08 01:30:04 +08:00
jakevin 0996f9feba feat(help): make adapter help agent-friendly (#1401) 2026-05-08 01:18:56 +08:00
jakevin 644d45177b feat(twitter): add unlike + retweet + unretweet + quote (write-action symmetry P0) (#1400)
Round 21 P0 — Twitter write-action symmetry (4 of 4: unlike, retweet, unretweet, quote).

## Scope
Closes write-action gap with existing siblings (`like`, `bookmark`, `unbookmark`, `delete`):
- `unlike` (UI strategy, navigateBefore:true)
- `retweet` (UI strategy)
- `unretweet` (UI strategy)
- `quote` (UI strategy, `/compose/post?url=` route — same family as `reply.js` `/compose/post?in_reply_to=`)

+745/-0 in initial commit, plus 3 progressive review fixes. Final: 4 adapters + 4 tests; modified `shared.js`, `shared.test.js`, manifest, docs.

## Iteration history (4 heads, 102/102 tests on final)

- `07836783` — initial 4 adapters + 4 tests, 96/96
- `55a89776` — fix #1: shared `parseTweetUrl()` URL invariant + quote post-submit verify (102/102)
- `dc9eab66` — fix #2: article-scoping for unlike/retweet/unretweet (delete.js sibling pattern)
- `8809d2c1` — fix #3: exact status-id matching (`match?.[1] === tweetId`) + quote-card exact id guard

## 4 progressive blockers caught (codex-mini0 lead + F-P-0 aux)

1. **URL validation (silent-clamp class)**: original passed any host containing `/status/<id>`. Fixed: `parseTweetUrl()` requires `https` + Twitter/X exact host + exact `/<user|i>/status/<id>` path; host-suffix, embedded URL, path-suffix all `ArgumentError` pre-nav.

2. **Quote silent-success illusion**: original click-implies-success without composer/toast verify. Fixed: pre-submit quoted-card exact id render assertion + post-submit success toast OR composer-clear assertion, otherwise return failed row.

3. **Broad querySelector scoping (delete.js sibling pattern)**: original state probe + click + post-click verify on conversation pages picked first matching button. Fixed: scope to `article` containing requested exact status id (sibling `clis/twitter/delete.js:22-23` pattern).

4. **Substring vs exact status-id matching**: `/status/123` substring-matched `/status/1234`. Fixed: regex `/\/status\/${id}(?:\/|$)/` segment-edge anchor + `match?.[1] === tweetId` exact compare.

## Cultural sediment (Round 21)

**Audit checklist 5 rules (pre-write upstream selection net)**:
1. cross-grep sibling URL-construction patterns before adopting
2. silent-clamp class detection (any normalize-then-trust path)
3. broad querySelector → article-scoping requirement
4. missing-validation early reject before navigation/IO
5. ID-based DOM/URL matching exact-not-substring

**Augment framing**: Round 21 audit-first 是 Round 18 字面量 self-check 的 **upstream pre-write 阶段**, 两者作用阶段不同, 共存比替换稳。

**Meta-anchor "Structural exactness for identity matching"** unifying:
- URL layer (#1391 isFacebookAuthRedirectPath: `\.php` + `(/|$)` segment edge)
- URL parser layer (#1392 parseGrokSessionId: bare UUID exact / URL host-exact-or-subdomain + path-exact)
- DOM layer (#1400 article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex or pathname segment-array exact compare)

Common invariant: boundary-lock structural shape, 不 trust substring 模糊 — fuzzy match 是 silent failure 温床。

## Validation gates (final head `8809d2c1`)

Local: Twitter tests 102/102, `node --check` touched files, `npx tsc --noEmit`, `npm run build`, typed-error-lint 189/189, silent-column-drop 103/103, doc-coverage 140/140, docs:build clean, listing-id advisory unchanged 13, `git diff --check` clean, merge-tree clean.

GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test×2 shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`.

## Strategy/UI boundary (better-solution verdict)

UI write path acceptable for P0 symmetry (matches existing Twitter write siblings). GraphQL write migration + structured `idempotent:true` flag are cross-sibling upgrades, P5 candidate, not P0 blockers.

Round 17 race-mitigation 第 8 连续 race-free execution (this round absorbed author scope-uncertainty hold-then-retract event without producing actual race).

Reviewers:
- Lead: @codex-mini0 (4-round iteration, all blockers caught)
- Aux: @First-principles-0 (better-solution triangulation, scope-discipline verdict, regression invariants)
- Author: @opencli-user
2026-05-08 01:10:54 +08:00
jakevin 8d201ae60b fix(browser): restart stale ready daemon (#1399) 2026-05-08 00:39:19 +08:00
jakevin 1fa44bda6b chore(release): 1.7.13 (#1398)
Release / release (push) Has been cancelled
- bump opencli to 1.7.13 (was 1.7.12)
- bump extension to 1.0.6 (was 1.0.5)
- finalize CHANGELOG: move Unreleased section to 1.7.13 with date,
  document Strategy.HEADER removal + OPENCLI_BROWSER_TIMEOUT rename
  as breaking, add fill-step routing fix, qwen detail command, and
  the dead-code/internal cleanup batch
2026-05-07 23:44:18 +08:00
jakevin bf914f20f1 fix(grok): replace sentinel rows + silent-clamp with typed errors, deliver image cmd (#1397)
fix(grok): replace sentinel rows and deliver image command
2026-05-07 22:45:45 +08:00
jakevin 6f45db1be9 fix(manifest): rescue 11 desktop adapter commands from factory pattern (#1396)
fix(manifest): rescue desktop factory commands
2026-05-07 22:02:12 +08:00
jakevin da833d3efe chore(release): clean stale metadata surfaces (#1395)
chore(release): clean stale metadata surfaces
2026-05-07 22:00:51 +08:00
Kagura abfd0e2180 fix(youtube): use watch page HTML for transcript captions (#1378)
Fixes #1376 — YouTube transcript command failed with `No captions available for this video` for all videos.

## Root cause
Transcript adapter used InnerTube `/youtubei/v1/player` API with Android client context (`clientName: 'ANDROID'`, version `20.10.38`) to retrieve caption track URLs. YouTube has restricted/deprecated this approach; the Android client no longer reliably returns captions data.

## Fix
Replace Step 1 (caption track retrieval) with watch page HTML bootstrap parsing — fetch `/watch?v=...` with cookies and extract `ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer`. This is the same approach used by sibling `clis/youtube/video.js`, so it's an alignment to existing site-local stable pattern, not a new invention.

## 2 head iteration
- `cf77f5e8` initial fix (Step 1 caption retrieval switch + 18/18 unit tests)
- `bb30788c` lead test hardening — source-contract regression test in `transcript.test.js`:
  - **positive lock**: must fetch `/watch?v=...`, parse `ytInitialPlayerResponse`, read `playerCaptionsTracklistRenderer`
  - **negative lock**: must NOT use `/youtubei/v1/player` or `clientName: 'ANDROID'` (prevents regression)
  - stale Android-InnerTube file header comment also updated

## Better-solution evaluation
- Official YouTube Data API captions surface (`developers.google.com/youtube/v3/docs/captions/download`) is owner-authorized API, NOT a public transcript replacement
- yt-dlp also relies on watch-page bootstrap path
- Existing `youtube/video.js` already uses the same `ytInitialPlayerResponse` extraction → this PR aligns transcript with stable site-local pattern instead of inventing a new path

## Typed failure / no-silent-empty boundaries
- watch HTML HTTP failure / missing `ytInitialPlayerResponse` / no `captionTracks` → `CommandExecutionError` (typed fail)
- Empty parsed XML → `EmptyResultError` (existing path, preserved)
- `Strategy.COOKIE` matches YouTube adapter family + `video.js`; cookies/session/consent unavailable → typed fail not silent empty success illusion

## Diff containment
Runtime change limited to Step 1 caption track discovery. XML fetch, segment parsing, chapters, raw/grouped formatting all unchanged.

## Verification
Local: YouTube adapter tests `19/19` (+1 from new test), `npm run build`, typed-error-lint `192/192`, silent-column-drop `103/103`, doc coverage `140/140`, `docs:build`, listing-id advisory unchanged `13`, `git diff --check`, merge-tree clean.
GitHub: build × 3 OS, unit × 2 shards, bun-test, adapter-test, audit, doc-coverage, docs-build all SUCCESS. PR CLEAN/MERGEABLE.

Author: kagura-agent (fork). Lead: codex-mini0. Aux: First-principles-0. Coordination: pr-monitor.
2026-05-07 21:50:30 +08:00
E2ern1ty 195333ff8a fix(xiaohongshu): improve image publishing — creator-center URL + tab priority + DataTransfer fallback (#1380)
Xiaohongshu image-note publishing reliability fixes for creator center UI (legacy raw-Error write command, not a typed-error migration).

## 3 changes (one publish-path repair)
1. **Open creator publish in image mode**: append `target=image` to the publish URL so it loads directly in image mode instead of default
2. **Exact `图文` tab priority**: prefer exact tab text matching before broad `startsWith/includes`, reducing parent-container misclicks while keeping fallback for UI wording variants
3. **DataTransfer fallback for `Chrome Not allowed`**: when CDP `setFileInput` returns the permission/bridge denial error, fall through to the existing DataTransfer upload path (CDP-first remains primary to avoid base64 bridge/payload limits)

## Lead hardening (`edf8107d`)
Added `clis/xiaohongshu/publish.test.js` regression coverage for all three claimed behaviors:
- `target=image` creator URL locked
- exact tab text matched before broad fallback
- `Chrome Not allowed` falling into DataTransfer path

## Better-solution evaluation (lead + aux 一致)
- **CDP-first kept**: CDP avoids base64 payload/bridge limits; `Not allowed` is a known permission failure class where fallback is appropriate. DataTransfer-first would weaken the common path and reintroduce large-payload fragility.
- **Exact tab text first**: XHS creator markup is private and volatile, selector-only alternative not clearly more stable. Exact text reduces misclicks while broader fallback + post-click `video_surface` check preserve resilience for wording shifts. If exact text disappears, command fails fast with screenshot instead of silent video-mode publish.
- **Scope boundary self-imposed**: not expanding to typed-error migration (publish.js is legacy raw-Error and typed-error-lint already accounts for it).

## Verification
Local: xiaohongshu publish tests `12/12`, typecheck, build/manifest, docs:build, typed-error-lint `189/189`, silent-column-drop `103/103`, doc coverage `140/140`, node --check, git diff --check.
GitHub: build × 3 OS, unit shards, bun-test, adapter-test, audit, docs-build, doc-coverage all SUCCESS. PR CLEAN/MERGEABLE.

Author: E2ern1ty (fork). Lead: codex-mini1. Aux: First-principles-1. Coordination: pr-monitor.
2026-05-07 21:45:08 +08:00
jakevin 3b585fb4d1 feat(grok): add browser chat baseline commands (read/history/detail/new/send/status) (#1392)
Phase 3 — Grok adapter baseline (LLM browser-chat command family, parallel to ChatGPT/Qwen/Yuanbao).

## Surface
6 commands: `status` / `history` / `read` / `detail` / `new` / `send`. Site-local `clis/grok/utils.js` justified by 6 commands sharing helpers, not over-abstraction.

## 4-head review iteration

1. **`b4e81bad`** — initial baseline (12 Grok/shared files)
2. **`0a8112fc`** — mechanical rebase (CHANGELOG conflict only, all 12 Grok files preserved business-equivalent through rebase)
3. **`481e87e2`** — security fix: `parseGrokSessionId()` SSRF-shape vulnerability close — switched from regex string match to `new URL()` parser with branch separation:
   - Bare UUID mode: only exact UUID shape (no URL/query suffix accepted)
   - URL mode: requires `https` scheme + exact `grok.com` or subdomain host + exact `/c/<uuid>` path
4. **`a082023c`** — test-only hardening: 2 additional negative anchors covering existing implementation rejections (bare UUID `?next=abc` query tail / `grok.com.evil.com` host-suffix trick)

## Negative anchor coverage (8 cases)
http / off-domain / fakegrok / host-suffix subdomain / embedded URL / path suffix / UUID-tail / bare query tail

## Better-solution evidence form
LLM browser-chat family pattern (matching ChatGPT/Qwen/Yuanbao baseline) + 5 live probes — not first-site hostile scrape. TipTap editor API send seam (`editor.commands.focus/clearContent/insertContent`) is correct boundary because Grok ignores DOM input events; isolated in `sendMessage()`. Lack of full TipTap mock = residual risk, not blocker.

## Invariants locked
- `parseGrokSessionId()` URL parser branch separation (bare UUID exact / URL exact path)
- `history --limit` rejects invalid/out-of-range
- `status` uses `null` for unknowns (no fabrication)
- Bubble extraction preserves image-only assistant turns (no silent HTML-only drop)
- Auth/empty semantics aligned with LLM browser-chat baseline family

## Verification
Local: Grok adapter tests `28/28`, typecheck, build/manifest, docs:build, typed-error-lint `189/189`, silent-column-drop `103/103`, doc coverage `140/140`, listing-id advisory `13` unchanged, diff-check clean.
GitHub: build ubuntu/macos/windows × unit-test 1/2 + 2/2, bun-test, adapter-test, audit, doc-coverage, docs-build all SUCCESS. PR CLEAN/MERGEABLE.

Lead: codex-mini1. Aux: First-principles-1. Coordination: pr-monitor.
2026-05-07 21:11:39 +08:00
jakevin 9cae777430 chore(release): pre-release P0/P1 cleanup (#1393)
* chore(release): pre-release P0/P1 cleanup

P0 fixes:
- delete src/analysis.ts (179 lines, 0 importers across src/clis/extension)
- remove dead OPENCLI_DIAGNOSTIC negative test assertion
- rename OPENCLI_BROWSER_TIMEOUT to OPENCLI_BROWSER_IDLE_TIMEOUT — the env
  controls workspace lease idle release, not command runtime; old name was
  misleading and undocumented (no fallback needed)
- add 'fill' to validate.ts KNOWN_STEP_NAMES so adapters using PR #1222's
  fill pipeline step do not trip "unknown step name" warnings during validate

P1 fixes:
- BrowserConnect daemon-not-running hint: replace stale "make sure port is
  available" with actionable "run opencli doctor / opencli daemon restart"
- TimeoutError hint: lead with --timeout flag, demote env var to secondary

* fix(validate): derive step allowlist from pipeline registry

@pr-monitor flagged the prior "add 'fill' to KNOWN_STEP_NAMES" fix as
treating only the symptom — two parallel hand-maintained lists will keep
drifting whenever a new pipeline step is registered.

Address the root cause: pipeline/registry.ts now exports
`getRegisteredStepNames()` and validate.ts builds KNOWN_STEP_NAMES from
that. Adding a step via `registerStep()` automatically allowlists it.

* test(validate): regression guard for pipeline step allowlist linkage

@pr-monitor follow-up: lock the validate ↔ pipeline registry linkage at
the test layer so future drift is caught immediately.

Changes:
- recompute KNOWN_STEP_NAMES per-call (was const at module load) so
  steps registered after validate.ts import (plugins, dynamic registration)
  are honoured
- add src/validate.test.ts with 3 cases:
  1. every step name from getRegisteredStepNames() exists
  2. an adapter using every currently registered step does not warn
  3. a step registered at runtime is automatically allowlisted by
     validate without any source change to validate.ts

* fix(capabilityRouting): add fill to BROWSER_ONLY_STEPS

Same double-list drift pattern as validate.ts KNOWN_STEP_NAMES (audit
follow-up flagged in this PR's evolution thread). The fill step was
registered in pipeline/registry.ts (PR #1222) but never added to the
browser-only allowlist in capabilityRouting.ts.

Concrete impact:
- shouldUseBrowserSession() didn't recognize a `[{ fill: ... }]` pipeline
  as needing a browser, so PUBLIC adapters using fill could end up
  without a page and crash inside stepFill at `page!.fillText(...)`
- pipeline/executor.ts's per-step retry policy (BROWSER_ONLY_STEPS gets
  2 retries on transient errors, others get 0) skipped fill — losing
  retry coverage on a DOM-touching step

Fix:
- add 'fill' to BROWSER_ONLY_STEPS
- add a documenting comment explaining BROWSER_ONLY_STEPS is the
  browser-touching subset of registered steps (not the full set)
- export _validateBrowserOnlyStepsAgainstRegistry() so the test layer
  catches the inverse drift (browser-only step that no longer exists)
- 3 new tests in capabilityRouting.test.ts:
  * pipeline with fill routes to browser session
  * BROWSER_ONLY_STEPS subset of registered step names
  * fill is in both lists

This addresses @pr-monitor follow-up #3 (audit similar double-list
patterns) for the obvious in-scope candidate. Other candidates outside
this PR's scope: build-manifest serialization vs registry shape, error
code unions vs lint baselines.

* test(validate): use Strategy.PUBLIC enum instead of string cast in regression test

Self-review nit: `strategy: 'public' as never` worked but bypassed the
typed CliOptions union. Use `Strategy.PUBLIC` so the test exercises the
real public API.
2026-05-07 21:11:34 +08:00
jakevin b2ebe211d1 feat(yuanbao): add browser-web baseline commands (status/read/detail/history/send) (#1394)
Wire up the standard browser-LLM command surface for Yuanbao, matching the
recently shipped chatgpt + claude + qwen baselines:
- status — login + current model + (agentId, convId) + URL
- read   — render the visible conversation as User/Assistant rows
- detail — open `<agentId>/<convId>` and read its messages
- history — list sidebar conversations with stable IDs
- send   — fire-and-forget, returns once the send button has been clicked

Refactor `ask.js` to share helpers (`sendYuanbaoMessage`, `normalizeBooleanFlag`)
with the new commands via `shared.js`, keeping the public ask behavior intact.

Notable bits:
- `parseYuanbaoSessionId` accepts only full chat URLs or `<agentId>/<convId>`
  pairs — Yuanbao chat URLs encode both, and silently opening the wrong agent
  on a bare UUID is a worse failure mode than throwing. URL regex anchored
  with `(?:[/?#]|$)` so 37+ char tails reject rather than truncate.
- `sendYuanbaoMessage` polls the send button (up to 3s) for the React
  re-render that drops `style__send-btn--disabled___*` after composer input —
  a fixed wait raced the debounce and produced silent no-op clicks.
- `getYuanbaoMessageBubbles` uses `data-conv-id`/`data-conv-idx`/
  `data-conv-speaker` attributes for stable per-turn identity (was relying
  on innerHTML alone).
- Status surfaces both human label (`Yuanbao`) and `dt-model-id`
  (`hunyuan_gpt_175B_0404`) — sentinel strings would silently look like a
  real model name; null is the typed-unknown signal.

Verified: 25 unit tests pass; targeted live smoke for status/read/detail/
history/new/send + ask round-trip on yuanbao.tencent.com.
2026-05-07 20:38:17 +08:00
jakevin b9b87a5c64 refactor(facebook/notifications): pipeline→func + typed errors + 7-col contract + runtime upfront limit (Phase 3 P5, #1391)
First Facebook adapter — Pattern C HTML scrape (lead 5 + author 4 = 7 endpoint family probe matrix dual-source negative evidence: graphql×3 / m.facebook redirect / login.php / checkpoint.php / fetch-patch / Messenger relay / ajax legacy 全 unauth 不可达, DOM walk over rendered notification rows + path-anchored auth detection 是当前 reviewable boundary).

Caller-visible delta: 3 cols (index/text/time) → 7 cols (+unread/+url/+notif_id/+notif_type).

[Bug fix] — 5 silent failures resolved
- silent-bad-shape: text.substring(0,150) → full body via per-row 'Mark as read' aria-label
- silent-bad-shape: time || '-' sentinel → string|null typed unknown
- silent-column-drop: unread badge / anchor href / notif_id / notif_t 暴露
- silent-empty-row: /login(.php)? + /checkpoint(.php)? redirect 返 [] → AuthRequiredError; empty/no-recoverable-text → EmptyResultError
- silent-clamp: limit 越界 silent clamp → ArgumentError (1-100), upfront before any navigation (navigateBefore: false)

[Structural refactor]
- pipeline → cli() func form + Strategy.COOKIE + navigateBefore: false (runtime upfront invariant 与 #1387 standard 拉齐)
- module-level pure exports: normalizeNotificationsLimit, stripMarkAsReadPrefix, stripAnchorChrome, parseNotifQuery, extractNotificationRowsFromDoc, isFacebookAuthRedirectPath, buildNotificationsScript
- Live IIFE 通过 \${fn.toString()} 嵌入 (dianping #1313 / hupu #1387 / xiaoe #1388 lineage)
- Locale 表 6 prefix / 4 badge label 显式列出
- AUTH_REQUIRED: sentinel → Node-side AuthRequiredError mapper

[Typed-error hardening]
- Path-anchored auth helper: isFacebookAuthRedirectPath(/^\/(?:login|checkpoint)(?:\.php)?(?:\/|\$)/i) — domain-invariant-first encoding (FB top-level auth-only invariant), 排除 /loginhelp /help/login /account/login/identify
- Three-layer navigateBefore=false invariant lock: registration assertion + manifest absence + executeCommand runtime page.goto-zero-call (test layer 与 invariant layer 完整对齐)
- Row-level silent-empty-row defense: anchor rows with no recoverable body text 直接 skip, 不 emit text:null success row

[Doc fix]
- docs/adapters/browser/facebook.md notifications enrichment + Output table (列类型 / null vs sentinel 语义) + auth/empty error contract
- Boy Scout audit: cross-checked profile / feed / search / marketplace-listings / marketplace-inbox 例 commands 与 args 定义一致

Tests
- notifications.test.js 39/39 + src/execution.test.ts 21/21
- Anti-pattern regression guards: not.toMatch(/text\.substring\(0,\s*150\)/) + not.toMatch(/time\s*\|\|/)
- JSDOM frozen-fixture (slim 13 lines, 0 blank): header listitem skip / full text / unread badge / query parsing / null time / blank-row skip / relative href absolute / 19-case auth path matrix
- typed-error-lint baseline 192 → 191 (silent-sentinel resolved 1)

Review iterations (4 head, A 组 codex-mini0 lead + First-principles-0 aux):
1. 052d2b18 (initial 29 tests) → 376cb50f (lead gate fix: Ubuntu lint + auth path-segment + anchor.href + 5 typed-error func tests)
2. 376cb50f → 0d6c1340 (pr-monitor grep cross-verify catch /login.php false-negative; lead 加 \\.php 边界)
3. 0d6c1340 → 3e5a5ff0 (opencli-user 19-case 实测 + lead 抽 named helper isFacebookAuthRedirectPath domain-invariant-first encoding + 2 row-shape silent-failure 顺手 catch)
4. 3e5a5ff0 → 36e44f73 (F-P-0 aux blocker: registry-injected navigateBefore 在 limit validation 之前 fire pre-nav 违反 #1387 upfront boundary; navigateBefore:false + 三层断言 registration/manifest/runtime executeCommand)

Closes #1391
2026-05-07 17:49:57 +08:00
jakevin 381f095706 feat(qwen): add detail command + fix stale message bubble selector (#1390)
* feat(qwen): add detail command + fix stale message bubble selector

`getMessageBubbles` was matching `[data-msgid="<id>-question|answer"]` from an
older Qianwen frontend. The reshipped DOM no longer carries that attribute on
chat turns; `[data-message-id]` now lives on citation cards inside assistant
responses, so the old selector silently returned an empty list and `qwen read`
had been silently broken.

Rewire to walk `[data-chat-question-wrap]` and `[data-chat-answers-wrap]` in
DOM order (correct Q/A interleaving) and synthesize stable IDs from the
nearest sibling `data-req-id` so `waitForAnswer.seenAssistantId` and
read/ask/detail dedupe paths keep working. Verified live against an existing
conversation: 3 user turns + 3 assistant turns extracted; old selector
returned 0.

`qwen detail <id|url>`: open a specific conversation by ID or full chat URL,
poll up to 20s for the transcript to render, return Role/Text rows. Adds
`parseQianwenSessionId` (5 unit tests covering ID/URL parsing + ArgumentError
on malformed input). Reuses the same site-level browser session as `read`/
`ask` so consecutive calls continue in the same Qwen tab.

- clis/qwen/detail.js (new)
- clis/qwen/utils.js (parseQianwenSessionId + getMessageBubbles rewire)
- clis/qwen/utils.test.js (new)
- docs/adapters/browser/qwen.md (detail entry + options/columns)
- cli-manifest.json (regenerated)

* fix(qwen): anchor URL regex to reject 33+ hex tail truncation

codex-coder review on PR #1390 caught that
`https://www.qianwen.com/chat/<33+ hex>` would silently truncate to the
first 32 chars and open the wrong conversation. Adds end-of-input /
slash / query / fragment boundary to the URL match group and two new
unit-test cases (digit tail + letters tail) covering the truncation gap.
2026-05-07 17:39:23 +08:00
jakevin 99986c3101 feat(chatgpt): add browser chat baseline commands
Add ChatGPT web ask/send/read/history/detail/new/status alongside existing image support. Tighten ChatGPT web helper selectors and typed error contracts, update docs/changelog, regenerate manifest, and seed local ChatGPT verify fixtures for ask/read.
2026-05-07 17:37:18 +08:00
jakevin 6f7eb6a76a refactor(xiaoe x3): pipeline→func + typed errors + content silent-drop fix (Phase 3 P1)
Phase 3 P1 (xiaoe catalog/courses/content) — pipeline→func refactor + typed-error hardening + content silent-drop bug fix + URL upfront validation + inherited legacy doc fix。

## Tags (PR body honesty 演进 dual-nature framing 试用)

- **[Bug fix]** `xiaoe/content` silent-column-drop (caller-visible delta)
- **[Structural refactor]** `xiaoe/catalog` + `xiaoe/courses` pipeline→func 包壳 (parity by construction, IIFE 字节级保留)
- **[Typed-error hardening]** 三 func `page.goto` + `page.evaluate` failure 包成 `CommandExecutionError`; `content/catalog` URL upfront `ArgumentError` (missing/malformed/non-https/off-domain) before navigation
- **[Doc fix]** `docs/adapters/browser/xiaoe.md` `courses --limit 10` (legacy doc 错误 inherit) + `--url` wording → 实际 positional `url` (manifest aligned)

## Per-tag detail

### [Bug fix] content silent-column-drop (real caller-visible bug)
adapter 名"提取小鹅通图文页面内容为文本", IIFE 返 `{title, content, content_length, image_count, images}`, 但 columns 只声明 `[title, content_length, image_count]` → `content` (那段文本本身) 被 silent drop。**用户拿到 "1234 chars" 但拿不到那 1234 chars** — adapter 名字撒谎了。
- Fix: 公开列 `[title, content, content_length, image_count]`, `content` 真 caller-visible delta
- Choice A (vs B reshape): legacy `images` 是 `JSON.stringify(slice(0, 20))` 截断/stringified 坏合同, **不暴露成新列** (避免把 silent-bad-shape 升级成公开坏合同), 留 follow-up 另开 explicit media/images contract
- `image_count` 用 `countXiaoeImages(doc)` 全页计数, 不 slice (既有 metadata 质量修正)

### [Structural refactor] catalog + courses pipeline→func wrapper (parity by construction)
- `pipeline:[]` form → `func` form
- IIFE body 字节级保留 (Xiaoe 没 public REST, Vue 私有 runtime 是唯一稳定 hook, JSDOM 复刻不了 Vue tree)
- Pure helpers extracted: `pickContentText`, `countXiaoeImages` (content) / `typeLabel`, `buildItemUrl`, `chapterUrlPath` (catalog) / `buildCourseUrl` (courses)
- IIFE 通过 `\${fn.toString()}` 嵌同一份代码 (dianping #1313 / hupu #1387 同模式)
- No live verify acceptable: IIFE 字节级保留 + helper 全 unit-test + manifest column shape 不变 = 行为 parity by construction
- `buildScript` 反向断言 `images.slice(0, 20)` legacy anti-pattern 不出现 (anti-pattern regression guard, 同 #1387 `documentElement.outerHTML` 反向 guard)

### [Typed-error hardening] 三 func navigation + evaluate boundary
- `requireXiaoePageUrl()` for `content/catalog`: missing/malformed/non-https/off-domain URL → upfront `ArgumentError` before `page.goto` (test asserts `expect(page.goto).not.toHaveBeenCalled()`)
- `content/catalog/courses`: `page.goto` moved inside try, navigation/evaluate failures both wrap as `CommandExecutionError`, no raw CDP/browser error path leaks
- Empty shell stays `EmptyResultError` (no reliable login-wall signal to justify `AuthRequiredError`, 避免 false positive — 应用 #1384 secUid 教训)

### [Doc fix] inherited legacy doc errors
- `xiaoe courses --limit 10` example removed (no `--limit` arg in manifest, legacy doc 错误 inherit)
- positional `url` wording aligned with manifest (was incorrectly `--url`)
- 同 #1386 positional docs 教训, 但延伸到 "继承 legacy doc 错误也是新 PR 责任" (Boy Scout typed-error hardening 在 doc 层延伸)

## Tests: 46/46 green
- 3 cmd registration contract
- pure helper unit tests (selector chain / image filter / URL priority / type label fallback / no synthetic URL)
- `buildScript` invariants (`images.slice(0, 20)` 反向断言)
- wire tests: ArgumentError upfront (BEFORE page.goto), EmptyResultError empty rows + empty content, CommandExecutionError navigation/evaluate failure, rows verbatim happy path

## Lint gates
- typed-error-lint 190/190 (no new) ✓
- silent-column-drop 103/103 (no new) ✓ (注: `pipeline:[]` IIFE string template AST walker 看不进, lint follow-up scope)
- doc-coverage 140/140 ✓
- listing-id-pairing advisory unchanged 13 ✓

## GitHub checks (head a6d37d70)
build ×3 / unit-test ×2 / bun-test / adapter-test / audit / doc-coverage / docs-build SUCCESS, smoke skipped, MERGEABLE / CLEAN

## Review
B 组: @codex-mini1 lead + @First-principles-1 aux, double-green confirmed, Round 17 race-mitigation 第 4 轮 protocol clean closeout (第 4 次连续无 race 执行: #1384 / #1386 / #1387 / #1388)。

## Sediment lessons
- Silent-failure 三类 taxonomy: silent-column-drop (列没声明) / silent-bad-shape (字段在但 shape 错) / silent-empty-row (错误状态返空行而不是抛 typed error) — 三类 fix 路径不同, blast radius 不同
- PR body honesty 演进 4 链: #1384 R4 race disclosure → #1386 positional docs 教训 → #1388 silent-failure 三类分开写 + dual-nature tag 矩阵
- F-P-1 first-principles call: 不顺手暴露 legacy 坏合同 (silent-bad-shape ≠ silent-drop, fix 路径完全不同)
2026-05-07 17:02:23 +08:00
jakevin e610260705 refactor(hupu/hot): pipeline→func + querySelectorAll + 4 enrichment columns (Phase 3 P3)
Phase 3 P3 (hupu/hot) — pipeline→func refactor + 2 真 bug 修 + 4 列 enrichment + JSDOM-frozen-fixture test pattern (#1313 复用) + anti-pattern regression guard。

## Summary
- Pipeline form (`pipeline:[]` + `documentElement.outerHTML` regex) → `func` form (`querySelectorAll('.t-info')` DOM walk)
- **Bug 1 修**: outerHTML regex 静默漏行 (markup 抖动就漏, mocked test 抓不到)
- **Bug 2 修**: regex 抓所有 9-digit 锚点 → ~70 个 anchor 但页面只 render 60 个 `.t-info` row → legacy adapter 每次返 ~10 个 phantom 行 (导航链接 conflated 成 thread 行)
- **4 enrichment columns** (4→8): `lights` (亮 count int|null, 万 expanded `1.2万→12000`) / `replies` (回复 count int|null) / `forum` (per-row sub-section) / `is_hot` (bool 暴露 hupu \" hot\" marker, 不 filter 行序保持页面顺序)
- columns/manifest/docs sync: `[rank, tid, title, lights, replies, forum, is_hot, url]`,`null` vs `0` 语义清楚

## Typed errors
- `--limit` 上游 `ArgumentError` for 0/-1/>100/1.5/non-numeric (BEFORE `page.goto`,**不 silent clamp**)
- 空页 `EmptyResultError`
- `page.evaluate` failure 包成 `CommandExecutionError` (test regression locked)

## JSDOM frozen-fixture test pattern (#1313 复用)
- 抽 `extractHupuHotRowsFromDoc(doc, limit, parseCount)` 为 module-level pure export
- in-page IIFE 通过 `\${fn.toString()}` 嵌同一份代码
- JSDOM test 直接调 export against `__fixtures__/hot-home.html` (slim 6-row hand-crafted fixture)
- 17/17 tests green (contract / normalize / parseCount / extract / buildHotScript invariants / wiring / phantom-anchor exclusion / evaluate-error envelope)

## Anti-pattern regression guard (#1313 fixture pattern 延伸)
- `buildHotScript` 反向断言 `not.toContain('documentElement.outerHTML')` 锁不回退到旧 broad regex
- `buildHotScript` 反向断言 `not.toContain('regex.exec')` 同向锁
- fixture 顶部 `.t-info` 外的 9-digit phantom anchor `639999999` 反向锁: 旧 broad regex 会抓到, 新 `.t-info` extractor 不抓 — 把 fixture 反向验证从断言层升到证据层

## Better-solution check (live probe evidence-based)
DOM `.t-info` = 60 visible rows, `window.\$\$data.pageData.threads` = 70 (10 hidden/non-rendered)。对"首页可见 hot rows" 任务, DOM walk 比 bootstrap JSON 更贴 source of truth (后者会引入 hidden/不渲染条目)。这条 60 vs 70 数字是设计决策的硬 justify, 不是设计意见。

## Lint gates
- typed-error-lint 190/190 (no new) ✓
- silent-column-drop 103/103 (no new) ✓
- doc-coverage 140/140 ✓
- listing-id-pairing advisory unchanged 13 ✓

## GitHub checks (head 874d4e4e)
build ×3 / unit-test ×2 / bun-test / adapter-test / audit / doc-coverage / docs-build SUCCESS, smoke skipped, MERGEABLE / CLEAN

## Review
A 组: @codex-mini0 lead + @First-principles-0 aux, double-green confirmed, Round 17 race-mitigation 第 4 轮 protocol clean closeout.
2026-05-07 16:59:17 +08:00
jakevin 464de7059e refactor(tiktok): write commands -> button-walker Route 1 with typed errors (Phase 3 P0.5)
Phase 3 P0.5: refactor 3 TikTok write commands (comment, follow, unfollow) from time-window-wait UI flow to a button-walker + state-verification path with a typed-error boundary, sharing a parallel helper structure to the #1384 read PR.

Two-layer helper boundary (clis/tiktok/utils.js extension):
- BUTTON_WALKER_HELPERS (browser side): button-walker (locate / pre-click state read / click / state-verify post-click) + cleanText reuse + cookie/auth-secUid plumbing for write-auth + plain Error throws on contract violations
- throwButtonWalkerError() (Node side): map browser-thrown errors -> typed CommandExecutionError (button missing / state-verify fail / captcha / rate-limit / navigation/eval/empty-row defensive failures) / AuthRequiredError (cookie + viewer secUid) / ArgumentError (upfront input validation). Explicitly NO EmptyResultError mapping (button contract violation is not an empty result, per #1384 R4 lesson on auth-vs-empty classification).

Per command:
- comment <video-url> <text>: button-walker click + state-verify by checking comment-list state (not wait-2s)
- follow <username>: pre-click state read distinguishes idempotent fast path (`already-following` / `already-friends`) from post-click success (`followed`). Post-click result causality preserved (post-click never returns `already-*`).
- unfollow <username>: pre-click `already-not-following` fast path; post-click `unfollowed`.

result enums (per row):
- comment: `posted` (no idempotent path - comments cannot dedupe)
- follow: `followed` | `already-following` | `already-friends` (last two pre-click only)
- unfollow: `unfollowed` | `already-not-following` (last one pre-click only)

retryable contract (in hint string `retryable=<bool> reason=<...>`):
- comment failures: retryable=false reason=server-fan-out
- follow/unfollow failures: retryable=true reason=idempotent (server-side dedupe is safe)

Lead push iterations during review (codex-mini1 maintainer-fixes-directly):
- f5730f16: rate-limit/captcha -> CommandExecutionError + retryable hint BEFORE auth regex (auth precedence bug); follow post-click success -> `followed` (NOT `already-friends`, fixing causality misclassification); navigation/empty-row defensive failures route through throwButtonWalkerError (containing raw Error leakage).
- b683f46c: parseTikTokVideoUrl() requires canonical /@user/video/<numeric-id> with only optional trailing slash/query; malformed suffixes (e.g. /123abc, extra path) -> upfront ArgumentError.
- f5dc91d6: docs examples updated to actual positional args for write commands (was stale --url/--text/--username flag form), covering write-rewrite + sibling like/unlike/save/unsave on touched docs file (Boy Scout).

Intentionally NOT addressed (separate scope, candidate post-merge follow-ups):
- Direct /api/commit/follow/user/ or /api/comment/publish/ (would require X-Bogus signing reverse engineering, separate risk surface)
- RetryableError as core typed-error metadata (currently encoded in hint string, post-merge candidate to import into engine)
- TikTok Studio creator metrics commands (separate Phase scope)

Validation:
- clis/tiktok/ tests: 64/64 (38 read from #1384 + 22 new write contract + 4 regression for blockers caught during review)
- typed-error-lint: 190/190
- silent-column-drop: 103/103
- doc-coverage: 140/140
- listing-id advisory: 13 unchanged
- docs:build pass, manifest 764 entries
- GitHub gates on f5dc91d6: build x3 / unit x2 / bun / adapter-test / docs-build / doc-coverage / audit all SUCCESS, smoke skipped, CLEAN/MERGEABLE

Reviewers: codex-mini1 (lead, 3 contract pushes f5730f16 -> b683f46c -> f5dc91d6), First-principles-1 (aux, validated 4 contract patches + better-solution check confirming button-walker Route 1 vs /api/commit/* + X-Bogus separation).
2026-05-07 16:33:00 +08:00
jakevin 9a7dd44b3e refactor(tiktok): 6 read commands -> page-context API (Phase 3 P0, absorbs #1382)
Phase 3 P0: refactor 6 TikTok read commands (explore, following, friends, live, notifications, user) from DOM/network-intercept to TikTok web's own page-context API endpoints, sharing one helper boundary.

Helper boundary (clis/tiktok/utils.js):
- BROWSER_HELPERS: in-browser fetchJson + cleanText + asNumber (null/'' -> null preserve missing-vs-zero distinction) + cookie/msToken plumbing
- VIDEO_ITEM_NORMALIZER: normalize page-context item -> row shape
- assertTikTokApiSuccess(data, label): unify TikTok in-band envelope (status_code/statusCode != 0; code 8 or auth-looking message -> AUTH_REQUIRED; other -> upstream label API failed)
- throwTikTokPageContextError() (Node side): map browser-thrown errors -> AuthRequiredError / EmptyResultError / CommandExecutionError

Per command:
- explore: /api/recommend/item_list/ pagination, --limit upfront ArgumentError
- following: /api/user/list/ relationships
- friends: /api/user/list/ + cross-filter
- live: /api/live/discover/ feed
- notifications: /api/notice/multi/ (status 8 -> AUTH_REQUIRED)
- user (absorbed from #1382): secUid resolve via __UNIVERSAL_DATA_FOR_REHYDRATION__ -> /api/user/detail/, /api/post/item_list/ pagination, /api/search/general/full/ exact-author fallback. !secUid -> EmptyResultError (NOT AuthRequiredError; auth still covered by HTTP 401/403 + envelope status_code 8/auth-looking msg). source field = bootstrap | profile-api | search-fallback in row/columns/manifest/docs/tests.

Closes #1382 (absorbed; #1382 closed without separate merge per WAWQAQ direction).

Validation:
- clis/tiktok/ tests: 38/38
- typed-error-lint: 190/190
- silent-column-drop: 103/103
- doc-coverage: 140/140
- docs:build pass, manifest no drift
- GitHub gates: build x3 / unit x2 / bun / adapter-test / audit / doc-coverage / docs-build all SUCCESS, smoke skipped, MERGEABLE

Reviewers: codex-mini0 (lead, push 4 boundary fixes 18cdf930 -> a1f1ada4 -> 53499609 -> 276dce3b), First-principles-0 (aux, caught secUid auth-vs-empty boundary + verified 6 cmd integral helper boundary).
2026-05-07 16:15:30 +08:00
jakevin b327da5b3c feat(llm): reuse browser sessions by site (#1385) 2026-05-07 15:49:25 +08:00
yorick 1b113a60bc pass example field through cli() registration (#1381) 2026-05-07 15:34:32 +08:00
jakevin fa7851bb9a feat(browser): add adapter session reuse (#1383) 2026-05-07 15:24:54 +08:00
Benjamin Liu d527571b7d test(gov-policy): JSDOM-against-frozen-fixture tests for in-browser extractors (#1340)
* test(gov-policy): JSDOM-against-frozen-fixture tests for in-browser extractors

Applies the pattern documented in skills/opencli-adapter-author/references/jsdom-fixture-pattern.md
(introduced in #1319 alongside the dianping reference test in #1313) to the
gov-policy adapter.

Refactor: the inline IIFE inside `page.evaluate` template literal is hoisted
to a top-level `extractSearchRows` / `extractRecentRows` function using bare
`document` / `location`. Same code now runs identically in:

  - the live browser (injected via `${extractor.toString()}`)
  - JSDOM unit tests (with `globalThis.document` / `globalThis.location` swapped)

Tests:

  - 6 new cases in clis/gov-policy/gov-policy.test.js (was commands.test.js).
  - 3 representative search result cards (1 with real article snippet, 2 with
    only publish-time in `.description`) and 5 recent listing rows in the
    fixtures.
  - ok:false fallback path covered for both extractors.
  - Lock-in: `要闻` type-tag prefix fusion in title and empty-source contract
    on recent listings (no `.source` / `.from` elements on that page) are
    asserted explicitly so a future selector tweak can't silently change them.

Reverse-validated against two buggy variants per the reference doc:
breaking the title selector and stripping the `要闻` prefix both fail the
JSDOM assertions with helpful diffs.

Fixture sanitization follows the reference doc step-by-step: scripts /
styles / iframes / comments / preload links stripped, image srcs replaced
with `placeholder.png`, trimmed to the minimum subtree that exercises the
extractor (3 search items, 5 recent rows), all whitespace-only lines
removed.

* fix(gov-policy): use typed errors for touched commands

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-07 12:46:13 +08:00
jakevin c6d5da54ee feat(web): add exhaustive same-origin frame mode (#1373) 2026-05-07 01:15:10 +08:00
jakevin 124adf73d1 fix(web): avoid duplicate iframe diagnostics (#1372) 2026-05-07 00:58:42 +08:00
jakevin 829edfea3a fix(web): include relevant iframes outside main content (#1371) 2026-05-07 00:44:52 +08:00
jakevin 67cde0e263 enrich(coupang): product detail cmd + replace silent clamp/sentinel/Error with typed errors (#1370)
* enrich(coupang): add product detail cmd + replace silent clamp/sentinel/Error with typed errors

Two enrichment changes plus three silent-failure fixes on top of existing
search / add-to-cart.

New cmd: coupang product
─────────────────────────
Pairs with search as the listing↔detail round-trip target. Reads a logged-in
product page and extracts a single canonical row with price, original_price,
discount_rate, rating, review_count, seller, brand, rocket, delivery_promise,
image_url, url. Three-source extractor (JSON-LD Product schema → bootstrap
globals → DOM) merged in priority order, mirroring the search.js pattern.

The columns use string|null typing — null means "upstream did not provide
this field on this product" (e.g. some items have no original_price).
Failures (login wall / page mismatch / page failed to render) raise typed
errors instead of silently returning empty rows, so callers can treat any
returned row as real data.

Search column shape: added product_id
─────────────────────────────────────
Listing must pair with detail by id. The data was already extracted by
normalizeSearchItem; only the columns array needed updating so the field
projects through to the rendered row. Per the listing-id-pairing convention
(PR #1297) the new column lets agents round-trip rows directly into
`coupang product` without re-scraping URLs.

Silent-failure fixes
────────────────────
1. search --limit silent clamp.
   Old: `Math.min(Math.max(Number(kwargs.limit||20),1),50)` silently
        rewrote `--limit 999` to 50 and `--limit 0` to 1.
   New: `parseLimitArg(raw, 20, 50)` throws ArgumentError on out-of-range
        / non-integer / negative input. Same convention as the typed-fail-fast
        memory & PR #1289.

2. search --page silent clamp.
   Old: `Math.max(Number(kwargs.page||1),1)` silently lifted negative pages.
   New: parsePageArg throws ArgumentError on non-positive input.

3. Generic `throw new Error(...)` → typed errors.
   - Empty query, unsupported --filter, missing --product-id/--url
     → ArgumentError
   - Login wall detection → AuthRequiredError('coupang.com', ...)
   - Empty result / filter-not-rendered → EmptyResultError
   - PRODUCT_MISMATCH / OPTION_REQUIRED / button-not-found / unknown
     ack failure (add-to-cart) → CommandExecutionError
   - The PRODUCT_MISMATCH and `actualProductId || 'unknown'` sentinel were
     also fixed (silent-sentinel was the audit hit there).

Coverage
────────
- 21 contract assertions in clis/coupang/coupang.test.js covering
  parseLimitArg / parsePageArg (no silent clamp), registry shape (search has
  product_id, product is read-class with expected columns, add-to-cart is
  write-class), and typed-error pre-flight rejections (empty query / bad
  filter / out-of-range limit & page / missing detail args).
- Manifest 763 → 764 (+1 entry: coupang/product).
- Audits: typed-error-lint 196 → 194 (resolved 2 silent-clamp/sentinel
  baseline entries; baseline updated). silent-column-drop 103/103 unchanged.

* fix(coupang): tighten product id and browser errors

* fix(coupang): require real product urls
2026-05-07 00:18:57 +08:00
jakevin a5a3248a77 refactor(linux-do): remove deprecated hot/category/latest compat shims (#1368)
* refactor(linux-do): remove deprecated hot/category/latest compat shims

The three shims have been pure backward-compat wrappers since linux-do/feed
became the unified entrypoint. With no stable release commitment to preserve,
they are pure surface cost: 3 manifest entries, 3 deprecated branches in help
output, and a `buildLinuxDoCompatFooter` helper that exists only to feed them.

- delete clis/linux-do/{hot,category,latest}.js
- drop now-orphaned `buildLinuxDoCompatFooter` from feed.js and unexport
  `executeLinuxDoFeed` (no external consumers remain)
- remove the Compatibility section in docs/adapters/browser/linux-do.md
- regenerate cli-manifest.json (-125 lines)

BREAKING CHANGE: `opencli linux-do hot|category|latest` are removed. Use
`opencli linux-do feed --view top --period <period>`,
`opencli linux-do feed --category <id-or-name>`, and
`opencli linux-do feed --view latest` instead.

* fix(linux-do): finish compat shim removal
2026-05-06 23:57:15 +08:00
jakevin dcaae37068 refactor(registry): remove dead adapter metadata (#1369)
* refactor(registry): remove dead adapter metadata

* docs(changelog): note header strategy removal
2026-05-06 23:49:39 +08:00
jakevin 12d88e4b23 refactor(runtime): unify command timeout into a single --timeout arg (#1364)
* refactor(runtime): unify command timeout into a single --timeout arg

Drop the cli-level `timeoutSeconds` build-time ceiling field. A command
now opts into runtime-enforced timeouts purely by declaring an arg named
`timeout`; the user-facing `--timeout` value (its default or override)
is the single authoritative knob, used both by the adapter polling loop
and by the runtime ceiling (with a 30s padding for return + closeWindow
+ trace export).

Behavior:
- Browser commands without a `--timeout` arg fall back to
  OPENCLI_BROWSER_COMMAND_TIMEOUT (default 60s, unchanged).
- Non-browser commands without a `--timeout` arg now run unbounded
  rather than against the previously implicit `timeoutSeconds` cap.
  Affected commands keep their old caps via newly added `--timeout` args.
- LLM adapters (gemini/claude/deepseek/doubao/qwen/yuanbao ask) keep
  their current `--timeout` defaults; the runtime ceiling is now strictly
  more generous (userTimeout + 30s vs. the previous 180s cap), so
  `--timeout 600` actually buys 600s of polling rather than dying at 180s.

Closes the design discussion that started from PR #1227, which proposed
a per-site `OPENCLI_GEMINI_ASK_TIMEOUT` env var to work around the same
underlying mismatch.

* fix(timeout): wire --timeout arg into chatgpt/gemini image adapter polling

codex-coder review on PR #1364 caught that the new --timeout arg I added
to chatgpt/image and gemini/image only drove the runtime ceiling — the
adapter still hardcoded `const timeout = 120`, so users passing
--timeout 240/600 saw runtime allow 270s/630s but the adapter stop
polling at 120s. That recreated the same single-knob mismatch this PR
was meant to delete.

Also add the browser-path runWithTimeout assertion codex-coder flagged
as missing: a browser command with --timeout default=5 must call
runWithTimeout with timeout: 35; a browser command without --timeout
arg must fall back to DEFAULT_BROWSER_COMMAND_TIMEOUT.

Image adapters now read kwargs.timeout and reject non-positive-integer
values with ArgumentError (no silent fallback). chatgpt/image.test.js
updated to pass an explicit timeout when calling .func directly (the
test bypasses arg coercion).

* fix(runtime): reject invalid timeout ceilings

* fix(timeout): normalize timeout args to integer values

* fix(timeout): preserve remaining command ceilings

* fix(runtime): validate timeout before browser setup
2026-05-06 23:30:03 +08:00
jakevin 4ef2cb8b1c enrich(toutiao): hot board (public) + bug fixes (silent column drop, partial render) (#1366)
* enrich(toutiao): hot board + bug fixes (silent column drop, partial render)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #3.

## New command
- `toutiao hot` (Strategy.PUBLIC, browser:false) — public homepage hot
  board via the toutiao.com hot-event/hot-board endpoint. No login required.
  Returns 8 stable columns (rank/id/title/query/hot_value/label/url/image).

## Bug fixes for `toutiao articles`
- **Silent column drop fixed**: `parseToutiaoArticlesText` previously
  did `if (title && stats) push(...)`, silently dropping any row where
  the stats span hadn't finished rendering by the time page.innerText
  was read. Slow-render bugs were invisible — adapter looked "complete"
  while writers saw extra rows in the dashboard. Partial rows now
  surface with `null` stat columns.
- **Silent clamp on `--page` removed**: out-of-range / non-integer
  values raise `ArgumentError` with explicit bounds [1, 4]. Same
  validation reused by both `articles` and `hot` via `parseArticlesPage`
  / `parseHotLimit` in `utils.js`.
- **Empty result typed**: zero-row scrape now raises `EmptyResultError`
  instead of returning `[]` silently (would otherwise look like a
  legitimate "no articles" response).

## Refactor
- Parser logic extracted to `clis/toutiao/utils.js` (alongside hot-row
  mapping, validators, and the hot-board URL constant).
- `articles.js` switches from declarative `pipeline:` to imperative
  `func` form so `parseArticlesPage` validation can run before the
  navigation step (declarative pipeline can't pre-validate args).
- Strategy is now explicit: `Strategy.COOKIE, browser: true` for
  articles (creator dashboard is logged-in only).

## hot field map
`ClusterIdStr` (or numeric `ClusterId`) → id; `Title` → title;
`QueryWord` → query (falls back to title); `HotValue` → hot_value
(non-negative numeric, else null); `Label`, `Url`, `Image` →
respective columns. `pickImage` walks `Image.url` → first truthy
`Image.url_list[]`. Empty-title rows are dropped (returns null) before
ranks are densely re-assigned 1..N.

## Tests
29 contract assertions across `parseArticlesPage` / `parseHotLimit` /
`parseToutiaoArticlesText` / `mapHotRow` + registry-level shape checks
+ `hot` adapter func behaviour (typed errors / no silent clamp / fetch
failure paths / dense-rank).

## Audits
- typed-error-lint: 196 = 196 (unchanged baseline)
- silent-column-drop: 103 = 103 (unchanged baseline)
- listing-id-pairing: hot has `id` column (round-trippable when a
  detail command lands later); advisory list unchanged.

## Manifest
757 → 758 entries (+1 for `hot`).

## Doc
- index.md: toutiao mode 🔐🌐/🔐 (hot is public, articles is logged-in)
- toutiao.md: per-command mode/domain table + column docs + prerequisites

* fix(toutiao): tighten hot and articles contracts
2026-05-06 23:17:32 +08:00
jakevin 69ee36f997 fix(linkedin): surface detail_error on --details (no silent catch / no silent empty) (#1363)
* fix(linkedin): surface detail_error on --details (no silent catch / no silent empty)

The previous --details enrichment path had two indistinguishable failure modes
that both produced `description: '', apply_url: ''`:

1. `if (!job.url)` early return — row had no jobId, so we couldn't navigate.
2. `} catch {}` — page.goto / page.evaluate threw (network, timeout, parse error).

Callers couldn't tell "upstream had no description" from "we failed to fetch",
and the catch swallowed every error without logging. For an enrichment that
costs one page navigation per row, silent failure is especially harmful — users
just see an empty cell with no way to debug.

Fix: replace empty strings with `null` for missing/failed rows, add a new
`detail_error` column (string|null) carrying a short typed reason:

  - 'no url'                — row had no jobId
  - 'fetch failed: <msg>'   — page.goto / page.evaluate threw
  - 'missing description'   — page loaded but body was empty
  - null                    — success

Every failure is also logged to stderr with the offending URL so debugging is
possible. Per-row failures still don't abort the batch (the original intent),
but they're now visible.

Tests: 13 new contract assertions in clis/linkedin/search.test.js covering
parseCsvArg, mapFilterValues (ArgumentError on unknown values), decodeLinkedinRedirect,
and 5 enrichJobDetails paths (no-url / goto-throw / empty-description / success /
multi-row-mixed). Added `export const __test__` for testability.

Audits clean: typed-error-lint 196/196, silent-column-drop 103/103.

* fix(linkedin): fail fast on auth walls
2026-05-06 22:57:09 +08:00
jakevin da2453cfbd enrich(reuters): article-detail + bug fixes (silent clamp, silent error envelope) (#1362)
* enrich(reuters): article-detail + bug fixes (silent clamp, silent error envelope)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #2.

- `reuters article-detail` — full article body + canonical metadata for a
  Reuters URL. Pairs with `reuters search` (use the `url` column to
  round-trip).

- **Silent clamp on `--limit` removed**: out-of-range values now raise
  `ArgumentError`. Validation happens before browser navigation.
- **Silent error envelope removed**: the in-page IIFE used to swallow
  `fetch` errors with `catch(e) {}` and return `{error: ...}`, then the node
  side did `if (!Array.isArray(data)) return [];`. Now:
  - in-page IIFE returns `{ ok, status, body, error? }` raw envelope
  - node side throws typed errors:
    - `CommandExecutionError` on in-page exception
    - `CliError(FETCH_ERROR)` on non-2xx upstream
    - `CommandExecutionError` on captcha HTML (200 + non-JSON body)
    - `EmptyResultError` on empty articles array
- **Empty query**: now `ARGUMENT_INVALID` instead of triggering an empty
  upstream call.
- **Column shape enriched**: previously dropped `section_path` and
  `authors` are now stable columns.

- `docs/adapters/browser/reuters.md`: full Commands / Columns / Error
  Behaviour section (was a 3-line stub).
- `docs/adapters/index.md`: add `article-detail` to the commands cell.

27 contract assertions across `parseLimit` / `mapSearchArticles` /
`mapArticleDetail` / `buildSearchScript` / `buildArticleDetailScript`
+ registry-level checks for both commands (Strategy, ARG validation
before nav, every typed-error path, success path).

- typed-error-lint: 196 → 195 (silent-clamp resolved on
  `clis/reuters/search.js:18`); baseline updated.
- silent-column-drop: 103 = 103 (unchanged).
- listing-id-pairing: advisory only (article-detail keys off `url`).

757 → 758 entries (+1 for `article-detail`).

* fix(reuters): type auth and fetch failures

* fix(reuters): preserve search detail round trip
2026-05-06 20:00:36 +08:00
jakevin 61c4637b4c enrich(ctrip): hotel-suggest + bug fixes (silent clamp, dropped columns, fake URL) (#1361)
* enrich(ctrip): hotel-suggest + bug fixes (silent clamp, dropped columns, fake URL)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #1.

## New command
- `ctrip hotel-suggest` — surfaces hotel-context suggestions (cities,
  business areas, individual hotels) via the same backing endpoint with
  searchType=H. Distinct from `ctrip search` (searchType=D) which returns
  destinations / scenic spots / railway stations.

## Bug fixes for `ctrip search`
- **Silent clamp on `--limit` removed**: out-of-range values (≤0, ≥51,
  non-integer) now raise `ArgumentError` with explicit bounds rather than
  silently snapping to [1, 50].
- **Silent column drop fixed**: previously the adapter discarded `id`,
  `cityId`, `cityName`, `provinceName`, `countryName`, `lat`, `lon`, `eName`
  and `displayType` from upstream rows. Now all are surfaced as stable
  columns.
- **Fake URL fixed**: previously `url` was always `''`. Now constructs
  canonical Ctrip URLs by `type` (City / Markland / Hotel / Zone / RailwayStation)
  and returns `null` (no silent fabrication) for unknown types.
- **In-band error envelope typed**: `Result: false` payloads now surface
  as `COMMAND_EXEC` (was previously not handled — adapter returned empty
  rows).

## Doc fix
- `Mode: 🔐 Browser` → `🌐 Public` (search uses public API, no login)
- Add `hotel-suggest` to commands table in both `docs/adapters/index.md`
  and `docs/adapters/browser/ctrip.md`.

## Coords picker
Mainland China rows ship `gdLat`/`gdLon` (gaode); international rows ship
`gLat`/`gLon` (wgs84). Adapter picks the first non-zero pair (zero is the
upstream sentinel for "missing"); returns `null` if all variants are zero.

## Tests
25 contract assertions across `parseLimit` / `pickCoords` / `buildUrl` /
`mapSuggestRow` + registry-level checks for both commands (Strategy /
shape parity / typed errors / no silent clamp).

## Audits
- typed-error-lint: 196 = 196 (unchanged baseline)
- silent-column-drop: 103 = 103 (unchanged baseline)
- listing-id-pairing: advisory only (search has `id` round-trip column)

## Manifest
757 → 758 entries (+1 for `hotel-suggest`).

* fix(ctrip): wrap suggest fetch and json failures
2026-05-06 19:34:13 +08:00
Benjamin Liu 4e92d7163a feat(browser): add --width / --height / --full-page flags to screenshot (#1339)
Closes #1334.

Exposes viewport overrides for `opencli browser screenshot` so an adapter or
ad-hoc shell user can render a page at a fixed width and capture the full
scrollable height. The ljg-card HTML to PNG pipeline use case.

Behavior:
- `--width W` only overrides device-metrics width; height is left unchanged.
- `--height H` only overrides height (ignored under `--full-page`).
- `--full-page` keeps the existing `captureBeyondViewport` shortcut.
- `--full-page --width W` first reflows at W, then re-overrides to (W, contentH)
  so the captured image reflects the layout at the requested width.
- Override is always cleared in `finally`, including on capture failure.
2026-05-06 19:17:14 +08:00
Benjamin Liu 6469a02ea6 feat(deepseek): add detail and send commands for explicit conversation control (#1344)
* feat(deepseek): add detail and send commands for explicit conversation control

doubao already ships `detail <id>` and `send` for ID-explicit conversation
read/write; deepseek had only `read` (current page only) plus the
implicit-resume `ask`. Adding both gives users a stable handle when they
know the conversation ID, without going through `ask`'s resume detection
or its full prompt-then-wait pipeline.

`deepseek detail <id>`:
  - parses a bare UUID or any URL containing `/a/chat/s/<id>`,
  - rejects malformed input via `ArgumentError` before any browser
    navigation,
  - navigates to `https://chat.deepseek.com/a/chat/s/<id>` and returns
    the visible message list,
  - throws `EmptyResultError` when the conversation has no rendered
    messages.

`deepseek send <id> <prompt>`:
  - takes the conversation id as a required positional, because the
    framework runs each browser command in an ephemeral per-command
    workspace (a fresh tab) and there is no shared "current conversation"
    across commands; the navigation must be explicit,
  - drives input through CDP `Input.insertText` via `page.nativeType`,
    mirroring the doubao adapter (#1278); `execCommand('insertText')` plus
    a synthesised input event leaves the React-controlled state desynced
    on a freshly-opened tab and the resulting click silently no-ops,
  - keeps the verification loop inside the same `page.evaluate` so the
    framework cannot close the tab mid-flight; counts user-class bubbles
    by text-match (DeepSeek virtualises the message list, so a numeric
    bubble-count check is unreliable),
  - throws `CommandExecutionError` with a specific reason when the
    textarea did not populate, the send button stayed disabled, the
    bubble never settled, or the optimistic render rolled back during
    a 3s settle window,
  - treats "Promise was collected" from the post-click eval as success,
    matching the existing pattern in `ask --file`.

Helper `parseDeepSeekConversationId` is exported from utils.js so the
same parser feeds both commands and round-trips the canonical lower-case
ID.

Tests:
  - utils.test.js: 5 cases covering bare UUID, upper-case
    normalisation, URL extraction with and without query string, empty /
    null / whitespace input, and non-UUID rejection.
  - detail.test.js: 5 cases covering registration, navigation +
    message return, URL normalisation, ArgumentError before browser
    navigation, and EmptyResultError on no-messages.
  - send.test.js: 7 cases covering registration, ArgumentError on bad
    id, full happy-path through nativeType + IIFE verification, the
    textarea-mount timeout, missing nativeType helper, focus failure,
    IIFE-reason translation to CommandExecutionError, and the
    "Promise was collected" success path.

Manifest auto-regenerated to register both commands.

Live-verified end-to-end against my own DeepSeek session:
  - `detail` returns the canonical message list for a bare UUID, parses
    a full chat URL, and rejects malformed IDs before any browser
    navigation,
  - `send` lands the prompt as the latest user message in the target
    conversation and gets an AI response back; reload of the
    conversation page in a separate tab confirms the message persisted
    server-side.

* docs(deepseek): document detail and send commands

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 19:13:54 +08:00
jakevin 8f2d510408 fix(browser): keep automation container window reusable
Keep the owned automation container window warm across lease release. Non-final owned leases close their tab; the final owned lease resets its tab to about:blank as a reusable placeholder. Update browser close wording to describe lease release rather than window closure.
2026-05-06 19:01:22 +08:00
jakevin b58e43da7f feat(extension): mark automation tabs with group 2026-05-06 17:57:13 +08:00
Benjamin Liu 5137aac036 fix(deepseek): skip pinned conversations and fail fast when resume target unavailable (#1343)
Closes #1342.

`opencli deepseek ask` (without --new) used to resume the most recent
conversation by clicking the first `a[href*="/a/chat/s/"]` in DOM order
after a fixed 2-second wait.

Two bugs:

  1. Pinned conversations sit in their own DOM section ("置顶") that
     renders above "30 天内" and friends. Click-first-anchor lands on the
     pinned thread, not the user's most recent. Reproduced live by
     pinning a conversation through the sidebar context menu and
     observing that the existing logic targets it instead of the most
     recent non-pinned thread.
  2. The 2s wait is fixed. On a slow network the sidebar has not
     populated yet, the click is a no-op, and `ask` silently falls
     through to the new-chat path. The user typed "follow up" and
     a brand-new conversation gets created.

Replace the click-first-anchor + fixed wait with a new helper
`pickResumeUrl(page)` in utils.js that:

  - polls the sidebar for up to 10s (5 attempts × 2s),
  - identifies pinned anchors by a text-based check on the section
    header (`/^\s*(置\s*顶|Pinned)\s*$/i`); DeepSeek's CSS-module
    class names are randomized per build, so the text is the only
    stable signal,
  - returns the URL of the first non-pinned anchor (or falls back to
    the first overall if every visible anchor is pinned),
  - returns null if no anchor surfaces in time.

`ask.js` calls the helper and `page.goto`s the returned URL. When the
helper returns null, `ask` now throws a `CommandExecutionError`
instead of silently navigating to a fresh chat. The user gets a clear
"pass --new" hint and their prompt is never sent to a wrong target.

Tests:
  - utils.test.js: 4 cases covering happy path, polling-then-success,
    timeout returns null, and a structural assertion that the embedded
    DOM walker uses text-based pinned detection.
  - ask.test.js: replaced the prior "still selects model when no
    conversation to resume" test (which exercised the silent
    fall-through) with a fail-fast assertion. Updated the resume-success
    test to mock the new helper.
2026-05-06 17:54:33 +08:00
jakevin f033481e67 feat: 2 read adapters (wttr, openfda) + contract tests (#1355)
* feat: 2 read adapters across 2 new sites + contract tests (wttr, openfda)

Trimmed from original Round 11 per WAWQAQ feedback (msg=3899a382): drop
novelty/niche sites (timeapi / zippopotam / spacedevs / citybik) — keep
only sites with clear real-world utility:

- wttr (current, forecast) — wttr.in weather, no auth, simple text/json toggle
- openfda (drug-label, food-recall) — FDA drug labels + food recall enforcement

13 contract tests across 2 sites cover Lucene operator query construction
(openfda +AND+ literal handling), [string] 1-elem array unwrap, brand-OR-
generic match, wttr [{value:"..."}] array-of-objects 1-elem unwrap.

Manifest 757→759 (+2). Audits clean: typed-error-lint=196 baseline.

* fix(openfda): use brand or generic label search
2026-05-06 17:47:05 +08:00
jakevin 39943c05e9 feat: 12 read adapters across 6 new sites + contract tests (round 7) (#1350)
* feat: 12 read adapters across 6 new sites + contract tests (round 7)

New sites: wikidata, lichess, rest-countries, nuget, flathub, oeis
- wikidata: search (wbsearchentities) + entity (Special:EntityData) — Q/P/L ids,
  localised label/description with English fallback
- lichess: user + top (perf rankings) — closed accounts → EmptyResultError, no
  silent disabled rows; 13 perf types validated
- rest-countries: country (substring) + region — population-sorted by default,
  flattened languages/currencies/capitals
- nuget: search + package (full version history) — registration page-walking
  for 100+ version histories; case-insensitive id with strict shape gate
- flathub: search + app — appId reverse-DNS, dual-shape timestamp coercion
  (search is unix-seconds int, /appstream is ISO string)
- oeis: search (paginated) + sequence — A-id zero-padded, 12-term preview with
  (+N) suffix, surfaces commentCount/formulaCount/etc instead of full graphs

Contract tests: 6 files × 7 assertions = 42 contract assertions, all green.
Live verified all 12 commands against real APIs.

Audits clean: typed-error 196=baseline, silent-column 103=baseline, 0 Round-7
listing-id-pairing violations (all 6 listings carry round-trip ids).

* fix(nuget): fail fast on malformed registration pages
2026-05-06 16:54:21 +08:00
jakevin 9ae44c228a fix(round5): strengthen contract tests (#1348) 2026-05-06 14:19:55 +08:00
jakevin 498ad3930c feat: 13 read adapters across 6 new sites (round 4) (#1347)
Six new public-API sites — package registries + Docker images + OpenAlex
scholarly works — all unauthenticated, no browser required.

  dockerhub  search image
  rubygems   search gem
  homebrew   formula cask popular
  packagist  search package
  maven      search artifact
  openalex   search work

Conventions held:
  - access: 'read' on every command
  - typed errors (ArgumentError / EmptyResultError / CommandExecutionError)
    instead of generic CliError or silent fallback
  - input validators per site (image slugs, gem names, Composer names,
    Maven coordinates, OpenAlex work-id / DOI normalization)
  - listing rows carry an id-shaped column (image / gem / token / package /
    coordinate / id) that round-trips into the corresponding detail command
  - HTTP 429 surfaces with retry hint, 404 → EmptyResultError

Audits:
  - check:typed-error-lint   → no new violations (baseline 196)
  - check:silent-column-drop → no new violations (baseline 103)
  - advise:listing-id-pairing → unchanged at 13
2026-05-06 13:38:43 +08:00
jakevin 55088bbb28 feat: 13 read adapters across 5 new sites + 4 extensions (round 3) (#1346)
New sites (8 commands):
- npm    : search / package / downloads (registry.npmjs.org + api.npmjs.org)
- pypi   : package / downloads (pypi.org + pypistats.org)
- crates : search / crate (crates.io)
- mdn    : search (developer.mozilla.org)
- nvd    : cve (services.nvd.nist.gov)

Extensions (5 commands; +1 dblp/author surfaced in index):
- hf            : spaces (Hugging Face Spaces by likes / created_at / last_modified)
- dblp          : venue (search dblp's venue registry by acronym/topic)
- coingecko     : derivatives (perpetual / futures markets, 24h volume)
- stackoverflow : related (related questions for a given question id)

All commands hit public unauthenticated endpoints (Strategy.PUBLIC, browser:false),
typed-fail-fast on bad inputs (no silent fallback / clamp), and round-trip listing
ids into their detail commands where applicable.

Audits (all green vs baseline):
- typed-error-lint        : 196 = 196 baseline, no new
- silent-column-drop      : 103 = 103 baseline, no new
- listing-id-pairing      : 13 advisory (was 12; +1 = dblp/venue with no
                            corresponding venue-detail command)

Doc coverage : 120/120 adapter dirs documented (+5 new doc pages, +4 updated)
Manifest     : 722 entries (was 709; +13 commands)

Live verified:
- npm search react / npm package react / npm downloads react --period last-week
- npm downloads react --period 2025-01-01:2025-01-05
- pypi package requests / pypi downloads requests --period recent / overall
- crates search tokio / crates crate serde
- mdn search fetch
- nvd cve CVE-2021-44228
- hf spaces --limit 3
- dblp venue ICLR
- coingecko derivatives --limit 3
- stackoverflow related 79935770 --limit 3
- typed-error sanity: invalid CVE id, bad npm name, bad --period
2026-05-06 13:14:41 +08:00
jakevin a78ceb1602 feat: 11 read adapters across 8 sites (round 2) (#1345)
* feat: 11 read adapters across 8 sites (dblp / steam / bbc / devto / lobsters / medium / coingecko / hf)

Round 2 of the adapter expansion sweep. All 11 commands hit public APIs (no
browser, no auth), follow the post-#1332 typed-error / no-silent-failure
discipline, and were live-verified against real endpoints.

New adapters:
- dblp/author      : recent publications for one author (resolve PID by name, or pass --pid)
- steam/search     : storefront name search (storesearch API)
- steam/app        : single app detail (appdetails API; HTML entities decoded)
- bbc/topic        : per-topic RSS (8 canonical BBC News feeds)
- devto/latest     : /api/articles/latest with --page pagination
- lobsters/domain  : stories from a specific source domain (/domains/<d>.json)
- medium/tag       : tag RSS (description full-length, no silent truncation)
- coingecko/exchanges  : trust score + 24h BTC volume leaderboard
- coingecko/categories : sector buckets with 6 sort options
- coingecko/global     : aggregate market totals + BTC/ETH dominance
- hf/paper         : single-paper detail by arXiv id (summary, ai_summary, ai_keywords, upvotes)

Also adds clis/steam/utils.js + clis/bbc/utils.js as shared helpers (HTML entity
decode, RSS parsing). All listings carry a round-trippable id where a detail
sibling exists; advise:listing-id-pairing reports zero new violations. typed-
error-lint and silent-column-drop gates both unchanged from baseline.

Manifest: 698 → 709 (+11 entries).

* fix: tighten adapter round2 contracts
2026-05-06 12:46:14 +08:00
jakevin 6f597a2a4b feat: 8 read adapters across 5 sites (arxiv / SO / coingecko / wikipedia / hf) (#1338)
* feat: add 13 read adapters across 6 sites (github / arxiv / SO / coingecko / wikipedia / hf)

New site:
- github: user, repo, search-repos, user-repos, releases (unauth REST API; 60 req/h IP limit)

Existing sites — gap-fill for high-traffic verticals:
- arxiv author (papers by author, newest first; au:"name" phrase match on the public Atom API)
- stackoverflow user / tag (Stack Exchange API 2.3, with HTML-entity decode for display names / titles)
- coingecko coin / trending (single-coin market detail; 24h trending search-volume)
- wikipedia page (full plain-text article extract; opt-in --paragraphs cap, no silent truncation)
- hf models / datasets (downloads/likes/trending/freshness sorted lists)

All adapters use Node-side func + typed errors per the post-#1332 convention:
- ArgumentError for invalid limit / bad enum / empty positional / malformed owner-repo
- EmptyResultError for genuinely-empty results (no silent return [])
- CommandExecutionError for upstream HTTP/JSON failures (rate limit / 5xx / parse)
- AuthRequiredError reserved for endpoints that genuinely refuse anonymous traffic
- No silent clamp on --limit; no sentinel rows; no scalar 'unknown' / '-' fallbacks

Audit gates locally green:
- check:typed-error-lint        196/196 (no new)
- check:silent-column-drop      103/103 (no new)
- check:doc-coverage --strict   113/113 (added github.md, extended 5 existing pages)
- advise:listing-id-pairing     advisory only (+2 wikipedia entries: title is the
                                round-trippable key into wikipedia/page; not a gate)

* chore: drop github adapter set per WAWQAQ directive

WAWQAQ (#opencli-pr-review): "我们不需要GitHub的adapter,因为已经有GH了"

Removes the 5 github commands + utils + docs added in 664ed1aa
(github/user, github/repo, github/search-repos, github/releases,
github/user-repos). The remaining 8 read commands across 5 sites
(arxiv author, stackoverflow user/tag, coingecko coin/trending,
wikipedia page, hf models/datasets) are unaffected.

Audit gates re-checked:
- check:typed-error-lint: 196/196 (baseline unchanged)
- check:silent-column-drop: 103/103 (baseline unchanged)
- doc-coverage: 112/112 (one less site documented)
- advise:listing-id-pairing: 12 advisory (unchanged)

* fix(adapter-expansion): tighten id and currency contracts
2026-05-06 02:24:16 +08:00
hanzi 2a85152875 feat(browser): add verified fill command (#1222)
* feat(browser): add verified fill command

* feat(browser): implement exact fill primitive

* docs(browser): document fill pipeline submit

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 02:22:50 +08:00
SnakeEye-sudo (Er. Sangam Krishna) 9c9c8f976d feat: add uisdc and aibase news adapters (closes #1201) (#1249)
* Add uisdc news adapter for CLI

Implements a CLI adapter for fetching the latest AI/design news from uisdc.com. Allows specifying the number of news items to return.

* feat(aibase): add aibase daily news adapter

This file implements a news adapter for AIbase that fetches the latest AI industry news and allows for configurable limits on the number of news items returned.

* fix(news): harden uisdc and aibase adapters

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 02:14:12 +08:00
jakevin bb1208149c docs(guide): add remote-orchestration page for SSH/frpc reverse tunnel (#1337)
Document the pattern for running opencli on a remote machine while keeping
the daemon and Chrome on the local machine. Reverse-tunnel local 19825
back to the remote (via SSH -R or frp) so the remote opencli still talks
to its own loopback and the daemon never leaves localhost.

Captures the rationale we landed on after reviewing #636: native
extension-to-remote-daemon support is deferred until the daemon protocol
gains authentication; in the meantime this is the safe, zero-code path
that achieves the same outcome.
2026-05-06 02:06:14 +08:00
Greatkai d0b1b6a89e feat(pubmed): revive public eutils adapter (#819)
Co-authored-by: jackwener <jakevingoo@gmail.com>
Co-authored-by: Greatkai <4587517+Greatkai@users.noreply.github.com>
2026-05-06 01:54:29 +08:00
Shawn f1a8a2ff2d fix(chatwise): target main composer in electron UI (#427)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 01:45:30 +08:00
Luke 1794933d06 feat: add tiktok creator-videos command (#1335)
* feat: add tiktok creator-videos command

TikTok Studio creator content list with views/likes/comments/saves/shares.

Hits the Studio item_list endpoint
(https://www.tiktok.com/tiktok/creator/manage/item_list/v1/?aid=1988) from a
logged-in /tiktokstudio/content session and pages with cursor until limit is
satisfied (server caps size at 50). Username for the resulting video URL is
extracted from the user_text= query param on play_addr / download_info entries,
falling back to scraping a[href*="/video/<id>"] from the Studio page DOM.

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

* fix(tiktok): regen manifest + replace silent-clamp with ArgumentError

- Regenerate cli-manifest.json (CI gate: must match `npm run build` output)
- Replace `Math.max(1, Number(args.limit) || 20)` and
  `Math.min(Math.max(limit, 1), 50)` with an explicit positive-integer
  guard + a server-cap-only ternary, per the silent-clamp guidance in
  references/typed-errors.md (typed-error-lint baseline is unchanged)

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

* fix(tiktok): tighten creator videos contract

---------

Co-authored-by: root <root@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-06 00:58:38 +08:00
Carson dbf1f6afa1 feat(weixin): add Sogou article search (#1250)
- 新增 `clis/weixin/search.js`:通过 Sogou 微信搜索做公众号文章发现,`access: read`
- typed fail-fast: bad query/page/limit upfront ArgumentError;captcha/频控/goto/wait/evaluate/unreadable payload/selector drift/partial card extraction → CommandExecutionError;no-result 页面 → EmptyResultError;--limit 不 silent-clamp >10 直接拒绝
- maintainer-fixes-directly 闭环:rebase 到 latest main `f4637486`、补 access:read、删除 silent-clamp pattern
- F-P-1 first-principles 评估:Sogou 是 public 搜索页 ≠ 微信官方 API,可接受边界 = fail-fast + 清晰字段契约,不做字段猜测/partial success
- CI 全绿(smoke-test SKIPPED);B 组 codex-mini1 lead green + First-principles-1 aux green on `31c2b035`
- 非阻塞残留:search.url 是 Sogou redirect link,串联 download 需后续单独支持 redirect resolution 或暴露 resolved mp URL

Round 15a B 组对位收口。
2026-05-06 00:52:05 +08:00
jeff_woo f4637486b0 fix(twitter): rewrite followers command using DOM extraction (#1324)
- 重写 `clis/twitter/followers.js` 从 INTERCEPT (已坏) 改为 Strategy.UI DOM extraction
- bio 提取用 `data-testid$="-follow"` selector minus pattern (locale-independent button identification)
- drop `followers` column (DOM 不可靠) — manifest 同步 `strategy=ui` + columns `[screen_name,name,bio]`
- typed fail-fast: bad limit / empty user → ArgumentError;无登录 profile link → AuthRequiredError;followers link selector drift → SELECTOR typed error;空 followers → EmptyResultError;不再 silent `return []` / sentinel rows
- 恢复 `normalizeScreenName()` (opencli-user `b37f7ae3` 误删):`@elonmusk` / `/elonmusk` 不再走错路径
- `page.scroll('bottom')` no-op 改 `page.autoScroll({ times: 1, delayMs: 500 })`,避免 `--limit` 超首屏时 silent partial
- CI 全绿 (smoke-test SKIPPED);A 组 codex-mini0 lead green + First-principles-0 aux green on `dc8ac93`
- F-P-0 first-principles reflection: 长期更稳方向是 GraphQL helper (像 twitter/following),本 PR 是 INTERCEPT 已坏情况下的最小可用收口

Round 14 A 组对位收尾。
2026-05-06 00:42:10 +08:00
Jack He b485e96d90 feat(xianyu): add publish command for listing items (#1282)
- 新增 `clis/xianyu/publish.js`:发布闲鱼商品(标题/描述/分类/价格/图片/condition),`access: write`
- 参数 upfront `ArgumentError`:空 title/description/category、非法 price/original_price、未知 condition、图片格式/数量/文件不存在
- DOM/UI fail-fast:表单缺失、分类选择失败、必填字段未填、file input 缺失/上传失败、submit 失败、发布失败或超时未确认 → `CommandExecutionError`;登录墙 → `AuthRequiredError`
- 删除 `status=failed` success-row anti-pattern:失败/未知发布结果不再作为 success 表格返回
- F-P-1 aux catch real-runtime blocker:`page.url()` 在 IPage/BasePage 无定义,test mock `url` 字段遮住;改 `page.getCurrentUrl()` + fallback publish URL,加 IPage-shape 回归锁住
- xianyu publish JSDOM 回归 + docs/README/index 同步
- B 组 codex-mini1 lead green + First-principles-1 aux green on `2d78144d`

Round 14 (B 组对位)。
2026-05-05 23:35:23 +08:00
YoungCan-Wang 36d22ef4d3 feat(codex): add projects/history sidebar commands with native click + typed fail-fast (#1307)
- 新增 `clis/codex/sidebar.js` 共享 helper,使用 `data-app-action-sidebar-*` DOM 属性 + native click 触发侧边栏导航
- 新增 `clis/codex/projects.js` 与 `clis/codex/history.js`:列表/详情双契约
- typed fail-fast 收口:
  * projects/history 空列表 → EmptyResultError
  * limit/timeout/index/thread-id 非法 → ArgumentError(拒绝 silent-clamp)
  * 目标项找不到 → EmptyResultError
  * sidebar DOM 缺失 → CommandExecutionError
- 新增 8 条 regression 单测(`clis/codex/sidebar.test.js`)
- B 组 codex-mini1 lead green + First-principles-1 aux green on `bcfd88ae`

Round 13 close-out.
2026-05-05 23:03:47 +08:00
jakevin 76869bf8be docs(adapter-author): typed-errors reference + 6 conventions from #1329 (#1332)
纯 doc PR (+251/-37, 3 files),codify Round 12-13 #1329 三轮 review 沉淀的 6 条规则。

**新增 `references/typed-errors.md`** (~190 LOC):
- 5-classification 落点表 (ArgumentError / AuthRequiredError / EmptyResultError / CommandExecutionError + 第五类) 清晰判定边界
- 4 大独立 anti-pattern:silent-clamp / sentinel-row / scalar sentinel (`'-' → null`) / generic `CliError('CODE')`
- 反例引用 commit-pinned GitHub permalink (`384bcd6f` / `42e5303c` / `2b8609b8`) 防 merge 后行号漂移

**`references/adapter-template.md` 翻转**:
- 翻过期"sentinel row 比 [] 安全"建议
- 补 browser-vs-signature callout (#1329 author lesson 8 处 `(_page, args)` 错签)
- 补 intermediate-object naming 规则 (R1 lesson)
- example/COOKIE 骨架改 typed errors,convertible.js 标 grandfathered
- `page.fetchJson()` 正例从 `Number(args.limit) || 20` 改成复用已显式 validate 的 `limit` (F-P-0 catch 模板自洽性 blocker)

**`SKILL.md`**: reference table row + 3 条新约定 (中间对象 key / browser-signature / typed-error routing) 替掉过时的 `CliError('CODE')` 建议

Author: @opencli-user (jackwener)
A 组 review:
- codex-mini0 lead: 推 `971d198` (anti-pattern 拆分 + commit-pinned permalink + adapter-template 措辞) + `7921c6b` (修 page.fetchJson 模板自洽性 blocker)
- First-principles-0 aux: catch page.fetchJson `Number(args.limit) || 20` 模板会被照抄 silent-clamp 的自洽性 blocker
2026-05-05 18:32:59 +08:00
jakevin a5d70466ba feat: add qwen / 1point3acres / coingecko adapters (#1329)
3 new sites / 17 adapters,+2471 LOC。

**adapters**:
- coingecko (PUBLIC): `top` 全球加密货币市值排行
- 1point3acres (Discuz, GBK/UTF-8 mixed): `digest/forum/hot/latest/search/notifications/thread/user`
- qwen (browser, COOKIE chat): `ask/send/image/history/status`

**typed fail-fast 全闭环 (A 组三轮迭代后)**:
- silent-column-drop heuristic key collision 修法:rename intermediate keys 避开 columns 名字
- 所有外部参数 (limit/page/contentLimit/timeout/page_size) 越界/非法 → `ArgumentError`
- fetch / non-2xx / malformed JSON / API error → `CommandExecutionError`
- empty / not-found → `EmptyResultError`
- qwen prompt 缺失 → `ArgumentError` (不是 CommandExecutionError)
- qwen/status 未知 model/session 用 typed `null` (不是 `'-'` sentinel)
- success-row 永远不塞 failure/empty 业务行
- `normalizeLimit(value, default, max, label)` 共享 helper for 1point3acres 5 adapters

Author: @opencli-user (jackwener)
A 组 review (三轮):
  - codex-mini0 lead: 第三轮 maintainer-fixes-directly 直接 push `c40daf7` 收掉 4 类深一层 contract 漏洞
  - First-principles-0 aux: 第二轮 catch typed-error-lint 9 条 + 第三轮 catch generic CliError / silent-clamp on page/timeout / success-row failure / qwen prompt class 4 类 hard blocker
2026-05-05 18:00:33 +08:00
JackyWay 7aeaa053f9 fix(xianyu): chat send button detection + textarea activation (#1328)
- normalizeBtn() 去全部 whitespace 覆盖真实 `发 送`
- async IIFE send path + textarea click/focus 后 set value + dispatch input/change 触发按钮渲染
- send-button-not-found → typed selectorError fail-fast,不再 silent success
- JSDOM 直接执行 in-browser script regression 覆盖 mocked-evaluate 抓不到 DOM 内部 bug

Author: @JackyWay
B 组 review: codex-mini1 lead (rebase + 测试补全) + First-principles-1 final aux green
2026-05-05 17:36:36 +08:00
jakevin 97708ac858 feat(help): split root --help adapters into External CLI / App / Site buckets (#1330)
Per WAWQAQ feedback in #OpenCLI thread on the flat "Site adapters (112)" listing:
the bucket conflates real web sites (bilibili, dianping, ...) with desktop apps
(chatgpt-app, chatwise, codex, cursor, discord-app, doubao-app, antigravity, notion).
Group them so agents that fall back to --help can scan by category.

Three buckets, sourced from existing metadata only — no new adapter schema:

- External CLIs: passthrough binaries from loadExternalClis() (docker, gh, vercel, ...)
- App adapters:  domain is `localhost` or any non-DNS string (no `.`)
- Site adapters: domain contains `.` (real DNS), or domain is unset (default)

The classifier is one line: `domain.includes('.') ? 'site' : 'app'`. Adapters
without a domain field default to site (most are public web scrapers like
arxiv / wikipedia / spotify / ...).

Verified against the live registry: 7 External CLIs, 8 App adapters
(antigravity, chatgpt-app, chatwise, codex, cursor, discord-app, doubao-app,
notion), 104 Site adapters.

Structured help (-f yaml/json) gains parallel `external_clis` / `app_adapters`
/ `site_adapters` keys; `commands` no longer leaks adapter names.

External CLIs are now hidden from the default Commands listing (mirrors how
site adapters were already filtered) and surfaced in their own section.
2026-05-05 15:11:53 +08:00
jakevin 65979f26c2 refactor(test): extract shared page mock, remove dead test (#1321)
- Add clis/test-utils.js with standard createPageMock utility
- Migrate 11 test files to use shared utility (removes ~300 lines of duplication)
- Delete extension/src/cdp.test.ts dead skip test (chrome.scripting.executeScript removed from source)
- Remove clis/pixiv/test-utils.js (superseded by shared utility)
2026-05-05 01:39:22 +08:00
jakevin 05f7217edf bump version to 1.7.12, extension to 1.0.5 (#1320)
Release / release (push) Has been cancelled
2026-05-05 01:10:29 +08:00
jakevin 2b15016801 docs(adapter-author): add jsdom-fixture-pattern reference for in-browser DOM extractors (#1319)
Codify the JSDOM-against-frozen-fixture pattern that PR #1313 introduced
for dianping (and that PR #1318 had to follow up to clean up). The skill
previously had no reference for this category of test, so authors of the
next adapter that hits silent-in-browser-DOM bugs would either reinvent
it or skip it.

Key conventions captured:

  - **Mandatory awk 'NF>0' as the final step of fixture creation.** The
    blank-line noise that PR #1318 removed (84.6% / 54.8% of file content
    in dianping/{shop,search}.html) came from manually stripping
    script/style content without collapsing the surrounding newlines.
    Skipping this step is the silent quality regression that the next
    fixture author would also hit.

  - **Trim-to-minimum but never re-flow content.** Some bugs depend on
    text-node adjacency without intervening whitespace
    (dianping #1312 bug #2: rating "4.8" + reviews "21241条" fused as
    "4.821241条"). Pretty-printing the meaningful mega-line would mask
    the very condition the test is meant to catch.

  - **Reverse-validate the regression guard.** "18/18 tests pass" only
    proves agreement with the current implementation, not that the test
    would have caught the original bug. Reintroducing the buggy variant
    must make the test fail — otherwise the fixture is over-stripped or
    the assertion is too loose.

  - **__fixtures__/ is the documented exception** to the "no committed
    HTML dumps" rule in the skill's "关键约定". Calling that out
    explicitly because the rule otherwise reads as "all HTML in repo is
    bad," which the dianping fixture pattern intentionally violates for
    a real reason.

Background: WAWQAQ in #1313 follow-up thread (`#OpenCLI:36d2f65a`) asked
twice — first about the visible blank-line noise (→ PR #1318 cleanup),
then about the root cause and what should improve in the workflow itself.
This is the workflow improvement.

No new tooling / CI gate / lint introduced (B 1-week gate freeze still
applies). When a fifth fixture site adopts this pattern,
`opencli browser fixture-snapshot` automation can be revisited; until
then, runbook discipline + skill reference is the right scope.
2026-05-05 00:58:49 +08:00
jakevin d071600684 chore(dianping/fixtures): strip whitespace-only lines from frozen HTML fixtures (#1318)
dianping/__fixtures__/search.html and shop.html came out of page.content() with
hundreds of blank lines that JSDOM ignores during parsing — pure visual / disk
noise that bloats reviewer diff and obscures the meaningful DOM subtree the
fixture freezes.

  search.html: 372 → 168 lines (-204 lines, -572 bytes)
  shop.html:    39 →   6 lines (-33 lines,  -64 bytes)

`awk 'NF>0'` keeps every line that has any non-whitespace character, so the
minified mega-line (where the rating-vs-reviews adjacency that triggers #1312
silent fusion lives) is preserved verbatim. dianping.test.js 18 tests still
pass, and reintroducing the buggy `headText.match(/(\d+)条/)` extractor still
makes the regression guard fail with the expected '821241条' (proving the
fusion-bug detection power is intact after the strip).

Per WAWQAQ feedback in #1313 thread; opus independently validated the same
approach before the cleanup.
2026-05-05 00:51:00 +08:00
jakevin 6985187705 test(dianping): JSDOM-against-frozen-fixture tests for in-browser extractors (#1313)
PR #1312 fixed two silent in-browser DOM bugs that the existing mocked
`page.evaluate` tests could not catch:

  1. shop title fallback split on ASCII `[]` while dianping renders
     full-width `【】`, so `name` was always empty (or `"undefined"`).
  2. headText `\s+` collapse fused rating "4.8" with reviews "21241条",
     so a head-wide `/\d+条/` regex captured "4.821241" → 5.

Both bugs only surfaced on live verify; mocked-evaluate unit tests fed
pre-baked results to the func and the real DOM walk never ran.

Make the in-browser extractor logic testable in CI:

  - clis/dianping/shop.js, clis/dianping/search.js: extract the IIFE
    bodies into top-level `extractShopFields()` / `extractSearchRows()`
    using bare `document` / `location`. The live adapters inject these
    via `page.evaluate(\`(\${fn.toString()})()\`)` so behavior is
    unchanged; both commands re-verified end-to-end against live
    dianping (shop returns name=芈重山老火锅(五道口店), reviews=21241,
    rating=4.8; search returns 3 result-shaped rows with correct ids).

  - clis/dianping/__fixtures__/shop.html (3.4KB), search.html (8.4KB):
    sanitized HTML snapshots — scripts/styles/iframes/comments stripped,
    img src placeholdered, only structural attributes kept. Trimmed to
    the minimum subtree needed to exercise the extractors (search keeps
    3 of 15 li cards; shop keeps .shop-head + .desc-info + .review-title
    plus full-width 【】 title and headText with the rating/reviews
    fusion preserved).

  - clis/dianping/dianping.test.js: add a fifth describe block —
    "extractors against frozen HTML fixtures" — that loads the fixtures
    via JSDOM, swaps `globalThis.document` / `globalThis.location`, and
    asserts the post-fix behavior:
      * shop: name=芈重山老火锅(五道口店), reviewsRaw=21241条, rating=4.8,
        breakdown={口味:4.8,环境:4.8,服务:4.8,食材:4.9}, hours, rank, subway.
      * search: 3 rows with correct shop_ids, names, reviewsRaw, priceRaw,
        starClass; round-trip through parseReviewCount/parsePrice mappers
        to lock in {rating:5.0,reviews:21231,price:109} et al.
      * ok:false branches: shop fixture without `.shop-head`, search
        fixture with empty `#shop-all-list`.

Manually verified the fixtures would catch the original bugs by running
buggy extractor variants against shop.html — ASCII-bracket fallback
returns `name="undefined"`, and head-wide `/\d+条/` returns `821241条`
(both fail the new assertions).

Test suite grows 14 → 18 passing tests; live verify of both commands
still produces correct output post-refactor.

Pattern intentionally limited to dianping as a reference point. If other
sites with in-browser DOM extraction encounter similar silent bugs, this
JSDOM-against-frozen-fixture pattern can be adopted per-site.
2026-05-05 00:42:28 +08:00
Benjamin Liu de0cee191c docs(cases): add three researcher workflow examples (#1317)
* docs(cases): add three researcher workflow examples

Add use cases under cases/ that exercise the recently-landed
researcher-friendly adapters:

- daily-rl-research-monitor.md uses arxiv recent + openreview venue
  + hf top to compress a morning paper-skim into one shell pipeline.
- find-paper-implementation.md chains arxiv search/paper + dblp
  search + hf top + openreview search to map a paper's canonical
  record, follow-ups, and community uptake.
- track-conference-papers.md walks openreview venue + reviews to
  shortlist accepted papers and digest review threads in batch.

Each file is a real workflow built on commands from #1289 (arxiv
recent), #1294 (openreview), and #1299 (dblp).

* docs(cases): correct venue ids and forum example to ones that return data

The first revision used "ICLR.cc/2026/Conference" and "ICLR 2026 oral"
as venue strings. Both return EMPTY_RESULT today because the venue is
not open. Update each case to use natural-language venue text that
OpenReview currently exposes ("ICLR 2024 oral", "NeurIPS 2025 oral")
and a real forum id (KS8mIvetg2, "Proving Test Set Contamination in
Black-Box Language Models") in the reviews / paper drill-down. Note
the arxiv free-text-search ranking quirk so the worked DPO example
makes sense.
2026-05-05 00:35:33 +08:00
jakevin 4de1b42ab7 chore(convention): retire listing↔detail id pairing CI gate, keep advisory (#1316)
PR #1297 introduced a CI gate that fails when a site has both a listing
and a detail command but the listing rows don't carry an id-shaped column.
The gate came with a 10-entry EXEMPT map (topic-string trending,
profile-attribute rows, UI-only sessions, ...) where each exemption
recorded a "why this listing legitimately doesn't pair" reason.

By the same filter that closed PR #1311 (write-without-delete-pair gate):

  Is "listing should pair with detail" a *permanent* anti-pattern, or
  case-by-case business judgment?

It's case-by-case. Topic-string listings and profile-attribute rows
genuinely don't pair with a detail command. The fact that we needed an
EXEMPT map with 10 entries and individual reason strings is the smell —
it's not the rule winning, it's the rule failing. Forcing every adapter
PR to either add an id column or file an exemption was a higher cognitive
cost than the silent-loss bugs the rule actually catches.

Changes:

- .github/workflows/ci.yml — drop the "Check listing↔detail id pairing"
  step. Other gates (silent-column-drop, typed-error-lint) stay in place.
- package.json — rename the script from `check:listing-id-pairing` to
  `advise:listing-id-pairing` to make the advisory nature explicit.
- scripts/check-listing-id-pairing.mjs — drop the `--strict` flag and the
  EXEMPT map. The script now always exits 0 and prints an advisory report
  of listings that don't carry an id-shaped column. Reviewers/authors use
  it as guidance, not a gate.
- docs/conventions/listing-detail-id-pairing.md — rewrite from "MUST" to
  "soft convention". Adds an explicit "why advisory, not a gate" section
  that lists the legitimate non-pairing categories so future readers know
  the rule's boundary.
- docs/developer/ts-adapter.md — match the advisory tone in the
  adapter-author guidance.

The doc, the script, and the column patterns table all stay — agents and
adapter authors can still consult them. What's gone is the CI failure and
the per-PR exempt-list maintenance burden.

Net diff: -34 lines (gate + EXEMPT map removed, advisory-tone doc adds
a small "why advisory" section).
2026-05-05 00:34:18 +08:00
jakevin c4a1d2a91c fix(audit): reduce silent column drop false positives (#1315) 2026-05-05 00:29:20 +08:00
jakevin e6b048b86c feat(browser): enforce verify row shape (#1314) 2026-05-05 00:16:51 +08:00
jakevin 5ad0b81d92 ci: gate new typed error lint violations
Adds a baseline CI gate for convention-audit typed-error lint findings. Also refreshes the silent-column-drop baseline for dianping changes already on main.
2026-05-04 23:45:36 +08:00
jakevin 46d0f24f57 ci: gate new silent column drops
Adds a baseline CI gate for convention-audit silent-column-drop findings so CI rejects only newly introduced table-output loss.
2026-05-04 23:24:13 +08:00
jakevin c1bbf0bc5d fix(dianping/shop): correct in-browser name and reviews extraction (#1312)
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:16:07 +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
jakevin c131b4b435 fix: stabilize manifest paths on Windows
Normalize manifest sourceFile paths and test symlink type behavior on Windows.
2026-05-02 08:44:38 +08:00
jakevin f202d65f87 refactor(cli): move external management under external (#1238) 2026-05-02 01:40:36 +08:00
jakevin 5c871dd7a3 refactor(adapter): split browser command signatures (#1237) 2026-05-02 01:33:37 +08:00
jakevin b41ee2b671 feat(update-check): show extension update notice on exit (#1236)
* feat(update-check): show extension update notice on exit

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

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

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

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

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

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

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

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

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

* fix(zhihu): harden collection read commands

---------

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

* fix(browser): tighten profile popup context id

* fix(browser): harden profile routing edge cases

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

* feat(facebook): add marketplace reply draft command

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

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

---------

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

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

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

Fixes #1230

* fix(twitter): harden following pagination

---------

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

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

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

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

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

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

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

* fix(boss): validate job type filter

* fix(boss): keep legacy campus experience alias

---------

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

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

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

Closes #1215

* fix(deepseek): harden vision upload mode

---------

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

Three fixes for chatgpt image command:

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

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

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

Fixes #1206

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

* fix(chatgpt): avoid reloads during image generation

---------

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

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

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

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

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

---------

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

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

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

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

Fixes #1198

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

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

* fix(zhihu): harden api write regressions

---------

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

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

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

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

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

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

* fix(zlibrary): harden input and empty extraction

---------

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

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

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

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

* chore: update CLI manifest

* test(jd): update item adapter expectations

* fix(jd): collect CSS detail images

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

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

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

---------

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

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

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

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

---------

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

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

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

* docs(browser): document bound session idle semantics

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

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

* refactor(browser): rename bind command

* fix(browser): bind only current window tabs

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

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

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

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

Closes #1174, closes #1175

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

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

---------

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

* fix(xiaohongshu): constrain author date stripping

---------

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

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

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

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

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

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

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

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

* fix(youtube): preserve videos tab fallback

---------

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

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

Fix by directly selecting the button via its ID.

* test(doubao): update send button selector assertions

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

---------

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

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

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

* test(toutiao): cover serialized articles parser

---------

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

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

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

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

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

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

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

---------

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

Closes #441

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

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

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

---------

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

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

---------

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

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

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

* fix(toutiao): preserve short article titles

---------

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

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

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

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

Closes #1149

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

* fix(cli): expose only explicit option sources

---------

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

* fix(powerchina): stabilize api detail urls

---------

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

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

Fixes #1157

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

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

Fixes #1156

* test(sinafinance): lock stock symbol matching

---------

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

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

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

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

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

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

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

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

* fix(web): keep stdout streaming output clean

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

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

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

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

Fixes #1124

* chore: regenerate cli-manifest.json

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

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

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

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

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

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

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

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

* fix(download): emit canonical markdown strikethrough

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

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

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

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

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

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

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

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

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

All 6 sites pass locally (37s total).

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

Four adapters covering the main 51job surface:

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

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

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

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

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

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

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

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

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

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

Closes #1140

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

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

---------

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

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

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

Fixes #1137

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

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

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

---------

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

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

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

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

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

* test(xiaoyuzhou): lock podcast episodes endpoint

---------

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

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

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

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

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

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

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

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

* fix(browser): harden analyze and xhr guards

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1111

* fix(deepseek): preserve explicit instant model contract

* fix(deepseek): guard expert selector arity

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(manifest): register bilibili video command

---------

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

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

Closes #1092

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

---------

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

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

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

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

---------

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

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

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

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

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

No back-compat shims per design directive.

125 targeted tests green; tsc clean.

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

Two blockers from PR #1112 review:

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

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

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

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

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

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

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

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

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

Addresses review blockers on #1104:

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

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

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

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

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

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

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

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

Changes:

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

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

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

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

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

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

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

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

---------

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

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

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

Supersedes the cache prototype in #1051.

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

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

Self-review findings on the network refactor:

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #1038

* test(download): cover saved article path

---------

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

Closes #1017

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

* fix(output): truncate capped table cells

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

---------

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

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

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

Closes #1077

Change-Id: Id03361ddb616912dff3bfa8e59e8b68716de590b

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

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

---------

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

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

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

* fix(browser): close remaining tab routing gates

* docs(browser): align target id wording

* docs(browser): refine target id examples

---------

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

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

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

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

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

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

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

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

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

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

* fix: stabilize twitter list manifest and query ids

* docs: add twitter list command discoverability

---------

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

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

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

---------

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

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

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

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

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

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

Closes #1058

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

Addresses @codex-coder review blockers:

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

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

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

* refactor: remove sessionExpired warning per product decision

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

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

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

* fix(antigravity): avoid private runtime import

* docs(antigravity): document serve timeout options

---------

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

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

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

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

* fix(xiaoyuzhou): align local strategy contract

* fix(xiaoyuzhou): align local api metadata

---------

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

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

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

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

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

* fix(nowcoder): register adapter and document usage

---------

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

* fix(youtube): normalize subscriptions channel fields

* docs(skills): add youtube command coverage

---------

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

* feat(xiaoyuzhou): add transcript download support

* docs(xiaoyuzhou): clarify credential file requirement

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

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

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

---------

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

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

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

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

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

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

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

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

* fix: register hot stock ranking adapters

---------

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

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

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

Fix this in two layers:

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

Before:

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

After:

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

* fix: reset network capture flags on closeWindow()

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

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

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

---------

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

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

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

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

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

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

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

* fix: fail explicitly when stale daemon replacement fails

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

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

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

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

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

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

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

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

* fix(grok): harden image flow and docs

---------

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

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

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

* fix(manifest): register uiverse commands

* docs(uiverse): add usage examples

---------

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

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

---------

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

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

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

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

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

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

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

* feat: migrate scrollTo to unified resolver pipeline

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

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

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

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

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

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

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

* fix(manifest): sync twitter bookmarks columns

---------

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

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

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

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

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

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

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

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

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

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

* docs: update extension zip filename to versioned format

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

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

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

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

---------

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

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

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

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

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

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

Closes #283

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

- cli-manifest.json: update site/modulePath/sourceFile from chatgpt to chatgpt-app
- skills/opencli-usage/desktop.md: update commands from chatgpt to chatgpt-app
2026-04-13 14:33:32 +08:00
jakevin 79a15e8353 Remove unused OPENCLI_SKIP_FETCH env var (#987)
The adapter sync already has version caching (skips if same version)
and makes no network requests, so this opt-out flag adds no value.
2026-04-13 14:09:10 +08:00
jakevin 6d769ff354 docs: document undocumented environment variables (#983)
Add missing env vars to both README and README.zh-CN:
- OPENCLI_SKIP_FETCH: skip adapter sync on global install
- OUTPUT: override output format (json/yaml/table)
- DEBUG=opencli: internal debug logging
- DEBUG_SNAPSHOT: DOM snapshot debug output
2026-04-13 14:01:46 +08:00
jakevin 988ed19223 fix: clean up stale .yaml adapter files from older versions (#953) (#986)
* fix: clean up stale .yaml adapter files from older versions (#953)

Users upgrading from v1.6.x retain .yaml adapter files in
~/.opencli/clis/ that trigger "Ignoring YAML adapter" warnings on
every run. The hash-based sync only tracks .js files, so these
legacy .yaml files are never cleaned up.

Add a cleanup step (3b) that removes .yaml/.yml files from user
adapter directories when the corresponding site exists in the
official package (i.e., the site has been migrated to .js).

* fix(fetch-adapters): narrow stale yaml cleanup
2026-04-13 13:17:27 +08:00
jakevin 51bc48ec61 feat: decouple extension version from CLI version (#985)
* feat: decouple extension version from CLI version

Extension and CLI had tightly coupled version numbers (both 1.7.2),
requiring manual sync across 3 files on every release. This decouples
them so each can release independently.

Changes:
- Extension version reset to 1.0.0 with independent versioning
- Extension sends compatRange (e.g. ">=1.7.0") in hello message
  so doctor can check CLI/extension compatibility
- Daemon stores and exposes extensionCompatRange via /status
- Doctor uses compatRange for compatibility checks (falls back to
  major-version check for older extensions without compatRange)
- Doctor shows extension update availability from cached GitHub
  Releases data
- release.yml always builds and attaches extension zip to every
  CLI release, so users always find both in the same release page
- build-extension.yml triggers on ext-v* tags (not v*) to avoid
  duplicate builds

* fix: version extension release assets
2026-04-13 12:43:34 +08:00
jakevin 72bc86cf41 fix: code audit round 2 — safety, hot-reload, error diagnostics (#982)
* fix: code audit round 2 — pruneEmptyDirs, evaluateWithArgs, hot-reload, error cause chain

1. pruneEmptyDirs: use path.relative() instead of startsWith() to prevent
   false boundary matches on overlapping directory names
2. evaluateWithArgs: add safe evaluate method that auto-serializes args via
   JSON.stringify, preventing injection by design
3. Hot-reload: detect mtime changes on user adapter files in daemon mode,
   invalidate module cache so edits take effect without restart
4. toEnvelope: preserve error cause chain in verbose mode for better
   production debugging

* fix: address review feedback on code audit round 2

- pruneEmptyDirs: resolve() paths before relative() check
- evaluateWithArgs: validate keys are valid JS identifiers
- hot-reload: only bust ESM cache on reload, not first load
- toEnvelope: move cause serialization into toEnvelope itself
  so all consumers (AI agents, MCP tools) get cause chain
2026-04-13 09:36:53 +08:00
jakevin ffb61c51ea fix: address code audit findings (C1-C4, I1, I4, I6) (#981)
* fix: address code audit findings (C1-C4, I1, I4, I6)

Security:
- C1: Fix page.evaluate injection in browser type/select commands and
  6 adapter files by using JSON.stringify for user input interpolation
- C2: Close WebSocket on CDP connect timeout to prevent resource leak
- C3: Reject CDP connect promise on Page.enable failure instead of
  silently swallowing the error

Reliability:
- C4: Guard against corrupted adapter-manifest.json hashes to prevent
  false-positive override deletion
- I1: Throw on pre-navigation failure instead of warn-and-continue
- I4: Use Map<string, Promise<void>> for lazy module loading to prevent
  concurrent double-imports of the same adapter

Performance:
- I6: Replace O(n) registry alias cleanup with O(k) direct deletion

* fix: address self-review findings on PR #981

- C1: add quotes around CSS selector attribute values in browser
  type/select to match other commands (get text/value/attributes)
- C2: clear this._ws in timeout handler to prevent race with open event
- C4: refine corruption guard — treat null/undefined hashes as empty,
  only skip sync for truly invalid types (string, number, array)
2026-04-13 09:24:01 +08:00
AstroHan 5dcbf92a59 fix(douban): classify tv search results correctly (#979) 2026-04-13 08:41:19 +08:00
AstroHan 83dce2430e fix(xiaohongshu): harden anti-detection flows (#980) 2026-04-13 08:40:48 +08:00
Tony Simons 4d1fa8a6e2 feat(clis/chatgptweb): add ChatGPT web image generation command (#973)
* feat(clis/chatgptweb): add ChatGPT web image generation command

Add `opencli chatgptweb image` command that generates images using
ChatGPT web (GPT-4o image generation) and saves them locally.

Features:
- Navigates to chatgpt.com/new with full page reload to ensure clean state
- Uses Playwright's page.type() for reliable text input in TipTap editor
- Closes sidebar if open (covers the chat composer on some layouts)
- Polls for response completion (handles thinking/throttling states)
- Extracts generated images from DOM (backend-api/estuary/content URLs)
- Downloads and saves as PNG/JPEG files to user-specified directory
- Supports --op for output directory and --sd to skip download

Files:
- clis/chatgptweb/image.js: CLI command definition
- clis/chatgptweb/utils.js: DOM helpers, send/wait/export functions

Works cross-platform (Linux/macOS/Windows) via OpenCLI browser automation.

* fix(chatgptweb): stabilize image generation flow

* docs(chatgptweb): add browser adapter guide

---------

Co-authored-by: Tony Simons <tony@tonysimons.dev>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-12 21:24:03 +08:00
Harvey Yue aa47de726d feat(bilibili): add feed-detail and enhance feed command (#974)
* feat(bilibili): add feed-detail and enhance feed command

* docs: add binance adapter documentation

* docs: add feed-detail command to bilibili docs

* docs: sync bilibili adapter contract for feed-detail

* docs: add ke adapter page for doc coverage

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-12 21:19:12 +08:00
runzhliu 232b6828be feat(ke): add Beike (贝壳找房) adapter with ershoufang, xiaoqu, zufang, chengjiao commands (#975)
Support browsing second-hand houses, neighborhoods, rentals, and
transaction records on ke.com with city/district/price filtering.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:21:59 +08:00
Ivan Xia 20e024a001 feat(maimai): add talent search with multi-dimensional filters (#977)
* feat(maimai): add talent search with multi-dimensional filters

Add maimai.cn talent search adapter with support for:
- Keyword search (query)
- Company filtering (multiple companies supported)
- School filtering (with 985/211 options)
- Location filtering (province/city)
- Work experience and education level filters
- Industry and position filters
- Direct chat availability
- Sort by relevance, activity, work years, or education

Features:
- Reuses Chrome login session for authentication
- Extracts candidate info: name, job title, company, work history
- Shows work years, education, age, active status
- Displays skill tags and mutual friends count

* fix docs and strategy for maimai adapter

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-12 16:16:55 +08:00
Alex Yang 01957bf654 feat(discord-app): add delete command to remove a message by ID (#976)
* feat(discord-app): add delete command to remove a message by ID

Adds a new `delete` command for the discord-app CLI that deletes a
message in the active channel by its snowflake ID. Uses the UI strategy
to hover the message, open the "More" menu, click "Delete Message", and
confirm the deletion dialog.

* docs: add binance adapter doc and update discord doc with delete command
2026-04-12 16:07:14 +08:00
jakevin 315cc59f8a chore: bump version to 1.7.2 (#972)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-11 22:14:43 +08:00
jakevin ebe6be945e fix(zsxq): update topic test for group_id parameter added in #963 (#971)
The test mock was missing the evaluate call for getActiveGroupId,
which was added when #963 introduced the group_id parameter.
2026-04-11 22:12:26 +08:00
iiilin b0a019121e feat(weibo): support for-you and following feed types (#959)
* feat(weibo): support for-you and following feed types

* docs: clarify weibo feed types

---------

Co-authored-by: iiilin <19162130+iiilin@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-11 22:04:01 +08:00
Paul Zhu c63bad34b0 feat(twitter): add lists command to retrieve user lists (#958)
* feat(twitter): add lists command to retrieve user lists

Add twitter/lists command that fetches Twitter/X lists for a user.
Supports:
- Lists with member and follower counts
- Private/public mode detection
- Default to current user if no user specified
- Works for any Twitter user

* docs: add lists command to twitter commands in README

Add twitter lists command to Built-in Commands table in both
English and Chinese README files

* fix(twitter): parse lists from card DOM instead of locale-specific page text

---------

Co-authored-by: isanwenyu <isanwenyu@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-11 21:58:46 +08:00
Hoshea 1ffe12f85e fix(zsxq): accept topic_id as string in getTopicFromResponse (#963)
* fix(zsxq): accept topic_id as string in getTopicFromResponse

The ZSXQ API returns topic_id as a string, but getTopicFromResponse()
only checked for typeof === 'number', causing it to fall through and
return null. This made 'opencli zsxq topic <id>' fail with NOT_FOUND
for all valid topic IDs.

* fix(zsxq): use group-scoped topic endpoint instead of bare /v2/topics/{id}

The ZSXQ API requires topics to be fetched within their group context.
Change /v2/topics/{id} -> /v2/groups/{groupId}/topics/{id} for both
the detail and comments endpoints. Also adds optional --group_id arg.
2026-04-11 21:49:02 +08:00
jakevin 30f216b2c1 fix: include adapter tests in default npm test (#969)
* fix: include adapter tests in default npm test

`npm test` only ran unit + extension projects, so adapter tests
(clis/**/*.test.js) were never exercised by the default test command.
Add --project adapter so they run alongside unit and extension tests.

* test: include adapter project in default npm test
2026-04-11 21:33:50 +08:00
jakevin 3c088da53e refactor: smart sync adapters — hash-based diff instead of full copy (#966)
* refactor: smart sync adapters instead of full copy (#sparse-override)

Replace unconditional full-copy of all adapters to ~/.opencli/clis/ with
hash-based smart sync that only copies files whose content has changed.

Changes:
- fetch-adapters.js: use SHA-256 content hashes to skip unchanged files;
  store per-file hashes in adapter-manifest.json
- discovery.ts: simplify ensureUserAdapters() to only create the directory
  (no longer triggers full copy on first run)
- main.ts: fix fast completion to check manifest file existence instead of
  directory existence (sparse override may have empty user dir)
- cli.ts: add `opencli adapter eject/reset/status` commands for managing
  local adapter overrides
- engine.test.ts: add tests for empty user dir and ensureUserAdapters

* fix: address review blockers — site-level sync + reset --all

1. Fix `adapter reset --all`: change <site> from required to optional
   argument so --all can be used without specifying a site name.

2. Change smart sync from file-level to site-level granularity:
   if any file in a site has changed upstream, overwrite the entire
   site directory. This matches the agreed product semantics — local
   modifications to any file in a site are replaced when upstream
   updates that site.

* fix: delete old site dir before writing updated adapter files

When a site has upstream changes, delete the entire site directory
first, then write the new version. This prevents stale files from
older versions lingering in the user directory.

* fix: reset --all preserves custom sites, only removes official overrides

Blocker 3 fix: reset --all now checks BUILTIN_CLIS to identify official
sites and only deletes those, preserving user-created custom sites.

* refactor: sparse sync deletes local overrides instead of copying new versions

Changed fetch-adapters.js semantics per team agreement:
- When an official site has upstream changes, DELETE the local override
  instead of copying the new version into ~/.opencli/clis/
- Runtime automatically falls back to package baseline
- ~/.opencli/clis/ becomes a true sparse override layer

* fix: reset <site> rejects custom sites, only allows official overrides

Single-site reset now checks BUILTIN_CLIS before deleting, matching
the same protection that reset --all already has.

* fix: reset <site> allows custom sites per product decision

Per @WAWQAQ: explicit single-site reset should work on custom sites too.
Differentiate messaging: official sites say "using official baseline",
custom sites say "removed custom site".

reset --all still only removes official overrides (bulk safety).

* fix: reset --all deletes all local sites including custom per product decision

Per @WAWQAQ: --all should clear the entire local working cache,
including custom sites. Single-site reset already handles both types.
2026-04-11 21:28:40 +08:00
jakevin 00e200b0b7 migrate: move binance adapters from src/clis/ to clis/ (#967)
Binance was the only adapter left in src/clis/ after the TS→JS
migration (PR #928). Move all 11 adapters and the test file to
clis/binance/, strip TypeScript syntax from the test, and switch
the test import to the @jackwener/opencli/pipeline package export.
2026-04-11 21:21:52 +08:00
jakevin 1fd578c404 chore: bump version to 1.7.1 (#965)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-11 20:28:23 +08:00
jakevin b0fd95279c docs: add CHANGELOG.md entry for v1.7.0 (#955)
Comprehensive release notes covering all changes since v1.6.1:
- Breaking changes: Node >= 21, YAML deprecated, .ts no longer loaded,
  error output as YAML envelope, tabId → targetId, operate → browser
- 10+ new adapters, 15+ adapter enhancements
- Major refactors: JS-first adapters, registry validation, strategy normalization
- Performance: P0 optimizations, fast-path completion, browser pipeline
- Upgrade guide with step-by-step migration instructions
2026-04-11 13:51:51 +08:00
jakevin 892ddcb19f docs: fix stale .ts adapter references in skills and guides (#954)
* docs: fix stale .ts adapter references in skills and guides

All adapters are now .js files. Update references in:
- opencli-oneshot SKILL.md (7 instances)
- opencli-autofix SKILL.md (1 instance)
- opencli-explorer references (15 instances)
- electron-app-cli guide (5 instances)

* docs: fix stale YAML/TS references in READMEs and zh docs

- Plugin type column: YAML/TS → JS (all 4 plugins have JS conversion PRs)
- synthesize command: "YAML adapters" / "TS adapters" → "JS adapters"
- zh index: remove "YAML 声明式" reference
- zh README: add missing vk plugin entry

* docs: fix remaining .ts references found in review

- electron-app-cli.md: "TypeScript desktop adapter" → "desktop adapter", file layout .ts → .js
- adapter-templates.md: section title "提取 utils.ts" → "提取 utils.js"
- opencli-explorer SKILL.md: "写 following.ts" → "写 following.js"
2026-04-11 13:13:27 +08:00
jakevin ee8d7cce77 docs: fix stale adapter counts and .ts reference (#950)
- README.md: "70+ pre-built adapters" → "87+"
- docs/comparison.md: "73+ sites" → "87+", ".ts adapter" → ".js adapter"
2026-04-11 13:13:21 +08:00
jakevin 420dc0f3c8 fix: DEBUG_SNAPSHOT should work without DEBUG=opencli (#952)
log.debug() requires DEBUG=opencli to output, which means
DEBUG_SNAPSHOT=1 alone no longer shows snapshot fallback diagnostics.
Use process.stderr.write directly since the DEBUG_SNAPSHOT guard
already controls when this diagnostic fires.
2026-04-11 13:13:14 +08:00
jakevin 0f90b42f71 fix: warn users when .ts adapters are found but not loaded (#951)
Users who created custom .ts adapters in ~/.opencli/clis/ will see
their commands silently disappear after upgrading to the JS-only
version. Add an explicit warning so they know to convert to .js.
2026-04-11 13:13:07 +08:00
jakevin 2469d12efd fix: resolve alias target correctly in validate command (#949)
The alias resolution logic checked `!registry.has(target)` before
calling `registry.get(target)`, which always returned undefined.
Moreover, aliases registered as `site/alias` keys meant `registry.has`
returned true, skipping the block entirely. The canonical name was
never resolved, so `validate site/alias` silently checked 0 commands.

Simplify to always resolve via `registry.get(target)` which handles
both canonical keys and alias keys correctly.
2026-04-11 13:13:02 +08:00
Harvey Yue 25014f1067 fix(bilibili): add missing domain for following cli (#947) 2026-04-11 12:36:41 +08:00
jakevin 63b7b291ab fix: clean up stale .ts adapter files during upgrade (#948)
Older versions (pre-1.7.1) shipped adapters as .ts files. When users
upgrade to a .js-only version, the old .ts files are left orphaned in
~/.opencli/clis/. Add a cleanup step that removes .ts files when a
corresponding .js official adapter exists.
2026-04-11 12:36:02 +08:00
jakevin 4a0b8054b2 fix: batch quality improvements — dedupe completion, unify logging, fix docs (#945)
* fix: batch quality improvements — dedupe completion, unify logging, fix docs

1. Extract shared completion code (BUILTIN_COMMANDS + shell scripts) into
   completion-shared.ts, eliminating duplication between completion.ts and
   completion-fast.ts.

2. Replace console.error/warn/log with log.* from logger.ts in:
   - daemon.ts (7 occurrences)
   - runtime.ts (1 occurrence)
   - cli.ts browserAction error handler (3 occurrences)
   - base-page.ts snapshot fallback (1 occurrence)
   - download/index.ts cookie warning (1 occurrence)
   - commands/daemon.ts (2 occurrences)

3. Fix Node version in build-extension.yml: 20 → 22 (matches package.json >=21)

4. Fix error handling consistency: tap.ts now throws CliError instead of bare Error

5. Remove 31 duplicate rows in docs/adapters/index.md (grok, gemini, yuanbao,
   notebooklm, doubao, weread + 25 more entries duplicated without .md suffix)

6. Update skill version: opencli-usage SKILL.md 1.6.9 → 1.7.0, adapter count 79 → 87

* fix: update daemon.test.ts to match logger migration

Tests now spy on process.stderr.write (used by log.*) instead of
console.log/console.error (no longer used by daemonStop).

* fix: address review feedback on PR #945

1. base-page.ts: restore DEBUG_SNAPSHOT env guard — log.debug uses a
   different env var (DEBUG=opencli), so keep the original gate to
   avoid breaking existing users.

2. daemon.ts: remove dead `prefix` variable left over from console.error
   migration.
2026-04-11 01:45:00 +08:00
jakevin 575986c656 perf: P0 performance optimizations (#944)
* perf: P0 performance optimizations — VM context reuse, startup parallelization, stealth caching

1. Reuse VM sandbox context in pipeline template engine instead of creating
   a new vm.createContext() on every expression evaluation. This eliminates
   ~0.3ms per call in map/filter loops over large arrays.

2. Cache sanitizeContext() results via WeakMap keyed by object reference.
   In pipeline loops, `args` and `data` are the same object across all
   iterations — the expensive JSON round-trip now runs only once per step.

3. Parallelize independent startup I/O: built-in CLI discovery now runs
   concurrently with ensureUserCliCompatShims and ensureUserAdapters,
   saving ~30-50ms on cold start.

4. Cache the stealth JS string (350 lines, pure static) after first
   generation — every subsequent goto() reuses the cached string.

* fix: address review feedback on P0 perf optimizations

1. sanitizeContext: cache JSON string instead of parsed object to prevent
   sandbox mutation from polluting subsequent calls
2. VM sandbox: clean non-whitelisted properties before each execution to
   prevent cross-expression state leakage
3. Startup parallelization: document registry overwrite semantics and
   confirm no shared-state race between parallel tasks
2026-04-11 01:31:34 +08:00
jakevin 110e047b3c refactor(validate): switch from YAML to registry-based validation (#943)
* refactor(validate): switch from YAML scanning to registry-based validation

The validate/verify commands only scanned YAML files, which are no
longer supported. Rewrite to validate commands from the in-memory
registry populated by discoverClis(), aligning with the JS-first
adapter architecture.

New checks: missing description, browser commands without domain,
pipeline step name typos, commands without func/pipeline, duplicate
arg names, and positional arg ordering.

* fix(validate): treat lazy-loaded commands as valid

Manifest-registered commands have _lazy=true and no func/pipeline
until execution time. Recognize this as a valid execution form.

* fix(validate): warn on empty registry, support alias targets

- Emit warning when registry is empty instead of silent PASS
- Resolve alias targets to canonical key before filtering
2026-04-11 01:17:14 +08:00
jakevin a9d21f3de0 fix: project hygiene — docs, lint, daemon restart (#942)
* fix: project hygiene — docs, lint, daemon restart, code fence

- Update Node version requirement from >= 20 to >= 21 in 7 doc files
  (README, README.zh-CN, installation guides, troubleshooting)
- Update adapter count from 79+ to 87+ in READMEs
- Remove duplicate `lint` script (identical to `typecheck`)
- Fix TESTING.md CI matrix: Node ['22'] instead of ['20', '22']
- Fix autofix SKILL.md code fence escaping (\``` → ~~~)
- Add daemon restart to postinstall so updated adapters are picked up
- Fix preuninstall to respect OPENCLI_DAEMON_PORT env var

* fix: align docs and skills with JS-first adapter contract

Adapters are now .js files (not .ts). Update all references across:
- README.md, README.zh-CN.md, CONTRIBUTING.md
- docs/guide/getting-started.md, docs/index.md
- skills/opencli-browser/SKILL.md, skills/opencli-explorer/SKILL.md

The runtime (discovery.ts) only loads .js from user clis/ directories,
and `opencli browser init` generates .js scaffolds. Documentation was
still teaching users to create .ts files.

* fix: update CI matrix to Node 22 only (drop Node 20)

package.json requires Node >= 21 (styleText dependency). The CI matrix
was still testing Node 20 which doesn't meet this requirement.

* fix: revert incorrect daemon restart from postinstall

The daemon (browser bridge) only handles CDP communication — it has no
knowledge of adapters. Adapter discovery, loading, and execution all
happen in the CLI process, which is fresh each invocation. The
_loadedModules cache in execution.ts is process-local and not a real
staleness concern. Remove the unnecessary restartDaemon() call.
2026-04-11 00:50:05 +08:00
jakevin 383d28fcf7 refactor: normalize strategy into runtime fields at registration time (#941)
Strategy is a 5-value enum (PUBLIC/COOKIE/HEADER/INTERCEPT/UI) that
the execution path was reading at two points — resolvePreNav() and
shouldUseBrowserSession() — to make decisions that are already fully
expressible by the existing `browser` and `navigateBefore` fields.

This commit introduces normalizeCommand() inside registerCommand(),
which expands strategy into concrete runtime fields at registration
time. After normalization, execution code never reads cmd.strategy.

normalizeCommand expansion rules:
  - strategy → browser: PUBLIC defaults to false, others to true.
    Explicit browser value always wins.
  - strategy + domain → navigateBefore:
    · COOKIE/HEADER + domain → 'https://{domain}' (pre-navigate)
    · Non-PUBLIC without domain → true (needs auth context, no URL)
    · PUBLIC → undefined (no auth needed)
    Explicit navigateBefore (false or string) always wins.

This matters because commands enter the registry from 4 sources
(cli(), manifest, generate-verified, tests), and previously only
cli() did strategy derivation. The other 3 constructed CliCommand
directly, leaving strategy as a runtime dependency. Now all sources
converge through registerCommand → normalizeCommand.

Changes:
  - registry.ts: add normalizeCommand(); simplify cli() to delegate
    all derivation to normalizeCommand via registerCommand()
  - execution.ts: resolvePreNav() no longer reads strategy; just
    reads the already-expanded navigateBefore field. Strategy import
    removed.
  - capabilityRouting.ts: shouldUseBrowserSession() checks
    cmd.navigateBefore (truthy = needs browser session) instead of
    cmd.strategy !== PUBLIC. Strategy import removed.
  - discovery.ts: manifest path no longer hardcodes browser default;
    delegates to normalizeCommand.
  - capabilityRouting.test.ts: test now reflects normalized command
    shape (navigateBefore: true for COOKIE without domain).

strategy is preserved as metadata on CliCommand — opencli list,
cascade probe, adapter generation, and documentation continue to
read it. Only the execution path stops consuming it.
2026-04-11 00:37:31 +08:00
jakevin f92f7571b4 chore: remove unused test-site.mjs script (#940)
Not referenced in package.json, CI, or documentation.
2026-04-11 00:19:02 +08:00
jakevin 93bd1eb88a docs: document autofix issue filing flow (#939) 2026-04-10 23:52:45 +08:00
jakevin 39a1d673ac feat(skill): add upstream issue filing step to opencli-autofix (#938)
Add Step 6 to the autofix skill: after a verified local fix, prepare a
GitHub issue draft and file it (with user confirmation) via `gh issue
create`. Pure skill/documentation approach — no new runtime code.

Closes the need addressed by #936 with zero code, zero tests to maintain.
2026-04-10 23:46:54 +08:00
jakevin cc18ed67b7 fix: sync package-lock.json to unblock CI (#937)
* fix: sync package-lock.json with package.json dependencies

package-lock.json was missing @emnapi/core@1.9.2 and
@emnapi/runtime@1.9.2 (transitive deps of @emnapi/wasi-threads),
causing `npm ci` to fail on all CI jobs.

* fix: resolve remaining CI failures after TS-to-JS adapter migration

- vitest.config.ts: update adapter project include/exclude from .test.ts
  to .test.{ts,js} to match converted adapter test files
- check-doc-coverage.sh: skip adapter directories containing only utility
  files (prefixed with _), fixing false positive for clis/slock/
- linux-do/topic-content.test.js: fix hardcoded reference to topic.ts
  (now topic.js after PR #928 migration)
2026-04-10 23:29:09 +08:00
jakevin 91c208c855 fix: address deep review findings (security, correctness, consistency) (#935)
* fix: address deep review findings (security, correctness, consistency)

1. Security: add path traversal guard for plugin manifest entry.path
2. Security: sanitize evaluate() index param via JSON.stringify
3. Correctness: fix startNetworkCapture idempotency (don't wipe entries on re-call)
4. Correctness: log pre-navigation failures instead of silently swallowing
5. Consistency: replace console.log/error with log module in commanderAdapter, external
6. Consistency: add PluginError class, convert user-facing plugin errors
7. Dedup: remove local isRecord() in plugin.ts, use shared utils.ts version
8. Clarify: document intentional double validateArgs call

* chore: remove unused chalk imports from external.ts and commanderAdapter.ts

* refactor: replace chalk with Node.js built-in util.styleText

- Remove chalk dependency, use `styleText` from `node:util` (stable in Node 21+)
- Bump engines to Node >= 21
- Update all 10 source files that used chalk
- Remove stale chalk mock from daemon.test.ts
- One fewer runtime dependency

* fix: tighten deep-review follow-up
2026-04-10 22:59:41 +08:00
jakevin 2288cf7149 fix: clean up legacy shim files and stale tmp files on upgrade (#934)
* fix: clean up legacy shim files and stale tmp files on upgrade

Add cleanup steps to fetch-adapters.js that run on every version upgrade:

1. Remove legacy compat shim files from ~/.opencli/ (registry.js,
   errors.js, utils.js, etc.) that were created by an older approach
   using file:// re-exports. Current approach uses node_modules symlink.
   Only deletes files containing "export * from 'file://" to avoid
   removing user-created files.

2. Remove legacy compat shim directories (browser/, download/, errors/,
   etc.) using the same safety check.

3. Clean up stale .plugins.lock.json.tmp-* files left behind by
   crashed processes. These accumulate over time (108 found on one
   machine) and clutter ~/.opencli/.

* fix: check every file in legacy shim directories before deleting

Instead of checking only the first file and deleting the entire
directory, now checks each file individually and only deletes files
matching the shim pattern. Directory is removed only if empty after
individual file cleanup.
2026-04-10 18:58:44 +08:00
jakevin 2457002167 chore: remove migration residuals (mapDistToSource, clean-yaml) (#931)
- Remove mapDistToSource() from diagnostic.ts — mapped dist/clis/
  paths back to clis/ but dist/clis/ no longer exists after JS-first
  migration. The function always returned null.
- Simplify resolveAdapterSourcePath() to check candidates directly
  without the dead dist→source mapping detour.
- Delete scripts/clean-yaml.cjs — walked dist/clis/ to delete YAML
  files, but dist/clis/ no longer exists.
- Remove clean-yaml script entry from package.json.
2026-04-10 16:41:44 +08:00
jakevin 714df646a5 fix(security): escape codegen strings and redact diagnostic body (#930)
1. candidateToJs: escape single quotes in site, name, domain, and arg
   name/type fields to prevent syntax errors in generated JS adapters.
   Previously only description and help fields were escaped.

2. diagnostic: pass network request body through redactText() to
   prevent sensitive data (JWT, bearer tokens) from leaking into
   repair context. responseBody/responsePreview already used
   sanitizeCapturedValue which calls redactText, but the body field
   only had truncation.
2026-04-10 15:29:32 +08:00
jakevin d2974a9ff6 refactor(adapters): convert adapter layer from TypeScript to JavaScript (#928)
* refactor(adapters): convert adapter layer from TypeScript to JavaScript

Core framework stays TypeScript; adapter layer moves to JS-first.
Adapters are essentially "executable config + browser scripts" that
barely use TS features — this simplifies the build/distribution pipeline
by removing the dist/clis/ intermediate compilation step.

Changes:
- Convert all 753 adapter files in clis/ from .ts to .js
- Update tsconfig to exclude clis/ from compilation
- Simplify build-manifest to scan clis/*.js directly (no dist/clis/)
- Update discovery, main, fetch-adapters to load JS adapters from clis/
- Update generate-verified to output .js artifacts
- Update package.json files field: dist/clis/ → clis/
- Fix all test files for the .ts → .js transition

* fix(main): use findPackageRoot for BUILTIN_CLIS path

The previous relative path (../../clis from __dirname) only worked for
dist/src/main.js but broke dev mode (tsx src/main.ts) where __dirname
is <repo>/src — resolving to /clis instead of <repo>/clis.

Use findPackageRoot() which works for both dev and prod paths.
2026-04-10 14:52:18 +08:00
jakevin b45a64d91d fix(build-manifest): import compiled JS from dist/clis/ instead of raw TS (#926)
* fix(build-manifest): import compiled JS from dist/clis/ instead of raw TS

Node's type stripping does not rewrite '.js' → '.ts' in import
specifiers, so dynamically importing .ts source files fails whenever
they contain relative imports like './utils.js'.

Switch to scanning dist/clis/ for compiled .js files after tsc runs.
This eliminates all 268 "Cannot find module" warnings and increases
manifest entries from 254 to 532 (previously half were silently skipped).

* fix: write manifest to dist/cli-manifest.json where runtime expects it

The runtime resolves BUILTIN_CLIS to dist/clis/ (relative to
dist/src/main.js), so discoverClis() looks for manifest at
dist/cli-manifest.json. Previously it was written to the package root
where the runtime never found it — manifest was effectively unused,
always falling through to filesystem scanning.
2026-04-10 12:58:34 +08:00
jakevin dbac7fc921 refactor(errors): unify error output as YAML envelope to stderr (#923)
* refactor(errors): unify error output as YAML envelope to stderr

Replace the 100+ line chalk renderError() switch-case with a single
YAML envelope output path. All errors now output a structured
{ok, error: {code, message, help, exitCode}} envelope to stderr,
regardless of TTY status.

This simplifies the error system from 5 mechanisms to 3:
1. Error Envelope (YAML → stderr) — unified error output
2. Exit codes (sysexits.h) — process exit semantics
3. Diagnostic (OPENCLI_DIAGNOSTIC=1) — autofix repair context

Removed: chalk error rendering, ERROR_ICONS map, classifyGenericError
regex classifier, BrowserConnectError-specific bridge status display.
Added: toEnvelope() utility, ErrorEnvelope type.

* refactor(errors): migrate adapters to throw CliError, update docs

- Migrate xueqiu adapters from return [{error,help}] to throw CliError
- xueqiu/utils.ts: fetchXueqiuJson now throws AuthRequiredError/
  CommandExecutionError instead of returning {error, help} objects
- Remove resolveColumns error fallback from output.ts (no longer needed)
- Add verbose stack trace support to error envelope
- Add ADAPTER_LOAD to AutoFix hint trigger codes
- Update skill docs (adapter-templates, explorer, oneshot, advanced-patterns)
  to recommend throw CliError pattern instead of return [{error, help}]

* fix: remove remaining dead error-forwarding in 4 xueqiu adapters + review fixes

- Remove `if ('error' in d) return [d]` from feed, hot, search, kline
  (fetchXueqiuJson now throws, so these were dead code)
- Add `stack?: string` to ErrorEnvelope interface (removes type cast hack)
- Fix adapter-templates.md: use AuthRequiredError instead of plain Error

* fix: migrate barchart/quote and yahoo-finance/quote to throw CliError

Last two adapters that silently returned [] on error instead of
throwing CommandExecutionError.

* fix: self-review fixes — doc evaluate crash, error messages, kline consistency

- adapter-templates.md: getServerContext was throwing AuthRequiredError
  inside a function serialized into page.evaluate() (browser has no
  CliError). Reverted to return {error} sentinel + func() body throw.
- yahoo-finance/quote, barchart/quote: include symbol in fallback error msg
- xueqiu/kline: throw EmptyResultError instead of returning [] for
  consistency with other xueqiu adapters
2026-04-10 03:20:53 +08:00
jakevin 309dadcf46 refactor(adapters): migrate pipeline adapters to func() + { error, help } pattern; docs: skill improvements (#922)
* docs(skills): add Tier 2.5 localStorage Bearer, SPA discovery, and test standards

From real-world experience building slock.ai CLI adapters:

- oneshot: add network-empty diagnosis, SPA baseURL bundle search, Tier 2.5
  localStorage Bearer template (with multi-tenant X-Server-Id pattern),
  updated auth quick-reference, file path note, opencli browser verify test flow
- explorer: add Tier 2.5 to decision tree and strategy table, update test section
  with opencli browser verify + Done standard, fix Step 5 path to ~/.opencli/clis/,
  add 4 new pitfall rows (SPA HTML, 400 context header, empty network, wrong dir)

* docs(skills): fix path conflict + add anti-change patterns from real adapters

Fix reviewer blocking issue:
- Remove the contradictory "~/.opencli/clis/" note that mixed user-local and
  repo-contributor workflows; replace with explicit two-scenario callout in
  Step 4, Step 5, pitfall table, and oneshot test section
- Template comments in oneshot restored to clis/<site>/<name>.ts (repo path)

Add "抗变更模式" section to explorer, based on opencli's own production code:
- Pattern 1: dynamic queryId discovery (twitter/shared.ts resolveTwitterQueryId)
  — scan loaded JS bundle by operationName (stable) to find queryId (unstable)
- Pattern 2: semantic DOM priority fallback (web/read.ts)
  — article > [role=main] > main > class-hint > body, pick largest text block
- Pattern 3: ordered selector array + timestamp comments (xiaohongshu/publish.ts)
  — first-match wins, comment records UI version and observed attribute values
- Pattern 4: nullish-coalescing field multi-path (xiaohongshu/user-helpers.ts)
  — covers camelCase/snake_case variants without assuming fixed key name

* docs(explorer): split SKILL.md into reference sub-documents

- Shrink main SKILL.md from 994 to 270 lines — core workflow only
- Extract all TS templates (Tier 1~4, pagination) to references/adapter-templates.md
- Add error handling standard: { error, remedy } pattern (remedy > hint)
- Add Tier 2.5 localStorage Bearer template with multi-tenant X-Server-Id example
- Extract cascading requests, tap debug, verbose mode, anti-change patterns to references/advanced-patterns.md
- Extract record workflow to references/record-workflow.md

* docs(skills): fix verify command — split by dev scenario

browser verify only reads ~/.opencli/clis/, not repo's clis/.
Split all verify instructions:
- Repo 贡献: npm run build + opencli <site> <cmd>
- 私人 adapter: opencli browser verify <site>/<name>

Fixes blocker in explorer:L209, L224 and oneshot:L286, L298

* docs(adapter-templates): add utils.ts extraction pattern for same-site adapters

* docs(skills): add decision matrix, stop conditions, sync comments

explorer: add path decision matrix before core workflow
oneshot: add explicit stop/switch conditions (when to escalate to explorer)
both: add keep-in-sync comment on the two-scenario verify block

* feat(slock): extract utils.ts + apply { error, help } pattern; docs: remedy→help

slock/utils.ts: new — getSlockContext(), resolveChannelId()
  - Shared token + workspace resolution, no more 4-line duplication
  - UUID regex (/^[0-9a-f]{8}-...$/) replaces fragile !includes('-')
  - Returns { error, help } instead of throwing

tasks.ts / members.ts / send.ts:
  - Import from utils.ts, remove all duplicated auth boilerplate
  - All errors return [{ error, help }], no more throw
  - members.ts: add limit arg (was unbounded before)

docs: rename remedy → help across all skill references

* refactor(adapters): migrate pipeline adapters to func() with { error, help } pattern

- slock: agents, channels, messages, servers now use getSlockContext/resolveChannelId
  from utils.ts; error handling uses { error, help } return instead of bare throws
- linux-do: export fetchLinuxDoJson from feed.ts; migrate search, topic, categories,
  tags, user-posts, user-topics from pipeline+throw to func() using fetchLinuxDoJson
- xueqiu: add utils.ts with fetchXueqiuJson helper; migrate hot, feed, search, stock,
  watchlist, hot-stock, groups, kline, earnings-date from pipeline+throw to func()

* fix(output): show error rows in table/csv/markdown when columns declared

When a command declares columns (e.g. ['rank', 'title', 'value']) but
returns an error row ({ error, help }), the declared columns would
render empty cells. Now resolveColumns detects the error key and falls
back to the row's actual keys, making diagnostics visible in all output
formats.

* chore: remove slock adapters from this PR

Slock adapters should be in a separate PR, not bundled with the
adapter refactor and skill docs improvements.
2026-04-10 02:29:35 +08:00
jakevin 56f371fbad docs(skills): improve oneshot & explorer with real-world SaaS patterns (#921)
* docs(skills): add Tier 2.5 localStorage Bearer, SPA discovery, and test standards

From real-world experience building slock.ai CLI adapters:

- oneshot: add network-empty diagnosis, SPA baseURL bundle search, Tier 2.5
  localStorage Bearer template (with multi-tenant X-Server-Id pattern),
  updated auth quick-reference, file path note, opencli browser verify test flow
- explorer: add Tier 2.5 to decision tree and strategy table, update test section
  with opencli browser verify + Done standard, fix Step 5 path to ~/.opencli/clis/,
  add 4 new pitfall rows (SPA HTML, 400 context header, empty network, wrong dir)

* docs(skills): fix path conflict + add anti-change patterns from real adapters

Fix reviewer blocking issue:
- Remove the contradictory "~/.opencli/clis/" note that mixed user-local and
  repo-contributor workflows; replace with explicit two-scenario callout in
  Step 4, Step 5, pitfall table, and oneshot test section
- Template comments in oneshot restored to clis/<site>/<name>.ts (repo path)

Add "抗变更模式" section to explorer, based on opencli's own production code:
- Pattern 1: dynamic queryId discovery (twitter/shared.ts resolveTwitterQueryId)
  — scan loaded JS bundle by operationName (stable) to find queryId (unstable)
- Pattern 2: semantic DOM priority fallback (web/read.ts)
  — article > [role=main] > main > class-hint > body, pick largest text block
- Pattern 3: ordered selector array + timestamp comments (xiaohongshu/publish.ts)
  — first-match wins, comment records UI version and observed attribute values
- Pattern 4: nullish-coalescing field multi-path (xiaohongshu/user-helpers.ts)
  — covers camelCase/snake_case variants without assuming fixed key name

* docs(explorer): split SKILL.md into reference sub-documents

- Shrink main SKILL.md from 994 to 270 lines — core workflow only
- Extract all TS templates (Tier 1~4, pagination) to references/adapter-templates.md
- Add error handling standard: { error, remedy } pattern (remedy > hint)
- Add Tier 2.5 localStorage Bearer template with multi-tenant X-Server-Id example
- Extract cascading requests, tap debug, verbose mode, anti-change patterns to references/advanced-patterns.md
- Extract record workflow to references/record-workflow.md

* docs(skills): fix verify command — split by dev scenario

browser verify only reads ~/.opencli/clis/, not repo's clis/.
Split all verify instructions:
- Repo 贡献: npm run build + opencli <site> <cmd>
- 私人 adapter: opencli browser verify <site>/<name>

Fixes blocker in explorer:L209, L224 and oneshot:L286, L298

* docs(adapter-templates): add utils.ts extraction pattern for same-site adapters

* docs(skills): add decision matrix, stop conditions, sync comments

explorer: add path decision matrix before core workflow
oneshot: add explicit stop/switch conditions (when to escalate to explorer)
both: add keep-in-sync comment on the two-scenario verify block
2026-04-10 01:47:37 +08:00
jakevin f6f7f04ff6 docs(skills): unify browser tool names to opencli browser commands (#920)
* docs(skills): unify browser tool names to `opencli browser` commands

Replace abstract MCP tool names (browser_navigate, browser_snapshot,
browser_network_requests, browser_click, browser_evaluate) with
concrete `opencli browser` CLI commands in explorer and oneshot skills.

This aligns all three browser-related skills into a clear hierarchy:
- opencli-browser: atomic command reference
- opencli-oneshot: 4-step quick generation workflow
- opencli-explorer: full site exploration workflow

* docs(skills): address review — demote explore, fix eval placeholder

1. Demote `opencli explore` from "recommended" to "supplementary helper"
   and make `opencli browser` the explicit primary path for API discovery.
2. Fix `url` undefined variable in eval example — use `<API URL>` placeholder.
2026-04-10 00:39:07 +08:00
jakevin 43f87d2ede fix: restore cross-platform entries in package-lock.json (#919)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
The lockfile generated on Node 25/darwin dropped optional+peer deps
(@emnapi/core, @emnapi/runtime) needed by CI on linux/x64, causing
npm ci to fail.
2026-04-09 23:07:31 +08:00
jakevin b87bbc7107 chore: bump version to 1.7.0 (#917)
Bump CLI, extension package.json, and extension manifest.json to 1.7.0.
Update package-lock.json.
2026-04-09 21:52:40 +08:00
jakevin da86659566 fix(jianyu): avoid early api bucket cutoff (#916) 2026-04-09 21:39:03 +08:00
GanFanNewOrder 606bc59f7b fix(jianyu): stabilize search and add detail extraction contract (#912)
* fix(jianyu): stabilize search and add detail extraction contract

* fix(jianyu): require query evidence for search results

---------

Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-09 21:28:20 +08:00
jakevin 2ddf571445 feat: auto-close adapter windows, add OPENCLI_WINDOW_FOCUSED, document config (#915)
* feat: auto-close adapter windows, add OPENCLI_WINDOW_FOCUSED, document config

1. Adapter commands now close the automation window immediately after
   completion instead of waiting for the 30s idle timeout.

2. OPENCLI_WINDOW_FOCUSED=1 opens automation windows in the foreground
   (useful for debugging). Default remains background.

3. Add Configuration section to README (EN/ZH) and opencli-usage skill
   listing all stable user-facing environment variables.

* Fix OPENCLI_WINDOW_FOCUSED to be per-request, not frozen at daemon startup

Move env var read from daemon (startup-time constant) to CLI side
(sendCommandRaw), so it works correctly with the persistent daemon model.
Each request now reads the env var fresh and includes windowFocused in
the command payload.
2026-04-09 21:27:02 +08:00
Clearner1 7f31df2912 fix(xiaoe): resolve missing episodes for long courses via auto-scroll (#904)
* fix(xiaoe): resolve missing episodes for long courses by handling lazy load

* fix(xiaoe): keep lazy-load scroll until inner list stabilizes

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-09 21:21:29 +08:00
jakevin b0c9966774 Remove daemon status/restart references from docs and READMEs (#914)
These commands were removed in the persistent daemon refactor.
Only `daemon stop` remains as a user-facing command.
2026-04-09 20:47:07 +08:00
jakevin 5f59f9b563 refactor: make daemon persistent, remove idle timeout (#913)
* refactor: make daemon persistent, remove idle timeout

- Remove IdleManager and 4-hour idle auto-exit
- Daemon now stays alive until explicit shutdown or uninstall
- Add preuninstall hook for best-effort daemon cleanup on npm uninstall
- Update docs to reflect persistent daemon model

* fix: remove stale idle timeout references from code and docs

* refactor: remove daemon status/restart commands and lastCliRequestTime

- Remove `daemon status` and `daemon restart` CLI commands (doctor covers diagnostics)
- Remove `lastCliRequestTime` tracking (no longer needed without idle timeout)
- Keep only `daemon stop` as the explicit shutdown command

* Add AbortSignal.timeout(3s) to preuninstall shutdown fetch

Prevents npm uninstall from hanging if the daemon port accepts
connections but never responds.
2026-04-09 20:37:04 +08:00
jakevin 45001ec025 chore: bump extension version to 1.6.10 (#911)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-09 20:02:55 +08:00
jakevin 2ad95fca06 chore: bump version to 1.6.10 (#910) 2026-04-09 19:36:26 +08:00
jakevin bbd1163a01 refactor: unify browser error classification and deduplicate retry logic (#908)
* refactor: unify browser error classification and deduplicate retry logic

Replace two overlapping error classification systems with a single
classifyBrowserError() that returns retry advice (retryable + delayMs):

- Extension/daemon transient errors → retryable, 1500ms delay
- CDP target navigation errors → retryable, 200ms delay
- Non-transient errors → not retryable

Deduplicate sendCommand/sendCommandFull retry loop into sendCommandRaw,
making both public functions thin return-value wrappers.

* fix: add error kind to prevent page-level retry of extension errors

classifyBrowserError() now returns a `kind` field:
- extension-transient: retried by daemon-client only
- target-navigation: retried by page-level settle logic
- non-retryable: no retry

Page.goto() and Page.evaluate() now only settle-retry on
target-navigation, preventing extension/daemon errors from being
silently swallowed as settle noise.
2026-04-09 18:10:15 +08:00
jakevin 611b640458 docs: mention refreshing packaged skills on update (#902) 2026-04-09 12:36:52 +08:00
jakevin 555626f409 feat: replace tabId with targetId as cross-layer page identity (#899)
Use Chrome CDP targetId (UUID) as the canonical page identity across
all layers (extension → daemon → CLI), demoting tabId to an
extension-internal routing detail.

- Add extension/src/identity.ts: bidirectional targetId ↔ tabId mapping
  with lazy refresh via chrome.debugger.getTargets()
- Update protocol: Command.page and Result.page carry targetId
- Update background.ts: resolveCommandTabId() and pageScopedResult()
  helpers; all page-scoped handlers return targetId
- Add sendCommandFull() to daemon-client for responses with page identity
- Update Page class: _page stores targetId, goto/selectTab extract it
- Update record.ts: injectedPages tracks by targetId
- Add extension tests to vitest config and CI test scripts
2026-04-09 12:26:32 +08:00
luka2chat fb09f8565d docs: fix desktop adapter commands to match actual CLI output (#900)
Synced all desktop adapter command lists in desktop.md with
the actual `opencli <adapter> --help` output:

- cursor: remove non-existent status/new/dump/screenshot; add composer
- codex: remove non-existent status/new/dump/screenshot
- chatgpt: add missing model command
- chatwise: remove non-existent new/screenshot
- notion: update descriptions to match help text
- discord-app: update descriptions to match help text
- doubao-app: reorder to match help output
- antigravity: remove non-existent ask; add serve/status

Also moved `status` to the top of each adapter section where it exists.
2026-04-09 12:20:26 +08:00
AstroHan caebede7c2 fix: repair baseline main ci checks (#901) 2026-04-09 12:19:44 +08:00
Mu b09749576c feat(jd,taobao,cnki): revive shopping adapters on current layout (#248)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-09 02:44:46 +08:00
jakevin 93a650bcfe perf: fast-path completion/version/shell-scripts to bypass full discovery (#898)
* perf: fast-path completion, version, and shell scripts to bypass full discovery

Lightweight commands (--get-completions, --version, completion <shell>) now
resolve before any heavy module loading. Key changes:

- New completion-fast.ts: manifest-based completion + shell script generators
  with zero dependency on registry/discovery/cli modules
- main.ts: static imports replaced with dynamic import() for the full startup
  path so the fast path never pays the cost of loading discovery, registry,
  Commander, hooks, etc.
- USER_CLIS_DIR inlined to avoid importing the entire discovery module
- completion.ts: removed manifest functions (moved to completion-fast.ts),
  now only used as fallback when manifest is unavailable

* fix: address review blockers from codex-mini0

1. --version fast path: only match when argv[0] is --version/-V,
   not anywhere in argv. Prevents intercepting `opencli gh --version`
   which should pass through to the subcommand.

2. Completion fast path: require ALL manifests to exist (hasAllManifests),
   not just one. If user clis dir exists but has no manifest, fall back
   to full discovery so user adapters aren't silently dropped.
   If user clis dir doesn't exist at all, skip its manifest requirement
   since there are no user adapters to miss.
2026-04-09 02:20:14 +08:00
Luke 3cc60273a8 feat: add jimeng workspaces list command (#897)
Co-authored-by: root <root@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:10:43 +08:00
ziiiiiwang 2883620ba2 Feat : add Gitee adapters and docs (#845)
* feat: add gitee adapters and docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: compact gitee trending table output

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: add gitee adapter documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(gitee): avoid faking user index values

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-09 01:29:48 +08:00
Elias@Didoo 97d152ef39 fix: retry on No window with id CDP error (#892)
* fix: retry on No window with id CDP error

* test(browser): lock transient window-id retry behavior

---------

Co-authored-by: Yun Xiao <yunxiao@agents.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-09 01:23:22 +08:00
Luke 0b11a1ed62 feat(jimeng): add workspace create command (#895)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-09 01:08:01 +08:00
jakevin a98cc8bdd3 docs(autofix): add "'Empty' ≠ 'Broken'" pre-check before repair loop (#896)
EMPTY_RESULT and structurally-valid SELECTOR failures are often not
adapter bugs — they're the platform shaping results under anti-scrape,
or a soft 404, or a legitimately empty search. Patching a working
adapter to chase a zero-result query breaks the next working path.

Add a pre-check section at the top of opencli-autofix listing four
rule-outs that must fail before a repair round is justified:

  1. Retry with an alternative query / entry point
  2. Spot-check the page in a normal Chrome tab
  3. Look for soft 404s (200 with empty payload)
  4. Remember that "0 results" from a search is a valid answer

Placed directly before "Step 1: Collect Diagnostic Context" so the
check runs at exactly the moment the agent would otherwise commit to
a repair round.

Audience/timing is the whole point: the skill is loaded precisely when
an error has occurred and the agent is deciding whether to repair, and
the pre-check intercepts that decision before it locks in.

11 lines of markdown, zero code, single file.

---

Inspired by https://github.com/eze-is/web-access by 一泽 Eze (MIT),
specifically the "平台返回的'内容不存在'不一定反映真实状态" mental model
from its SKILL.md. Adapted into concrete, actionable checks for
opencli's EMPTY_RESULT classification.

Note: an earlier version of this PR also added a tool-selection
decision table and a subagent-verb rule to opencli-usage. Both were
removed after review because opencli-usage only loads *after* an agent
has committed to using opencli — advice placed there arrives too late
to change tool selection, and is not seen by the main agent at
delegation time. The insights are still valid; they just don't have a
load-time match in the current skill system. This PR keeps only the
change where audience and timing actually line up.
2026-04-09 01:05:38 +08:00
jakevin aa83fb71b2 refactor(skills): unify command reference by site instead of technology (#894)
* refactor(skills): unify command reference by site instead of technology

- Merge Browser-based and Public API sections into single alphabetical
  table with type emoji tags (🌐//🖥️/🔧)
- Delete browser.md and public-api.md (replaced by unified SKILL.md table)
- Add GitHub/DevOps and collaboration rows to capability lookup
- Remove stale File column from capability table

* feat(skills): add commands.md with merged examples + 8 missing adapters

- Create commands.md: merge browser.md + public-api.md into single
  alphabetical-by-site reference with detailed usage examples
- Add 8 missing adapters: 1688, hupu, jianyu, lesswrong, quark,
  xianyu, xiaoe, yuanbao
- Bump skill version 1.6.3 → 1.6.9 to match package.json
2026-04-09 00:32:30 +08:00
jakevin 6b51a41372 feat(skills): add External CLI section to opencli-usage (#893)
- Add dedicated External CLI section listing all 7 registered CLIs
  (gh, obsidian, docker, lark-cli, dws, wecom-cli, vercel)
- Include install/register commands so AI agents know how to manage them
- Move gh from Desktop to External CLI section
- Update desktop.md to remove gh and reference External CLI section
2026-04-08 23:49:15 +08:00
jakevin 90722be08e refactor(skills): merge opencli-generate into opencli-explorer (#891)
* refactor(skills): merge opencli-generate into opencli-explorer

opencli-generate was a thin wrapper over generateVerifiedFromUrl,
essentially an internal pipeline orchestration. Merge its entry point
into opencli-explorer as the automated fast path, keeping one unified
skill for adapter creation.

- Delete skills/opencli-generate/SKILL.md
- Add automated generation tip to opencli-explorer SKILL.md
- Update README/README.zh-CN skill references
- Update skill-generate.ts comment

* fix(docs): fix dead link in yaml-adapter deprecation page

Change ../../CONTRIBUTING.md to ./contributing (VitePress internal link).
2026-04-08 23:38:16 +08:00
jakevin 810547c9a9 refactor: eliminate any types in core (non-clis) files (#886)
Replace explicit `any` with `unknown` + narrowing or concrete types across
all core src/ files (non-`clis/**`). Core drops from ~60 `any` occurrences
to a handful of documented, unavoidable boundaries.

Mechanical error-handler cleanup (uses getErrorMessage() from errors.ts):
  - cli.ts, cascade.ts, download/*.ts, external.ts, plugin.ts, doctor.ts
    — catch (err: any) → catch (err) + getErrorMessage(err)

Pipeline steps — typed params with per-step interfaces:
  - pipeline/steps/intercept.ts — InterceptParams, signature uses unknown
  - pipeline/steps/tap.ts       — TapParams
  - pipeline/steps/download.ts  — DownloadParams + DownloadedItem
    (ytdlp_args is now coerced via String(v) for defence-in-depth)

Probe / boundary typing:
  - cascade.ts — FetchProbeResponse interface; also fixes a latent bug
    where result.success could be assigned undefined (masked by any)
    by wrapping with !!(…)

Browser-side injected scripts — structural types at the TS boundary
(types stripped by tsc emit before .toString() runs, runtime unchanged):
  - scripts/store.ts     — PiniaStore / VuexModule / VueApp
  - scripts/framework.ts — VueAppEl / FrameworkWindow

Runtime detection:
  - runtime-detect.ts — BunGlobal interface; getRuntimeVersion reads Bun
    into a local to avoid non-null assertions.

Test files — precise structural casts replacing `as any`:
  - browser.test.ts             — `{ _state: string }` cast for private
                                   state; full DaemonStatus shape for
                                   the getDaemonHealth mock
  - browser/dom-helpers.test.ts — globalThis as Record<string, unknown>
  - browser/cdp.test.ts         — (...args: unknown[]) in mock handlers
  - runtime-detect.test.ts      — matches runtime-detect.ts BunGlobal
  - output.test.ts              — logSpy.mock.calls typed with unknown[]
  - engine.test.ts, snapshotFormatter.test.ts, pipeline/executor.test.ts
    — narrow structural casts / removed stale any casts

Verification:
  - npx tsc --noEmit: clean
  - npx vitest run (excluding e2e/smoke): 1415 passed, 1 skipped
2026-04-08 23:08:23 +08:00
jakevin 70b1145b5e refactor: migrate all CLI adapters from YAML to TypeScript (#887)
* refactor: remove version field from GenerateOutcome and EarlyHint

All consumers are in the same repo and evolve together — version field
adds ceremony without practical value at this stage.

Keeps schema_version in VerifiedArtifactMetadata (sidecar file format).

* refactor: migrate all 123 CLI adapters from YAML to TypeScript

Remove YAML as an adapter format entirely. All adapters now use
TypeScript with cli() from @jackwener/opencli/registry.

- Convert 123 YAML adapter files to TypeScript via batch script
- Remove YAML scanning from discovery.ts (registerYamlCli, yaml import)
- Remove scanYaml() and shouldReplaceManifestEntry() from build-manifest.ts
- Change synthesize.ts to output JSON candidates (internal format)
- Change generate-verified.ts to write .ts adapter files instead of .yaml
- Delete yaml-schema.ts (dead code) and scripts/yaml-to-ts.mjs (one-time tool)
- Update all tests to match new format

Closes discussion in #OpenCLI thread 47ddba82.

* fix: close YAML migration gaps in plugin scaffold, validation, and scan

- plugin-scaffold.ts: generate hello.ts (TS pipeline) instead of hello.yaml
- plugin.ts validatePluginStructure: no longer accept .yaml as valid command file
- plugin.ts scanPluginCommands: remove .yaml/.yml from scanned extensions
- discovery.ts: add explicit log.warn() when YAML files detected in clis/ or plugins/
- plugin.test.ts: update all test fixtures from .yaml to .js
- plugin-scaffold.test.ts: update hello.yaml references to hello.ts
- Delete dead src/yaml-schema.ts

Resolves PR #887 review blockers from @mbp-codex-pr0.

* refactor: complete YAML removal across docs, skills, record, and binance adapters

Code changes:
- record.ts: candidate output changed from .yaml (yaml.dump) to .json (JSON.stringify), removed js-yaml import
- src/clis/binance: convert all 11 YAML adapters to TypeScript cli() format
- binance/commands.test.ts: rewrite to use registry instead of yaml.load
- skill-generate.test.ts, diagnostic.test.ts: update mock paths from .yaml to .ts
- build-manifest.ts, synthesize.ts: update stale YAML comments

Documentation:
- README.md: remove .yaml from Dynamic Loader, fix plugin types, fix synthesize comment
- README.zh-CN.md: fix synthesize comment
- CONTRIBUTING.md: replace YAML Adapter section with Pipeline Adapter (TS), update arg examples
- docs/developer/yaml-adapter.md: replaced with deprecation redirect
- docs/developer/architecture.md: remove YAML pipeline references
- docs/developer/contributing.md: remove YAML adapter section
- docs/developer/ai-workflow.md: YAML → TS in synthesize description
- docs/guide/getting-started.md: remove .yaml from loader, update engine description
- docs/guide/plugins.md: remove YAML plugin option, update plugin types
- docs/index.md, docs/comparison.md: remove YAML adapter references
- docs/zh/guide/plugins.md: remove .yaml from scan description

Skills:
- opencli-explorer/SKILL.md: rewrite YAML vs TS decision tree to TS-only
- opencli-oneshot/SKILL.md: replace YAML templates with TS cli() templates
- opencli-generate/SKILL.md: YAML artifact path → TS artifact path
- opencli-usage/SKILL.md, plugins.md: update adapter format references

* fix: clean up remaining YAML adapter references in docs

- docs/zh/guide/plugins.md: replace YAML plugin example with TS pipeline
- docs/developer/testing.md: YAML Adapter heading → Adapter, remove validate line
- TESTING.md: same fix in root testing doc
- CONTRIBUTING.md: remove "YAML validation" comment
- docs/.vitepress/config.mts: mark YAML Adapter Guide as (Deprecated) in nav
- docs/advanced/download.md: remove "YAML Adapters" from pipeline step heading
2026-04-08 23:01:08 +08:00
jakevin f2de4ad63b docs: restructure README narrative (#885)
* docs: restructure readme narrative

* docs: clarify generate and agent entry points
2026-04-08 21:34:02 +08:00
jakevin ad9cce34d7 refactor: remove version field from GenerateOutcome and EarlyHint (#884)
All consumers are in the same repo and evolve together — version field
adds ceremony without practical value at this stage.

Keeps schema_version in VerifiedArtifactMetadata (sidecar file format).
2026-04-08 21:16:46 +08:00
jakevin 1662e9a73c refactor: rename operate to browser (#883)
* refactor: rename operate to browser

* fix: preserve browser rename compatibility

* fix: bump generate outcome schema version

* fix: keep generate outcome schema at v1
2026-04-08 21:03:57 +08:00
jakevin 991c8ce944 feat: P2 EarlyHint callback channel for cost gating (#882)
* fix: use Strategy.PUBLIC enum in skill-generate test to fix typecheck regression

* feat: add P2 EarlyHint callback channel to generateVerifiedFromUrl

Add optional onEarlyHint callback for internal cost gating before verify stage.

- EarlyHint type: version, stage, continue, reason, confidence, candidate?
- 3 emit points: explore (viable/not), synthesize (candidate/not), cascade (auth/ok)
- candidate only on synthesize/cascade + continue:true (not on stop or explore)
- unsupported-required-args goes directly to P1 terminal, no P2 hint emitted
- 6 new tests covering all hint paths + guardrails
2026-04-08 19:51:05 +08:00
jakevin 9365afc05f fix: use Strategy.PUBLIC enum in skill-generate test (#881) 2026-04-08 19:36:05 +08:00
jakevin 7ad8c1c42a feat: opencli-generate skill spec + thin wrapper (#880)
* docs: add opencli-generate skill spec (SKILL.md)

Captures A+B consensus from team discussion:
- Input: url + goal? (natural language intent hint)
- Output: SkillOutput with machine-readable fields + human message
- Decision tree: thin mapping from GenerateOutcome
- Guardrails: no re-orchestration, no auto-escalation, no new taxonomy
- P1/P2 boundary: P1 is single source of truth, P2 transparent to skill

* fix: address review nits on skill spec

- Make path explicitly optional in needs-human-check decision tree
- Add missing non-array-result message template

* feat: add GenerateOutcome → SkillOutput thin wrapper

Implements the skill mapping layer per opencli-generate SKILL.md:
- mapOutcomeToSkillOutput: thin translation from P1 contract to agent-facing output
- executeGenerateSkill: entry point accepting SkillInput (url + goal?)
- Message templates for all StopReason and EscalationReason values
- 8 tests covering all outcome paths and contract shape validation

* fix: prefer outcome.message for richer context in needs-human-check

When GenerateOutcome has a message (e.g. "required args: id"), use it
instead of the generic template, so the specific args info reaches the user.
2026-04-08 19:28:16 +08:00
VegetableDog cb34aa1009 fix(docs): add missing .md extension to adapter index links (#874)
All 68 browser adapter links and 8 desktop adapter links in
docs/adapters/index.md were missing the .md file extension,
causing broken links when navigating the documentation on GitHub.
2026-04-08 19:25:43 +08:00
jakevin 05e1b939a5 test: make verified path assertions cross-platform (#879) 2026-04-08 19:09:12 +08:00
jakevin 6ef55e85e9 chore: release v1.6.9 (#875) 2026-04-08 19:06:32 +08:00
jakevin bc82450311 feat: verified generate pipeline with structured contract (#878)
* feat: add verified generate pipeline

* Refine verified generate v1 flow

* Tighten verified generate v1 contract

* upgrade GenerateOutcome contract: structured taxonomy + sidecar metadata

Contract changes per team consensus (5 design principles):

1. Rename BlockReason taxonomy by skill decision needs:
   - no-api-discovered → no-viable-api-surface
   - auth-required → auth-too-complex
   - browser-unavailable → execution-environment-unavailable

2. Add stage + confidence to all blocked outcomes so skill knows
   where it stopped and how sure the system is.

3. Replace flat candidate/issue in needs-human-check with structured
   EscalationContext: stage, reason, confidence, suggested_action,
   candidate with explicit reusable + reusability_reason.

4. Add sidecar metadata (.meta.json) for verified artifacts —
   separates product/provenance contract from executable YAML.

5. Export shared decision language types (Stage, Confidence,
   StopReason, EscalationReason, SuggestedAction, ReusabilityKind)
   for future early-hint contract consistency.

* fix: make reusability contract explicit and self-consistent

Addresses @First-principles-0 review:

1. Add reusable + reusability_reason to VerifiedAdapter so success
   outcome is self-contained — skill doesn't need to read sidecar
   metadata or assume success implies reusable.

2. Rename 'candidate-yaml' → 'unverified-candidate' to resolve
   semantic clash with reusable: false. Now the pairing is always
   consistent:
   - reusable: true  + verified-artifact (success)
   - reusable: true  + unverified-candidate (candidate usable with manual args)
   - reusable: false + unverified-candidate (verify failed, candidate exists)
   - reusable: false + not-reusable (nothing worth keeping)

* refactor: merge reusable + reusability_reason into single reusability enum

Removes dual-truth contract (boolean + string) in favor of a single
Reusability enum ('verified-artifact' | 'unverified-candidate' | 'not-reusable').

- VerifiedAdapter.reusability replaces .reusable + .reusability_reason
- EscalationContext.candidate.reusability replaces .reusable + .reusability_reason
- Top-level GenerateOutcome.reusability present on all success and needs-human-check outcomes
- Sidecar metadata (.meta.json) retains reusable + reusability_reason for external compat
- Updated all 7 tests to assert single reusability field
2026-04-08 18:57:31 +08:00
jakevin 276ab8b8bf chore: bump version to 1.6.9 (#876)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-08 16:03:35 +08:00
jakevin 85d73b5b83 refactor(doctor/status): unify daemon health checks into getDaemonHealth() (#873)
- Add getDaemonHealth() returning 'stopped' | 'no-extension' | 'ready'
- Delete discover.ts (thin wrapper with no value)
- Bridge uses getDaemonHealth() + _pollUntilReady() (eliminates duplicate polling)
- Doctor simplified: live check auto-starts daemon; no-live mode does minimal
  auto-start only when stopped (avoids misreporting idle-exit as failure)
- CommanderAdapter preserves error message/hint detail (not just generic title)
- All callers use single unified status entry point
2026-04-08 15:53:40 +08:00
AstroHan 79fb0822fc fix(test): repair binance pipeline imports (#870) 2026-04-08 14:30:35 +08:00
Yohan fd116fe4bc fix(xiaohongshu): scope note interaction selectors to .interact-container (#839)
* fix(xiaohongshu): scope note interaction selectors to .interact-container

The .like-wrapper / .collect-wrapper / .chat-wrapper class names are
also used by every comment's like/reply buttons in the comment section.
querySelector returned the FIRST match — which on a note with comments
is a comment's count, not the post's. As a result, `xiaohongshu note`
returned wrong like/collect/comment counts for any note that had user
comments.

Scoping each selector to .interact-container (the post's main
interaction bar) returns the correct post-level counts.

Verified on multiple notes:
- Note A: was returning likes=2, now correctly returns likes=74
- Note B: was returning likes=1, now correctly returns likes=796
- Note C: was returning likes=1, now correctly returns likes=269

* test(xiaohongshu): add regression check for .interact-container selector scope

Verify the evaluate script passes scoped selectors so unscoped
versions can't silently regress. Follows reviewer suggestion to
assert on page.evaluate.mock.calls[0][0].

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:18:44 +08:00
jakevin ea8b1a567b fix(browser): make bridge and doctor reflect real connection state (#871) 2026-04-08 14:11:01 +08:00
sline a3eb2dc90a feat: add GitHub Trending, Binance, and Weather adapters (24 commands) (#214)
* feat: add GitHub Trending, Binance, and Weather (Open Meteo) adapters

GitHub Trending (2 commands, browser mode):
- repos: trending repositories with stars, forks, language filter
- developers: trending developers with popular repos
  Supports --since daily/weekly/monthly and --language filter

Binance (11 commands, public API via data-api.binance.vision):
- top: top trading pairs by 24h volume
- price: single pair 24h price stats
- prices: latest prices for all pairs
- ticker: 24h ticker statistics
- gainers: top gaining pairs by 24h change
- losers: top losing pairs by 24h change
- trades: recent trades for a pair
- depth: order book bid prices
- asks: order book ask prices
- klines: candlestick/kline data
- pairs: list active trading pairs

Weather / Open Meteo (11 commands, free public API, no key needed):
- current: current weather for a city
- forecast: daily forecast up to 16 days
- hourly: hourly forecast
- search: city geocoding lookup
- air: air quality index (simple)
- air-quality: detailed air quality (US/EU AQI, PM2.5, PM10, ozone, NO2, SO2)
- sunrise: sunrise/sunset times with UV index
- wind: detailed wind forecast with gusts and 80m altitude
- precipitation: rain/snow forecast with probability
- history: historical weather up to 92 past days
- compare: side-by-side weather comparison across cities

All 24 commands tested with live data. 258 existing tests pass.

* docs: add missing douban, sinablog, substack adapter documentation

* fix(binance): sort numeric metrics and filter active pairs

* chore: drop non-binance adapters from pr214

* chore: drop binance docs from pr214

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-08 01:08:00 +08:00
X 61b247f90c feat(quark): add Quark Drive adapter (#858)
* feat(quark): add Quark Drive adapter (ls, mkdir, mv, rename, rm, save, share-tree)

Browser-based adapter for Quark Cloud Drive (pan.quark.cn) using cookie
strategy. Supports file browsing, folder management, and saving shared
files with task polling for async operations.

* fix(quark): address review feedback on adapter correctness and docs

- ls: fix depth off-by-one (default 0 now lists target folder only)
- save/mv: report success only after pollTask confirms completion; throw on timeout
- save/mv: reject combining --to and --to-fid instead of silently preferring --to-fid
- utils: check content-type before calling r.json() to handle non-JSON responses gracefully
- tests: add quark graceful auth-failure E2E cases for all 7 commands
- docs: add Browser Bridge extension to prerequisites; fix mkdir --parent example; expand command table with positional args; add Notes section explaining stoken flow

* fix(quark): map auth failures and cover utils

* fix(quark): reuse auth mapping for share-tree

---------

Co-authored-by: xzy <xzy@mbp-m5.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-08 00:28:59 +08:00
AstroHan ce559da7fc feat(zhihu): add interaction commands (#868)
* feat(zhihu): add interaction commands

* fix(zhihu): tighten interaction target anchoring

* fix(zhihu): scope comment authorship proof

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-07 23:17:51 +08:00
jakevin 56d15d186b feat: Self-Repair protocol for automatic adapter fixing (#866)
* feat: add Self-Repair protocol for automatic adapter fixing

When an AI agent uses opencli and a command fails, the agent
automatically diagnoses the failure, fixes the adapter, and retries.

- Add CLAUDE.md with Self-Repair protocol (auto-loaded by Claude Code)
- Add designs/self-repair-protocol.md documenting the approach
- Update opencli-repair skill: add Safety Boundaries (AUTH/BROWSER → STOP,
  sourcePath-only scope, max 3 rounds), fix AUTH_REQUIRED guidance
- Update opencli-usage skill: add Self-Repair section

Key design decisions:
- Repair target is always RepairContext.adapter.sourcePath (works for both
  repo-local clis/ and user-local ~/.opencli/clis/)
- Only adapter files may be modified, never core src/
- Max 3 repair rounds per failure
- AUTH_REQUIRED and BROWSER_CONNECT are hard stops (report, don't modify)

* fix: align auth boundary and scope language across all documents

- Remove "Auth changed (AUTH_REQUIRED)" exploration section from
  opencli-repair skill — contradicted the hard stop rule above it
- Update design doc: scope language matches repo-local + explicit skill
  delivery model, not universal product behavior
- Update usage skill: reference sourcePath instead of "files under clis/"

* fix: replace remaining repo-relative clis/ paths with sourcePath in design doc

* refactor: rename opencli-repair to opencli-autofix, remove CLAUDE.md

CLAUDE.md was wrong — users don't work inside the opencli repo, and
the protocol shouldn't assume Claude Code. The skill is the portable
delivery mechanism for any AI agent.

- Rename skills/opencli-repair → skills/opencli-autofix
- Remove CLAUDE.md (not the right delivery mechanism)
- Update all references in usage skill and design doc
- Design doc rewritten to reflect skill-first approach

* fix: use sourcePath in example repair session

* feat: emit AutoFix hint on repairable adapter errors

When a command fails with a repairable error (SELECTOR, EMPTY_RESULT,
COMMAND_EXEC, or generic http/not-found), the error output now includes
a hint telling agents to re-run with OPENCLI_DIAGNOSTIC=1 for repair
context. This is the trigger mechanism that bridges the gap between
"command failed" and "agent enters autofix loop".

Non-repairable errors (AUTH_REQUIRED, BROWSER_CONNECT, ARGUMENT) do not
emit the hint — these require user action, not adapter fixes.

* fix: narrow AutoFix hint to adapter-drift errors only

Remove hint from CommandExecutionError (covers env/launcher/runtime
issues, not adapter drift) and generic http errors (often temporary
site issues). Keep hint only for SelectorError, EmptyResultError,
and generic not-found — clear adapter-drift signals.
2026-04-07 23:00:03 +08:00
jakevin 57d59d5e01 fix: graceful fallback when extension lacks network-capture support (#865)
When the Browser Bridge extension is older than the CLI, sending
'network-capture-start' to the daemon returns 'Unknown action',
causing explore and operate-open to crash with an unhandled error.

Wrap startNetworkCapture calls with .catch() so they degrade
gracefully — explore continues without network capture data, and
operate-open falls back to the JS interceptor injection.
2026-04-07 20:42:14 +08:00
Kyrie Cai f6c13ef159 fix plugin host root resolution (#852)
Co-authored-by: Kyrie <kyrie@mallab.world>
2026-04-07 20:32:08 +08:00
GanFanNewOrder 9c5571eba3 feat(jianyu): add search adapter for bid notices (#849)
* feat(jianyu): add search adapter for bid notices

* docs(jianyu): add adapter usage guide

* docs(jianyu): neutralize examples and add adapter guide

---------

Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
2026-04-07 19:20:00 +08:00
jakevin 813e282356 fix(twitter): relax reply composer textarea timeout from 8s to 15s (#862)
The compose page needs to load Draft.js editor which is heavier than
primaryColumn. 8s is too tight for slow networks and will cause flaky
failures. 15s aligns with the file input timeout (20s) in magnitude.
2026-04-07 18:50:39 +08:00
陈家名 4ea8f7e8c8 fix(twitter): use composer for text replies (#860)
Co-authored-by: 陈家名 <chenjiaming@kezaihui.com>
2026-04-07 18:08:14 +08:00
dependabot[bot] d85681e1cc chore(deps): bump @types/node from 22.19.15 to 25.5.2 (#838)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.15 to 25.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:42:16 +08:00
dependabot[bot] f7e7a37d8f chore(deps): bump vitest from 4.1.1 to 4.1.2 (#835)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:42:04 +08:00
dependabot[bot] 1e3904ba3c chore(deps): bump undici from 7.24.6 to 8.0.2 (#837)
Bumps [undici](https://github.com/nodejs/undici) from 7.24.6 to 8.0.2.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.6...v8.0.2)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:41:55 +08:00
AstroHan b8f0f583ef fix: include intercepted payloads in diagnostic (#829)
* fix: include intercepted payloads in diagnostic

* refactor: isolate captured payloads in diagnostic

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-06 15:20:34 +08:00
AstroHan 1cd0b4b404 fix: correct misleading behaviors in engine, fix, and generate (#826)
- engine.ts: replace `git add -A` with scope-aware `execFileSync` to
  stage only files matching config.scope globs, and guard against empty
  scope degenerating into staging all files
- fix.ts: pass prompt via stdin `input` option instead of shell string
  interpolation to prevent $, backtick, and other metacharacter expansion
- generate.ts: update stale comment that claimed unimplemented pipeline
  steps (register, verify, Strategy Cascade)
2026-04-06 15:05:29 +08:00
jakevin 60c92e1150 chore: bump version to 1.6.8 (#825)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-06 03:24:52 +08:00
jakevin 009c25b955 refactor: remove scoring heuristic, use noise filter + structured metadata (#824)
* refactor: remove scoring heuristic, replace with noise filter + metadata

The scoring mechanism was a pre-LLM heuristic that compressed rich endpoint
metadata into a single number. Since this project is designed for AI Agents,
the agent can reason about structured metadata directly.

Changes:
- Remove scoreEndpoint/scoreRequest/scoreWriteRequest and all score fields
- Replace with isNoiseUrl() filter (tracking/beacon/pixel) + isUsefulEndpoint()
- Remove artificial confidence percentages (was score/20)
- Sort by itemCount (transparent, observable) instead of weighted score
- Endpoints now expose full structured metadata for agent consumption
- Net reduction: -43 lines

* fix: widen endpoint filter to keep single-object JSON and stats/metric URLs

- Remove stats/metric from noise pattern — these are often business APIs
- Relax isUsefulEndpoint to keep any JSON endpoint, not just arrays
  (preserves /me, /profile, /detail and other single-object APIs)

* fix: add deterministic endpoint ordering for generate/synthesize path

The AI agent path doesn't need ranking, but generate/synthesize still
pick candidates[0] as default — this needs a stable, explainable order.

- Add endpointSortKey() with transparent observable signals: array items,
  detected fields, API path patterns, query params
- Update synthesize chooseEndpoint fallback to use itemCount + field count
- Sort key is internal only; not exposed as score to external consumers
2026-04-06 03:21:15 +08:00
tiaot33 5553300597 feat(linux-do): split topic content into a dedicated command (#821)
* feat(linux-do): split topic content into a dedicated command

Move the old main-post path out of linux-do topic so topic stays a summarized first-page reader while topic-content becomes the Markdown-focused entrypoint for full post bodies.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(linux-do): update topic content handling to include YAML front matter

* fix(linux-do): update default output format to plain for topic-content rendering

* fix(linux-do): replace js-yaml with inline YAML serialization for topic-content

Adapters must only import node builtins, relative modules, or opencli
public APIs. Hand-roll the simple front matter serialization to remove
the third-party js-yaml dependency.

* fix(linux-do): refine YAML quoting to only escape colons followed by space

Colons in URLs (e.g. https://) are valid unquoted YAML values. Only
quote when a colon is followed by a space or appears at end of line.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-06 02:59:56 +08:00
jakevin d61dd7be0e refactor: extract shared scoring + consolidate time utils (#823)
* refactor: extract shared scoring logic and consolidate time format utils

- Extract applyUrlScoreAdjustments() and scoreArrayResponse() to analysis.ts,
  eliminating duplicated endpoint scoring between explore.ts and record.ts
- Consolidate formatDuration/formatUptime into a single formatDuration(ms)
  in download/progress.ts, reused by commands/daemon.ts

* fix: preserve explore scoring semantics and round daemon uptime

- Revert explore.ts scoreEndpoint to original inline /api/ /x/ bonus
  without record's tracking/analytics penalty (blocker from review)
- Math.round uptime*1000 to avoid floating-point noise in daemon status
2026-04-06 02:50:37 +08:00
jakevin e9867dcab0 feat(extension): v1.6.8 — fix scripting permission + refresh icons (#822)
* feat(extension): v1.6.8 — remove unused scripting permission, refresh icons

- Remove unused `scripting` permission (Chrome Web Store rejection fix)
- Bump version 1.6.7 → 1.6.8
- Redesign icons with neon gradient style (pure SVG paths, glow effect)

* revert icons to original, fix package.json version to 1.6.8

* test: remove stale scripting permission assertion
2026-04-06 02:07:00 +08:00
williamxie1989 bb7b26bc4a feat(xueqiu): add kline and groups adapters (#809)
* feat(xueqiu): add kline and groups adapters

Add kline.yaml: fetch candlestick/OHLCV data from Xueqiu v5 chart API.
Supports custom days lookback and outputs date, open, high, low, close,
volume, percent.

Add groups.yaml: list Xueqiu portfolio/group entries.


* fix(xueqiu): correct groups.yaml to use /portfolio/list.json API

The previous implementation used /portfolio/stock/list.json which only
returns stocks in a single group and does not return the group list.
Switch to /portfolio/list.json which returns all portfolio groups
including 实盘, 沪深, 港股, 美股, 模拟(pid=-4), 持仓 etc.


* fix(xueqiu): replace watchlist category param with pid selector

- Remove the unused 'category' parameter (the API ignores it;
  all groups live under category=1 regardless)
- Replace with 'pid' parameter to allow fetching any group:
  -4=simulated, -5=SH/SZ, -6=US stocks, -7=HK stocks, etc.
- API path still uses category=1 but pid is now user-controllable


---------
2026-04-06 02:01:29 +08:00
BruceLoveDecimal d2ae28786c feat:add 1688 assets downloadable (#820)
* feat:add 1688 assets downloadable

* fix: remove duplicate visitedRoots declaration and fix normalizeMediaUrl re-export

- Remove unused outer `visitedRoots` variable in scriptToReadAssets()
- Move normalizeMediaUrl test to import from shared.ts where it's defined
- Remove unused normalizeMediaUrl import from assets.ts

---------

Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-06 01:59:39 +08:00
jakevin 1abff0cc1d feat(operate): unify network capture + implement CDP consoleMessages (#816)
* feat(operate): unify network capture + implement CDP consoleMessages

- operate open: start session capture before navigation (catches initial requests)
- operate network: prefer readNetworkCapture() over JS interceptor
- CDPPage: implement consoleMessages() via Runtime.consoleAPICalled

Part of #810

* fix(operate): use correct daemon/CDP entry field names for network capture

Daemon and CDP capture entries use responseStatus/responseContentType/
responsePreview (not status/contentType/responseBody). Fix the
normalization in operate network to match the actual entry shape from
extension/src/cdp.ts.

* fix(cdp): capture Runtime.exceptionThrown in consoleMessages

- Register Runtime.exceptionThrown handler to capture uncaught exceptions
  as error-level messages (most valuable diagnostic signal)
- 'error' filter now returns both console.error() and warning/exception
  entries, matching typical severity-based logging semantics
2026-04-05 22:55:03 +08:00
jakevin 1d46c5f934 feat(cdp): implement session-level network capture for CDPPage (#815)
* feat(cdp): implement session-level network capture for CDPPage

Implements startNetworkCapture() and readNetworkCapture() on CDPPage using
CDP Network domain events. Updates explore.ts to prefer session capture
over Performance API networkRequests().

Closes part of #810

* fix(cdp): use Network.loadingFinished for reliable body capture

- Move getResponseBody call from responseReceived to loadingFinished,
  matching the extension's implementation pattern
- Use extension-compatible entry shape (responseStatus, responseContentType,
  responsePreview) instead of custom field names
- Remove unreliable 100ms sleep hack in readNetworkCapture()
- Align with extension/src/cdp.ts:419-437 for consistency

* fix(cdp): drain buffer on readNetworkCapture to match daemon contract

readNetworkCapture() must clear the buffer after reading, matching the
daemon Page's read-and-drain behavior. Without this, repeated reads
would return stale entries.

* fix(cdp): await in-flight body fetches before returning from readNetworkCapture

Track all pending getResponseBody promises and await them in
readNetworkCapture() before draining the buffer. This ensures
explore/diagnostic consumers always get entries with responsePreview
populated, not empty shells where the body fetch hasn't resolved yet.

* fix(explore): handle both legacy and capture entry field names

parseNetworkRequests now maps both shapes:
- Legacy: status, contentType, responseBody
- Capture (extension/CDP): responseStatus, responseContentType, responsePreview

Also clears _pendingBodyFetches on startNetworkCapture reset.
2026-04-05 22:50:55 +08:00
jakevin d7fe7a7ffa fix(scaffold): replace non-existent extract step with select in YAML template (#814) 2026-04-05 22:50:43 +08:00
jakevin 7b55b8c595 test: remove flaky bloomberg e2e tests (#818)
* test: remove flaky bloomberg e2e tests

Bloomberg RSS endpoints are unreliable in CI, causing intermittent
e2e-headed failures unrelated to code changes.

* test: remove flaky bloomberg e2e tests

Bloomberg RSS feeds are unreliable in CI, causing false failures
in e2e-headed runs. Remove bloomberg tests from both
public-commands.test.ts and browser-public-extended.test.ts.
2026-04-05 22:50:24 +08:00
jakevin 15268da8f3 fix: add safety boundaries to diagnostic output (#806)
* fix: add safety boundaries to diagnostic output

- Redact sensitive headers (Authorization, Cookie, etc.) from network requests
- Redact sensitive URL query parameters (token, key, secret, etc.)
- Cap individual fields: snapshot (100K chars), adapter source (50K chars),
  network requests (50 entries, 4K body each), stack trace (5K chars)
- Enforce 256KB total output budget with graceful degradation:
  drops snapshot first, then page state entirely
- Export truncate/redactUrl helpers for testing

* fix: add free-text redaction for all diagnostic string channels

Addresses review feedback: snapshot, consoleErrors, error message/hint/stack
could contain inline secrets (Bearer tokens, JWTs, cookie values, token=value
patterns). All string channels now pass through redactText() before emission.

- Add redactText() with patterns for Bearer tokens, JWTs, cookie values,
  and inline key=value secrets
- Apply redactText to: error.message, error.hint, error.stack,
  page.snapshot, page.consoleErrors
- Add 6 new test cases for redactText and error message redaction

* fix: resolve adapter source path and add page state collection timeout

Fixes #808 items 1 and 3:

1. adapter.source was missing for all command types because buildRepairContext
   only checked cmd._modulePath (set only for manifest lazy-loaded TS).
   Now resolveAdapterSourcePath() checks cmd.source first, skips manifest:
   pseudo-paths, and maps dist/clis/*.js back to source clis/*.ts.

3. collectPageState() had no timeout — a hung CDP connection would block
   error propagation indefinitely. Now wrapped with 5s Promise.race timeout,
   falling back to emitting diagnostic without page state.

* fix: track sourceFile in manifest for YAML adapter source resolution

YAML commands inlined in the manifest previously lost their original file
path, causing resolveAdapterSourcePath() to return undefined. Add
sourceFile field to ManifestEntry so discovery can reconstruct the
editable source path for both YAML and TS commands.
2026-04-05 19:50:03 +08:00
jakevin a3efdc16de refactor: centralize build path resolution (#807) 2026-04-05 19:46:58 +08:00
jakevin d51338cbf3 chore: bump version to 1.6.7
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-05 18:07:55 +08:00
jakevin 664a971ed5 feat: structured diagnostic output for AI-driven adapter repair (#802)
* feat: add structured diagnostic output for AI-driven adapter repair

When OPENCLI_DIAGNOSTIC=1 is set, failed commands emit a RepairContext
JSON to stderr containing the error, adapter source, and browser state
(DOM snapshot, network requests, console errors). AI Agents consume
this to diagnose and fix adapters when websites change.

Also adds the opencli-repair skill guide for AI Agents.

* fix: correct e2e test binary path to dist/src/main.js

The e2e helpers pointed to dist/main.js but the actual build output
is at dist/src/main.js (matching package.json "main" field). This
caused all e2e-headed tests to fail with "Cannot find module".

* fix: correct dist/main.js path in autoresearch scripts

* fix: emit diagnostic for pre-session browser failures

When browser connection fails before the session callback runs
(e.g., BrowserConnectError), the inner diagnostic catch never fires.
Use a flag to ensure the outer catch emits diagnostic as a fallback.

* test: tolerate unavailable Bloomberg RSS feeds in e2e

* test: skip flaky bloomberg businessweek e2e test

The Bloomberg Businessweek RSS feed is intermittently unavailable,
causing CI failures unrelated to code changes.

* revert: restore bloomberg businessweek e2e coverage
2026-04-05 18:04:49 +08:00
Kai 97a547c6c5 fix: avoid inserting completion config inside multi-line shell commands (#796)
* fix: avoid inserting completion config inside multi-line shell commands

The postinstall zshrc insertion logic splits backslash-continued blocks
(e.g. zinit stanzas) when it finds a compinit match inside them, which
breaks the user's shell config. Walk backward past continuation lines
so the insertion lands before the entire logical command.

* fix: append zsh completion to end of .zshrc instead of splicing

Replace the fragile compinit-searching splice logic with a simple
append, matching the strategy already used for bash. This avoids
breaking multi-line commands (e.g. zinit blocks with zicompinit).

Still detects existing compinit to avoid adding a duplicate call.

* fix: stop modifying shell rc files in postinstall

Replace the fragile .zshrc/.bashrc modification logic with a safer
approach: only write completion files and print setup instructions.

The previous approach tried to parse and splice into rc files, which
broke multi-line shell commands (e.g. zinit blocks with backslash
continuations matching /compinit/). Instead of attempting to fix the
parser, remove rc modification entirely — this matches the approach
used by rustup, homebrew, and other CLI tools.

Closes #788

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-05 17:39:23 +08:00
jakevin eedc47aa26 chore: bump version to 1.6.6
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-05 17:34:57 +08:00
jakevin 8c41411860 fix: route copied adapters through opencli exports
* fix(discovery): expose runtime deps to user adapters

* fix: route copied adapters through opencli exports

* refactor: route adapter status output through logger
2026-04-05 17:31:45 +08:00
jakevin ed69e839ab chore: bump version to 1.6.5 (#797)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-05 16:07:02 +08:00
jakevin 4fe9a73ebc refactor: migrate adapter imports to package exports (#795)
* refactor: migrate adapter imports to package exports

Replace all relative imports (../../src/registry.js, ../../browser/cdp.js, etc.)
with package exports (@jackwener/opencli/registry, @jackwener/opencli/errors, etc.)
across all 484 adapter files.

This decouples adapter import resolution from directory structure:
- User CLIs in ~/.opencli/clis/ resolve via node_modules symlink
- Internal adapters resolve via Node.js self-referencing
- No more shim files needed for import resolution

Changes:
- package.json: add sub-path exports for all public modules
- clis/**: replace relative imports with @jackwener/opencli/...
- discovery.ts: simplify ensureUserCliCompatShims to symlink-only
- registry-api.ts: export CommandArgs type
- Remove root-level shim directories (browser/, download/, pipeline/)
- Remove shim entries from tsconfig.json include and package.json files

* test: add regression tests for package exports

Prevents regressions like #788/#791 by:
1. Scanning all adapter files for forbidden relative imports
   (../../src/, ../../browser/, etc.) — fails if any remain
2. Verifying every package.json export maps to an existing source file

18 new test cases.

* fix: use junction on Windows + broaden test patterns

- discovery.ts: use 'junction' symlink type on Windows (no admin required)
- package-exports.test.ts: generalize forbidden patterns to catch any
  depth of ../ traversal (not just ../../ and ../../../)

* fix: update stale vi.mock/importActual paths in adapter tests

Test files still used old relative paths for vi.mock() and
vi.importActual() calls. Updated 5 test files to use package exports.
Also broadened regression test patterns to catch mock/importActual paths.

* fix: use rm instead of unlink for symlink cleanup, add warn on failure

Addresses review feedback from Astro-Han:
- rm() handles both symlinks and stale directories (unlink fails on dirs)
- Log a warning when symlink creation fails instead of silent catch

* docs: update import examples to use package exports

Update all documentation, contributing guides, and skills to use
@jackwener/opencli/registry instead of ../../src/registry.js.

Without this, users following the docs would write adapters with
broken imports since the old shim files are no longer created.
2026-04-05 16:02:17 +08:00
jakevin a1dd817886 chore: bump version to 1.6.4 (#794)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-05 14:57:45 +08:00
AstroHan 20957cbc7b fix: resolve version 0.0.0 and user CLI load failures (#788) (#789)
Bug 1: version.ts used a single-level parent lookup for package.json,
which broke after #784 changed rootDir from "src" to "." (version.js
now lives in dist/src/ instead of dist/).  Walk up until package.json
is found — works in both dev (src/) and prod (dist/src/).

Bug 2: adapters copied to ~/.opencli/clis/ import ../../src/registry.js
etc., which resolves to ~/.opencli/src/.  Derive src/ compat shims from
the existing rootShims list so these imports resolve correctly.
2026-04-05 14:54:30 +08:00
jakevin ab58aa4098 fix(docs): update outdated paths and command lists across all docs (#787)
- Replace 140 instances of `src/clis/` → `clis/` across 12 doc files
  (path changed after repo restructure)
- Remove non-command `rpc` and `rankings` from notebooklm/amazon
  command lists in README, README.zh-CN, SKILL.md, and adapters index
  (these are internal utility modules, not user-facing commands)
- Add `deep-research` and `deep-research-result` to gemini adapter doc
- Add `movers-shakers` and `new-releases` to amazon adapter doc
- Update notebooklm doc examples to use canonical commands instead of
  deprecated aliases (`metadata` → `get`, `notes-list` → `note-list`)
- Bump SKILL.md version to 1.6.3
2026-04-05 03:53:52 +08:00
jakevin 76b5d53bb5 chore: bump version to 1.6.3 (#786)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-05 03:46:13 +08:00
jakevin 00f6062e74 docs: fix outdated commands and adapter counts (#785)
* docs: fix outdated commands and adapter counts in README and skills

- Add gemini deep-research and deep-research-result commands
- Fix notebooklm: remove non-existent select/metadata/notes-list, add rpc
- Add amazon movers-shakers, new-releases, rankings commands
- Add missing weibo commands in zh-CN README
- Fix linux-do missing hot/latest/category in zh-CN README
- Update adapter count from 73+ to 79+
- Update skills version to 1.6.2
- Add full spotify command list in skills SKILL.md

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

* fix(test): update xiaohongshu note tests for search_result URL change

buildNoteUrl now uses /search_result/<id> instead of /explore/<id> for
bare note IDs. Update test expectations to match:
- buildNoteUrl test: expect /search_result/ not /explore/
- goto URL assertion: expect /search_result/ not /explore/
- empty shell hint: match actual error message text

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

* fix(test): update xiaohongshu comments test for search_result URL change

- Bare note ID now navigates to /search_result/ not /explore/
- Full URL inputs are preserved as-is (including /explore/ URLs)

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 03:39:50 +08:00
jakevin 12176de1a5 refactor: simplify core modules (#784)
* refactor: simplify core modules — remove root shims, consolidate error classification, streamline cascade/interceptor, clean up synthesize

1. Remove root-level shim files (errors.ts, logger.ts, registry.ts, types.ts, utils.ts, launcher.ts) — update all ~840 adapter imports to reference src/ directly
2. Consolidate interceptor: reuse shared DISGUISE_FN in tap interceptor instead of reimplementing
3. Unify error classification: single ClassifiedError type with icon/exitCode/hint lookup table, eliminating duplicated pattern matching between resolveExitCode and renderError
4. Simplify cascade probe: replace repetitive switch cases with PROBE_OPTIONS lookup map
12. Clean up synthesize.ts: remove deprecated snake_case field aliases (recommended_args, recommended_columns, recommendedColumnsLegacy) and unnecessary constant aliases

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

* docs: update import paths in contributor docs and skill templates

Update all documentation and skill files to reference src/ directly,
matching the shim removal in the previous commit.

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

* fix: update new Gemini adapter imports to use src/ paths

Fix imports in newly added deep-research adapter files that were
still referencing the deleted root shim files.

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

* fix: update xiaohongshu tests for /search_result/ URL change

Tests now expect /search_result/<id> for bare note IDs (matching
the note-helpers.ts change from PR #774) and updated empty-shell
hint assertion.

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

* fix: update LessWrong and hupu adapter imports to use src/ paths

Fix imports in newly merged LessWrong and hupu/mentions adapter
files that were still referencing the deleted root shim files.

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

* test(xueqiu): mock logger via src path

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 03:35:12 +08:00
kevin.zhang 8bd36aaa37 fix(hupu): add mentions command (#757)
* feat(hupu): add hupu cli adapter

* fix(hupu): prevent detail from returning the wrong thread

* refactor: deduplicate shared utilities in hupu adapter

- Merge postHupuJson and postHupuReplyJson into single function with mode parameter
- Move stripHtml and decodeHtmlEntities to utils.ts, remove duplicate definitions

* fix(hupu): add mentions command

* fix: move mentions.ts to clis/hupu/, remove src/clis/hupu duplicates

Post PR #782 restructure: adapter files live at root clis/, not src/clis/.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-05 03:26:40 +08:00
2356 changed files with 213762 additions and 87447 deletions
+3 -3
View File
@@ -9,11 +9,11 @@ outputs:
runs:
using: composite
steps:
- name: Install real Chrome (stable)
uses: browser-actions/setup-chrome@v1
- name: Install real Chrome for Testing
uses: browser-actions/setup-chrome@v2
id: setup-chrome
with:
chrome-version: stable
chrome-version: latest
- name: Verify Chrome installation
shell: bash
+7 -8
View File
@@ -3,7 +3,7 @@ name: Build Chrome Extension
on:
push:
branches: [ "main" ]
tags: [ "v*.*.*" ]
tags: [ "ext-v*" ]
paths:
- 'extension/**'
- '.github/workflows/build-extension.yml'
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
cache: 'npm'
cache-dependency-path: extension/package-lock.json
@@ -44,23 +44,22 @@ jobs:
- name: Create Extension ZIP
run: |
EXT_VERSION=$(node -p "require('./extension/package.json').version")
cd extension-package
zip -r ../opencli-extension.zip .
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v7
with:
name: opencli-extension-build
path: |
opencli-extension.zip
path: opencli-extension-v*.zip
retention-days: 7
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v3.0.0
with:
files: |
opencli-extension.zip
files: opencli-extension-v*.zip
draft: false
prerelease: false
env:
+33 -7
View File
@@ -38,6 +38,32 @@ jobs:
- name: Build
run: npm run build
# Guard: committed cli-manifest.json must match the one build regenerates.
# Prevents silent drift where unrelated adapter entries vanish or change
# across PRs (agent hits unexpected manifest diff → surgical-merge churn).
- name: Check cli-manifest.json is up-to-date
if: runner.os == 'Linux'
shell: bash
run: |
if ! git diff --exit-code -- cli-manifest.json; then
echo "::error::cli-manifest.json is out of sync with the source. Run 'npm run build' and commit the result."
exit 1
fi
# Guard: adapter rows must not silently emit keys omitted from `columns`.
# Existing findings are tracked in scripts/silent-column-drop-baseline.json;
# this gate rejects newly introduced drops while allowing incremental cleanup.
- name: Check silent column drops
if: runner.os == 'Linux'
run: npm run check:silent-column-drop
# Guard: adapters should fail with typed errors instead of silently
# returning empty arrays, clamping user input, or inventing sentinel data.
# Existing findings are tracked in scripts/typed-error-lint-baseline.json.
- name: Check typed-error lint baseline
if: runner.os == 'Linux'
run: npm run check:typed-error-lint
# ── Unit tests (vitest shard) ──
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
@@ -47,7 +73,7 @@ jobs:
fail-fast: false
matrix:
os: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["ubuntu-latest"]') }}
node-version: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["20","22"]') || fromJSON('["22"]') }}
node-version: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["22"]') || fromJSON('["22"]') }}
shard: [1, 2]
steps:
- uses: actions/checkout@v6
@@ -61,7 +87,7 @@ jobs:
run: npm ci
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
run: npx vitest run --project unit --project extension --reporter=verbose --shard=${{ matrix.shard }}/2
# ── Bun compatibility check ──
bun-test:
@@ -84,8 +110,12 @@ jobs:
- name: Run unit tests under Bun
run: bun vitest run --project unit --reporter=verbose
# Adapter tests are pure unit tests — OS doesn't affect results.
# Adapter tests are pure unit tests — OS doesn't affect results. Gated off
# `pull_request` to keep PR CI under ~2 minutes; adapter authors run focused
# tests locally before pushing, and `push` to main / nightly cron / manual
# dispatch still guard the merged state.
adapter-test:
if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
needs: build
steps:
@@ -136,12 +166,8 @@ jobs:
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
- name: Run smoke tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
timeout-minutes: 15
+32 -16
View File
@@ -1,6 +1,10 @@
name: E2E Headed Chrome
on:
# E2E removed from `pull_request` to keep PR feedback under ~2 minutes; PR-time
# protection is the CI workflow (typecheck / unit / lint / adapter / build).
# E2E still guards `main` directly, runs nightly, and on release tag push so
# protocol/CDP/extension contract regressions are caught before they ship.
push:
branches: [main, dev]
paths:
@@ -13,18 +17,11 @@ on:
- 'tests/smoke/**'
- '.github/actions/setup-chrome/**'
- '.github/workflows/e2e-headed.yml'
pull_request:
branches: [main, dev]
paths:
- 'extension/**'
- 'src/browser/**'
- 'src/daemon.ts'
- 'src/execution.ts'
- 'src/interceptor.ts'
- 'tests/e2e/**'
- 'tests/smoke/**'
- '.github/actions/setup-chrome/**'
- '.github/workflows/e2e-headed.yml'
tags: ['v*']
schedule:
# Daily 08:00 UTC — catch flake / Chrome-version drift even when no commits
# touched the watched paths recently.
- cron: '0 8 * * *'
workflow_dispatch:
concurrency:
@@ -59,16 +56,35 @@ jobs:
- name: Build
run: npm run build
- name: Build extension
run: npm run build --prefix extension
- name: Run AX Chrome smoke (Linux, via xvfb)
if: runner.os == 'Linux'
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
- name: Run AX Chrome smoke (macOS / Windows)
if: runner.os != 'Linux'
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '1'
run: npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
- name: Run E2E tests (Linux, via xvfb)
if: runner.os == 'Linux'
env:
OPENCLI_AX_E2E: '0'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
- name: Run E2E tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '0'
run: npx vitest run tests/e2e/ --reporter=verbose
+32 -1
View File
@@ -26,10 +26,41 @@ 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
- name: Build extension
run: npm run build
working-directory: extension
- name: Package extension
run: npm run package:release -- --out ../extension-package
working-directory: extension
- name: Create extension ZIP
run: |
EXT_VERSION=$(jq -r .version extension/package.json)
cd extension-package
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v3.0.0
with:
generate_release_notes: true
files: |
opencli-extension-v*.zip
- name: Publish to npm
run: npm publish --provenance --access public
+1
View File
@@ -3,6 +3,7 @@ dist/
!extension/dist/
*.tsbuildinfo
.opencli/
.worktrees/
.mcp.json
*.log
.DS_Store
+441 -1
View File
@@ -1,5 +1,445 @@
# Changelog
## [1.7.21](https://github.com/jackwener/opencli/compare/v1.7.20...v1.7.21) (2026-05-14)
Adapter polish release: new web search adapters, better Browser Bridge tab group reuse, and social adapters returning to one-shot tab leases. Extension package version is bumped to 1.0.15 for the Browser Bridge fix.
### Features
* **search** — add DuckDuckGo, Brave, and Yahoo web search adapters. ([#1546](https://github.com/jackwener/opencli/issues/1546))
* **boss** — support job-seeker `chatlist` and `chatmsg` adapters. ([#1539](https://github.com/jackwener/opencli/issues/1539))
### Bug Fixes
* **extension** — reuse existing `OpenCLI Adapter` tab groups before creating new ones, including cross-window discovery, legacy `OpenCLI` title fallback, and deterministic candidate selection. ([#1541](https://github.com/jackwener/opencli/issues/1541))
* **twitter, reddit** — default browser-backed social adapters back to ephemeral tab leases. Twitter/X and Reddit commands now release their site tab after each run while keeping the shared Adapter window available for reuse; persistent sessions remain reserved for AI/chat-style adapters that need long-lived conversation state. ([#1569](https://github.com/jackwener/opencli/issues/1569))
* **xiaohongshu, rednote** — unwrap Browser Bridge `page.evaluate` envelopes in search adapters. ([#1561](https://github.com/jackwener/opencli/issues/1561))
* **facebook/feed** — add fallback extraction for empty article nodes. ([#1538](https://github.com/jackwener/opencli/issues/1538))
### Internal
* **ci** — add Windows native binding lockfile entries for Rolldown/Rollup optional packages. ([#1563](https://github.com/jackwener/opencli/issues/1563))
* **extension** — add regression coverage for the adapter tab group `groupId` tiebreaker. ([#1566](https://github.com/jackwener/opencli/issues/1566))
## [1.7.20](https://github.com/jackwener/opencli/compare/v1.7.19...v1.7.20) (2026-05-14)
External CLI surface cleanup + Browser Bridge WebSocket lifecycle hardening. Two BREAKING changes around external CLIs: built-in `tg`/`discord`/`wx` (was `tg-cli`/`discord-cli`/`wx-cli`) now match their real binary names, and Notion's in-tree CDP adapter is replaced by the official `ntn` external CLI.
### ⚠ BREAKING CHANGES
* **notion** — remove the in-tree `clis/notion/` CDP-on-Desktop adapter (8 commands: `status` / `search` / `read` / `new` / `write` / `sidebar` / `favorites` / `export`). Notion has shipped an official CLI at <https://ntn.dev>, registered as a first-class external CLI in `external-clis.yaml`. Migration: install `ntn` from <https://ntn.dev> (`curl -fsSL https://ntn.dev | bash`), then use `opencli ntn <command>`. Auto-install is intentionally not configured because the official installer is a shell script while OpenCLI external installs run shell-free command strings. The official CLI uses the public Notion API rather than reverse-engineering the Desktop UI, so it survives Notion app updates and exposes a wider command surface (blocks / databases / properties / comments) than the reverse-engineered adapter could. ([#1559](https://github.com/jackwener/opencli/issues/1559))
* **external** — drop the `-cli` suffix from built-in external CLI subcommand names. `opencli tg-cli`, `opencli discord-cli`, `opencli wx-cli` are now `opencli tg`, `opencli discord`, `opencli wx`, matching the real binary names that those tools install as. Root help still shows the package lineage as `tg(tg-cli)` / `discord(discord-cli)` / `wx(wx-cli)`. ([#1544](https://github.com/jackwener/opencli/issues/1544))
### Features
* **twitter** — `bookmarks` and `bookmark-folder` now include media via `extractMedia`, reaching parity with `timeline` / `search`. ([#1555](https://github.com/jackwener/opencli/issues/1555))
* **twitter/list-tweets** — include media via `extractMedia` (parity with `timeline` / `search`). ([#1464](https://github.com/jackwener/opencli/issues/1464))
### Bug Fixes
* **daemon** — report ambiguous browser command outcomes with a distinct `command_result_unknown` errorCode and `503` when the extension WebSocket drops between command dispatch and result delivery. `sendCommandRaw()` treats this code as hard non-retryable, so write-side commands (`navigate` / `click` / `type` / `eval`) won't be silently re-issued and double-executed. Daemon exposes a `commandResultUnknown` counter on `/status` for future observability. ([#1558](https://github.com/jackwener/opencli/issues/1558))
* **extension** — keep active daemon WebSocket; stale sockets no longer clobber active connection (`onopen` / `onclose` / `onmessage` are all gated by `ws !== thisWs` short-circuit), and `safeSend` only fires when `readyState === OPEN`. ([#1540](https://github.com/jackwener/opencli/issues/1540))
* **extension** — coalesce concurrent daemon WebSocket connects via an in-flight promise. Startup / keepalive / reconnect triggering `connect()` during the daemon-probe or context-lookup async gap no longer creates duplicate real WebSocket connections. ([#1554](https://github.com/jackwener/opencli/issues/1554))
* **external** — distinguish external CLI executable names from distribution/project names in root help. Built-in aliases such as `tg`, `discord`, `wx` remain the callable `opencli <name> ...` entrypoints while help renders `tg(tg-cli)`, `discord(discord-cli)`, `wx(wx-cli)` to show their package lineage. ([#1560](https://github.com/jackwener/opencli/issues/1560))
### Docs
* **browser** — clarify named session lifecycle in the Browser Bridge guide. ([#1542](https://github.com/jackwener/opencli/issues/1542))
## [1.7.19](https://github.com/jackwener/opencli/compare/v1.7.18...v1.7.19) (2026-05-14)
Major hotfix + simplification batch. Extension bumped to 1.0.14. Node floor lowered to v20 so the long tail of Node v20v21.6 users no longer crashes at module load. `opencli browser` user surface replaces required-flag `--session <name>` with a `<session>` positional. `page.evaluate(fn, ...args)` adds a type-safe alternative to the implicit auto-IIFE string form. Twitter cursor pagination no longer silently caps at ~500 items.
### ⚠ BREAKING CHANGES
* **browser** — replace the `--session <name>` flag with a `<session>` positional argument that immediately follows `browser`. `opencli browser work click 12` instead of `opencli browser --session work click 12`; `opencli browser work bind` instead of `opencli browser bind --session work`. Required-flag semantics are now encoded structurally as a positional, matching the Docker/git convention for required operation-target identifiers. The internal `--session` flag is preserved for the daemon protocol and for direct `program.parseAsync` callers but is no longer part of the user-facing surface. ([#1505](https://github.com/jackwener/opencli/issues/1505))
* **env** — remove `OPENCLI_KEEP_TAB`. The flag was a debugging shortcut, not a config dimension: `--keep-tab true|false` on the command line is the single source of truth, and adapter `siteSession: 'persistent'` already pins persistent site tabs as a hard constraint. Removing the env eliminates a globally-leaking process state that overrode every browser command in the shell. ([#1509](https://github.com/jackwener/opencli/issues/1509))
* **extension** — remove the internal `surface\\0session` command-session backdoor. Browser Bridge commands now route only through structured `session` + `surface` fields; lease-key strings remain an extension-internal registry detail. ([#1510](https://github.com/jackwener/opencli/issues/1510))
### Features
* **browser** — add `page.evaluate(fn, ...args)` for type-safe browser-context evaluation with JSON-serialized arguments. String evaluation remains supported, but new adapter code should use function form to avoid implicit `wrapForEval` auto-IIFE magic. ([#1508](https://github.com/jackwener/opencli/issues/1508))
* **twitter** — default `tweets` command to the logged-in user when `user` is omitted, and fix the sibling envelope-unwrap silent bug. ([#1531](https://github.com/jackwener/opencli/issues/1531))
* **zhihu** — add `answer-detail` to fetch a single answer's full content. ([#1528](https://github.com/jackwener/opencli/issues/1528))
* **zhihu** — paginate question answers and recommendations. ([#1517](https://github.com/jackwener/opencli/issues/1517))
* **reddit/read** — `--expand-more` via `/api/morechildren` + 7-kind typed errors. ([#1492](https://github.com/jackwener/opencli/issues/1492))
* **reddit** — add `whoami`, `home`, `subreddit-info` read commands. ([#1491](https://github.com/jackwener/opencli/issues/1491))
* **ctrip** — add `hotel-search` + flight browser-mode commands. ([#1489](https://github.com/jackwener/opencli/issues/1489))
### Bug Fixes
* **browser** — `page.evaluate()` / `evaluateInFrame()` now return the user JavaScript value directly. Browser Bridge `exec` previously routed through a shared `pageScopedResult` helper that spread / wrapped the lease's `session` into the result `data`, contaminating arbitrary user returns: array / primitive returns came back as `{ session, data }` envelopes, and plain-object returns had an extra `session` key injected (overwriting any user `session` field). `google search` and `xiaohongshu search` were the visible repro — Chrome rendered results correctly but adapters extracted an empty array. Fixed in extension 1.0.14 by reverting `pageScopedResult` to its pre-1461 form (`{ id, ok, data, page }`); no client-side unwrap is needed. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **twitter** — raise fixed cursor-pagination caps in `bookmarks` / `likes` / `tweets` / `timeline` / `bookmark-folder` / `list-tweets` / `search` / `following`. The old `i < 5` / `i < 10` literals and following's `Math.ceil(limit / 50) + 2` formula imposed hidden result ceilings below `--limit`; the loop now treats the page count as a high runaway guard while `--limit` and cursor exhaustion control normal pagination. ([#1532](https://github.com/jackwener/opencli/issues/1532))
* **twitter** — repair `list-add` / `list-tweets` / `lists` / `following` after 2026-05 site changes. ([#1503](https://github.com/jackwener/opencli/issues/1503))
* **twitter** — repair `search` and `tweets` readback. ([#1512](https://github.com/jackwener/opencli/issues/1512))
* **twitter** — make reply submission robust. ([#1511](https://github.com/jackwener/opencli/issues/1511))
* **google/search** — wait for `#rso a h3` before extracting, falling back to the existing fixed wait. On Chrome 148 + Linux Wayland the DOM can settle before SERP anchors are populated, making extraction return empty even with the envelope bug fixed. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **google/search** — wrap evaluate return value in object to fix serialization. ([#1523](https://github.com/jackwener/opencli/issues/1523))
* **google-scholar/search** — wrap evaluate return to fix serialization. ([#1525](https://github.com/jackwener/opencli/issues/1525))
* **xiaohongshu/search** — extract initially visible cards before scrolling, then merge post-scroll rows by URL. Xiaohongshu's virtualized masonry layout can evict the initial cards from the DOM after scroll, so the previous always-scroll-then-extract flow could lose the top results. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **xiaohongshu** — `parseLikes` handles `2.1w` / `1.5万` / `1.2k` shortforms. ([#1504](https://github.com/jackwener/opencli/issues/1504))
* **xiaohongshu+rednote/search** — fall back to href-based note cards when `section.note-item` class is dropped. ([#1507](https://github.com/jackwener/opencli/issues/1507))
* **xueqiu** — `kline` / `earnings-date` format dates in Asia/Shanghai instead of UTC. ([#1498](https://github.com/jackwener/opencli/issues/1498))
* **download** — clamp progress percentages. ([#1520](https://github.com/jackwener/opencli/issues/1520))
### Internal
* **runtime** — lower the Node floor to `>=20.0.0`. Three coupled changes: drop all `util.styleText()` usage (added in Node v21.7.0 / v20.12.0; previously crashed v21.0v21.6 at module load), downgrade `undici` from `^8.0.2` (engines `>=22.19.0`) to `^6.25.0` (engines `>=18.17`, retains `Agent` / `EnvHttpProxyAgent` / `fetch`), and lower `MIN_SUPPORTED_NODE_MAJOR` from 21 to 20 so the startup guard matches the declared `engines.node`. Smoke-tested on v20.0.0 / v21.2.0 / v22.22.2. The semantic markers (`[OK]` / `[WARN]` / `[FAIL]` / `` / `⚠` / `✖`) keep their meaning; ANSI colors were redundant for the primarily agent-facing CLI. ([#1524](https://github.com/jackwener/opencli/issues/1524))
* **extension 1.0.14** — `pageScopedResult` no longer injects `session` into `data`. The field had no consumers and contaminated `exec` results with arbitrary user-JS shapes; routing-relevant identity is already exposed via `Result.page`. ([#1518](https://github.com/jackwener/opencli/issues/1518))
* **extension 1.0.13** — remove the internal command-session lease-key backdoor. ([#1510](https://github.com/jackwener/opencli/issues/1510))
* **ci** — drop `e2e-headed` and `adapter-test` from `pull_request` triggers (kept on `push` to main / nightly / `workflow_dispatch`). PR-time CI now targets ~2 min wall-time. ([#1521](https://github.com/jackwener/opencli/issues/1521), [#1522](https://github.com/jackwener/opencli/issues/1522))
* **scripts** — auto-refresh `dist/` before `build-manifest`. ([#1490](https://github.com/jackwener/opencli/issues/1490))
## [1.7.18](https://github.com/jackwener/opencli/compare/v1.7.17...v1.7.18) (2026-05-12)
Hotfix release for the 1.7.17 doctor regression: `opencli doctor` failed connectivity probe with `Browser session is required` because the doctor probe didn't pass a session to the new strict-session browser bridge. Also adds new adapters and adapter fixes that were ready immediately after 1.7.17.
### Bug Fixes
* **doctor** — pass an internal `__doctor__` browser session to the live connectivity probe so `opencli doctor` works again under the explicit-session browser model introduced in 1.7.17. ([#1485](https://github.com/jackwener/opencli/issues/1485))
* **browser** — `--session <name>` is now declared as a `requiredOption` so Commander itself rejects calls missing the flag before runtime, and the help line is marked `(required)` instead of being hidden under `Options:`. ([#1485](https://github.com/jackwener/opencli/issues/1485))
* **doubao/ask** — restore Assistant detection after the 2026-05 DOM refactor. ([#1484](https://github.com/jackwener/opencli/issues/1484))
* **youtube** — request `srv3` format for caption URLs. ([#1422](https://github.com/jackwener/opencli/issues/1422))
### Features
* **rednote** — add `rednote.com` adapter mirroring xiaohongshu read commands. ([#1475](https://github.com/jackwener/opencli/issues/1475))
* **reddit** — add `reply` command for replying to comments. ([#1428](https://github.com/jackwener/opencli/issues/1428))
## [1.7.17](https://github.com/jackwener/opencli/compare/v1.7.16...v1.7.17) (2026-05-12)
Extension bumped to 1.0.12 (workspace → session lease routing, drop `handleSessions` handler). Major simplification pass: browser/adapter session model rewrite, `--workspace` removed, doctor surface trimmed to its core job.
### ⚠ BREAKING CHANGES
* **browser session model** — replace the browser-facing `--workspace` model with explicit `--session <name>` on `opencli browser *`. Browser commands now require a session name, `browser bind`/`unbind` use `--session`, and bind no longer accepts `--domain`, `--path-prefix`, or `--allow-navigate-bound`. Browser primitives keep their session tab by design; the browser namespace no longer exposes `--keep-tab`. ([#1461](https://github.com/jackwener/opencli/issues/1461))
* **adapter site sessions** — replace adapter metadata `browserSession: { reuse: 'site' }` with `siteSession: 'persistent'`, and replace the user override `--reuse <none|site>` / `OPENCLI_BROWSER_REUSE` with `--site-session <ephemeral|persistent>`. Persistent site sessions keep a stable site tab open without idle expiry. ([#1462](https://github.com/jackwener/opencli/issues/1462))
* **doctor** — remove `--no-live` and `--sessions` flags from `opencli doctor`. Doctor always runs the live browser connectivity probe (that's its core job); session enumeration was never part of health diagnosis. The underlying `'sessions'` daemon protocol action and the `BrowserSessionInfo` public type are removed as dead code. ([#1470](https://github.com/jackwener/opencli/issues/1470))
### Features
* **chatgpt** — `ask` and `send` now accept local image paths and upload them through the composer before submitting the prompt. ([#1476](https://github.com/jackwener/opencli/issues/1476))
### Internal
* **extension 1.0.12** — drop `handleSessions` action handler (no remaining consumers after doctor cleanup).
* **extension 1.0.11** — switch Browser Bridge lease routing from user-facing workspaces to explicit browser sessions.
## [1.7.16](https://github.com/jackwener/opencli/compare/v1.7.15...v1.7.16) (2026-05-11)
Extension bumped to 1.0.10 (rename adapter-owned tab group `OpenCLI Automation``OpenCLI Adapter`). Performance and stability sweep across browser-backed adapters; new external CLI integrations (tg-cli, discord-cli, wx-cli).
### Features
* **openreview** — add `author` command for ID-explicit publication lookup. ([#1365](https://github.com/jackwener/opencli/issues/1365))
* **external** — register `tg-cli`, `discord-cli`, and `wx-cli` as external CLI integrations. ([#1458](https://github.com/jackwener/opencli/issues/1458))
### Bug Fixes
* **xiaohongshu** — fall back to base64 upload when CDP `DOM.setFileInputFiles` returns `Not allowed` on creator center. ([#1374](https://github.com/jackwener/opencli/issues/1374))
* **chatgpt** — switch to locale-stable send button selector so non-English UIs don't break send. ([#1354](https://github.com/jackwener/opencli/issues/1354))
### Performance
* **adapters** — hoist cookie reads to `page.getCookies` across Tier 1 (25 files), eliminating per-call CDP round trips. ([#1450](https://github.com/jackwener/opencli/issues/1450))
* **twitter** — drop redundant `goto + wait` in adapter steps; framework auto pre-navigates. ([#1451](https://github.com/jackwener/opencli/issues/1451))
* **twitter** — enable `browserSession.reuse: 'site'` on 17 read-only adapters so repeated reads share one tab. ([#1454](https://github.com/jackwener/opencli/issues/1454))
* **reddit** — opt 13 browser-backed adapters into shared site-tab lease. ([#1455](https://github.com/jackwener/opencli/issues/1455))
* **claude** — replace fixed-sleep waits with selector-based readiness on streaming flows. ([#1452](https://github.com/jackwener/opencli/issues/1452))
* **deepseek** — replace fixed-sleep waits with selector-based readiness on streaming flows. ([#1449](https://github.com/jackwener/opencli/issues/1449))
* **chatgpt** — replace fixed-sleep waits with selector-based readiness (D3). ([#1456](https://github.com/jackwener/opencli/issues/1456))
### Refactor
* **browser** — split interactive and automation windows so `opencli browser *` and adapter-driven background commands no longer share one Chrome window; tab groups are isolated by role.
### Internal
* **extension 1.0.10** — rename the adapter-owned Chrome tab group from `OpenCLI Automation` to `OpenCLI Adapter`. ([#1457](https://github.com/jackwener/opencli/issues/1457))
* **docs** — list `tg-cli`, `discord-cli`, `wx-cli` in External CLI README sections. ([#1459](https://github.com/jackwener/opencli/issues/1459))
## [1.7.15](https://github.com/jackwener/opencli/compare/v1.7.14...v1.7.15) (2026-05-10)
Extension bumped to 1.0.9 (Accessibility.enable allowlist + downloads permission + cross-origin frame target attach for AX). Major Browser Agent Runtime release: full Phase 0/1/2 alignment with `vercel-labs/agent-browser` model — CDP-primary input, AX snapshot/refs with stale recovery, semantic locators across all primitives, full form toolbelt (hover/focus/dblclick/check/uncheck/upload/drag/wait-download), annotated screenshots, and same-origin iframe AX routing. Cross-origin OOPIF AX is best-effort (Chrome extension API limitation).
### ⚠ BREAKING CHANGES
* **browser lifecycle** — replace `--focus` / `OPENCLI_WINDOW_FOCUSED` with `--window foreground|background` / `OPENCLI_WINDOW`, and replace `--live` / `OPENCLI_LIVE` with `--keep-tab true|false` / `OPENCLI_KEEP_TAB`. `opencli browser *` defaults to a foreground window and keeps its tab; browser-backed adapter commands default to a background automation window and release their tab unless the adapter uses site-level reuse.
### Features
* **help / browser** — `opencli browser --help -f yaml|json` now emits a structured, agent-ready index of all browser leaf commands (including nested `tab`, `get`, and `dialog` commands), their positionals, command options, namespace options, and root global options. Individual browser commands also support structured help, backed by a shared Commander option/argument spec extractor.
* **help / built-in namespaces** — `opencli daemon|plugin|adapter|profile --help -f yaml|json` now emit the same structured payload as `browser`. One agent call returns every leaf's positionals, options, descriptions, and global options — no per-leaf `--help` follow-ups needed. Original namespace descriptions are preserved through `applyRootSubcommandSummaries()` via a snapshot at namespace declaration time.
* **browser state** — add opt-in AX snapshot refs via `browser state --source ax`, including backend-node click resolution and role/name stale-ref recovery for the Phase 0 browser-agent runtime prototype.
* **browser state** — AX snapshots now include same-origin iframe refs, and `browser state --compare-sources` prints DOM-vs-AX observation metrics for the Phase 1 default-source decision without dumping page contents.
* **browser locators** — `browser find`, `browser click`, and `browser get text|value|attributes` now accept semantic locator flags (`--role`, `--name`, `--label`, `--text`, `--testid`) so agents can act on common controls without a separate state-ref lookup.
* **browser locators** — semantic locator flags now work across input/action primitives (`type`, `fill`, `select`, `hover`, `focus`, `dblclick`, `check`, `uncheck`, `upload`) plus prefixed `--from-*` / `--to-*` locators for `drag`.
* **browser actions** — add `browser hover`, `browser focus`, and `browser dblclick` primitives backed by the same target resolver and CDP input path as `browser click`.
* **browser actions** — add `browser check` and `browser uncheck` primitives that ensure checkbox / radio / aria-checked controls reach the requested state instead of blindly toggling.
* **browser upload** — add `browser upload <target> <file...>` to attach local files to `input[type=file]` targets through CDP `DOM.setFileInputFiles`, with local path validation and file-input verification.
* **browser actions** — add `browser drag <source> <target>` for CDP mouse drag sequences between two resolved element centers.
* **browser wait / extension 1.0.8** — add `browser wait download [pattern]` backed by Chrome's downloads lifecycle API, so agents can wait for file downloads by filename/URL pattern and receive completed/failed download metadata.
* **browser state / extension 1.0.9** — AX snapshots can now route same-origin iframe refs through `frameId`. Cross-origin OOPIF AX routing is best-effort because real Chrome extension smoke tests show `chrome.debugger` may not expose attachable iframe targets to extensions.
* **browser screenshot** — add `browser screenshot --annotate`, which refreshes DOM refs and overlays visible `[N]` labels on the screenshot so visual inspection maps back to `browser click <ref>` targets.
### Bug Fixes
* **browser click** — `browser click` now prefers CDP `Input.dispatchMouseEvent` over DOM `el.click()`, so custom dropdowns that depend on pointer/mouse events (Radix, shadcn, Material UI, Mercury-style category pickers) open and select reliably while retaining JS click as a fallback for older backends or zero-rect targets.
* **browser state / extension 1.0.7** — `browser state --source ax` now enables the CDP Accessibility domain before reading the AX tree, fixing real-Chrome snapshots that previously returned only `RootWebArea` with zero refs.
* **help / build** — every positional arg must now declare a non-empty `help` string. The build-manifest step fails closed when a positional has empty / whitespace-only / missing `help`, so `opencli <site> <cmd> --help` always shows callers what each parameter is for. Pre-existing offenders (`twitter followers/following/list-add/list-remove/list-tweets/search/thread`, `reddit search/subreddit/user/user-comments/user-posts`, `douyin stats/update`, `bilibili subtitle`, `jike search`) now have explicit help text — most notably `twitter followers [user]` and `following [user]` now document that omitting the user fetches the currently logged-in account.
## [1.7.14](https://github.com/jackwener/opencli/compare/v1.7.13...v1.7.14) (2026-05-08)
### Features
* **help** — adapter help is now agent-friendly: per-command listings drop the `[options]` noise from globally-shared options (`--format`, `--trace`, `-v`, `-h`, etc.) and only mention them at the site level, so `opencli twitter` etc. read like a flat command index. ([#1401](https://github.com/jackwener/opencli/issues/1401))
* **twitter** — write-action symmetry P0: add `unlike`, `retweet`, `unretweet`, and `quote` to round out the read/write coverage. ([#1400](https://github.com/jackwener/opencli/issues/1400))
### Bug Fixes
* **browser daemon** — `npm install -g @jackwener/opencli@latest` now correctly auto-restarts a stale ready-state daemon so users pick up the new version without a manual `opencli daemon restart`. ([#1399](https://github.com/jackwener/opencli/issues/1399))
## [1.7.13](https://github.com/jackwener/opencli/compare/v1.7.12...v1.7.13) (2026-05-07)
Extension bumped to 1.0.6 (screenshot `--width` / `--height` / `--full-page` flags, automation tab group color marker, automation container reuse fix).
### ⚠ BREAKING CHANGES
* **linux-do** — remove deprecated compatibility shims `linux-do hot`, `linux-do category`, `linux-do latest`. Use `linux-do feed --view top --period <period>`, `linux-do feed --category <id-or-name>`, and `linux-do feed --view latest` instead.
* **grok ask** — drop the `--web` flag and the legacy `<textarea>` composer path. The default flow is now the only path and uses the current ProseMirror+TipTap composer (the path that used to require `--web true`). Existing scripts passing `--web` will get an "unknown option" error from commander; remove the flag.
* **env** — rename `OPENCLI_BROWSER_TIMEOUT` to `OPENCLI_BROWSER_IDLE_TIMEOUT`. The variable controls workspace lease idle release time, not per-command runtime; the new name reflects that. Old name was undocumented and removed without a fallback.
* **registry** — remove the unused `Strategy.HEADER`; adapter authors should use `Strategy.COOKIE` and set headers explicitly inside browser-side fetches.
### Features
* **observation** — add trace artifact primitives, `browser console`, `browser network --since/--follow/--failed`, and adapter `--trace=retain-on-failure` for failure-retained browser evidence.
* **autofix** — retire `OPENCLI_DIAGNOSTIC`; adapter repair now uses `--trace retain-on-failure`, trace `summary.md`, and error-envelope trace metadata.
* **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929))
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
* **browser session** — adapter commands can opt into site-level tab reuse with `browserSession.reuse = 'site'`; Grok and other browser-backed LLM adapters now keep a shared site tab by default, and users can override with `--reuse <none|site>`.
* **chatgpt** — add browser-web baseline commands: `ask`, `send`, `read`, `history`, `detail`, `new`, and `status`.
* **grok** — add browser-web baseline commands: `read`, `history`, `detail`, `new`, `send`, and `status` (existing `ask` and `image` unchanged).
* **yuanbao** — add browser-web baseline commands: `send`, `status`, `read`, `history`, and `detail` (joining the existing `ask` and `new`).
* **qwen** — add `detail` command for opening a specific historical conversation by id.
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
### Bug Fixes
* **pipeline / capabilityRouting** — the `fill` pipeline step (introduced in [#1222](https://github.com/jackwener/opencli/issues/1222)) now correctly triggers a browser session and gets transient retry coverage; previously a pipeline using only `fill` could crash on a missing page object. ([#1393](https://github.com/jackwener/opencli/issues/1393))
* **xiaohongshu publish** — improve image publishing reliability via creator-center URL routing, tab priority handling, and DataTransfer fallback.
* **youtube** — use watch-page HTML for transcript captions to recover when the public transcript API is unavailable.
* **desktop adapters** — restore 11 desktop adapter commands that were lost from the manifest due to a factory-pattern regression.
### Internal
* **cleanup** — remove dead `src/analysis.ts` (179 lines, 0 importers), retire `OPENCLI_DIAGNOSTIC` test residue, derive validator step allowlist from the live pipeline registry to prevent future drift.
## [1.7.8](https://github.com/jackwener/opencli/compare/v1.7.7...v1.7.8) (2026-04-25)
### Features
* **powerchina** — procurement search adapter. ([#1155](https://github.com/jackwener/opencli/issues/1155))
* **toutiao** — `articles` adapter for 头条号 creator dashboard. ([#1148](https://github.com/jackwener/opencli/issues/1148))
* **weixin** — `create-draft` and `drafts` commands for Official Account. ([#1095](https://github.com/jackwener/opencli/issues/1095))
### Bug Fixes
* **chatgpt-app** — use AX send flow and support zh-CN generating state. ([#1135](https://github.com/jackwener/opencli/issues/1135))
* **deepseek** — fix history titles and resume conversation on `ask`. ([#1153](https://github.com/jackwener/opencli/issues/1153))
* **amazon** — fall back discussion to product page. ([#1154](https://github.com/jackwener/opencli/issues/1154))
* **sinafinance** — match stock symbol in addition to name. ([#1158](https://github.com/jackwener/opencli/issues/1158))
### Chores
* **extension** — restore pre-1.6.8 neon terminal icons. ([#1177](https://github.com/jackwener/opencli/issues/1177))
## [1.7.7](https://github.com/jackwener/opencli/compare/v1.7.6...v1.7.7) (2026-04-23)
### Features
* **51job** — comprehensive adapter: `search`, `hot`, `detail`, `company`. ([#1132](https://github.com/jackwener/opencli/issues/1132))
* **weread** — `ai-outline` command for AI-generated book outlines. ([#1141](https://github.com/jackwener/opencli/issues/1141))
* **web/download** — video/audio/iframe download + `--stdout` streaming. ([#1146](https://github.com/jackwener/opencli/issues/1146))
* **download** — hardened HTML→Markdown pipeline with better element handling. ([#1143](https://github.com/jackwener/opencli/issues/1143))
* **verify** — fixture-based value validation + skill docs for COOKIE pitfalls. ([#1131](https://github.com/jackwener/opencli/issues/1131))
* **agent-native retrospective** — analyze / verify guards / fixture content checks. ([#1133](https://github.com/jackwener/opencli/issues/1133))
* **twitter** — expose `has_media` and `media_urls` columns. ([#1115](https://github.com/jackwener/opencli/issues/1115))
### Bug Fixes
* **core** — quality audit fixes: elapsed=0 display, daemon error handler state reset, cause chain truncation guard, download cookie expiry, launcher async kill, verbose error logging. ([#1151](https://github.com/jackwener/opencli/issues/1151))
* **daemon** — allow extension ping CORS for reachability probing. ([#1150](https://github.com/jackwener/opencli/issues/1150))
* **deepseek** — separate thinking process from response in `--think` mode. ([#1142](https://github.com/jackwener/opencli/issues/1142))
* **deepseek** — use position-based model selection instead of text matching. ([#1123](https://github.com/jackwener/opencli/issues/1123))
* **weread/book** — add fallback selectors for reader page without cover. ([#1138](https://github.com/jackwener/opencli/issues/1138))
* **xiaoyuzhou** — correct podcast-episodes API endpoint. ([#1129](https://github.com/jackwener/opencli/issues/1129))
* **bilibili** — resolve full video URLs and preserve full description. ([#1118](https://github.com/jackwener/opencli/issues/1118))
### Docs
* Fix stale references in READMEs and autofix skill doc. ([#1130](https://github.com/jackwener/opencli/issues/1130))
* Restore and rewrite `opencli-usage` as orientation skill. ([#1128](https://github.com/jackwener/opencli/issues/1128))
## [1.7.6](https://github.com/jackwener/opencli/compare/v1.7.5...v1.7.6) (2026-04-21)
Extension bumped to 1.0.2 (body-truncation signal unified across raw / detail / fallback paths).
### Features
* **Window lifecycle flags** — `--live` (or `OPENCLI_LIVE=1`) keeps the automation window open after a command finishes; `--focus` (or `OPENCLI_WINDOW_FOCUSED=1`) brings the window to the foreground. Works on any subcommand. ([#1122](https://github.com/jackwener/opencli/issues/1122))
* **Selector-first browser interactions** — `find` / `get` / `click` / `type` / `select` accept CSS selectors in addition to numeric refs; `--nth` disambiguates multiple matches. ([#1112](https://github.com/jackwener/opencli/issues/1112))
* **Agent-native browser payload** — structured `network` bodies with truncation signal, `get html --as json` with `--depth` / `--children-max` / `--text-max` budgets, new `browser extract` command for long-form content with resume cursor. ([#1104](https://github.com/jackwener/opencli/issues/1104))
* **`network --filter <fields>`** — filter captured requests by body-shape path segments for quick API discovery. ([#1103](https://github.com/jackwener/opencli/issues/1103))
* **`get html --as json`** — structured HTML tree output; no more silent truncation on raw `--as html`. ([#1102](https://github.com/jackwener/opencli/issues/1102))
* **`browser network` rewrite** — agent-native discovery with cache keys and shape preview. ([#1100](https://github.com/jackwener/opencli/issues/1100))
* **Compound form fields** — date / select / file controls surface a `compound` envelope with format, options, `accept`. Cascading stale-ref recovery + bbox 0.99 dedup for tagged elements. ([#1116](https://github.com/jackwener/opencli/issues/1116))
* **twitter `tweets`** — fetch a user's recent posts. ([#1098](https://github.com/jackwener/opencli/issues/1098))
* **bilibili `video`** — new video command. ([#1110](https://github.com/jackwener/opencli/issues/1110))
* **deepseek `--file`** — file upload support on `ask`. ([#1093](https://github.com/jackwener/opencli/issues/1093))
### Bug Fixes
* **twitter** — 5s timeout on `resolveTwitterQueryId` to prevent hang. ([#1106](https://github.com/jackwener/opencli/issues/1106))
* **youtube** — fall back to Videos tab when Home has no videos. ([#1109](https://github.com/jackwener/opencli/issues/1109))
* **jianyu** — keep accessible detail urls in search. ([#1099](https://github.com/jackwener/opencli/issues/1099))
* **jianyu** — block inaccessible detail links and verification pages. ([#918](https://github.com/jackwener/opencli/issues/918))
### Docs
* **opencli-browser skill** — restored and upgraded for selector-first workflow. ([#1119](https://github.com/jackwener/opencli/issues/1119))
* **Window lifecycle** — sync README + skill docs with `--live` / `--focus` behavior. ([#1125](https://github.com/jackwener/opencli/issues/1125))
### Extension (1.0.2)
* Unify body-truncation contract across raw / detail / fallback network paths; surface `body_truncated` / `body_full_size` / `body_truncation_reason`. ([#1104](https://github.com/jackwener/opencli/issues/1104))
## [1.7.5](https://github.com/jackwener/opencli/compare/v1.7.4...v1.7.5) (2026-04-20)
Extension bumped to 1.0.1 (multi-tab routing + cross-origin iframe).
### Features
* **DeepSeek adapter** — browser-based `ask` / `history` / `new` / `read` / `status` ([#1088](https://github.com/jackwener/opencli/issues/1088))
* **Eastmoney adapters** — 13 finance adapters as Phase A oracle: `quote`, `rank`, `kline`, `sectors`, `etf`, `holders`, `money-flow`, `northbound`, `longhu`, `kuaixun`, `convertible`, `index-board`, `announcement` ([#1091](https://github.com/jackwener/opencli/issues/1091))
* **Twitter GraphQL lists** — `list-tweets`, `list-add`, `list-remove` ([#1076](https://github.com/jackwener/opencli/issues/1076))
* **nowcoder adapter** — 牛客网 with 16 commands ([#1036](https://github.com/jackwener/opencli/issues/1036))
* **Chinese academic & policy adapters** — `baidu-scholar`, `google-scholar`, `wanfang`, `gov-law`, `gov-policy` ([#243](https://github.com/jackwener/opencli/issues/243))
* **Download saved path** — `web read` and `weixin download` now show saved file location ([#1042](https://github.com/jackwener/opencli/issues/1042))
* **Cross-origin iframe support** — CDP execution context for iframed content ([#1084](https://github.com/jackwener/opencli/issues/1084))
### Improvements
* **Multi-tab routing** — hardened target isolation and tab routing ([#1072](https://github.com/jackwener/opencli/issues/1072))
* **Skill consolidation** — 6 skills merged into 3 (`opencli-adapter-author`, `opencli-autofix`, `smart-search`); removed mechanical commands `explore` / `synthesize` / `generate` / `cascade` / `record` ([#1094](https://github.com/jackwener/opencli/issues/1094))
* **Browser docs rewrite** — docs reoriented for AI Agent use case ([#1080](https://github.com/jackwener/opencli/issues/1080))
* **antigravity serve** — configurable timeout + auto-reconnect ([#859](https://github.com/jackwener/opencli/issues/859), [#1063](https://github.com/jackwener/opencli/issues/1063))
* **Design debt cleanup** — deprecated APIs, arg validation, dead plugin code ([#1065](https://github.com/jackwener/opencli/issues/1065))
### Bug Fixes
* **xiaoyuzhou** — migrate from broken SSR to authenticated API ([#1059](https://github.com/jackwener/opencli/issues/1059)); accept `CONFIG_ERROR` in E2E guard ([#1066](https://github.com/jackwener/opencli/issues/1066))
* **xiaohongshu** — detect draft save success ([#1060](https://github.com/jackwener/opencli/issues/1060)); verify title input sticks on publish ([#1050](https://github.com/jackwener/opencli/issues/1050))
* **twitter** — repair lists scraping from detail pages ([#1053](https://github.com/jackwener/opencli/issues/1053))
* **zsxq** — separate content from title, remove title truncation ([#1079](https://github.com/jackwener/opencli/issues/1079))
* **extension** — per-workspace idle timeout for browser sessions ([#1064](https://github.com/jackwener/opencli/issues/1064))
### Revert
* Undo output renderer table-formatting patch ([#1085](https://github.com/jackwener/opencli/issues/1085), reverts [#1081](https://github.com/jackwener/opencli/issues/1081))
### Extension (1.0.1)
* Multi-tab routing support ([#1072](https://github.com/jackwener/opencli/issues/1072))
* Cross-origin iframe CDP contexts ([#1084](https://github.com/jackwener/opencli/issues/1084))
## [1.7.0](https://github.com/jackwener/opencli/compare/v1.6.1...v1.7.0) (2026-04-11)
This is a major release with significant internal architecture changes.
Adapter code, validation, and error handling have been modernized.
### ⚠ BREAKING CHANGES
* **Node.js >= 21 required** — `import.meta.dirname` is used in core modules; Node 20 and below will fail at startup.
* **YAML adapters deprecated** — YAML-based `.yaml` adapters are no longer loaded. Existing YAML adapters must be converted to JS via `cli()` API. A deprecation warning is emitted if `.yaml` files are detected.
* **`.ts` adapters no longer loaded at runtime** — The runtime only discovers `.js` files. If you have `.ts` adapters in `~/.opencli/clis/`, compile them to `.js` or rewrite using plain JS. A warning is printed when `.ts` files without a matching `.js` are found.
* **Error output format changed** — All errors are now emitted as a structured YAML envelope to stderr. Scripts parsing stdout for `[{error, help}]` must switch to stderr / exit code. ([#923](https://github.com/jackwener/opencli/issues/923))
* **`tabId` replaced by `targetId`** — Cross-layer page identity now uses `targetId`. Extensions and plugins referencing `tabId` must update. ([#899](https://github.com/jackwener/opencli/issues/899))
* **`operate` renamed to `browser`** — All `opencli operate` commands are now `opencli browser`. ([#883](https://github.com/jackwener/opencli/issues/883))
### Features
* **auto-close adapter windows** — Browser tabs opened by adapters are automatically closed after execution; configurable via `OPENCLI_WINDOW_FOCUSED`. ([#915](https://github.com/jackwener/opencli/issues/915))
* **Self-Repair protocol** — Automatic adapter fixing when commands fail. ([#866](https://github.com/jackwener/opencli/issues/866))
* **EarlyHint callback** — Cost gating channel for generate pipeline. ([#882](https://github.com/jackwener/opencli/issues/882))
* **verified generate pipeline** — Structured contract for AI-driven adapter generation. ([#878](https://github.com/jackwener/opencli/issues/878))
* **structured diagnostic output** — AI-driven adapter repair gets structured diagnostics. ([#802](https://github.com/jackwener/opencli/issues/802))
* **auto-downgrade to YAML in non-TTY** — Machine-readable output when piped. ([#737](https://github.com/jackwener/opencli/issues/737))
* **Browser Use improvements** — Better click/type/state handling for browser automation. ([#707](https://github.com/jackwener/opencli/issues/707))
* **CDP session-level network capture** — Full network capture support for CDPPage. ([#815](https://github.com/jackwener/opencli/issues/815), [#816](https://github.com/jackwener/opencli/issues/816))
* **AutoResearch framework** — V2EX/Zhihu test suites (194 tasks). ([#731](https://github.com/jackwener/opencli/issues/731), [#717](https://github.com/jackwener/opencli/issues/717), [#741](https://github.com/jackwener/opencli/issues/741))
* **new adapters:** Gitee ([#845](https://github.com/jackwener/opencli/issues/845)), 闲鱼 ([#696](https://github.com/jackwener/opencli/issues/696)), 1688 ([#650](https://github.com/jackwener/opencli/issues/650), [#820](https://github.com/jackwener/opencli/issues/820)), LessWrong ([#773](https://github.com/jackwener/opencli/issues/773)), 虎扑 ([#751](https://github.com/jackwener/opencli/issues/751)), 小鹅通 ([#617](https://github.com/jackwener/opencli/issues/617)), 元宝 ([#693](https://github.com/jackwener/opencli/issues/693)), 即梦 ([#897](https://github.com/jackwener/opencli/issues/897), [#895](https://github.com/jackwener/opencli/issues/895)), Quark Drive ([#858](https://github.com/jackwener/opencli/issues/858)), GitHub Trending/Binance/Weather ([#214](https://github.com/jackwener/opencli/issues/214))
* **adapter enhancements:** Instagram post/reel/story/note ([#671](https://github.com/jackwener/opencli/issues/671)), Twitter image posts/replies ([#666](https://github.com/jackwener/opencli/issues/666), [#756](https://github.com/jackwener/opencli/issues/756)), 知乎 interactions ([#868](https://github.com/jackwener/opencli/issues/868)), Bilibili b23.tv short URL ([#740](https://github.com/jackwener/opencli/issues/740)), 雪球 kline/groups ([#809](https://github.com/jackwener/opencli/issues/809)), Amazon unified ranking ([#724](https://github.com/jackwener/opencli/issues/724)), Gemini deep-research ([#778](https://github.com/jackwener/opencli/issues/778)), 新浪财经热搜 ([#736](https://github.com/jackwener/opencli/issues/736)), linux-do topic split ([#821](https://github.com/jackwener/opencli/issues/821)), JD/淘宝/CNKI revived ([#248](https://github.com/jackwener/opencli/issues/248))
### Bug Fixes
* **security:** escape codegen strings and redact diagnostic body ([#930](https://github.com/jackwener/opencli/issues/930))
* **bilibili:** add missing domain for following cli ([#947](https://github.com/jackwener/opencli/issues/947))
* clean up stale `.ts` adapter files during upgrade ([#948](https://github.com/jackwener/opencli/issues/948))
* clean up legacy shim files and stale tmp files on upgrade ([#934](https://github.com/jackwener/opencli/issues/934))
* address deep review findings (security, correctness, consistency) ([#935](https://github.com/jackwener/opencli/issues/935))
* batch quality improvements — dedupe completion, unify logging, fix docs ([#945](https://github.com/jackwener/opencli/issues/945))
* graceful fallback when extension lacks network-capture support ([#865](https://github.com/jackwener/opencli/issues/865))
* handle missing electron executable gracefully ([#747](https://github.com/jackwener/opencli/issues/747))
* recover drifted tabs instead of abandoning them ([#715](https://github.com/jackwener/opencli/issues/715))
* retry on "No window with id" CDP error ([#892](https://github.com/jackwener/opencli/issues/892))
* **launcher:** graceful degradation and manual CDP override for Windows ([#744](https://github.com/jackwener/opencli/issues/744))
* **xiaohongshu:** scope note interaction selectors, replace blind retry with MutationObserver ([#839](https://github.com/jackwener/opencli/issues/839), [#730](https://github.com/jackwener/opencli/issues/730))
* **twitter:** relax reply composer timeout, use composer for text replies ([#862](https://github.com/jackwener/opencli/issues/862), [#860](https://github.com/jackwener/opencli/issues/860))
* **doubao:** preserve image URLs, connect to correct CDP target ([#708](https://github.com/jackwener/opencli/issues/708), [#674](https://github.com/jackwener/opencli/issues/674))
* **gemini:** stabilize ask reply state handling ([#735](https://github.com/jackwener/opencli/issues/735))
* **douban:** fix marks pagination and improve subject data extraction ([#752](https://github.com/jackwener/opencli/issues/752))
* **jianyu:** avoid early API bucket cutoff, stabilize search ([#916](https://github.com/jackwener/opencli/issues/916), [#912](https://github.com/jackwener/opencli/issues/912))
* **xiaoe:** resolve missing episodes for long courses via auto-scroll ([#904](https://github.com/jackwener/opencli/issues/904))
### Refactoring
* **adapters:** convert adapter layer from TypeScript to JavaScript ([#928](https://github.com/jackwener/opencli/issues/928))
* **adapters:** migrate all CLI adapters from YAML to TypeScript, then to JS ([#887](https://github.com/jackwener/opencli/issues/887), [#922](https://github.com/jackwener/opencli/issues/922))
* **validate:** switch from YAML-file scanning to registry-based validation ([#943](https://github.com/jackwener/opencli/issues/943))
* **strategy:** normalize strategy into runtime fields at registration time ([#941](https://github.com/jackwener/opencli/issues/941))
* **errors:** unify error output as YAML envelope to stderr ([#923](https://github.com/jackwener/opencli/issues/923))
* **daemon:** make daemon persistent, remove idle timeout ([#913](https://github.com/jackwener/opencli/issues/913))
* **browser:** unify browser error classification and deduplicate retry logic ([#908](https://github.com/jackwener/opencli/issues/908))
* **monorepo:** adapter separation — `clis/` at root ([#782](https://github.com/jackwener/opencli/issues/782))
* rename `operate` to `browser` ([#883](https://github.com/jackwener/opencli/issues/883))
* eliminate `any` types in core files ([#886](https://github.com/jackwener/opencli/issues/886))
* migrate adapter imports to package exports ([#795](https://github.com/jackwener/opencli/issues/795))
### Performance
* **P0 optimizations** — faster startup, reduced overhead ([#944](https://github.com/jackwener/opencli/issues/944))
* fast-path completion/version/shell-scripts to bypass full discovery ([#898](https://github.com/jackwener/opencli/issues/898))
* optimize browser pipeline — tab query dedup, parallel stealth, incremental snapshots ([#713](https://github.com/jackwener/opencli/issues/713))
* reduce round-trips in browser command hot path ([#712](https://github.com/jackwener/opencli/issues/712))
* skip blank page on first browser command ([#710](https://github.com/jackwener/opencli/issues/710))
### Documentation
* restructure README narrative ([#885](https://github.com/jackwener/opencli/issues/885))
* add Android Chrome usage guide ([#687](https://github.com/jackwener/opencli/issues/687))
* add Electron app CLI quickstart guide
* fix stale `.ts` references across skills and docs ([#954](https://github.com/jackwener/opencli/issues/954))
* unify skill command references and merge opencli-generate into opencli-explorer ([#891](https://github.com/jackwener/opencli/issues/891), [#894](https://github.com/jackwener/opencli/issues/894))
### Upgrade Guide
1. **Update Node.js** to v21 or later (v22 LTS recommended).
2. **Run `npm install -g @jackwener/opencli@latest`** — the preuninstall hook gracefully stops the old daemon; the first browser command after upgrade auto-restarts it.
3. **If you have custom `.ts` adapters** in `~/.opencli/clis/`, rename or compile them to `.js`. A warning will be printed on startup if stale `.ts` files are detected.
4. **If you have custom `.yaml` adapters**, convert them to JS using the `cli()` API (see `skills/opencli-adapter-author/references/adapter-template.md`).
5. **If you parse error output from stdout**, switch to stderr. Errors are now structured YAML envelopes with typed exit codes.
## [1.6.1](https://github.com/jackwener/opencli/compare/v1.6.0...v1.6.1) (2026-04-02)
@@ -13,7 +453,7 @@
### Features
* **opencli-operate:** add browser control commands for Claude Code skill ([#614](https://github.com/jackwener/opencli/issues/614))
* **opencli-browser:** add browser control commands for Claude Code skill ([#614](https://github.com/jackwener/opencli/issues/614))
* **docs:** add tab completion to getting started guides ([#658](https://github.com/jackwener/opencli/issues/658))
+51 -62
View File
@@ -18,7 +18,6 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npm test
npm run test:adapter
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -26,54 +25,48 @@ npm link
## Adding a New Site Adapter
This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows.
All adapters use TypeScript. Use the pipeline API for data-fetching commands, and `func()` for complex browser interactions.
### YAML Adapter (Recommended for data-fetching commands)
### Pipeline Adapter (Recommended for data-fetching commands)
Create a file like `src/clis/<site>/<command>.yaml`:
```yaml
site: mysite
name: trending
description: Trending posts on MySite
domain: www.mysite.com
strategy: public # public | cookie | header
browser: false # true if browser session is needed
args:
query:
positional: true
type: str
required: true
description: Search keyword
limit:
type: int
default: 20
description: Number of items
pipeline:
- fetch:
url: https://api.mysite.com/trending
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
score: ${{ item.score }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [rank, title, score, url]
```
See [`hackernews/top.yaml`](src/clis/hackernews/top.yaml) for a real example.
### TypeScript Adapter (For complex browser interactions)
Create a file like `src/clis/<site>/<command>.ts`:
Create a file like `clis/<site>/<command>.js`:
```typescript
import { cli, Strategy } from '../../registry.js';
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'mysite',
name: 'trending',
description: 'Trending posts on MySite',
domain: 'www.mysite.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of items' },
],
columns: ['rank', 'title', 'score', 'url'],
pipeline: [
{ fetch: { url: 'https://api.mysite.com/trending' } },
{ map: {
rank: '${{ index + 1 }}',
title: '${{ item.title }}',
score: '${{ item.score }}',
url: '${{ item.url }}',
}},
{ limit: '${{ args.limit }}' },
],
});
```
See [`hackernews/top.js`](clis/hackernews/top.js) for a real example.
### func() Adapter (For complex browser interactions)
Create a file like `clis/<site>/<command>.js`:
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'mysite',
@@ -109,12 +102,12 @@ cli({
});
```
Use `opencli explore <url>` to discover APIs and see [opencli-explorer skill](./skills/opencli-explorer/SKILL.md) if you need the full adapter workflow.
Install the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md) if you need the full adapter workflow — recon → API discovery → field decoding → `opencli browser verify`.
### Validate Your Adapter
```bash
# Validate YAML syntax and schema
# Validate adapter
opencli validate
# Test your command
@@ -137,16 +130,12 @@ Use **positional** for the primary, required argument of a command (the "what"
Do **not** convert an argument to positional just because it appears first in the file. If the argument is optional, acts like a filter, or selects a mode/configuration, it should usually stay a named option.
YAML example:
```yaml
args:
query:
positional: true # ← primary arg, user types it directly
type: str
required: true
limit:
type: int # ← config arg, user types --limit 10
default: 20
Pipeline example:
```typescript
args: [
{ name: 'query', positional: true, required: true, help: 'Search query' }, // ← primary arg
{ name: 'limit', type: 'int', default: 20, help: 'Max results' }, // ← config arg
]
```
TS example:
@@ -162,8 +151,8 @@ args: [
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
```bash
npm test # Core unit tests (non-adapter)
npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili
npm test # Default local gate: unit + extension + adapter tests
npm run test:adapter # Adapter-only project (useful while iterating on adapters)
npx vitest run tests/e2e/ # E2E tests
npx vitest run # All tests
```
@@ -196,9 +185,9 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel
3. Run the checks that apply:
```bash
npx tsc --noEmit # Type check
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if you touched adapter logic)
opencli validate # YAML validation (if applicable)
npm test # Default local gate: unit + extension + adapter
npm run test:adapter # Adapter-only project (optional while iterating on adapters)
opencli validate # Adapter validation
```
4. Commit using conventional commit format
5. Push and open a PR
+252 -100
View File
@@ -1,168 +1,312 @@
# OpenCLI
> **Make any website, Electron App, or Local Tool your CLI.**
> Zero risk · Reuse Chrome/Chromium login · AI-powered discovery · Universal CLI Hub
> **Turn websites, browser sessions, Electron apps, and local tools into deterministic interfaces for humans and AI agents.**
> Reuse your logged-in browser, automate live workflows, and crystallize repeated actions into reusable CLI commands.
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
OpenCLI gives you one surface for three different kinds of automation:
**Built for AI Agents** — Load the [`opencli-operate` skill](./skills/opencli-operate/SKILL.md) to give any AI agent (Claude Code, Cursor) direct browser control. Operate any website, then crystallize those interactions into reusable CLI commands. Configure `opencli list` in your `AGENT.md` or `.cursorrules` so the AI auto-discovers all available tools.
- **Use built-in adapters** for sites like Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, Twitter/X, and [many more](#built-in-commands).
- **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
- **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
**CLI for Electron Apps** — Turn any Electron application into a CLI tool. Recombine, script, and extend apps like Antigravity Ultra from the terminal. AI agents can now control other AI apps natively.
---
It also works as a **CLI hub** for local tools such as `gh`, `docker`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT.
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Browser Automation** — `operate` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
- **Website → CLI** — Turn any website into a deterministic CLI: 70+ pre-built adapters, or crystallize your own with `opencli record`.
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, etc.) directly from the terminal via CDP.
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type/fill, extract, screenshot — all through your logged-in Chrome session.
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `operate` controls the browser directly.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification.
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, tg, discord, wx, etc).
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
- **Broad coverage** — 73+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
---
## Quick Start
### 1. Install Browser Bridge Extension
### 1. Install OpenCLI
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome/Chromium extension + micro-daemon (zero config, auto-start).
OpenCLI requires **Node.js >= 21**.
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
```bash
node --version
npm install -g @jackwener/opencli
```
### 2. Install the Browser Bridge Extension
OpenCLI connects to Chrome/Chromium through a lightweight Browser Bridge extension plus a small local daemon. The daemon auto-starts when needed.
**Option A — Chrome Web Store (recommended):**
Install **OpenCLI** from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk).
**Option B — Manual install:**
1. Download the latest `opencli-extension-v{version}.zip` from the GitHub [Releases page](https://github.com/jackwener/opencli/releases).
2. Unzip it, open `chrome://extensions`, and enable **Developer mode**.
3. Click **Load unpacked** and select the unzipped folder.
### 2. Install OpenCLI
**Install via npm (recommended)**
### 3. Verify the setup
```bash
npm install -g @jackwener/opencli
opencli doctor
```
# Install AI skills for Claude Code / Cursor
### 4. Optional: name your Chrome profile
Each Chrome profile runs its own OpenCLI extension instance. If you use multiple Chrome profiles, list the connected profiles and assign local aliases:
```bash
opencli profile list
opencli profile rename <contextId> work
opencli profile use work
opencli --profile work browser state
```
With only one connected profile, OpenCLI uses it automatically. With multiple connected profiles and no default, OpenCLI asks you to choose instead of guessing.
### 5. Run your first commands
```bash
opencli list
opencli hackernews top --limit 5
opencli bilibili hot --limit 5
```
## For Humans
Use OpenCLI directly when you want a reliable command instead of a live browser session:
- `opencli list` shows every registered command.
- `opencli <site> <command>` runs a built-in or generated adapter.
- `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.
### Install skills
```bash
npx skills add jackwener/opencli
```
### 3. Verify & Try
Or install only what you need:
```bash
opencli doctor # Check extension + daemon connectivity
opencli daemon status # Check daemon state (PID, uptime, memory)
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
**Try it out:**
### Which skill to use
```bash
opencli list # See all commands
opencli hackernews top --limit 5 # Public API, no browser needed
opencli bilibili hot --limit 5 # Browser command (requires Extension)
```
| Skill | When to use | Example prompt to your AI agent |
|-------|------------|-------------------------------|
| **opencli-adapter-author** | Operate a site in real time, or write a reusable adapter for a new site | "Help me check my Xiaohongshu notifications" / "Write an adapter for douyin trending" / "Make a command that grabs the top posts from this page" |
| **opencli-autofix** | Repair a broken adapter when a built-in command fails | "`opencli zhihu hot` is returning empty — fix it" |
| **opencli-browser** | Browser automation reference for AI agents | "Use browser commands to scrape this page" |
| **opencli-usage** | Quick reference for all OpenCLI commands and sites | "What commands does OpenCLI have for Twitter?" |
| **smart-search** | Search across existing OpenCLI capabilities | "Find me a Bilibili trending adapter" |
### 4. Browser Automation — Make Websites Accessible for AI Agents
### How it works
Point your AI agent (Claude Code, Cursor) to [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md). It has everything needed — full command reference, examples, and workflow.
Once `opencli-adapter-author` is installed, your AI agent can:
Available commands: `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, `close`.
1. **Navigate** to any URL using your logged-in browser
2. **Read** page content via structured DOM snapshots (not screenshots)
3. **Interact** — click buttons, fill forms, select options, press keys
4. **Extract** data from the page or intercept network API responses
5. **Wait** for elements, text, or page transitions
### Update
The agent handles all the `opencli browser` commands internally — you just describe what you want done in natural language.
```bash
npm install -g @jackwener/opencli@latest
```
**Skill references:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — browser operation + adapter authoring, end-to-end
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — repair broken adapters
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — browser automation reference
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — command and site reference
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — capability search
### Install AI Skills
Available browser commands include `open`, `state`, `click`, `type`, `fill`, `select`, `keys`, `wait`, `get`, `find`, `extract`, `frames`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`.
OpenCLI provides [skills](./skills/) for AI agents (Claude Code, etc.):
`opencli browser` commands require a `<session>` positional immediately after `browser`. `opencli browser work open <url>` and `opencli browser work tab new [url]` both return a target ID. Use `opencli browser work tab list` to inspect target IDs, then pass `--tab <targetId>` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select <targetId>` promotes that tab to the default target for later untargeted commands in the same session.
```bash
# Install all OpenCLI skills
npx skills add jackwener/opencli
## Core Concepts
# Or install specific skills
npx skills add jackwener/opencli --skill opencli-usage # Command reference
npx skills add jackwener/opencli --skill opencli-operate # Browser automation for AI agents
npx skills add jackwener/opencli --skill opencli-explorer # Adapter development guide
npx skills add jackwener/opencli --skill opencli-oneshot # Quick command reference
```
### `browser`: AI Agent browser control
---
`opencli browser` commands are the low-level primitives that AI Agents use to operate websites. You don't run these manually — instead, install the `opencli-adapter-author` skill into your AI agent, describe what you want in natural language, and the agent handles the browser operations.
### For Developers
For example, tell your agent: *"Help me check my Xiaohongshu notifications"* — the agent will use `opencli browser <session> open`, `state`, `click`, etc. under the hood.
**Install from source**
### Built-in adapters: stable commands
```bash
git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && npm run build && npm link
```
Use site-specific commands such as `opencli hackernews top` or `opencli reddit hot` when the capability already exists. These are deterministic and work without browser — ideal for both humans and AI agents.
**Load Source Browser Bridge Extension**
### Writing a new adapter
1. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
2. Click **Load unpacked** and select the `extension/` directory from this repository.
When the site you need is not yet covered, use the `opencli-adapter-author` skill. It takes the agent end-to-end:
---
1. Recon the site and classify its pattern (SPA / SSR / JSONP / Token / Streaming).
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`.
4. Decode response fields and design output columns.
5. `opencli browser recon analyze <url>` for one-shot recon, then `opencli browser recon init <site>/<name>` → write adapter → `opencli browser recon 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
OpenCLI is not only for websites. It can also:
- expose local binaries like `gh`, `docker`, `obsidian`, `tg`, `discord`, `wx`, or custom tools through `opencli <tool> ...`
- control Electron desktop apps through dedicated adapters and CDP-backed integrations
## Prerequisites
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
- **Chrome or Chromium** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
- **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 commands reuse your Chrome/Chromium login session. You must be logged into the target website in Chrome or Chromium before running commands. If you get empty data or errors, check your login status first.
> **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.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge |
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
| `OPENCLI_WINDOW` | command default | Set to `foreground` or `background` to override Browser Bridge window placement. Browser-backed commands also accept `--window <foreground\|background>`. |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
| `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) |
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
`opencli browser *` requires an explicit `<session>` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser <session> close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab.
## Update
```bash
npm install -g @jackwener/opencli@latest
# If you use the packaged OpenCLI skills, refresh them too
npx skills add jackwener/opencli
```
Or refresh only the skills you actually use:
```bash
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
## For Developers
Install from source:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
To load the source Browser Bridge extension:
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select this repository's `extension/` directory.
## Built-in Commands
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
| **tieba** | `hot` `posts` `search` `read` |
| **hupu** | `hot` `search` `detail` `reply` `like` `unlike` |
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
| **1688** | `search` `item` `store` |
| **gemini** | `new` `ask` `image` |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` |
| **1688** | `search` `item` `assets` `download` `store` |
| **gitee** | `trending` `search` `user` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |
| **xianyu** | `search` `item` `chat` `publish` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
| **uiverse** | `code` `preview` |
| **baidu-scholar** | `search` |
| **google-scholar** | `search` `cite` `profile` |
| **gov-law** | `search` `recent` |
| **gov-policy** | `search` `recent` |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` |
| **wanfang** | `search` |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
100+ site surfaces in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast`, `podcast-episodes`, `episode`, `download`, and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## CLI Hub
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install when a safe package-manager command is configured.
| External CLI | Description | Example |
|--------------|-------------|---------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker | `opencli docker ps` |
| **ntn** | Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments | `opencli ntn pages list` |
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
| **dingtalk** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dingtalk msg send --to user "hello"` |
| **wecom** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom msg send --to user "hello"` |
| **dws** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom-cli msg send --to user "hello"` |
| **tg(tg-cli)** | Telegram — local-first sync, search, and export via MTProto for AI agents | `opencli tg search "AI news" -f json` |
| **discord(discord-cli)** | Discord — local-first sync, search, and export via SQLite for AI agents | `opencli discord recent --channel general` |
| **wx(wx-cli)** | WeChat — query local WeChat data: sessions, messages, search, contacts, export | `opencli wx search "OpenCLI"` |
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
```bash
opencli register mycli
opencli external register mycli
```
**Manual install** — some external CLIs use official shell-script installers rather than shell-free package-manager commands. For `ntn`, install from <https://ntn.dev> first, then run `opencli ntn ...`.
### Desktop App Adapters
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
@@ -172,9 +316,8 @@ Control Electron desktop apps directly from the terminal. Each adapter has its o
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatGPT App** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
@@ -187,21 +330,31 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| Platform | Content Types | Notes |
|----------|---------------|-------|
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
| **rednote** | Images, Videos | Downloads all media from a signed rednote note URL |
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | From user media tab or single tweet |
| **douban** | Images | Poster / still image lists |
| **pixiv** | Images | Original-quality illustrations, multi-page |
| **1688** | Images, Videos | Downloads page-visible product media from item pages |
| **xiaoyuzhou** | Audio, Transcript | Downloads episode audio and transcript JSON/text with local credentials |
| **zhihu** | Articles (Markdown) | Exports with optional image download |
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
For video downloads, install `yt-dlp` first: `brew install yt-dlp`
```bash
opencli xiaohongshu download abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
opencli rednote download "https://www.rednote.com/search_result/<id>?xsec_token=..." --output ./rednote
opencli bilibili download BV1xxx --output ./bilibili
opencli twitter download elonmusk --limit 20 --output ./twitter
opencli 1688 download 841141931191 --output ./1688-downloads
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
```
`opencli xiaoyuzhou download` and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
## Output Formats
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
@@ -230,8 +383,8 @@ opencli follows Unix `sysexits.h` conventions so it integrates naturally with sh
```bash
opencli spotify status || echo "exit $?" # 69 if browser not running
opencli github issues 2>/dev/null
[ $? -eq 77 ] && opencli github auth # auto-auth if not logged in
opencli gh issue list 2>/dev/null
[ $? -eq 77 ] && opencli gh auth login # auto-auth if not logged in
```
## Plugins
@@ -247,25 +400,24 @@ opencli plugin uninstall my-tool
| Plugin | Type | Description |
|--------|------|-------------|
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles |
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | TS | VK (VKontakte) wall, feed, and search |
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | JS | GitHub Trending repositories |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | JS | Multi-platform trending aggregator |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | JS | 稀土掘金 (Juejin) hot articles |
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | JS | VK (VKontakte) wall, feed, and search |
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
## For AI Agents (Developer Guide)
> **Quick mode**: To generate a single command for a specific page URL, see [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — just a URL + one-line goal, 4 steps done.
Before writing any adapter code, read the [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md). It takes you end-to-end:
> **Full mode**: Before writing any adapter code, read [opencli-explorer skill](./skills/opencli-explorer/SKILL.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
- Discover the right endpoint via `opencli browser <session> network`, `eval`, or the interceptor fallback.
- Decide auth strategy (`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`).
- Run `opencli browser recon analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser recon init`.
- Verify with `opencli browser recon verify <site>/<name>` before shipping.
```bash
opencli explore https://example.com --site mysite # Discover APIs + capabilities
opencli synthesize mysite # Generate YAML adapters
opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
```
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
## Testing
@@ -273,10 +425,10 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
## Troubleshooting
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions` in Chrome or Chromium.
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
- **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs.
- **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
+263 -138
View File
@@ -1,157 +1,267 @@
# OpenCLI
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
> 零风控 · 复用 Chrome/Chromium 登录 · AI 自动发现接口 · 全能 CLI 枢纽
> **把网站、浏览器会话、Electron 应用和本地工具,统一变成适合人类与 AI Agent 使用的确定性接口。**
> 复用浏览器登录态,先自动化真实操作,再把高频流程沉淀成可复用的 CLI 命令。
[![English](https://img.shields.io/badge/docs-English-1D4ED8?style=flat-square)](./README.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh``docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。
OpenCLI 可以用同一套 CLI 做三类事情:
**专为 AI Agent 打造**:加载 [`opencli-operate` skill](./skills/opencli-operate/SKILL.md),赋予 AI AgentClaude Code、Cursor 等)直接操控浏览器的能力——操作任意网站,并将这些交互沉淀为可复用的 CLI 命令。在 `AGENT.md``.cursorrules` 中配置 `opencli list`AI 即可自动发现并调用所有可用工具
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用
- **让 AI Agent 操作任意网站**:在你的 AI AgentClaude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。
- **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。
**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
---
除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh``docker``tg``discord``wx``ntn`Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT 等 Electron 应用。
## 亮点
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **浏览器自动化** — `operate` 赋予 AI Agent 直接操控浏览器的能力:点击、输入、提取、截图,任意交互皆可脚本化
- **网页转 CLI** — 将任意网站变成确定性命令行工具:73+ 预置适配器,或用 `opencli record` 沉淀自己的操作
- **多站点覆盖** — 73+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
- **零风控** — 复用 Chrome/Chromium 登录态,无需存储任何凭证
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker` 等本地 CLI
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
- **AI 原生** — `explore` 自动发现 API`synthesize` 生成适配器,`cascade` 探测认证策略,`operate` 直接控制浏览器
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
## 前置要求
- **Node.js**: >= 20.0.0
- **Chrome 或 Chromium** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com
> **⚠️ 重要**:大多数命令复用你的 Chrome/Chromium 登录状态。运行命令前,你必须已在 Chrome 或 Chromium 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
### Browser Bridge 扩展配置
你可以选择以下任一方式安装扩展:
**方式一:下载构建好的安装包(推荐)**
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后在 Chrome 或 Chromium 中打开 `chrome://extensions`,启用右上角的 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹。
**方式二:加载源码(针对开发者)**
1. 同样在 `chrome://extensions` 开启 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库代码树中的 `extension/` 文件夹。
完成!运行任何 opencli 浏览器命令时,后台微型 daemon 会自动启动与浏览器通信。无需配 API Token,零代码配置。
> **Tip**:后续诊断和 daemon 管理:
> ```bash
> opencli doctor # 检查扩展和 daemon 连通性
> opencli daemon status # 查看 daemon 状态
> opencli daemon stop # 停止 daemon
> ```
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT 等)。
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入/填充、提取、截图——全部通过你的已登录 Chrome 会话完成。
- **网站 → CLI** — 把任何网站变成确定性 CLI:100+ 站点能力已注册,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
- **CLI 枢纽** — 统一发现、自动安装、透传任何外部 CLIgh、docker、obsidian、tg、discord、wx 等)。
- **零 LLM 成本** — 运行时不消耗模型 token,跑 10,000 次也不花一分钱。
- **确定性输出** — 相同命令,相同输出结构,每次一致。可管道、可脚本、CI 友好。
## 快速开始
### npm 全局安装(推荐)
### 1. 安装 OpenCLI
OpenCLI 要求 **Node.js >= 21**
```bash
node --version
npm install -g @jackwener/opencli
```
# 安装 AI SkillsClaude Code / Cursor
### 2. 安装 Browser Bridge 扩展
OpenCLI 通过轻量 Browser Bridge 扩展和本地微型 daemon 与 Chrome/Chromium 通信。daemon 会按需自动启动。
**方式 A — Chrome Web Store(推荐):**
在 [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) 安装 **OpenCLI** 扩展。
**方式 B — 手动安装:**
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension-v{version}.zip`
2. 解压后打开 `chrome://extensions`,启用 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的目录。
### 3. 验证环境
```bash
opencli doctor
```
### 4. 跑第一个命令
```bash
opencli list
opencli hackernews top --limit 5
opencli bilibili hot --limit 5
```
## 给人类用户
如果你只是想稳定地调用网站或桌面应用能力,主路径很简单:
- `opencli list` 查看当前所有命令
- `opencli <site> <command>` 调用内置或生成好的适配器
- `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 会话替你操作网站。
### 安装 skill
```bash
npx skills add jackwener/opencli
```
直接使用
或只装需要的 skill
```bash
opencli list # 查看所有命令
opencli list -f yaml # 以 YAML 列出所有命令
opencli hackernews top --limit 5 # 公共 API,无需浏览器
opencli bilibili hot --limit 5 # 浏览器命令
opencli zhihu hot -f json # JSON 输出
opencli zhihu hot -f yaml # YAML 输出
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill opencli-browser
npx skills add jackwener/opencli --skill opencli-usage
npx skills add jackwener/opencli --skill smart-search
```
### 从源码安装(面向开发者)
### 选择哪个 skill
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link # 链接到全局环境
opencli list # 可以在任何地方使用了!
```
| Skill | 适用场景 | 你对 AI Agent 说的话 |
|-------|---------|-------------------|
| **opencli-adapter-author** | 实时操作任意网站,或为新站点写可复用适配器 | "帮我看看小红书的通知" / "帮我做一个抖音热门的适配器" / "帮我做一个抓取这个页面热帖的命令" |
| **opencli-autofix** | 内置命令失败时修复已有适配器 | "`opencli zhihu hot` 返回空了,修一下" |
| **opencli-browser** | 浏览器自动化参考文档 | "用浏览器命令抓取这个页面" |
| **opencli-usage** | 所有命令和站点的快速参考 | "OpenCLI 有哪些 Twitter 相关的命令?" |
| **smart-search** | 在现有 OpenCLI 能力里搜索 | "帮我找个 B 站热门相关的适配器" |
### 更新
### 工作原理
安装 `opencli-adapter-author` skill 后,你的 AI Agent 可以:
1. **导航**到任意 URL,使用你的已登录浏览器
2. **读取**页面内容——通过结构化 DOM 快照(不是截图)
3. **交互**——点击按钮、填写表单、选择选项、按键
4. **提取**页面数据或拦截网络 API 响应
5. **等待**元素、文本或页面跳转
Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自然语言描述想做的事。
**Skill 参考文档:**
- [`skills/opencli-adapter-author/SKILL.md`](./skills/opencli-adapter-author/SKILL.md) — 浏览器操作 + 适配器编写,全流程
- [`skills/opencli-autofix/SKILL.md`](./skills/opencli-autofix/SKILL.md) — 修复已有适配器
- [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 浏览器自动化参考
- [`skills/opencli-usage/SKILL.md`](./skills/opencli-usage/SKILL.md) — 命令和站点参考
- [`skills/smart-search/SKILL.md`](./skills/smart-search/SKILL.md) — 能力搜索
`browser` 可用命令包括:`open``state``click``type``fill``select``keys``wait``get``find``extract``frames``screenshot``scroll``back``eval``network``tab list``tab new``tab select``tab close``init``verify``close`
`opencli browser` 命令必须紧跟一个 `<session>` 位置参数。`opencli browser work open <url>``opencli browser work tab new [url]` 都会返回 target ID。`opencli browser work tab list` 用来查看当前已存在 tab 的 target ID,再通过 `--tab <targetId>` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select <targetId>`,才会把该 tab 设为同一 session 后续未指定 target 的默认目标。
## 核心概念
### `browser`AI Agent 的浏览器控制层
`opencli browser` 命令是 AI Agent 操作网站的底层原语。你不需要手动运行这些命令——把 `opencli-adapter-author` skill 安装到你的 AI Agent 中,用自然语言描述你想做的事,Agent 会自动处理浏览器操作。
比如你告诉 Agent:*"帮我看看小红书的通知"*——Agent 会在底层调用 `opencli browser <session> open``state``click` 等命令。
### 内置适配器:稳定命令
当某个站点能力已经存在时,优先使用 `opencli hackernews top``opencli reddit hot` 这类稳定命令。这些命令是确定性的,无需浏览器——人类和 AI Agent 都可以直接使用。
### 为新站点写适配器
当你需要的网站还没覆盖时,用 `opencli-adapter-author` skill,它会把 Agent 带到闭环:
1. 侦察站点,分类 patternSPA / SSR / JSONP / Token / Streaming
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. 定认证策略——`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
4. 字段解码 + 设计输出列
5. `opencli browser recon analyze <url>` 一步侦察,再 `opencli browser recon init <site>/<name>` → 写适配器 → `opencli browser recon verify <site>/<name>`
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
### CLI 枢纽与桌面端适配器
OpenCLI 不只是网站 CLI,还可以:
- 统一代理本地二进制工具,例如 `gh``docker``obsidian``tg``discord``wx`
- 通过专门适配器和 CDP 集成控制 Electron 桌面应用
## 前置要求
- **Node.js**: >= 21.0.0(标准 npm 安装路径要求)
- **Bun**: >= 1.0(可选替代运行时)
- 浏览器型命令需要 Chrome 或 Chromium 处于运行中,并已登录目标网站
> **重要**:浏览器型命令直接复用你的 Chrome/Chromium 登录态。如果拿到空数据或出现权限类失败,先确认目标站点已经在浏览器里打开并完成登录。
## 配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
| `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground``background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window <foreground\|background>` |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com` |
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
`opencli browser *` 必须紧跟一个 `<session>` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser <session> close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`
## 更新
```bash
npm install -g @jackwener/opencli@latest
# 如果你在用打包发布的 OpenCLI skills,也一起刷新
npx skills add jackwener/opencli
```
### 浏览器自动化 — 让 AI Agent 直接控制浏览器
将 [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) 指向你的 AI AgentClaude Code、Cursor),即可开箱即用,内含完整命令参考与使用示例。
可用命令:`open``state``click``type``select``keys``wait``get``screenshot``scroll``back``eval``network``init``verify``close`
### 安装 AI Skills
OpenCLI 提供 [skills](./skills/) 供 AI AgentClaude Code 等)使用:
如果你只装了部分 skill,也可以只刷新自己在用的:
```bash
# 安装所有 OpenCLI skills
npx skills add jackwener/opencli
# 或安装特定 skill
npx skills add jackwener/opencli --skill opencli-usage # 命令参考
npx skills add jackwener/opencli --skill opencli-operate # 浏览器自动化(AI Agent 专用)
npx skills add jackwener/opencli --skill opencli-explorer # 适配器开发指南
npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参考
npx skills add jackwener/opencli --skill opencli-adapter-author
npx skills add jackwener/opencli --skill opencli-autofix
npx skills add jackwener/opencli --skill smart-search
```
## 面向开发者
从源码安装:
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link
```
加载源码版 Browser Bridge 扩展:
1. 打开 `chrome://extensions` 并启用 **开发者模式**
2. 点击 **加载已解压的扩展程序**,选择本仓库里的 `extension/` 目录
## 内置命令
运行 `opencli list` 查看完整注册表。
| 站点 | 命令 | 模式 |
|------|------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
| **hupu** | `hot` `search` `detail` `reply` `like` `unlike` | 浏览器 |
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `history` `export` | 桌面端 |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
| **uiverse** | `code` `preview` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
| **baidu-scholar** | `search` | 公开 |
| **google-scholar** | `search` `cite` `profile` | 公开 |
| **gov-law** | `search` `recent` | 公开 |
| **gov-policy** | `search` `recent` | 公开 |
| **nowcoder** | `hot` `trending` `topics` `recommend` `creators` `companies` `jobs` `search` `suggest` `experience` `referral` `salary` `papers` `practice` `notifications` `detail` | 公开 / 浏览器 |
| **wanfang** | `search` | 公开 |
| **xiaoyuzhou** | `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` `auth` | 本地凭证 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **youtube** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
| **bbc** | `news` | 公共 API |
@@ -160,6 +270,8 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **pubmed** | `search` `article` `author` `citations` `related` | 公开 |
| **openreview** | `search` `venue` `paper` `reviews` | 公开 |
| **paperreview** | `submit` `review` `feedback` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
@@ -168,44 +280,48 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **web** | `read` | 浏览器 |
| **weibo** | `hot` `search` | 浏览器 |
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` | 浏览器 |
| **yahoo-finance** | `quote` | 浏览器 |
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
| **chaoxing** | `assignments` `exams` | 浏览器 |
| **grok** | `ask` | 浏览器 |
| **grok** | `ask` `image` | 浏览器 |
| **hf** | `top` | 公开 |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 浏览器 |
| **linux-do** | `feed` `search` `categories` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` | 浏览器 |
| **1688** | `search` `item` `store` | 浏览器 |
| **gemini** | `new` `ask` `image` | 浏览器 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` `rankings` | 浏览器 |
| **1688** | `search` `item` `assets` `download` `store` | 浏览器 |
| **gitee** | `trending` `search` `user` | 公开 / 浏览器 |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` | 浏览器 |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 浏览器 |
| **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` | 浏览器 |
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **xianyu** | `search` `item` `chat` | 浏览器 |
| **xianyu** | `search` `item` `chat` `publish` | 浏览器 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |
73+ 适配器**[→ 查看完整命令列表](./docs/adapters/index.md)**
100+ 站点能力**[→ 查看完整命令列表](./docs/adapters/index.md)**
`*` `opencli xiaoyuzhou podcast``podcast-episodes``episode``download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
### 外部 CLI 枢纽
@@ -216,14 +332,18 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **ntn** | Notion CLI — 基于官方 Notion API 的页面、数据库、块、搜索、评论命令 | `opencli ntn pages list` |
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
| **dingtalk** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dingtalk msg send --to user "hello"` |
| **wecom** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom msg send --to user "hello"` |
| **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
| **wecom-cli** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom-cli msg send --to user "hello"` |
| **tg(tg-cli)** | Telegram CLI — 基于 MTProto 的本地优先同步、搜索、导出,面向 AI Agent | `opencli tg search "AI news" -f json` |
| **discord(discord-cli)** | Discord CLI — 基于 SQLite 的本地优先同步、搜索、导出,面向 AI Agent | `opencli discord recent --channel general` |
| **wx(wx-cli)** | 微信本地数据 CLI — 会话、聊天记录、搜索、联系人、导出 | `opencli wx search "OpenCLI"` |
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
**自动安装**:如果你运行 `opencli gh ...` 时系统中还没有 `gh`,OpenCLI 会优先尝试通过系统包管理器安装,然后自动重试命令
**自动安装**:如果某个外部 CLI 配置了安全的包管理器安装命令,OpenCLI 会优先尝试安装后再执行;`ntn` 的官方安装方式是 shell 脚本,请先按 <https://ntn.dev> 手动安装
**注册自定义本地 CLI**
@@ -240,9 +360,8 @@ opencli register mycli
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatGPT App** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt-app.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
@@ -258,6 +377,8 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
| **1688** | 图片、视频 | 下载商品页中可见的商品素材 |
| **小宇宙** | 音频、转录 | 使用本地凭证下载单集音频和转录 JSON / 文本 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
@@ -277,7 +398,9 @@ brew install yt-dlp
```bash
# 下载小红书笔记中的图片/视频
opencli xiaohongshu download abc123 --output ./xhs
opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
opencli rednote download "https://www.rednote.com/search_result/<id>?xsec_token=..." --output ./rednote
# 下载B站视频(需要 yt-dlp
opencli bilibili download BV1xxx --output ./bilibili
@@ -292,6 +415,15 @@ opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./
# 下载豆瓣电影海报 / 剧照
opencli douban download 30382501 --output ./douban
# 下载 1688 商品页中的图片 / 视频素材
opencli 1688 download 841141931191 --output ./1688-downloads
# 下载小宇宙单集音频
opencli xiaoyuzhou download 69b3b675772ac2295bfc01d0 --output ./xiaoyuzhou
# 下载小宇宙单集转录
opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-transcripts
# 导出知乎文章为 Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
@@ -302,6 +434,8 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
`opencli xiaoyuzhou download``transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`
## 输出格式
@@ -346,7 +480,7 @@ esac
## 插件
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 JS 格式,启动时自动发现。
```bash
opencli plugin install github:user/opencli-plugin-my-tool # 安装
@@ -360,9 +494,10 @@ opencli plugin uninstall my-tool # 卸载
| 插件 | 类型 | 描述 |
|------|------|------|
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | 多平台热榜聚合 |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金热门文章 |
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | JS | GitHub Trending 仓库 |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | JS | 多平台热榜聚合 |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | JS | 稀土掘金热门文章 |
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | JS | VK (VKontakte) 动态、信息流和搜索 |
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
@@ -370,36 +505,26 @@ opencli plugin uninstall my-tool # 卸载
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
> **快速模式**:只想为某个页面快速生成一个命令?看 [opencli-oneshot skill](./skills/opencli-oneshot/SKILL.md) — 给一个 URL + 一句话描述,4 步搞定。
在动代码前,先读 [`opencli-adapter-author` skill](./skills/opencli-adapter-author/SKILL.md)。它把整个流程串起来:
> **完整模式**:在编写任何新代码前,先阅读 [opencli-explorer skill](./skills/opencli-explorer/SKILL.md)。它包含完整的适配器探索开发指南、API 探测流程、5级认证策略以及常见陷阱。
- 侦察站点,选定 patternSPA / SSR / JSONP / Token / Streaming
-`opencli browser <name> network``eval`、interceptor 等找到目标 endpoint
- 定认证策略(`PUBLIC` / `COOKIE` / `INTERCEPT` / `UI` / `LOCAL`
- 先用 `opencli browser recon analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser recon init` 生成骨架
- 交付前用 `opencli browser recon verify <site>/<name>` 验证
```bash
# 1. Deep Explore — 网络拦截 → 响应分析 → 能力推理 → 框架检测
opencli explore https://example.com --site mysite
# 2. Synthesize — 从探索成果物生成 evaluate-based YAML 适配器
opencli synthesize mysite
# 3. Generate — 一键完成:探索 → 合成 → 注册
opencli generate https://example.com --goal "hot"
# 4. Strategy Cascade — 自动降级探测:PUBLIC → COOKIE → HEADER
opencli cascade https://api.example.com/data
```
探索结果输出到 `.opencli/explore/<site>/`
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
## 常见问题排查
- **"Extension not connected" 报错**
- 确保你当前的 Chrome 或 Chromium 已安装且**开启了** opencli Browser Bridge 扩展`chrome://extensions`检查)
- 确保你已从 [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) 安装 OpenCLI 扩展,且`chrome://extensions`**已启用**
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
- 其他 Chrome/Chromium 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
- **返回空数据,或者报错 "Unauthorized"**
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
- 确保 Node.js 版本 `>= 20`
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
- OpenCLI 要求 **Node.js >= 21**。先执行 `node --version`,如果版本过低先升级,再重试命令
- **Daemon 问题**
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
+23 -23
View File
@@ -30,12 +30,15 @@ tests/
├── smoke/
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
src/
── **/*.test.ts # 单元测试(当前 32 个文件
── **/*.test.ts # 单元测试(unit project
clis/
└── **/*.test.{ts,js} # adapter 测试(adapter project
```
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|---|---|---:|---|---|
| 单元测试 | `src/**/*.test.ts` | 32 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 |
| 单元测试 | `src/**/*.test.ts` | 32 | `npm test` | 内部模块、pipeline、runtime |
| Adapter 测试 | `clis/**/*.test.{ts,js}` | - | `npm test` / `npm run test:adapter` | adapter 命令与数据归一化 |
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
@@ -43,13 +46,13 @@ src/
## 当前覆盖范围
### 单元测试32 个文件)
### 单元测试与 Adapter 测试
| 领域 | 文件 |
|---|---|
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/search.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` |
| 站点 / adapter 逻辑 | `clis/apple-podcasts/commands.test.ts`, `clis/apple-podcasts/utils.test.ts`, `clis/bloomberg/utils.test.ts`, `clis/chaoxing/utils.test.ts`, `clis/coupang/utils.test.ts`, `clis/google/utils.test.ts`, `clis/grok/ask.test.ts`, `clis/twitter/timeline.test.ts`, `clis/weread/utils.test.ts`, `clis/xiaohongshu/creator-note-detail.test.ts`, `clis/xiaohongshu/creator-notes-summary.test.ts`, `clis/xiaohongshu/creator-notes.test.ts`, `clis/xiaohongshu/search.test.ts`, `clis/xiaohongshu/user-helpers.test.ts`, `clis/xiaoyuzhou/utils.test.ts`, `clis/youtube/transcript-group.test.ts`, `clis/zhihu/download.test.ts` |
这些测试覆盖的重点包括:
@@ -94,14 +97,17 @@ find tests/smoke -name '*.test.ts' | sort
```bash
npm ci # 安装依赖
npm run build # 编译(E2E / smoke 测试需要 dist/main.js
npm run build # 编译(E2E / smoke 测试需要 dist/src/main.js
```
### 运行命令
```bash
# 全部单元测试
npx vitest run src/
# 默认本地测试口径(unit + extension + adapter
npm test
# 只跑 adapter project
npm run test:adapter
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
npx vitest run tests/e2e/
@@ -110,7 +116,7 @@ npx vitest run tests/e2e/
npx vitest run tests/smoke/
# 单个测试文件
npx vitest run src/clis/apple-podcasts/commands.test.ts
npm test -- --run clis/apple-podcasts/commands.test.ts
npx vitest run tests/e2e/management.test.ts
# 全部测试
@@ -123,7 +129,7 @@ npx vitest src/
### 浏览器命令本地测试须知
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/src/main.js`
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
@@ -132,10 +138,9 @@ npx vitest src/
## 如何添加新测试
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
### 新增 Adapter(如 `clis/producthunt/trending.ts`
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验
2. 根据 adapter 类型,在对应测试文件补一个 `it()` block
1. 根据 adapter 类型,在对应测试文件补一个 `it()` block
```typescript
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
@@ -193,7 +198,8 @@ it('producthunt me fails gracefully without login', async () => {
| Job | 触发条件 | 内容 |
|---|---|---|
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
| `unit-test` | push/PR 到 `main`,`dev` | Node `20``22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 |
| `unit-test` | push/PR 到 `main`,`dev` | Node `22` 运行 `unit + extension`,按 `2` shard 并行 |
| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 单独运行 `adapter` project |
| `smoke-test` | `schedule``workflow_dispatch` | 安装真实 Chrome`xvfb-run` 执行 `tests/smoke/` |
### `e2e-headed.yml`
@@ -202,19 +208,18 @@ it('producthunt me fails gracefully without login', async () => {
|---|---|---|
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome,并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome。
### Sharding
单元测试使用 vitest 内置 shard并在 Node `20` / `22` 两个版本上运行
CI 里的 `unit-test` job 使用 vitest shard只切 `unit + extension`,避免和独立的 `adapter-test` job 重复
```yaml
strategy:
matrix:
node-version: ['20', '22']
shard: [1, 2]
steps:
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
- run: npx vitest run --project unit --project extension --reporter=verbose --shard=${{ matrix.shard }}/2
```
---
@@ -228,12 +233,7 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
```yaml
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
```
CI 通过 `./.github/actions/setup-chrome` 准备真实 Chrome,再直接执行测试。
---
+152 -152
View File
@@ -2,8 +2,8 @@
{
"name": "extract-title-example",
"steps": [
"opencli operate open https://example.com",
"opencli operate eval \"document.title\""
"opencli browser open https://example.com",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
@@ -13,8 +13,8 @@
{
"name": "extract-title-iana",
"steps": [
"opencli operate open https://www.iana.org",
"opencli operate eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
"opencli browser open https://www.iana.org",
"opencli browser eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
],
"judge": {
"type": "nonEmpty"
@@ -23,8 +23,8 @@
{
"name": "extract-paragraph-wiki-js",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
"opencli browser open https://en.wikipedia.org/wiki/JavaScript",
"opencli browser eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
@@ -34,8 +34,8 @@
{
"name": "extract-paragraph-wiki-python",
"steps": [
"opencli operate open \"https://en.wikipedia.org/wiki/Python_(programming_language)\"",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
"opencli browser open \"https://en.wikipedia.org/wiki/Python_(programming_language)\"",
"opencli browser eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
@@ -45,8 +45,8 @@
{
"name": "extract-github-stars",
"steps": [
"opencli operate open https://github.com/browser-use/browser-use",
"opencli operate eval \"document.querySelector('#repo-stars-counter-star')?.textContent?.trim()\""
"opencli browser open https://github.com/browser-use/browser-use",
"opencli browser eval \"document.querySelector('#repo-stars-counter-star')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
@@ -56,8 +56,8 @@
{
"name": "extract-github-description",
"steps": [
"opencli operate open https://github.com/anthropics/claude-code",
"opencli operate eval \"document.querySelector('p.f4, [data-testid=about-description], .f4.my-3, .BorderGrid-cell p')?.textContent?.trim()\""
"opencli browser open https://github.com/anthropics/claude-code",
"opencli browser eval \"document.querySelector('p.f4, [data-testid=about-description], .f4.my-3, .BorderGrid-cell p')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
@@ -66,8 +66,8 @@
{
"name": "extract-github-readme-heading",
"steps": [
"opencli operate open https://github.com/vercel/next.js",
"opencli operate eval \"document.querySelector('[data-testid=readme] h1, [data-testid=readme] h2, #readme h1, #readme h2, article h1, article h2, .markdown-body h1, .markdown-body h2')?.textContent?.trim()\""
"opencli browser open https://github.com/vercel/next.js",
"opencli browser eval \"document.querySelector('[data-testid=readme] h1, [data-testid=readme] h2, #readme h1, #readme h2, article h1, article h2, .markdown-body h1, .markdown-body h2')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
@@ -76,8 +76,8 @@
{
"name": "extract-npm-downloads",
"steps": [
"opencli operate open https://www.npmjs.com/package/zod",
"opencli operate eval \"document.querySelector('[data-nosnippet]')?.textContent?.trim() || document.querySelector('p.f2874b88')?.textContent?.trim()\""
"opencli browser open https://www.npmjs.com/package/zod",
"opencli browser eval \"document.querySelector('[data-nosnippet]')?.textContent?.trim() || document.querySelector('p.f2874b88')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
@@ -87,9 +87,9 @@
{
"name": "extract-npm-description",
"steps": [
"opencli operate open https://www.npmjs.com/package/express",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
"opencli browser open https://www.npmjs.com/package/express",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
],
"judge": {
"type": "nonEmpty"
@@ -98,8 +98,8 @@
{
"name": "list-hn-top5",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.titleline > a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.titleline > a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -109,8 +109,8 @@
{
"name": "list-hn-top10",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.athing')].slice(0,10).map(tr=>{const a=tr.querySelector('.titleline>a');const s=tr.nextElementSibling?.querySelector('.score');return{title:a?.textContent,score:parseInt(s?.textContent)||0}}))\""
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.athing')].slice(0,10).map(tr=>{const a=tr.querySelector('.titleline>a');const s=tr.nextElementSibling?.querySelector('.score');return{title:a?.textContent,score:parseInt(s?.textContent)||0}}))\""
],
"judge": {
"type": "arrayMinLength",
@@ -120,8 +120,8 @@
{
"name": "list-books-5",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,5).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,5).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -131,8 +131,8 @@
{
"name": "list-books-10",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,10).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,10).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -142,8 +142,8 @@
{
"name": "list-quotes-3",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote, [class*=quote]')].slice(0,3).map(el=>({text:(el.querySelector('.text, [class*=text]')?.textContent)||(el.querySelector('span')?.textContent),author:(el.querySelector('.author, [class*=author]')?.textContent)||(el.querySelector('small')?.textContent)})))\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.quote, [class*=quote]')].slice(0,3).map(el=>({text:(el.querySelector('.text, [class*=text]')?.textContent)||(el.querySelector('span')?.textContent),author:(el.querySelector('.author, [class*=author]')?.textContent)||(el.querySelector('small')?.textContent)})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -153,8 +153,8 @@
{
"name": "list-quotes-tags",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,5).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent,tags:[...el.querySelectorAll('.tag')].map(t=>t.textContent)})))\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,5).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent,tags:[...el.querySelectorAll('.tag')].map(t=>t.textContent)})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -164,8 +164,8 @@
{
"name": "list-github-trending",
"steps": [
"opencli operate open https://github.com/trending",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,3).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim()),desc:el.querySelector('p')?.textContent?.trim()})))\""
"opencli browser open https://github.com/trending",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,3).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim()),desc:el.querySelector('p')?.textContent?.trim()})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -175,8 +175,8 @@
{
"name": "list-github-trending-lang",
"steps": [
"opencli operate open https://github.com/trending/python",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,5).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim())})))\""
"opencli browser open https://github.com/trending/python",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,5).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim())})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -186,8 +186,8 @@
{
"name": "list-jsonplaceholder-posts",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/posts",
"opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(p=>({id:p.id,title:p.title})))\""
"opencli browser open https://jsonplaceholder.typicode.com/posts",
"opencli browser eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(p=>({id:p.id,title:p.title})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -197,8 +197,8 @@
{
"name": "list-jsonplaceholder-users",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/users",
"opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).map(u=>({name:u.name,email:u.email})))\""
"opencli browser open https://jsonplaceholder.typicode.com/users",
"opencli browser eval \"JSON.stringify(JSON.parse(document.body.innerText).map(u=>({name:u.name,email:u.email})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -208,9 +208,9 @@
{
"name": "search-google",
"steps": [
"opencli operate open https://www.google.com/search?q=opencli+github",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
"opencli browser open https://www.google.com/search?q=opencli+github",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
],
"judge": {
"type": "arrayMinLength",
@@ -221,11 +221,11 @@
{
"name": "search-ddg",
"steps": [
"opencli operate open https://duckduckgo.com",
"opencli operate state",
"opencli operate type 1 \"weather beijing\"",
"opencli operate keys Enter",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a]')].slice(0,3).map(a=>a.textContent))\""
"opencli browser open https://duckduckgo.com",
"opencli browser state",
"opencli browser type 1 \"weather beijing\"",
"opencli browser keys Enter",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a]')].slice(0,3).map(a=>a.textContent))\""
],
"judge": {
"type": "nonEmpty"
@@ -235,10 +235,10 @@
{
"name": "search-ddg-tech",
"steps": [
"opencli operate open https://duckduckgo.com",
"opencli operate eval \"document.querySelector('input[name=q]').value='TypeScript tutorial';document.querySelector('form').submit();'submitted'\"",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>({title:a.textContent,url:a.href})))\""
"opencli browser open https://duckduckgo.com",
"opencli browser eval \"document.querySelector('input[name=q]').value='TypeScript tutorial';document.querySelector('form').submit();'submitted'\"",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -249,9 +249,9 @@
{
"name": "search-wiki",
"steps": [
"opencli operate open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
"opencli browser open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
@@ -262,9 +262,9 @@
{
"name": "search-npm",
"steps": [
"opencli operate open https://www.npmjs.com/search?q=react",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3, .package-list-item h3, a[class*=package] h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
"opencli browser open https://www.npmjs.com/search?q=react",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3, .package-list-item h3, a[class*=package] h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
],
"judge": {
"type": "arrayMinLength",
@@ -275,9 +275,9 @@
{
"name": "search-github",
"steps": [
"opencli operate open https://github.com/search?q=browser+automation&type=repositories",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.search-title a, [data-testid=results-list] a.Link--primary')].slice(0,3).map(a=>a.textContent?.trim().replace(/\\\\s+/g,' ')))\""
"opencli browser open https://github.com/search?q=browser+automation&type=repositories",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.search-title a, [data-testid=results-list] a.Link--primary')].slice(0,3).map(a=>a.textContent?.trim().replace(/\\\\s+/g,' ')))\""
],
"judge": {
"type": "arrayMinLength",
@@ -288,10 +288,10 @@
{
"name": "nav-click-link-example",
"steps": [
"opencli operate open https://example.com",
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title + ' ' + location.href\""
"opencli browser open https://example.com",
"opencli browser eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title + ' ' + location.href\""
],
"judge": {
"type": "contains",
@@ -301,10 +301,10 @@
{
"name": "nav-click-hn-first",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"document.querySelector('.titleline a')?.click();'clicked'\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"document.querySelector('.titleline a')?.click();'clicked'\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -313,9 +313,9 @@
{
"name": "nav-click-hn-comments",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"document.querySelector('.subtext a:last-child')?.click(); 'clicked'\"",
"opencli operate eval \"document.title\""
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"document.querySelector('.subtext a:last-child')?.click(); 'clicked'\"",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -324,9 +324,9 @@
{
"name": "nav-click-wiki-link",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
"opencli operate eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
"opencli operate eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
"opencli browser open https://en.wikipedia.org/wiki/JavaScript",
"opencli browser eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
"opencli browser eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
@@ -335,9 +335,9 @@
{
"name": "nav-click-github-tab",
"steps": [
"opencli operate open https://github.com/vercel/next.js",
"opencli operate eval \"document.querySelector('[data-tab-item=i1issues-tab] a, #issues-tab')?.click(); 'clicked'\"",
"opencli operate eval \"document.title\""
"opencli browser open https://github.com/vercel/next.js",
"opencli browser eval \"document.querySelector('[data-tab-item=i1issues-tab] a, #issues-tab')?.click(); 'clicked'\"",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -346,12 +346,12 @@
{
"name": "nav-go-back",
"steps": [
"opencli operate open https://example.com",
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli operate wait time 2",
"opencli operate back",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
"opencli browser open https://example.com",
"opencli browser eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli browser wait time 2",
"opencli browser back",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
@@ -361,9 +361,9 @@
{
"name": "nav-multi-step",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.next a')?.click(); 'clicked'\"",
"opencli operate eval \"document.querySelector('.quote .text')?.textContent\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.next a')?.click(); 'clicked'\"",
"opencli browser eval \"document.querySelector('.quote .text')?.textContent\""
],
"judge": {
"type": "nonEmpty"
@@ -372,10 +372,10 @@
{
"name": "scroll-footer-quotes",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate scroll down",
"opencli operate scroll down",
"opencli operate eval \"document.querySelector('footer, .footer, .tags-box')?.textContent?.trim().slice(0,100)\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser scroll down",
"opencli browser scroll down",
"opencli browser eval \"document.querySelector('footer, .footer, .tags-box')?.textContent?.trim().slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
@@ -384,10 +384,10 @@
{
"name": "scroll-footer-books",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate scroll down",
"opencli operate scroll down",
"opencli operate eval \"document.querySelector('.pager .current')?.textContent?.trim()\""
"opencli browser open https://books.toscrape.com",
"opencli browser scroll down",
"opencli browser scroll down",
"opencli browser eval \"document.querySelector('.pager .current')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
@@ -397,8 +397,8 @@
{
"name": "scroll-long-page",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/posts",
"opencli operate eval \"JSON.parse(document.body.innerText).length\""
"opencli browser open https://jsonplaceholder.typicode.com/posts",
"opencli browser eval \"JSON.parse(document.body.innerText).length\""
],
"judge": {
"type": "matchesPattern",
@@ -408,8 +408,8 @@
{
"name": "scroll-find-element",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.next a')?.href\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.next a')?.href\""
],
"judge": {
"type": "nonEmpty"
@@ -418,8 +418,8 @@
{
"name": "scroll-lazy-load",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"document.querySelectorAll('article.product_pod').length\""
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"document.querySelectorAll('article.product_pod').length\""
],
"judge": {
"type": "matchesPattern",
@@ -429,8 +429,8 @@
{
"name": "form-simple-name",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var el=document.querySelector('[name=custname]');el.value='OpenCLI Test';el.dispatchEvent(new Event('input',{bubbles:true}));el.value\""
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var el=document.querySelector('[name=custname]');el.value='OpenCLI Test';el.dispatchEvent(new Event('input',{bubbles:true}));el.value\""
],
"judge": {
"type": "contains",
@@ -441,8 +441,8 @@
{
"name": "form-text-inputs",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var n=document.querySelector('[name=custname]');n.value='Alice';n.dispatchEvent(new Event('input',{bubbles:true}));var t=document.querySelector('[name=custtel]');t.value='555-1234';t.dispatchEvent(new Event('input',{bubbles:true}));n.value+'|'+t.value\""
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var n=document.querySelector('[name=custname]');n.value='Alice';n.dispatchEvent(new Event('input',{bubbles:true}));var t=document.querySelector('[name=custtel]');t.value='555-1234';t.dispatchEvent(new Event('input',{bubbles:true}));n.value+'|'+t.value\""
],
"judge": {
"type": "contains",
@@ -453,8 +453,8 @@
{
"name": "form-radio-select",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"document.querySelector('[value=medium]').checked=true;document.querySelector('[value=medium]').dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('[value=medium]').checked\""
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"document.querySelector('[value=medium]').checked=true;document.querySelector('[value=medium]').dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('[value=medium]').checked\""
],
"judge": {
"type": "contains",
@@ -464,8 +464,8 @@
{
"name": "form-checkbox",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var cb=document.querySelector('[value=cheese]');cb.checked=true;cb.dispatchEvent(new Event('change',{bubbles:true}));cb.checked\""
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var cb=document.querySelector('[value=cheese]');cb.checked=true;cb.dispatchEvent(new Event('change',{bubbles:true}));cb.checked\""
],
"judge": {
"type": "contains",
@@ -475,8 +475,8 @@
{
"name": "form-textarea",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments], textarea[name=delivery], textarea');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
"opencli browser open https://httpbin.org/forms/post",
"opencli browser eval \"var ta=document.querySelector('textarea[name=comments], textarea[name=delivery], textarea');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
],
"judge": {
"type": "contains",
@@ -486,8 +486,8 @@
{
"name": "form-login-fake",
"steps": [
"opencli operate open https://the-internet.herokuapp.com/login",
"opencli operate eval \"var u=document.querySelector('#username');u.value='testuser';u.dispatchEvent(new Event('input',{bubbles:true}));var p=document.querySelector('#password');p.value='testpass';p.dispatchEvent(new Event('input',{bubbles:true}));u.value+'|'+p.value\""
"opencli browser open https://the-internet.herokuapp.com/login",
"opencli browser eval \"var u=document.querySelector('#username');u.value='testuser';u.dispatchEvent(new Event('input',{bubbles:true}));var p=document.querySelector('#password');p.value='testpass';p.dispatchEvent(new Event('input',{bubbles:true}));u.value+'|'+p.value\""
],
"judge": {
"type": "contains",
@@ -498,8 +498,8 @@
{
"name": "complex-wiki-toc",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.toc li a, #toc li a, .vector-toc-contents a')].slice(0,8).map(a=>a.textContent?.trim()))\""
"opencli browser open https://en.wikipedia.org/wiki/JavaScript",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.toc li a, #toc li a, .vector-toc-contents a')].slice(0,8).map(a=>a.textContent?.trim()))\""
],
"judge": {
"type": "arrayMinLength",
@@ -509,9 +509,9 @@
{
"name": "complex-books-detail",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"document.querySelector('article.product_pod h3 a')?.click();'clicked'\"",
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent,price:document.querySelector('.price_color')?.textContent})\""
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"document.querySelector('article.product_pod h3 a')?.click();'clicked'\"",
"opencli browser eval \"JSON.stringify({title:document.querySelector('h1')?.textContent,price:document.querySelector('.price_color')?.textContent})\""
],
"judge": {
"type": "nonEmpty"
@@ -520,9 +520,9 @@
{
"name": "complex-quotes-page2",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.next a')?.click();'clicked'\"",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.next a')?.click();'clicked'\"",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
@@ -532,8 +532,8 @@
{
"name": "complex-github-repo-info",
"steps": [
"opencli operate open https://github.com/expressjs/express",
"opencli operate eval \"JSON.stringify({lang:document.querySelector('[itemprop=programmingLanguage]')?.textContent?.trim(),license:document.querySelector('[data-analytics-event*=license] span, .Layout-sidebar [href*=LICENSE]')?.textContent?.trim()})\""
"opencli browser open https://github.com/expressjs/express",
"opencli browser eval \"JSON.stringify({lang:document.querySelector('[itemprop=programmingLanguage]')?.textContent?.trim(),license:document.querySelector('[data-analytics-event*=license] span, .Layout-sidebar [href*=LICENSE]')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
@@ -542,9 +542,9 @@
{
"name": "complex-hn-story-comments",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"document.querySelector('.subtext a:last-child')?.click();'clicked'\"",
"opencli operate eval \"document.querySelector('.fatitem .titleline a')?.textContent\""
"opencli browser open https://news.ycombinator.com",
"opencli browser eval \"document.querySelector('.subtext a:last-child')?.click();'clicked'\"",
"opencli browser eval \"document.querySelector('.fatitem .titleline a')?.textContent\""
],
"judge": {
"type": "nonEmpty"
@@ -553,8 +553,8 @@
{
"name": "complex-multi-extract",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/TypeScript",
"opencli operate eval \"JSON.stringify({title:document.title,firstParagraph:document.querySelector('#mw-content-text p')?.textContent?.slice(0,150)})\""
"opencli browser open https://en.wikipedia.org/wiki/TypeScript",
"opencli browser eval \"JSON.stringify({title:document.title,firstParagraph:document.querySelector('#mw-content-text p')?.textContent?.slice(0,150)})\""
],
"judge": {
"type": "contains",
@@ -564,8 +564,8 @@
{
"name": "bench-reddit-top5",
"steps": [
"opencli operate open https://old.reddit.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('#siteTable .thing .title a.title')].slice(0,5).map(a=>a.textContent))\""
"opencli browser open https://old.reddit.com",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('#siteTable .thing .title a.title')].slice(0,5).map(a=>a.textContent))\""
],
"judge": {
"type": "arrayMinLength",
@@ -576,9 +576,9 @@
{
"name": "bench-imdb-matrix",
"steps": [
"opencli operate open https://www.imdb.com/title/tt0133093/",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
"opencli browser open https://www.imdb.com/title/tt0133093/",
"opencli browser wait time 3",
"opencli browser eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
],
"judge": {
"type": "contains",
@@ -589,8 +589,8 @@
{
"name": "bench-npm-zod",
"steps": [
"opencli operate open https://www.npmjs.com/package/zod",
"opencli operate eval \"JSON.stringify({name:document.querySelector('h1 span, #top h2')?.textContent?.trim(),description:document.querySelector('[data-testid=package-description], p.package-description-redundant')?.textContent?.trim()})\""
"opencli browser open https://www.npmjs.com/package/zod",
"opencli browser eval \"JSON.stringify({name:document.querySelector('h1 span, #top h2')?.textContent?.trim(),description:document.querySelector('[data-testid=package-description], p.package-description-redundant')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
@@ -600,8 +600,8 @@
{
"name": "bench-wiki-search",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/Machine_learning",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
"opencli browser open https://en.wikipedia.org/wiki/Machine_learning",
"opencli browser eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
@@ -612,8 +612,8 @@
{
"name": "bench-github-profile",
"steps": [
"opencli operate open https://github.com/torvalds",
"opencli operate eval \"JSON.stringify({name:document.querySelector('[itemprop=name]')?.textContent?.trim(),bio:document.querySelector('[data-bio-text]')?.textContent?.trim()||document.querySelector('.p-note')?.textContent?.trim()})\""
"opencli browser open https://github.com/torvalds",
"opencli browser eval \"JSON.stringify({name:document.querySelector('[itemprop=name]')?.textContent?.trim(),bio:document.querySelector('[data-bio-text]')?.textContent?.trim()||document.querySelector('.p-note')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
@@ -623,9 +623,9 @@
{
"name": "bench-books-category",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"document.querySelector('a[href*=science]')?.click();'clicked'\"",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod h3 a')].slice(0,3).map(a=>a.getAttribute('title')))\""
"opencli browser open https://books.toscrape.com",
"opencli browser eval \"document.querySelector('a[href*=science]')?.click();'clicked'\"",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('article.product_pod h3 a')].slice(0,3).map(a=>a.getAttribute('title')))\""
],
"judge": {
"type": "arrayMinLength",
@@ -636,9 +636,9 @@
{
"name": "bench-quotes-author",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.author + a, a[href*=author]')?.click();'clicked'\"",
"opencli operate eval \"document.querySelector('.author-description, .author-details p')?.textContent?.slice(0,100)\""
"opencli browser open https://quotes.toscrape.com",
"opencli browser eval \"document.querySelector('.author + a, a[href*=author]')?.click();'clicked'\"",
"opencli browser eval \"document.querySelector('.author-description, .author-details p')?.textContent?.slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
@@ -648,10 +648,10 @@
{
"name": "bench-ddg-images",
"steps": [
"opencli operate open https://duckduckgo.com",
"opencli operate eval \"document.querySelector('input[name=q]').value='sunset';document.querySelector('form').submit();'submitted'\"",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>a.textContent))\""
"opencli browser open https://duckduckgo.com",
"opencli browser eval \"document.querySelector('input[name=q]').value='sunset';document.querySelector('form').submit();'submitted'\"",
"opencli browser wait time 3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>a.textContent))\""
],
"judge": {
"type": "arrayMinLength",
@@ -663,8 +663,8 @@
{
"name": "bench-httpbin-headers",
"steps": [
"opencli operate open https://httpbin.org/headers",
"opencli operate eval \"JSON.parse(document.body.innerText).headers['User-Agent']\""
"opencli browser open https://httpbin.org/headers",
"opencli browser eval \"JSON.parse(document.body.innerText).headers['User-Agent']\""
],
"judge": {
"type": "nonEmpty"
@@ -674,8 +674,8 @@
{
"name": "bench-jsonapi-todo",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/todos",
"opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(t=>({id:t.id,title:t.title,completed:t.completed})))\""
"opencli browser open https://jsonplaceholder.typicode.com/todos",
"opencli browser eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(t=>({id:t.id,title:t.title,completed:t.completed})))\""
],
"judge": {
"type": "arrayMinLength",
+5 -5
View File
@@ -58,7 +58,7 @@ async function main() {
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
// Show only failing tasks
for (const task of tasks) {
try { exec('opencli operate close'); } catch {}
try { exec('opencli browser close'); } catch {}
let lastOutput = '';
for (const step of task.steps) lastOutput = exec(step);
const passed = lastOutput.trim().length > 0; // simplified check
@@ -83,7 +83,7 @@ async function main() {
// Phase 1: Gather — run the task and capture output
console.log('Phase 1: Gathering symptoms...');
try { exec('opencli operate close'); } catch {}
try { exec('opencli browser close'); } catch {}
let lastOutput = '';
for (let i = 0; i < task.steps.length; i++) {
@@ -120,7 +120,7 @@ ${lastOutput.slice(0, 500)}
4. If CONFIRMED: describe the root cause and suggest a fix
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
Do NOT fix the code — just diagnose. Use opencli browser commands to investigate.`;
try {
const result = execSync(
@@ -152,11 +152,11 @@ Do NOT fix the code — just diagnose. Use opencli operate commands to investiga
}
// Re-run task for fresh output
try { exec('opencli operate close'); } catch {}
try { exec('opencli browser close'); } catch {}
for (const step of task.steps) lastOutput = exec(step);
}
try { exec('opencli operate close'); } catch {}
try { exec('opencli browser close'); } catch {}
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
}
+3 -2
View File
@@ -115,9 +115,10 @@ Do NOT modify test files.
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
try {
// Pass prompt via stdin `input` option to avoid shell metacharacter expansion
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
'claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence',
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', input: prompt, stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const lines = result.split('\n').filter(l => l.trim());
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
+2 -2
View File
@@ -3,8 +3,8 @@
* /autoresearch — Main autonomous iteration loop.
*
* Usage:
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
* npx tsx autoresearch/commands/run.ts --preset browser-reliability
* npx tsx autoresearch/commands/run.ts --preset browser-reliability --iterations 5
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
*
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
+1 -1
View File
@@ -5,7 +5,7 @@
*/
export interface AutoResearchConfig {
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
/** Plain-language goal, e.g. "Increase browser pass rate to 59/59" */
goal: string;
/** Glob patterns for files the agent can modify */
scope: string[];
+8 -4
View File
@@ -13,7 +13,7 @@
* Phase 8: Repeat
*/
import { execSync } from 'node:child_process';
import { execSync, execFileSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
@@ -124,13 +124,17 @@ export class Engine {
/** Phase 4: Commit changes */
private commit(description: string): string | null {
// Stage all changes in scope (but not untracked outside scope)
exec('git add -A');
if (!this.config.scope.length) return null; // no scope = nothing to stage
// Stage only files matching scope globs (avoid staging unrelated changes)
// Use execFileSync to bypass shell glob expansion so git handles pathspecs directly
execFileSync('git', ['add', '--', ...this.config.scope], {
cwd: ROOT, timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
});
const diff = exec('git diff --cached --quiet; echo $?');
if (diff === '0') return null; // no changes
try {
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
execStrict(`git commit -m "experiment(browser): ${description.replace(/"/g, '\\"')}"`);
return exec('git rev-parse --short HEAD');
} catch {
// Hook failure
+3 -3
View File
@@ -2,7 +2,7 @@
/**
* Layer 1: Deterministic Browse Command Testing
*
* Runs predefined opencli operate command sequences against real websites.
* Runs predefined opencli browser command sequences against real websites.
* No LLM involved — tests command reliability only.
*
* Usage:
@@ -137,12 +137,12 @@ function main() {
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
// Summary
const trainResults = results.filter(r => r.set === 'train');
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env npx tsx
/**
* Layer 5: Publish Testing — end-to-end content creation via operate commands
* Layer 5: Publish Testing — end-to-end content creation via browser commands
*
* Tests the full chain: read content → navigate to platform → fill title+body → (optionally) publish → verify → cleanup
*
@@ -78,7 +78,7 @@ function judge(criteria: JudgeCriteria, output: string): boolean {
}
function runCommand(cmd: string, timeout = 30000): string {
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
const localCmd = cmd.replace(/^opencli /, `node dist/src/main.js `);
try {
return execSync(localCmd, {
cwd: PROJECT_ROOT,
@@ -179,12 +179,12 @@ function main() {
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
// Summary
const totalPassed = results.filter(r => r.passed).length;
+7 -7
View File
@@ -2,7 +2,7 @@
/**
* Layer 4: Save as CLI Testing — "Save as CLI" Pipeline
*
* Tests the full operate init → write adapter → operate verify flow.
* Tests the full browser init → write adapter → browser verify flow.
* Validates that browser exploration can be crystallized into reusable CLI adapters.
*
* Usage:
@@ -55,7 +55,7 @@ function judge(criteria: JudgeCriteria, output: string): boolean {
case 'contains':
return output.toLowerCase().includes(criteria.value.toLowerCase());
case 'arrayMinLength': {
// operate verify outputs table text; try JSON parse first, then count non-empty lines
// browser verify outputs table text; try JSON parse first, then count non-empty lines
try {
const arr = JSON.parse(output);
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
@@ -80,10 +80,10 @@ function judge(criteria: JudgeCriteria, output: string): boolean {
const PROJECT_ROOT = join(__dirname, '..');
/** Run a command, using local dist/main.js instead of global opencli for consistency */
/** Run a command, using the local built entrypoint instead of global opencli for consistency */
function runCommand(cmd: string, timeout = 30000): string {
// Use local build so tests always run against the current source
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
const localCmd = cmd.replace(/^opencli /, `node dist/src/main.js `);
try {
return execSync(localCmd, {
cwd: PROJECT_ROOT,
@@ -121,7 +121,7 @@ function runTask(task: SaveTask): TaskResult {
try {
// Phase 1: init — create scaffold
const initOutput = runCommand(`opencli operate init ${site}/${command}`);
const initOutput = runCommand(`opencli browser init ${site}/${command}`);
if (!existsSync(adapterPath)) {
return {
name: task.name, phase: 'init', passed: false,
@@ -141,9 +141,9 @@ function runTask(task: SaveTask): TaskResult {
writeFileSync(adapterPath, task.adapter, 'utf-8');
}
// Phase 3: verify — run the adapter via operate verify
// Phase 3: verify — run the adapter via browser verify
const verifyOutput = runCommand(
`opencli operate verify ${site}/${command}`,
`opencli browser verify ${site}/${command}`,
45000, // longer timeout for network calls
);
+4 -4
View File
@@ -2,7 +2,7 @@
/**
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
*
* Spawns Claude Code with the opencli-operate skill. Claude Code
* Spawns Claude Code with the opencli-adapter-author skill. Claude Code
* completes the task using browse commands AND judges its own result.
*
* Task format: YAML with judge_context (multi-criteria, like Browser Use)
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESULTS_DIR = join(__dirname, 'results');
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md');
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-adapter-author', 'SKILL.md');
// ── Types ──────────────────────────────────────────────────────────
@@ -100,7 +100,7 @@ function runSkillTask(task: SkillTask): TaskResult {
const urlPart = task.url ? ` Start URL: ${task.url}` : '';
const criteria = task.judge_context.map((c, i) => `${i + 1}. ${c}`).join('\n');
const prompt = `Complete this browser task using opencli operate commands:
const prompt = `Complete this browser task using opencli browser commands:
TASK: ${task.task}${urlPart}
@@ -110,7 +110,7 @@ ${criteria}
At the very end of your response, output a JSON verdict on its own line:
{"success": true/false, "explanation": "brief explanation"}
Always close the browser with 'opencli operate close' when done.`;
Always close the browser with 'opencli browser close' when done.`;
try {
const output = execSync(
+2 -2
View File
@@ -168,12 +168,12 @@ function main() {
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
// Summary by layer
const layers = [...new Set(results.map(r => r.layer))].sort();
+2 -2
View File
@@ -178,12 +178,12 @@ function main() {
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
try { runCommand('opencli browser close'); } catch { /* ignore */ }
// Summary by layer
const layers = [...new Set(results.map(r => r.layer))].sort();
@@ -0,0 +1,24 @@
/**
* Preset: Browser Command Reliability
*
* Optimizes opencli browser commands against the Layer 1 deterministic test suite.
* Metric: number of passing browse-tasks (out of 59).
*/
import type { AutoResearchConfig } from '../config.js';
export const browserReliability: AutoResearchConfig = {
goal: 'Increase browser command pass rate to 59/59 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
guard: 'npm run build',
minDelta: 1,
};
+3 -3
View File
@@ -1,4 +1,4 @@
export { operateReliability } from './operate-reliability.js';
export { browserReliability } from './browser-reliability.js';
export { skillQuality } from './skill-quality.js';
export { v2exReliability } from './v2ex-reliability.js';
export { zhihuReliability } from './zhihu-reliability.js';
@@ -6,7 +6,7 @@ export { combinedReliability } from './combined-reliability.js';
export { saveReliability } from './save-reliability.js';
import type { AutoResearchConfig } from '../config.js';
import { operateReliability } from './operate-reliability.js';
import { browserReliability } from './browser-reliability.js';
import { skillQuality } from './skill-quality.js';
import { v2exReliability } from './v2ex-reliability.js';
import { zhihuReliability } from './zhihu-reliability.js';
@@ -14,7 +14,7 @@ import { combinedReliability } from './combined-reliability.js';
import { saveReliability } from './save-reliability.js';
export const PRESETS: Record<string, AutoResearchConfig> = {
'operate-reliability': operateReliability,
'browser-reliability': browserReliability,
'skill-quality': skillQuality,
'v2ex-reliability': v2exReliability,
'zhihu-reliability': zhihuReliability,
@@ -1,24 +0,0 @@
/**
* Preset: Operate Command Reliability
*
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
* Metric: number of passing browse-tasks (out of 59).
*/
import type { AutoResearchConfig } from '../config.js';
export const operateReliability: AutoResearchConfig = {
goal: 'Increase operate command pass rate to 59/59 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
guard: 'npm run build',
minDelta: 1,
};
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Preset: Save as CLI Reliability
*
* Optimizes the "Save as CLI" pipeline: operate init → write adapter → run.
* Optimizes the "Save as CLI" pipeline: browser init → write adapter → run.
* Covers PUBLIC (no auth) and COOKIE (browser session) strategies.
* Metric: number of passing save-tasks.
*/
@@ -9,12 +9,12 @@
import type { AutoResearchConfig } from '../config.js';
export const saveReliability: AutoResearchConfig = {
goal: 'Increase "Save as CLI" pipeline pass rate to 100%. The flow is: operate init creates a scaffold, user writes adapter code, opencli discovers and runs it. Covers both PUBLIC (fetch API) and COOKIE (browser session) strategies. Focus on: init template correctness, user CLI discovery, adapter loading, verify command robustness, and browser session handling.',
goal: 'Increase "Save as CLI" pipeline pass rate to 100%. The flow is: browser init creates a scaffold, user writes adapter code, opencli discovers and runs it. Covers both PUBLIC (fetch API) and COOKIE (browser session) strategies. Focus on: init template correctness, user CLI discovery, adapter loading, verify command robustness, and browser session handling.',
scope: [
'src/cli.ts',
'src/discovery.ts',
'src/registry.ts',
'skills/opencli-operate/SKILL.md',
'skills/opencli-adapter-author/SKILL.md',
'autoresearch/save-tasks.json',
'autoresearch/save-adapters/*.ts',
],
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Preset: Skill E2E Quality
*
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
* Optimizes the opencli-adapter-author SKILL.md against the Layer 2 LLM E2E test suite.
* Metric: number of passing skill-tasks (out of 35).
*/
@@ -10,7 +10,7 @@ import type { AutoResearchConfig } from '../config.js';
export const skillQuality: AutoResearchConfig = {
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
scope: [
'skills/opencli-operate/SKILL.md',
'skills/opencli-adapter-author/SKILL.md',
],
metric: 'pass_count',
direction: 'higher',
+2 -2
View File
@@ -1,14 +1,14 @@
/**
* Preset: V2EX Command Reliability
*
* Optimizes opencli operate commands against the V2EX-specific test suite.
* Optimizes opencli browser commands against the V2EX-specific test suite.
* 40 tasks across 5 difficulty layers (atomic → complex chain).
*/
import type { AutoResearchConfig } from '../config.js';
export const v2exReliability: AutoResearchConfig = {
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
goal: 'Increase V2EX browser command pass rate to 40/40 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Preset: Zhihu Command Reliability
*
* Optimizes opencli operate commands against the Zhihu test suite.
* Optimizes opencli browser commands against the Zhihu test suite.
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
*/
@@ -9,7 +9,7 @@
import type { AutoResearchConfig } from '../config.js';
export const zhihuReliability: AutoResearchConfig = {
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
goal: 'Increase Zhihu browser command pass rate to 60/60 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
+144 -144
View File
@@ -5,12 +5,12 @@
"type": "fill-only",
"description": "Navigate to tweet composer, fill in content (no publish)",
"steps": [
"opencli operate open https://x.com/compose/tweet",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval - fill only test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval - fill only test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -24,26 +24,26 @@
"type": "publish",
"description": "Post a tweet, verify success, then delete it",
"steps": [
"opencli operate open https://x.com/compose/tweet",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
"opencli operate wait time 4",
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
"opencli browser wait time 4",
"opencli browser eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
],
"judge": {
"type": "matchesPattern",
"pattern": "post|sent|Your post|X"
},
"cleanup": [
"opencli operate open https://x.com/home",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
"opencli browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
],
"note": "6-step chain: open compose → paste text → click post → wait → verify toast → cleanup: find tweet → menu → delete → confirm"
},
@@ -53,29 +53,29 @@
"type": "publish",
"description": "Read HN top story title, compose a tweet about it, post, then delete",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
"opencli operate open https://x.com/compose/tweet",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const title = document.title || 'HN Story'; const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Interesting from HN: ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
"opencli operate wait time 4",
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
"opencli browser open https://news.ycombinator.com",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const title = document.title || 'HN Story'; const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Interesting from HN: ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
"opencli browser wait time 4",
"opencli browser eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
],
"judge": {
"type": "matchesPattern",
"pattern": "post|sent|Your post|X"
},
"cleanup": [
"opencli operate open https://x.com/home",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
"opencli browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
],
"note": "9-step cross-site chain: read HN title → navigate to twitter compose → paste content → post → verify → cleanup delete"
},
@@ -85,13 +85,13 @@
"type": "fill-only",
"description": "Navigate to own profile, find latest tweet, open reply box, fill reply text",
"steps": [
"opencli operate open https://x.com/home",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
"opencli browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -105,13 +105,13 @@
"type": "fill-only",
"description": "Navigate to a popular question, open answer editor, fill in answer content (no publish)",
"steps": [
"opencli operate open https://www.zhihu.com/question/19550225",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这是一个 OpenCLI 发文测试,时间戳: ' + Date.now() + '</p><p>这段内容用于验证 operate 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
"opencli browser open https://www.zhihu.com/question/19550225",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这是一个 OpenCLI 发文测试,时间戳: ' + Date.now() + '</p><p>这段内容用于验证 browser 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -125,13 +125,13 @@
"type": "fill-only",
"description": "Navigate to zhihu article editor (zhuanlan), fill title + body (no publish)",
"steps": [
"opencli operate open https://zhuanlan.zhihu.com/write",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const ta = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'); if (!ta) return 'no-title-input'; ta.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(ta, '[AutoTest] OpenCLI 发文能力验证 ' + Date.now()); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const editor = document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是 OpenCLI autoresearch 发文测试集的一部分。</p><p>测试链路:导航 → 填写标题 → 填写正文 → 验证内容。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'))?.value || ''; const body = document.querySelector('[contenteditable=true]')?.textContent || ''; return JSON.stringify({ title: title.slice(0, 50), body: body.slice(0, 50) }); })()\""
"opencli browser open https://zhuanlan.zhihu.com/write",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const ta = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'); if (!ta) return 'no-title-input'; ta.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(ta, '[AutoTest] OpenCLI 发文能力验证 ' + Date.now()); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const editor = document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是 OpenCLI autoresearch 发文测试集的一部分。</p><p>测试链路:导航 → 填写标题 → 填写正文 → 验证内容。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'))?.value || ''; const body = document.querySelector('[contenteditable=true]')?.textContent || ''; return JSON.stringify({ title: title.slice(0, 50), body: body.slice(0, 50) }); })()\""
],
"judge": {
"type": "contains",
@@ -145,16 +145,16 @@
"type": "fill-only",
"description": "Read HN top story, then navigate to zhihu question and fill an answer about it",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
"opencli operate open https://www.zhihu.com/question/19550225",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || document.querySelector('[data-zop-retarget=\\\"answer\\\"]'); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 分享一个来自 Hacker News 的有趣内容</p><p>这是一个跨平台内容搬运测试,时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'))?.textContent || ''\""
"opencli browser open https://news.ycombinator.com",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
"opencli browser open https://www.zhihu.com/question/19550225",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || document.querySelector('[data-zop-retarget=\\\"answer\\\"]'); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 分享一个来自 Hacker News 的有趣内容</p><p>这是一个跨平台内容搬运测试,时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'))?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -168,16 +168,16 @@
"type": "fill-only",
"description": "Navigate to compose, type first tweet, add thread tweet, type second tweet, verify both",
"steps": [
"opencli operate open https://x.com/compose/tweet",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 1 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'first-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const addBtn = document.querySelector('[data-testid=\\\"addButton\\\"]') || document.querySelector('[aria-label=\\\"Add post\\\"]'); if (addBtn) { addBtn.click(); return 'thread-added'; } return 'no-add-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const box = boxes[boxes.length - 1]; if (!box) return 'no-second-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 2 - continuation'); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'second-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const t1 = boxes[0]?.textContent || ''; const t2 = boxes[boxes.length - 1]?.textContent || ''; return JSON.stringify({ tweet1: t1, tweet2: t2 }); })()\""
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 1 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'first-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const addBtn = document.querySelector('[data-testid=\\\"addButton\\\"]') || document.querySelector('[aria-label=\\\"Add post\\\"]'); if (addBtn) { addBtn.click(); return 'thread-added'; } return 'no-add-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const box = boxes[boxes.length - 1]; if (!box) return 'no-second-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 2 - continuation'); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'second-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const t1 = boxes[0]?.textContent || ''; const t2 = boxes[boxes.length - 1]?.textContent || ''; return JSON.stringify({ tweet1: t1, tweet2: t2 }); })()\""
],
"judge": {
"type": "contains",
@@ -191,15 +191,15 @@
"type": "fill-only",
"description": "Navigate to home, find first tweet, open retweet menu, select Quote, fill quote text, verify",
"steps": [
"opencli operate open https://x.com/home",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const retweet = tweet.querySelector('[data-testid=\\\"retweet\\\"]'); if (retweet) { retweet.click(); return 'retweet-menu-opened'; } return 'no-retweet-btn'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Quote') || item.textContent?.includes('引用')) { item.click(); return 'quote-selected'; } } return 'no-quote-option'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Quote retweet test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'quote-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
"opencli browser open https://x.com/home",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const retweet = tweet.querySelector('[data-testid=\\\"retweet\\\"]'); if (retweet) { retweet.click(); return 'retweet-menu-opened'; } return 'no-retweet-btn'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Quote') || item.textContent?.includes('引用')) { item.click(); return 'quote-selected'; } } return 'no-quote-option'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Quote retweet test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'quote-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -213,14 +213,14 @@
"type": "fill-only",
"description": "Search 'opencli' on twitter, find first result, click reply, fill reply text, verify",
"steps": [
"opencli operate open https://x.com/search?q=opencli&src=typed_query&f=live",
"opencli operate wait time 4",
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply from search result ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'reply-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
"opencli browser open https://x.com/search?q=opencli&src=typed_query&f=live",
"opencli browser wait time 4",
"opencli browser eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
"opencli browser eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply from search result ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'reply-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -234,16 +234,16 @@
"type": "fill-only",
"description": "Search 'AI agent' on zhihu, click first question result, click 写回答, fill answer, verify",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=AI%20agent",
"opencli operate wait time 4",
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-question-links'; const link = links[0]; const href = link.getAttribute('href'); return 'found: ' + href; })()\"",
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-links'; const link = links[0]; const href = link.getAttribute('href'); const match = href.match(/\\\\/question\\\\/(\\\\d+)/); if (match) { window.location.href = 'https://www.zhihu.com/question/' + match[1]; return 'navigating-to-question'; } link.click(); return 'clicked-link'; })()\"",
"opencli operate wait time 4",
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || Array.from(document.querySelectorAll('a')).find(a => a.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] AI agent 搜索后回答测试 ' + Date.now() + '</p><p>这是通过搜索 → 进入问题 → 填写回答的完整链路测试。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
"opencli browser open https://www.zhihu.com/search?type=content&q=AI%20agent",
"opencli browser wait time 4",
"opencli browser eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-question-links'; const link = links[0]; const href = link.getAttribute('href'); return 'found: ' + href; })()\"",
"opencli browser eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-links'; const link = links[0]; const href = link.getAttribute('href'); const match = href.match(/\\\\/question\\\\/(\\\\d+)/); if (match) { window.location.href = 'https://www.zhihu.com/question/' + match[1]; return 'navigating-to-question'; } link.click(); return 'clicked-link'; })()\"",
"opencli browser wait time 4",
"opencli browser eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || Array.from(document.querySelectorAll('a')).find(a => a.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] AI agent 搜索后回答测试 ' + Date.now() + '</p><p>这是通过搜索 → 进入问题 → 填写回答的完整链路测试。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -257,15 +257,15 @@
"type": "fill-only",
"description": "Navigate to question page, scroll to first answer, click comment, fill comment text, verify",
"steps": [
"opencli operate open https://www.zhihu.com/question/19550225",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const answer = document.querySelector('[data-testid=\\\"answer\\\"]') || document.querySelector('.AnswerItem') || document.querySelector('.List-item'); if (answer) { answer.scrollIntoView({ behavior: 'smooth', block: 'center' }); return 'answer-scrolled'; } return 'no-answer'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const commentBtns = document.querySelectorAll('button'); for (const btn of commentBtns) { if (btn.textContent?.match(/评论|条评论|comment/i)) { btn.click(); return 'comment-opened: ' + btn.textContent.trim(); } } const commentIcons = document.querySelectorAll('[data-testid=\\\"comment\\\"]') || []; for (const icon of commentIcons) { icon.click(); return 'comment-icon-clicked'; } return 'no-comment-btn'; })()\"",
"opencli operate wait time 2",
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-comment-editor'; editor.focus(); if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { editor.value = '[AutoTest] 评论测试 ' + Date.now(); editor.dispatchEvent(new Event('input', { bubbles: true })); } else { editor.innerHTML = '<p>[AutoTest] 评论测试 ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); } return 'comment-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; return editor.value || editor.textContent || ''; })()\""
"opencli browser open https://www.zhihu.com/question/19550225",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const answer = document.querySelector('[data-testid=\\\"answer\\\"]') || document.querySelector('.AnswerItem') || document.querySelector('.List-item'); if (answer) { answer.scrollIntoView({ behavior: 'smooth', block: 'center' }); return 'answer-scrolled'; } return 'no-answer'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const commentBtns = document.querySelectorAll('button'); for (const btn of commentBtns) { if (btn.textContent?.match(/评论|条评论|comment/i)) { btn.click(); return 'comment-opened: ' + btn.textContent.trim(); } } const commentIcons = document.querySelectorAll('[data-testid=\\\"comment\\\"]') || []; for (const icon of commentIcons) { icon.click(); return 'comment-icon-clicked'; } return 'no-comment-btn'; })()\"",
"opencli browser wait time 2",
"opencli browser eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-comment-editor'; editor.focus(); if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { editor.value = '[AutoTest] 评论测试 ' + Date.now(); editor.dispatchEvent(new Event('input', { bubbles: true })); } else { editor.innerHTML = '<p>[AutoTest] 评论测试 ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); } return 'comment-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; return editor.value || editor.textContent || ''; })()\""
],
"judge": {
"type": "contains",
@@ -279,15 +279,15 @@
"type": "fill-only",
"description": "Navigate to zhuanlan editor, fill title, fill body with multiple paragraphs and bold text, verify",
"steps": [
"opencli operate open https://zhuanlan.zhihu.com/write",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] 格式化文章测试 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是第一段:OpenCLI 格式化发文测试。</p><p><strong>[AutoTest-Bold] 这是加粗的第二段,用于验证富文本格式。</strong></p><p>这是第三段,包含普通文本内容,时间戳: ' + Date.now() + '。</p><p>这是第四段,测试多段落填充能力。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled-with-formatting'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; const hasBold = editor.querySelector('strong') || editor.querySelector('b'); const paragraphs = editor.querySelectorAll('p'); return JSON.stringify({ paragraphCount: paragraphs.length, hasBold: !!hasBold, preview: editor.textContent?.slice(0, 80) }); })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), bodyHasBold: body.includes('AutoTest-Bold'), bodyLength: body.length }); })()\""
"opencli browser open https://zhuanlan.zhihu.com/write",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] 格式化文章测试 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是第一段:OpenCLI 格式化发文测试。</p><p><strong>[AutoTest-Bold] 这是加粗的第二段,用于验证富文本格式。</strong></p><p>这是第三段,包含普通文本内容,时间戳: ' + Date.now() + '。</p><p>这是第四段,测试多段落填充能力。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled-with-formatting'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; const hasBold = editor.querySelector('strong') || editor.querySelector('b'); const paragraphs = editor.querySelectorAll('p'); return JSON.stringify({ paragraphCount: paragraphs.length, hasBold: !!hasBold, preview: editor.textContent?.slice(0, 80) }); })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), bodyHasBold: body.includes('AutoTest-Bold'), bodyLength: body.length }); })()\""
],
"judge": {
"type": "contains",
@@ -301,16 +301,16 @@
"type": "fill-only",
"description": "Read zhihu hot topic title, navigate to twitter compose, fill tweet with zhihu content, verify",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const hotItem = document.querySelector('.HotItem-content a') || document.querySelector('.HotList-item a') || document.querySelector('[data-testid=\\\"hot-item\\\"] a') || document.querySelector('.HotItem a'); if (hotItem) return hotItem.textContent?.trim()?.slice(0, 60) || 'no-text'; const titles = document.querySelectorAll('h2'); for (const t of titles) { if (t.textContent?.trim().length > 5) return t.textContent.trim().slice(0, 60); } return 'no-hot-topic'; })()\"",
"opencli operate state save zhihu_hot_title",
"opencli operate open https://x.com/compose/tweet",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Zhihu热榜话题搬运: 知乎上正在热议的话题 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'tweet-filled-with-zhihu'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const hotItem = document.querySelector('.HotItem-content a') || document.querySelector('.HotList-item a') || document.querySelector('[data-testid=\\\"hot-item\\\"] a') || document.querySelector('.HotItem a'); if (hotItem) return hotItem.textContent?.trim()?.slice(0, 60) || 'no-text'; const titles = document.querySelectorAll('h2'); for (const t of titles) { if (t.textContent?.trim().length > 5) return t.textContent.trim().slice(0, 60); } return 'no-hot-topic'; })()\"",
"opencli browser state save zhihu_hot_title",
"opencli browser open https://x.com/compose/tweet",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
"opencli browser eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Zhihu热榜话题搬运: 知乎上正在热议的话题 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'tweet-filled-with-zhihu'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
],
"judge": {
"type": "contains",
@@ -324,17 +324,17 @@
"type": "fill-only",
"description": "Read twitter trending/explore topic, navigate to zhihu zhuanlan editor, fill title and body, verify",
"steps": [
"opencli operate open https://x.com/explore/tabs/trending",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const trends = document.querySelectorAll('[data-testid=\\\"trend\\\"]'); if (trends.length > 0) { const first = trends[0]; return first.textContent?.trim()?.slice(0, 80) || 'no-text'; } const spans = document.querySelectorAll('span'); for (const s of spans) { if (s.textContent?.startsWith('#') || s.textContent?.includes('Trending')) { return s.textContent.trim().slice(0, 80); } } return 'no-trending-topic'; })()\"",
"opencli operate state save twitter_trending",
"opencli operate open https://zhuanlan.zhihu.com/write",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] Twitter热点搬运: 来自推特的热门话题 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这篇文章搬运自 Twitter 热门话题。</p><p>Twitter 上正在讨论的热门话题为大家带来了新的视角和思考。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
"opencli operate wait time 1",
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), body: body.slice(0, 60) }); })()\""
"opencli browser open https://x.com/explore/tabs/trending",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const trends = document.querySelectorAll('[data-testid=\\\"trend\\\"]'); if (trends.length > 0) { const first = trends[0]; return first.textContent?.trim()?.slice(0, 80) || 'no-text'; } const spans = document.querySelectorAll('span'); for (const s of spans) { if (s.textContent?.startsWith('#') || s.textContent?.includes('Trending')) { return s.textContent.trim().slice(0, 80); } } return 'no-trending-topic'; })()\"",
"opencli browser state save twitter_trending",
"opencli browser open https://zhuanlan.zhihu.com/write",
"opencli browser wait time 3",
"opencli browser eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] Twitter热点搬运: 来自推特的热门话题 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这篇文章搬运自 Twitter 热门话题。</p><p>Twitter 上正在讨论的热门话题为大家带来了新的视角和思考。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
"opencli browser wait time 1",
"opencli browser eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), body: body.slice(0, 60) }); })()\""
],
"judge": {
"type": "contains",
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Layer 4: Save as CLI — test the full save pipeline
# Tests: operate init → write adapter → operate verify
# Tests: browser init → write adapter → browser verify
set -euo pipefail
cd "$(dirname "$0")/.."
+249 -249
View File
@@ -5,7 +5,7 @@
{
"name": "v2ex-open-home",
"steps": [
"opencli operate open https://v2ex.com/"
"opencli browser open https://v2ex.com/"
],
"judge": {
"type": "contains",
@@ -15,8 +15,8 @@
{
"name": "v2ex-state-home",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state"
"opencli browser open https://v2ex.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
@@ -26,8 +26,8 @@
{
"name": "v2ex-get-title",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.title\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
@@ -37,8 +37,8 @@
{
"name": "v2ex-click-tab",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
],
"judge": {
"type": "contains",
@@ -48,9 +48,9 @@
{
"name": "v2ex-scroll-down",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate scroll down --amount 500",
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
"opencli browser open https://v2ex.com/",
"opencli browser scroll down --amount 500",
"opencli browser eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
],
"judge": {
"type": "contains",
@@ -60,8 +60,8 @@
{
"name": "v2ex-get-first-topic-text",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
@@ -70,8 +70,8 @@
{
"name": "v2ex-eval-extract-titles",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -81,8 +81,8 @@
{
"name": "v2ex-get-url",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate get url"
"opencli browser open https://v2ex.com/",
"opencli browser get url"
],
"judge": {
"type": "contains",
@@ -92,10 +92,10 @@
{
"name": "v2ex-back-navigation",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate back",
"opencli operate get url"
"opencli browser open https://v2ex.com/",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser back",
"opencli browser get url"
],
"judge": {
"type": "matchesPattern",
@@ -105,9 +105,9 @@
{
"name": "v2ex-wait-page-load",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate wait selector \"a[href^='/t/']\"",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
"opencli browser open https://v2ex.com/",
"opencli browser wait selector \"a[href^='/t/']\"",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
],
"judge": {
"type": "contains",
@@ -120,8 +120,8 @@
{
"name": "v2ex-hot-topics",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -131,8 +131,8 @@
{
"name": "v2ex-node-list",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
],
"judge": {
"type": "arrayMinLength",
@@ -142,9 +142,9 @@
{
"name": "v2ex-topic-meta",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
"opencli browser eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -154,8 +154,8 @@
{
"name": "v2ex-node-topics",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -165,8 +165,8 @@
{
"name": "v2ex-node-pagination-info",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -176,8 +176,8 @@
{
"name": "v2ex-tab-content",
"steps": [
"opencli operate open https://v2ex.com/?tab=jobs",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
"opencli browser open https://v2ex.com/?tab=jobs",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -187,11 +187,11 @@
{
"name": "v2ex-topic-replies-extract",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
"opencli browser eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
],
"judge": {
"type": "nonEmpty"
@@ -200,8 +200,8 @@
{
"name": "v2ex-topic-reply-count",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
],
"judge": {
"type": "arrayMinLength",
@@ -211,8 +211,8 @@
{
"name": "v2ex-member-info",
"steps": [
"opencli operate open https://v2ex.com/member/Livid",
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
"opencli browser open https://v2ex.com/member/Livid",
"opencli browser eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
],
"judge": {
"type": "contains",
@@ -222,8 +222,8 @@
{
"name": "v2ex-search-results",
"steps": [
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
"opencli browser open https://www.google.com/search?q=site:v2ex.com+TypeScript",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
],
"judge": {
"type": "arrayMinLength",
@@ -236,11 +236,11 @@
{
"name": "v2ex-click-topic-read",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -249,10 +249,10 @@
{
"name": "v2ex-click-author-profile",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -262,10 +262,10 @@
{
"name": "v2ex-navigate-node-from-home",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -275,10 +275,10 @@
{
"name": "v2ex-pagination-page2",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
],
"judge": {
"type": "matchesPattern",
@@ -288,12 +288,12 @@
{
"name": "v2ex-topic-and-back",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate back",
"opencli operate wait time 1",
"opencli operate get url"
"opencli browser open https://v2ex.com/",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser back",
"opencli browser wait time 1",
"opencli browser get url"
],
"judge": {
"type": "matchesPattern",
@@ -303,10 +303,10 @@
{
"name": "v2ex-tab-then-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=creative",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
"opencli browser open https://v2ex.com/?tab=creative",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -315,11 +315,11 @@
{
"name": "v2ex-scroll-find-more",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli operate scroll down --amount 1000",
"opencli operate scroll down --amount 1000",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli browser scroll down --amount 1000",
"opencli browser scroll down --amount 1000",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
@@ -329,10 +329,10 @@
{
"name": "v2ex-node-to-topic-content",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -342,10 +342,10 @@
{
"name": "v2ex-multi-tab-compare",
"steps": [
"opencli operate open https://v2ex.com/?tab=tech",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
"opencli operate open https://v2ex.com/?tab=creative",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
"opencli browser open https://v2ex.com/?tab=tech",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
"opencli browser open https://v2ex.com/?tab=creative",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
],
"judge": {
"type": "arrayMinLength",
@@ -355,12 +355,12 @@
{
"name": "v2ex-topic-reply-to-author",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -372,10 +372,10 @@
{
"name": "v2ex-reply-type-text",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
],
"judge": {
"type": "contains",
@@ -386,10 +386,10 @@
{
"name": "v2ex-favorite-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -400,10 +400,10 @@
{
"name": "v2ex-thank-reply-find",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -414,10 +414,10 @@
{
"name": "v2ex-reply-form-detect",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
],
"judge": {
"type": "contains",
@@ -427,9 +427,9 @@
{
"name": "v2ex-create-topic-form-detect",
"steps": [
"opencli operate open https://v2ex.com/new",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
"opencli browser open https://v2ex.com/new",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
],
"judge": {
"type": "nonEmpty"
@@ -442,8 +442,8 @@
{
"name": "v2ex-collect-hot-authors",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
],
"judge": {
"type": "arrayMinLength",
@@ -453,10 +453,10 @@
{
"name": "v2ex-multi-node-compare",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
"opencli operate open https://v2ex.com/go/go",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
"opencli browser open https://v2ex.com/go/go",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -466,10 +466,10 @@
{
"name": "v2ex-topic-deep-read",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -479,10 +479,10 @@
{
"name": "v2ex-cross-page-data-collect",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
"opencli operate open https://v2ex.com/go/programmer?p=2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
"opencli browser open https://v2ex.com/go/programmer?p=2",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
@@ -492,11 +492,11 @@
{
"name": "v2ex-full-workflow",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
"opencli browser eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
],
"judge": {
"type": "matchesPattern",
@@ -509,9 +509,9 @@
{
"name": "v2ex-state-click-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate click 1"
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser click 1"
],
"judge": {
"type": "contains",
@@ -521,9 +521,9 @@
{
"name": "v2ex-state-click-tab-tech",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state",
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
"opencli browser open https://v2ex.com/",
"opencli browser state",
"opencli browser eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
],
"judge": {
"type": "matchesPattern",
@@ -533,8 +533,8 @@
{
"name": "v2ex-state-count-interactive",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state"
"opencli browser open https://v2ex.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
@@ -544,9 +544,9 @@
{
"name": "v2ex-state-scroll-state",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate scroll down --amount 500",
"opencli operate state"
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser scroll down --amount 500",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
@@ -556,9 +556,9 @@
{
"name": "v2ex-type-search-box",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state",
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
"opencli browser open https://v2ex.com/",
"opencli browser state",
"opencli browser eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
],
"judge": {
"type": "contains",
@@ -568,10 +568,10 @@
{
"name": "v2ex-get-value-after-type",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
],
"judge": {
"type": "nonEmpty"
@@ -580,8 +580,8 @@
{
"name": "v2ex-screenshot-exists",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
"opencli browser open https://v2ex.com/",
"opencli browser screenshot /tmp/v2ex-test-screenshot.png"
],
"judge": {
"type": "nonEmpty"
@@ -590,8 +590,8 @@
{
"name": "v2ex-get-html-selector",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate get html --selector h1"
"opencli browser open https://v2ex.com/",
"opencli browser get html --selector h1"
],
"judge": {
"type": "nonEmpty"
@@ -600,8 +600,8 @@
{
"name": "v2ex-keys-escape",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate keys Escape"
"opencli browser open https://v2ex.com/",
"opencli browser keys Escape"
],
"judge": {
"type": "contains",
@@ -611,8 +611,8 @@
{
"name": "v2ex-wait-text",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate wait text V2EX"
"opencli browser open https://v2ex.com/",
"opencli browser wait text V2EX"
],
"judge": {
"type": "matchesPattern",
@@ -625,12 +625,12 @@
{
"name": "v2ex-chain-3-pages",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.title\"",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"document.title\"",
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"document.title\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.title\"",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"document.title\"",
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -639,13 +639,13 @@
{
"name": "v2ex-chain-navigate-extract-back",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
"opencli operate back",
"opencli operate wait time 1",
"opencli operate eval \"document.title\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
"opencli browser back",
"opencli browser wait time 1",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
@@ -655,12 +655,12 @@
{
"name": "v2ex-chain-multi-node-scroll",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate scroll down --amount 500",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli operate open https://v2ex.com/go/go",
"opencli operate scroll down --amount 500",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
"opencli browser open https://v2ex.com/go/python",
"opencli browser scroll down --amount 500",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli browser open https://v2ex.com/go/go",
"opencli browser scroll down --amount 500",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
@@ -670,10 +670,10 @@
{
"name": "v2ex-chain-topic-replies-pagination",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelectorAll('.reply_content').length\""
],
"judge": {
"type": "matchesPattern",
@@ -683,9 +683,9 @@
{
"name": "v2ex-chain-member-topics",
"steps": [
"opencli operate open https://v2ex.com/member/Livid",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
"opencli browser open https://v2ex.com/member/Livid",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()\"",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
],
"judge": {
"type": "nonEmpty"
@@ -694,11 +694,11 @@
{
"name": "v2ex-chain-search-navigate-extract",
"steps": [
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.title\""
"opencli browser open https://www.google.com/search?q=site:v2ex.com+Python",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -707,12 +707,12 @@
{
"name": "v2ex-chain-tab-topic-author",
"steps": [
"opencli operate open https://v2ex.com/?tab=tech",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
"opencli browser open https://v2ex.com/?tab=tech",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
],
"judge": {
"type": "nonEmpty"
@@ -721,12 +721,12 @@
{
"name": "v2ex-chain-node-page2-extract",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli operate open https://v2ex.com/go/programmer?p=2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli operate open https://v2ex.com/go/programmer?p=3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli browser open https://v2ex.com/go/programmer?p=2",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli browser open https://v2ex.com/go/programmer?p=3",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
],
"judge": {
"type": "arrayMinLength",
@@ -736,13 +736,13 @@
{
"name": "v2ex-chain-full-interaction",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate state",
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser state",
"opencli browser eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
"opencli browser eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
],
"judge": {
"type": "matchesPattern",
@@ -752,15 +752,15 @@
{
"name": "v2ex-chain-deep-5-step",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
"opencli operate back",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelectorAll('.reply_content').length\"",
"opencli browser back",
"opencli browser eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
@@ -773,10 +773,10 @@
{
"name": "v2ex-rapid-navigate",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"location.pathname\""
"opencli browser open https://v2ex.com/",
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser open https://v2ex.com/go/python",
"opencli browser eval \"location.pathname\""
],
"judge": {
"type": "contains",
@@ -786,10 +786,10 @@
{
"name": "v2ex-eval-after-click",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 1",
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 1",
"opencli browser eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
],
"judge": {
"type": "contains",
@@ -799,10 +799,10 @@
{
"name": "v2ex-scroll-and-extract",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate scroll down --amount 2000",
"opencli operate wait time 1",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser scroll down --amount 2000",
"opencli browser wait time 1",
"opencli browser eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
],
"judge": {
"type": "arrayMinLength",
@@ -812,8 +812,8 @@
{
"name": "v2ex-concurrent-eval",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
"opencli browser open https://v2ex.com/",
"opencli browser eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
],
"judge": {
"type": "matchesPattern",
@@ -823,8 +823,8 @@
{
"name": "v2ex-unicode-content",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
],
"judge": {
"type": "nonEmpty"
@@ -836,9 +836,9 @@
{
"name": "v2ex-agent-click-first-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
],
"judge": {
"type": "matchesPattern",
@@ -849,10 +849,10 @@
{
"name": "v2ex-agent-type-search",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state",
"opencli operate type 3 TypeScript",
"opencli operate get value 3"
"opencli browser open https://v2ex.com/",
"opencli browser state",
"opencli browser type 3 TypeScript",
"opencli browser get value 3"
],
"judge": {
"type": "contains",
@@ -863,11 +863,11 @@
{
"name": "v2ex-agent-click-navigate-back",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
"opencli browser open https://v2ex.com/?tab=hot",
"opencli browser state",
"opencli browser eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -876,8 +876,8 @@
{
"name": "v2ex-agent-state-has-interactive",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state"
"opencli browser open https://v2ex.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
@@ -887,9 +887,9 @@
{
"name": "v2ex-agent-state-after-scroll",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate scroll down --amount 800",
"opencli operate state"
"opencli browser open https://v2ex.com/go/programmer",
"opencli browser scroll down --amount 800",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
+247 -247
View File
@@ -5,7 +5,7 @@
{
"name": "zhihu-open-home",
"steps": [
"opencli operate open https://www.zhihu.com/"
"opencli browser open https://www.zhihu.com/"
],
"judge": {
"type": "contains",
@@ -15,8 +15,8 @@
{
"name": "zhihu-get-title",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"document.title\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "contains",
@@ -26,8 +26,8 @@
{
"name": "zhihu-state",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate state"
"opencli browser open https://www.zhihu.com/",
"opencli browser state"
],
"judge": {
"type": "matchesPattern",
@@ -37,8 +37,8 @@
{
"name": "zhihu-get-url",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate get url"
"opencli browser open https://www.zhihu.com/hot",
"opencli browser get url"
],
"judge": {
"type": "contains",
@@ -48,9 +48,9 @@
{
"name": "zhihu-scroll-down",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate scroll down --amount 500",
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
"opencli browser open https://www.zhihu.com/",
"opencli browser scroll down --amount 500",
"opencli browser eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
],
"judge": {
"type": "contains",
@@ -60,8 +60,8 @@
{
"name": "zhihu-click-tab-hot",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
],
"judge": {
"type": "contains",
@@ -71,10 +71,10 @@
{
"name": "zhihu-back-navigation",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate back",
"opencli operate get url"
"opencli browser open https://www.zhihu.com/",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser back",
"opencli browser get url"
],
"judge": {
"type": "matchesPattern",
@@ -84,9 +84,9 @@
{
"name": "zhihu-wait-page-load",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate wait text 推荐",
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
"opencli browser open https://www.zhihu.com/",
"opencli browser wait text 推荐",
"opencli browser eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
],
"judge": {
"type": "contains",
@@ -96,8 +96,8 @@
{
"name": "zhihu-keys-escape",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate keys Escape"
"opencli browser open https://www.zhihu.com/",
"opencli browser keys Escape"
],
"judge": {
"type": "matchesPattern",
@@ -107,8 +107,8 @@
{
"name": "zhihu-screenshot",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate screenshot /tmp/zhihu-test.png"
"opencli browser open https://www.zhihu.com/",
"opencli browser screenshot /tmp/zhihu-test.png"
],
"judge": {
"type": "nonEmpty"
@@ -120,8 +120,8 @@
{
"name": "zhihu-feed-titles",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -131,8 +131,8 @@
{
"name": "zhihu-hot-list",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -142,8 +142,8 @@
{
"name": "zhihu-hot-metrics",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -153,8 +153,8 @@
{
"name": "zhihu-nav-tabs",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
],
"judge": {
"type": "contains",
@@ -164,8 +164,8 @@
{
"name": "zhihu-feed-with-authors",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -175,8 +175,8 @@
{
"name": "zhihu-feed-types",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
],
"judge": {
"type": "matchesPattern",
@@ -186,8 +186,8 @@
{
"name": "zhihu-user-avatar",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
],
"judge": {
"type": "nonEmpty"
@@ -196,8 +196,8 @@
{
"name": "zhihu-search-input-exists",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
],
"judge": {
"type": "contains",
@@ -210,11 +210,11 @@
{
"name": "zhihu-question-title",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -223,10 +223,10 @@
{
"name": "zhihu-question-meta",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -236,10 +236,10 @@
{
"name": "zhihu-first-answer",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -249,10 +249,10 @@
{
"name": "zhihu-answer-votes",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -262,10 +262,10 @@
{
"name": "zhihu-question-buttons",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -275,10 +275,10 @@
{
"name": "zhihu-multiple-answers",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -288,10 +288,10 @@
{
"name": "zhihu-question-description",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
],
"judge": {
"type": "nonEmpty"
@@ -300,10 +300,10 @@
{
"name": "zhihu-answer-count-number",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
],
"judge": {
"type": "matchesPattern",
@@ -316,10 +316,10 @@
{
"name": "zhihu-hot-to-question",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -328,10 +328,10 @@
{
"name": "zhihu-feed-to-question",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -340,12 +340,12 @@
{
"name": "zhihu-question-to-author",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -354,11 +354,11 @@
{
"name": "zhihu-search-navigate",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
"opencli operate wait time 5",
"opencli operate scroll down --amount 300",
"opencli operate wait time 1",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
"opencli browser open https://www.zhihu.com/search?type=content&q=TypeScript",
"opencli browser wait time 5",
"opencli browser scroll down --amount 300",
"opencli browser wait time 1",
"opencli browser eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
],
"judge": {
"type": "nonEmpty"
@@ -367,9 +367,9 @@
{
"name": "zhihu-topic-page",
"steps": [
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
"opencli browser open https://www.zhihu.com/topic/19552832/hot",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
],
"judge": {
"type": "nonEmpty"
@@ -378,9 +378,9 @@
{
"name": "zhihu-user-profile",
"steps": [
"opencli operate open https://www.zhihu.com/people/excited-vczh",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
"opencli browser open https://www.zhihu.com/people/excited-vczh",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -390,11 +390,11 @@
{
"name": "zhihu-question-and-back",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate get url"
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser get url"
],
"judge": {
"type": "contains",
@@ -404,11 +404,11 @@
{
"name": "zhihu-scroll-load-more",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli operate scroll down --amount 2000",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli browser scroll down --amount 2000",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelectorAll('.HotItem-content').length\""
],
"judge": {
"type": "matchesPattern",
@@ -421,10 +421,10 @@
{
"name": "zhihu-upvote-button-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
],
"judge": {
"type": "contains",
@@ -435,10 +435,10 @@
{
"name": "zhihu-follow-question-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "contains",
@@ -449,10 +449,10 @@
{
"name": "zhihu-comment-button-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "matchesPattern",
@@ -463,10 +463,10 @@
{
"name": "zhihu-bookmark-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
],
"judge": {
"type": "matchesPattern",
@@ -477,10 +477,10 @@
{
"name": "zhihu-write-answer-btn",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "contains",
@@ -491,10 +491,10 @@
{
"name": "zhihu-share-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "contains",
@@ -508,10 +508,10 @@
{
"name": "zhihu-hot-read-answer-author",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -521,12 +521,12 @@
{
"name": "zhihu-hot-to-author-profile",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
],
"judge": {
"type": "nonEmpty"
@@ -535,8 +535,8 @@
{
"name": "zhihu-multi-hot-topics",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -546,11 +546,11 @@
{
"name": "zhihu-search-then-read",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
"opencli browser open https://www.zhihu.com/search?type=content&q=Python",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -559,12 +559,12 @@
{
"name": "zhihu-question-scroll-answers",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate scroll down --amount 1000",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser scroll down --amount 1000",
"opencli browser wait time 1",
"opencli browser eval \"document.querySelectorAll('.RichContent-inner').length\""
],
"judge": {
"type": "matchesPattern",
@@ -574,10 +574,10 @@
{
"name": "zhihu-compare-tabs",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
],
"judge": {
"type": "nonEmpty"
@@ -586,9 +586,9 @@
{
"name": "zhihu-user-answers",
"steps": [
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
"opencli operate wait time 4",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
"opencli browser open https://www.zhihu.com/people/excited-vczh/answers",
"opencli browser wait time 4",
"opencli browser eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
],
"judge": {
"type": "nonEmpty"
@@ -597,9 +597,9 @@
{
"name": "zhihu-topic-questions",
"steps": [
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/topic/19552832/hot",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -612,11 +612,11 @@
{
"name": "zhihu-search-basic",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
"opencli operate wait time 5",
"opencli operate scroll down --amount 300",
"opencli operate wait time 1",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
"opencli browser open https://www.zhihu.com/search?type=content&q=AI",
"opencli browser wait time 5",
"opencli browser scroll down --amount 300",
"opencli browser wait time 1",
"opencli browser eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
],
"judge": {
"type": "nonEmpty"
@@ -625,9 +625,9 @@
{
"name": "zhihu-search-people",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
"opencli browser open https://www.zhihu.com/search?type=people&q=Python",
"opencli browser wait time 3",
"opencli browser eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
],
"judge": {
"type": "nonEmpty"
@@ -636,9 +636,9 @@
{
"name": "zhihu-search-topic",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
"opencli browser open https://www.zhihu.com/search?type=topic&q=编程",
"opencli browser wait time 3",
"opencli browser eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
],
"judge": {
"type": "nonEmpty"
@@ -647,11 +647,11 @@
{
"name": "zhihu-search-click-result",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.title\""
"opencli browser open https://www.zhihu.com/search?type=content&q=Rust编程",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -660,9 +660,9 @@
{
"name": "zhihu-search-filter-answers",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
"opencli browser open https://www.zhihu.com/search?type=content&q=Docker",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -672,13 +672,13 @@
{
"name": "zhihu-search-and-back",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli operate wait time 5",
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli operate wait time 3",
"opencli operate get url"
"opencli browser open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli browser wait time 5",
"opencli browser eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli browser wait time 3",
"opencli browser get url"
],
"judge": {
"type": "contains",
@@ -691,11 +691,11 @@
{
"name": "zhihu-full-browse-workflow",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -705,12 +705,12 @@
{
"name": "zhihu-deep-author-chain",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -720,11 +720,11 @@
{
"name": "zhihu-cross-question-compare",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
"opencli browser eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -734,12 +734,12 @@
{
"name": "zhihu-search-read-chain",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
"opencli browser open https://www.zhihu.com/search?type=content&q=Claude",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
"opencli browser eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -748,13 +748,13 @@
{
"name": "zhihu-3-page-chain",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"document.title\"",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli operate open https://www.zhihu.com/people/excited-vczh",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
"opencli browser open https://www.zhihu.com/",
"opencli browser eval \"document.title\"",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli browser open https://www.zhihu.com/people/excited-vczh",
"opencli browser wait time 2",
"opencli browser eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
@@ -763,12 +763,12 @@
{
"name": "zhihu-hot-scroll-deep-read",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate scroll down --amount 1000",
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser scroll down --amount 1000",
"opencli browser eval \"document.querySelectorAll('.HotItem-content a').length\"",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
"opencli browser wait time 2",
"opencli browser eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
],
"judge": {
"type": "matchesPattern",
@@ -781,11 +781,11 @@
{
"name": "zhihu-rapid-navigate",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate open https://www.zhihu.com/people/excited-vczh",
"opencli operate wait time 2",
"opencli operate eval \"location.pathname\""
"opencli browser open https://www.zhihu.com/",
"opencli browser open https://www.zhihu.com/hot",
"opencli browser open https://www.zhihu.com/people/excited-vczh",
"opencli browser wait time 2",
"opencli browser eval \"location.pathname\""
],
"judge": {
"type": "contains",
@@ -795,10 +795,10 @@
{
"name": "zhihu-hot-click-verify-url",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
],
"judge": {
"type": "contains",
@@ -808,13 +808,13 @@
{
"name": "zhihu-scroll-lazy-answers",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
"opencli operate scroll down --amount 2000",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"document.querySelectorAll('.RichContent-inner').length\"",
"opencli browser scroll down --amount 2000",
"opencli browser wait time 2",
"opencli browser eval \"document.querySelectorAll('.RichContent-inner').length\""
],
"judge": {
"type": "matchesPattern",
@@ -824,8 +824,8 @@
{
"name": "zhihu-extract-structured",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
@@ -835,10 +835,10 @@
{
"name": "zhihu-question-answer-chain",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
"opencli browser open https://www.zhihu.com/hot",
"opencli browser eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli browser wait time 3",
"opencli browser eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
],
"judge": {
"type": "matchesPattern",
-2
View File
@@ -1,2 +0,0 @@
// Shim: re-exports from src/browser/ for adapter imports
export * from '../src/browser/cdp.js';
-2
View File
@@ -1,2 +0,0 @@
// Shim: re-exports from src/browser/ for adapter imports
export * from '../src/browser/page.js';
-2
View File
@@ -1,2 +0,0 @@
// Shim: re-exports from src/browser/ for adapter imports
export * from '../src/browser/utils.js';
+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.
+56
View File
@@ -0,0 +1,56 @@
# Daily RL research monitor
A 30-second morning routine that surfaces what changed overnight in reinforcement-learning and large-model research, without opening a browser.
## What I wanted
Before reading anything, decide where to spend my 20 minutes of paper time:
- which `cs.LG` and `cs.AI` papers landed in the last 24 hours
- which OpenReview submissions at recent venues (NeurIPS 2025 right now, ICLR 2024 / NeurIPS 2024 as historical reference) carry titles and primary areas relevant to my work
- which papers the Hugging Face Daily Papers community is talking about today
Skim signals, then drill in. The point is to filter, not to read everything.
## Commands
```bash
# 1. arxiv recent in the two relevant categories (newest 30 each)
opencli arxiv recent cs.LG --limit 30 -f json > /tmp/lg.json
opencli arxiv recent cs.AI --limit 30 -f json > /tmp/ai.json
# 2. NeurIPS 2025 oral track from OpenReview (use natural-language
# venue text; the EMPTY_RESULT error helpfully echoes valid syntax
# if a venue is not yet open)
opencli openreview venue "NeurIPS 2025 oral" --limit 50 -f json > /tmp/neurips.json
# 3. Hugging Face Daily Papers (community-upvoted research)
opencli hf top --period daily --limit 20 -f json > /tmp/hf.json
```
That is the entire collection step. The four files together are the whole signal surface for one morning.
## What I do with the output
Pipe the four JSON files into a one-shot LLM digest with a fixed prompt:
```
Here are four JSON arrays of papers from the last 24 hours.
Group them into:
1. Direct hits on RLHF / preference optimization / reasoning RL.
2. Adjacent (offline RL, world models, agent benchmarks).
3. Notable infra (training, evaluation, data).
For each, give me title + arxiv id + one-sentence why-it-matters.
Skip everything that is review / survey / position paper.
```
The LLM compresses ~120 entries into a 10-line shortlist in seconds. I then open whichever 2 to 3 papers actually clear the bar.
## Why CLI beats the browser version
- Four pages of clicking and scrolling collapses into four `opencli` calls.
- The output is structured JSON, so the digest prompt can reason about it deterministically. No copy-paste, no "I missed paper 14".
- Works inside any agent loop. A scheduled task can run the four commands, push them to an LLM, and message the digest somewhere. No browser kept open.
- Zero token cost on the OpenCLI side. The only paid step is the digest call at the end.
The arxiv adapter's `recent <category>` (added in #1289) is the lever here. Without it I would have to fall back to the arxiv listings page, which means scraping HTML in agent code instead of consuming a structured listing.
+57
View File
@@ -0,0 +1,57 @@
# Find a paper's implementation and follow-up work
Given a single paper title or arxiv id, walk three sources in one chain to find the canonical reference, follow-up citations, and any community-fine-tuned models or Spaces that already build on it.
## What I wanted
I read a paper abstract, decide it is interesting, and want to answer three questions before deciding to actually re-read the paper or reproduce it:
1. Has anyone already implemented or fine-tuned on top of it (Hugging Face)?
2. Who has cited or extended it (dblp / OpenReview)?
3. What is the canonical bibliographic record (dblp key for citation, full arxiv metadata for reading)?
Doing this in a browser means three tabs and two minutes of context-switching. The point is to compress that into one shell pipeline.
## Commands
Worked example: "Direct Preference Optimization" (DPO).
```bash
# 1. Canonical arxiv record (full abstract, authors, pdf url, categories).
# Note: arxiv free-text search ranks by recency, so the original DPO
# paper does not always come back first. When the canonical id is
# already known, hit `arxiv paper <id>` directly.
opencli arxiv search "Direct Preference Optimization" --limit 5 -f json
opencli arxiv paper 2305.18290 -f json
# 2. dblp bibliography record + co-authors + venue history
opencli dblp search "Direct Preference Optimization" --limit 5 -f json
# 3. Community uptake on Hugging Face: trending Daily Papers that mention DPO
opencli hf top --period monthly --limit 50 -f json | jq '.[] | select(.title | test("DPO|preference"; "i"))'
# 4. Conference review record (if posted to OpenReview)
opencli openreview search "Direct Preference Optimization" --limit 5 -f json
```
Three of the four are public-strategy adapters, no browser session needed. The OpenReview call also lands without auth for public venues.
## What I do with the output
For DPO the chain produces:
- arxiv record: paper id `2305.18290`, full abstract, pdf link.
- dblp record: canonical key `conf/nips/RafailovSMMEF23`, NeurIPS 2023, co-author list (useful to find related work by same lab).
- HF Daily Papers (last 30 days): every paper whose title mentions DPO or preference. Each one is a candidate "follow-up work I should know about".
- OpenReview: the original submission's review thread, if posted (lets me see what reviewers actually pushed back on, which is more useful than the published abstract).
I dump all four JSON outputs into a single LLM call with the prompt: *"Build a one-paragraph 'state of the field' summary for this paper as of today. Cite each follow-up by arxiv id."* That gives me a research-debt brief in 30 seconds.
## Why this is worth a CLI chain
- Each adapter alone is just "search a website". The value is the chain. Four `opencli` calls feed into one LLM call. No browser, no copy-paste.
- Output is identifier-rich (arxiv id, dblp key, venue id, HF paper id). I can re-feed any of those into the next call, e.g. once I find a follow-up arxiv id from HF Daily Papers I run `opencli arxiv paper <new-id>` immediately.
- Survives use inside an agent loop. Same chain runs unattended for a batch of 20 papers from a reading list.
- Zero token cost for the discovery half. Only the final summary step pays for inference.
Without `opencli dblp search` (added in #1299) and `opencli openreview search` (added in #1294), this whole pipeline used to require either web scraping in agent code or paying for a research-paper API. Both adapters being public-strategy means they slot in cleanly.
+75
View File
@@ -0,0 +1,75 @@
# Track a conference's accepted papers and reviews from the terminal
Once an OpenReview venue opens its decisions (or releases reviews publicly during the discussion phase), I want a one-shot way to pull the full venue listing and dive into individual review threads, without clicking through 200+ submission pages.
## What I wanted
For each major venue I follow (ICLR, NeurIPS, ICML), the same three things every time decisions are visible:
1. The full list of accepted papers at the venue, with titles and forum ids.
2. For any paper I flagged interesting from the list: the full review thread, including reviewer scores, rebuttals, and the AC's decision rationale.
3. A way to pipe both into LLM-driven shortlisting ("which of these 100 oral papers actually intersect with my research direction").
The OpenReview UI is fine for one paper at a time, but unusable for batch reasoning across the whole acceptance list.
## Commands
Worked example: ICLR 2024 oral track, then drill into one paper's reviews using a real forum id.
```bash
# 1. Full list of papers at a venue (natural-language venue text;
# if the venue is not yet open OpenReview returns EMPTY_RESULT
# with a help line listing valid forms)
opencli openreview venue "ICLR 2024 oral" --limit 200 -f json > /tmp/iclr-2024.json
# 2. Pick a forum id from the listing, fetch the full review thread.
# Example: "Proving Test Set Contamination in Black-Box Language Models"
opencli openreview reviews KS8mIvetg2 -f json > /tmp/reviews.json
# 3. Single paper metadata if needed
opencli openreview paper KS8mIvetg2 -f json
```
`venue` returns each entry with a forum id you can hand straight back into `reviews` and `paper`. No id lookup gymnastics. `reviews` returns the full thread as a JSON array: a `PAPER` row with the abstract, then one `REVIEW` row per reviewer (with `rating`, `confidence`, summary, weaknesses, questions), followed by author rebuttals and the AC's decision rationale.
## What I do with the output
Two distinct workflows depending on the phase of the venue:
### Phase A: filtering the acceptance list
After `venue` returns 200 entries, dump the JSON into an LLM with the prompt:
```
Here is the full acceptance list at <venue>. Filter to papers that intersect
with my research interests:
- reinforcement learning from preference / reward feedback
- reasoning training (process reward, RLVR, RLHF variants)
- long-horizon agent benchmarks
For each match: title + forum_id + one-sentence why-it-matters.
```
This collapses 200 papers to a 10-paper shortlist in seconds. The forum ids are the keys I will use in Phase B.
### Phase B: depth-reading the shortlist
For each shortlisted forum id, run `opencli openreview reviews <forum-id>` and feed the JSON to an LLM with the prompt:
```
Summarize the review thread:
- reviewer scores
- the strongest critique
- whether the rebuttal addressed it
- final decision and AC rationale
```
This is faster than reading three reviews + rebuttal + meta-review per paper. For 10 papers this turns 60 minutes of OpenReview clicking into 10 minutes of summary reading, then I open the actual reviews only for papers where the summary flagged something worth knowing.
## Why this beats opening OpenReview
- One `venue` call replaces scrolling a paginated UI for 200+ papers.
- `reviews` returns the entire thread as JSON, so an LLM can reason over the whole review-rebuttal-decision arc at once. The web view forces you to scroll three reviews + N rebuttals + meta separately.
- Forum ids returned from `venue` are stable and reusable across calls. Easy to keep a personal reading list as `forum-ids.txt` and run `for id in $(cat forum-ids.txt); do opencli openreview reviews $id; done`.
- The whole loop is public-strategy. No login required for venues with public reviewing.
`opencli openreview` (added in #1294) is the lever. Before this adapter existed, the same workflow needed either OpenReview's Python client or HTML scraping inside agent code. Both have higher friction than `opencli openreview reviews <forum-id>` returning structured JSON in one shot.
+28927
View File
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { assertAuthenticatedState, buildDetailUrl, buildProvenance, cleanText, extractOfferId, gotoAndReadState, uniqueMediaSources, } from './shared.js';
function scriptToReadAssets() {
return `
(() => {
const root = window.context ?? {};
const model = root.result?.global?.globalData?.model ?? null;
const gallery = root.result?.data?.gallery?.fields ?? null;
const defaultSrcProps = ['data-lazyload-src', 'data-src', 'data-ks-lazyload', 'currentSrc', 'src'];
const groups = [
{ key: 'main', type: 'image', selectors: ['#dt-tab img', '.detail-gallery-turn img.detail-gallery-img', '.img-list-wrapper img.od-gallery-img', '.od-scroller-item span'] },
{ key: 'video', type: 'video', selectors: ['.lib-video video', 'video[src]', 'video source[src]'] },
{ key: 'sku', type: 'image', selectors: ['.pc-sku-wrapper .prop-item-inner-wrapper', '.sku-item-wrapper', '.specification-cell', '.sku-filter-button', '.expand-view-item', '.feature-item img'], srcProps: ['backgroundImage'] },
{ key: 'detail', type: 'image', selectors: ['.de-description-detail img', '#detailContentContainer img', '.html-description img', '.html-description source', '.desc-lazyload-container img'] },
];
const assets = [];
const seen = new Set();
const normalizeUrl = (value) => {
if (typeof value !== 'string') return '';
let next = value
.replace(/^url\\((.*)\\)$/i, '$1')
.replace(/^['"]|['"]$/g, '')
.replace(/\\\\u002F/g, '/')
.replace(/&amp;/g, '&')
.trim();
if (!next || next.startsWith('blob:') || next.startsWith('data:')) return '';
if (next.startsWith('//')) next = 'https:' + next;
try {
return new URL(next, location.href).toString();
} catch {
return '';
}
};
const push = (type, group, url, source) => {
const normalized = normalizeUrl(url);
if (!normalized) return;
const key = type + ':' + normalized;
if (seen.has(key)) return;
seen.add(key);
assets.push({ type, group, url: normalized, source });
};
const queryAllDeep = (selector) => {
const results = [];
const visitedRoots = new Set();
const walkRoots = (root, fn) => {
if (!root || visitedRoots.has(root)) return;
visitedRoots.add(root);
fn(root);
const childElements = root.querySelectorAll ? Array.from(root.querySelectorAll('*')) : [];
for (const child of childElements) {
if (child && child.shadowRoot) {
walkRoots(child.shadowRoot, fn);
}
}
};
walkRoots(document, (root) => {
if (root.querySelectorAll) {
results.push(...Array.from(root.querySelectorAll(selector)));
}
});
return results;
};
const valuesFromElement = (element, srcProps) => {
const values = [];
const props = srcProps && srcProps.length ? srcProps : defaultSrcProps;
for (const prop of props) {
try {
if (prop === 'backgroundImage') {
const bg = getComputedStyle(element).backgroundImage || '';
const matches = bg.match(/url\\(([^)]+)\\)/g) || [];
for (const match of matches) {
const clean = match.replace(/^url\\(/, '').replace(/\\)$/, '');
values.push(clean);
}
continue;
}
const direct = element[prop];
if (typeof direct === 'string' && direct) values.push(direct);
const attr = element.getAttribute ? element.getAttribute(prop) : '';
if (attr) values.push(attr);
} catch {}
}
if (element.tagName === 'SOURCE' && element.parentElement?.tagName === 'VIDEO') {
values.push(element.src || element.getAttribute('src') || '');
}
if (element.tagName === 'VIDEO') {
values.push(element.currentSrc || '');
values.push(element.src || '');
}
return values;
};
for (const group of groups) {
for (const selector of group.selectors) {
for (const element of queryAllDeep(selector)) {
for (const value of valuesFromElement(element, group.srcProps)) {
push(group.type, group.key, value, 'dom:' + selector);
}
}
}
}
const scriptTexts = Array.from(document.scripts).map((script) => script.textContent || '');
const videoRegex = /https?:\\/\\/[^"'\\s]+\\.(?:mp4|m3u8)(?:\\?[^"'\\s]*)?/gi;
for (const scriptText of scriptTexts) {
const matches = scriptText.match(videoRegex) || [];
for (const match of matches) {
push('video', 'video', match, 'script');
}
}
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
return {
href: window.location.href,
title: document.title || '',
offerTitle: model?.offerTitleModel?.subject ?? '',
offerId: model?.tradeModel?.offerId ?? '',
gallery: toJson(gallery),
scannedAssets: assets,
};
})()
`;
}
function normalizeAssets(payload) {
const offerId = cleanText(String(payload.offerId ?? '')) || extractOfferId(cleanText(payload.href)) || null;
const itemUrl = offerId ? buildDetailUrl(offerId) : cleanText(payload.href);
const seededAssets = [
...((payload.gallery?.mainImage ?? []).map((url) => ({ type: 'image', group: 'main', url, source: 'page_state:mainImage' }))),
...((payload.gallery?.offerImgList ?? []).map((url) => ({ type: 'image', group: 'main', url, source: 'page_state:offerImgList' }))),
...((payload.gallery?.wlImageInfos ?? []).map((item) => ({
type: 'image',
group: 'main',
url: item?.fullPathImageURI ?? '',
source: 'page_state:wlImageInfos',
}))),
];
const assets = uniqueMediaSources([...seededAssets, ...(payload.scannedAssets ?? [])]);
const mainImages = assets.filter((item) => item.type === 'image' && item.group === 'main').map((item) => item.url);
const skuImages = assets.filter((item) => item.type === 'image' && item.group === 'sku').map((item) => item.url);
const detailImages = assets.filter((item) => item.type === 'image' && item.group === 'detail').map((item) => item.url);
const videos = assets.filter((item) => item.type === 'video').map((item) => item.url);
const otherImages = assets
.filter((item) => item.type === 'image' && !['main', 'sku', 'detail'].includes(item.group))
.map((item) => item.url);
return {
offer_id: offerId,
title: cleanText(payload.offerTitle) || cleanText(payload.title) || null,
item_url: itemUrl,
main_images: mainImages,
sku_images: skuImages,
detail_images: detailImages,
videos,
other_images: otherImages,
raw_assets: assets,
source: [...new Set(assets.map((item) => cleanText(item.source)).filter(Boolean))],
main_count: mainImages.length,
sku_count: skuImages.length,
detail_count: detailImages.length,
video_count: videos.length,
...buildProvenance(cleanText(payload.href) || itemUrl),
};
}
async function readAssetsPayload(page, itemUrl) {
const state = await gotoAndReadState(page, itemUrl, 2500, 'assets');
assertAuthenticatedState(state, 'assets');
await page.autoScroll({ times: 3, delayMs: 400 });
await page.wait(1);
return await page.evaluate(scriptToReadAssets());
}
export async function extractAssetsForInput(page, input) {
const itemUrl = buildDetailUrl(String(input ?? ''));
const payload = await readAssetsPayload(page, itemUrl);
return normalizeAssets(payload);
}
cli({
site: '1688',
name: 'assets',
access: 'read',
description: '列出 1688 商品页可提取的图片/视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 商品 URL 或 offer ID(如 887904326744',
},
],
columns: ['offer_id', 'title', 'main_count', 'sku_count', 'detail_count', 'video_count'],
func: async (page, kwargs) => {
return [await extractAssetsForInput(page, String(kwargs.input ?? ''))];
},
});
export const __test__ = {
normalizeAssets,
};
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './assets.js';
import { __test__ as sharedTest } from './shared.js';
describe('1688 assets normalization', () => {
it('normalizes gallery and scanned assets into grouped media lists', () => {
const result = __test__.normalizeAssets({
href: 'https://detail.1688.com/offer/887904326744.html',
title: '测试商品 - 阿里巴巴',
offerTitle: '测试商品',
offerId: 887904326744,
gallery: {
mainImage: ['//img.example.com/main-1.jpg'],
offerImgList: ['https://img.example.com/main-2.jpg'],
wlImageInfos: [{ fullPathImageURI: 'https://img.example.com/main-3.jpg' }],
},
scannedAssets: [
{ type: 'image', group: 'sku', url: 'https://img.example.com/sku-1.png', source: 'dom:.sku' },
{ type: 'image', group: 'detail', url: 'https://img.example.com/detail-1.jpg', source: 'dom:.detail' },
{ type: 'video', group: 'video', url: 'https://video.example.com/demo.mp4', source: 'script' },
{ type: 'image', group: 'detail', url: 'blob:https://detail.1688.com/1', source: 'ignore' },
],
});
expect(result.offer_id).toBe('887904326744');
expect(result.main_images).toEqual([
'https://img.example.com/main-1.jpg',
'https://img.example.com/main-2.jpg',
'https://img.example.com/main-3.jpg',
]);
expect(result.sku_images).toEqual(['https://img.example.com/sku-1.png']);
expect(result.detail_images).toEqual(['https://img.example.com/detail-1.jpg']);
expect(result.videos).toEqual(['https://video.example.com/demo.mp4']);
expect(result.main_count).toBe(3);
expect(result.video_count).toBe(1);
});
it('normalizes media urls from style syntax and protocol-relative URLs', () => {
expect(sharedTest.normalizeMediaUrl('url("//img.example.com/1.jpg")')).toBe('https://img.example.com/1.jpg');
expect(sharedTest.normalizeMediaUrl('blob:https://detail.1688.com/1')).toBe('');
});
});
+77
View File
@@ -0,0 +1,77 @@
import * as path from 'node:path';
import { formatCookieHeader } from '@jackwener/opencli/download';
import { downloadMedia } from '@jackwener/opencli/download/media-download';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cleanText } from './shared.js';
import { extractAssetsForInput } from './assets.js';
function extFromUrl(url, fallback) {
try {
const ext = path.extname(new URL(url).pathname).toLowerCase();
if (ext && ext.length <= 8)
return ext;
}
catch {
// ignore
}
return fallback;
}
function toDownloadItems(offerId, assets) {
const items = [];
const pushImages = (urls, prefix) => {
urls.forEach((url, index) => {
items.push({
type: 'image',
url,
filename: `${offerId}_${prefix}_${String(index + 1).padStart(2, '0')}${extFromUrl(url, '.jpg')}`,
});
});
};
pushImages(assets.main_images, 'main');
pushImages(assets.sku_images, 'sku');
pushImages(assets.detail_images, 'detail');
pushImages(assets.other_images, 'other');
assets.videos.forEach((url, index) => {
items.push({
type: 'video',
url,
filename: `${offerId}_video_${String(index + 1).padStart(2, '0')}${extFromUrl(url, '.mp4')}`,
});
});
return items;
}
cli({
site: '1688',
name: 'download',
access: 'read',
description: '批量下载 1688 商品页可提取的图片和视频素材',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 商品 URL 或 offer ID(如 887904326744',
},
{ name: 'output', default: './1688-downloads', help: '输出目录' },
],
columns: ['index', 'type', 'status', 'size'],
func: async (page, kwargs) => {
const assets = await extractAssetsForInput(page, String(kwargs.input ?? ''));
const offerId = cleanText(assets.offer_id) || '1688';
const items = toDownloadItems(offerId, assets);
const browserCookies = await page.getCookies({ domain: '1688.com' });
return downloadMedia(items, {
output: String(kwargs.output || './1688-downloads'),
subdir: offerId,
cookies: formatCookieHeader(browserCookies),
browserCookies,
filenamePrefix: offerId,
timeout: 60000,
});
},
});
export const __test__ = {
extFromUrl,
toDownloadItems,
};
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './download.js';
describe('1688 download helpers', () => {
it('builds stable filenames for grouped assets', () => {
const items = __test__.toDownloadItems('887904326744', {
offer_id: '887904326744',
title: '测试商品',
item_url: 'https://detail.1688.com/offer/887904326744.html',
main_images: ['https://img.example.com/a.jpg'],
sku_images: ['https://img.example.com/b.png'],
detail_images: ['https://img.example.com/c.webp'],
videos: ['https://video.example.com/d.mp4'],
other_images: [],
raw_assets: [],
source: [],
main_count: 1,
sku_count: 1,
detail_count: 1,
video_count: 1,
source_url: 'https://detail.1688.com/offer/887904326744.html',
fetched_at: new Date().toISOString(),
strategy: 'cookie',
});
expect(items.map((item) => item.filename)).toEqual([
'887904326744_main_01.jpg',
'887904326744_sku_01.png',
'887904326744_detail_01.webp',
'887904326744_video_01.mp4',
]);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { isRecord } from '@jackwener/opencli/utils';
import { assertAuthenticatedState, buildDetailUrl, buildProvenance, canonicalizeSellerUrl, cleanMultilineText, cleanText, extractLocation, extractMemberId, extractOfferId, extractShopId, gotoAndReadState, normalizePriceTiers, parseMoqText, parsePriceText, toNumber, uniqueNonEmpty, } from './shared.js';
function normalizeItemPayload(payload) {
const href = cleanText(payload.href);
const bodyText = cleanMultilineText(payload.bodyText);
const sellerName = cleanText(payload.seller?.companyName);
const sellerUrlRaw = cleanText(payload.seller?.winportUrl
?? payload.seller?.sellerWinportUrlMap?.defaultUrl
?? payload.seller?.sellerWinportUrlMap?.indexUrl);
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw);
const offerId = cleanText(String(payload.offerId ?? '')) || extractOfferId(href) || null;
const memberId = cleanText(payload.seller?.memberId) || extractMemberId(sellerUrlRaw || href) || null;
const shopId = extractShopId(sellerUrl ?? href);
const unit = cleanText(payload.trade?.unit);
const priceDisplay = cleanText(payload.trade?.priceDisplay);
const priceRange = parsePriceText(priceDisplay ? `¥${priceDisplay}` : bodyText);
const moqText = extractMoqText(bodyText, payload.trade?.beginAmount, unit);
const moq = parseMoqText(moqText);
const services = uniqueServices(payload);
const serviceBadges = uniqueNonEmpty(services.map((service) => cleanText(service.serviceName)));
const attributes = normalizeVisibleAttributes(payload.trade?.offerIDatacenterSellInfo);
const priceTiers = normalizePriceTiers(payload.trade?.offerPriceModel?.currentPrices ?? [], unit || null);
const images = uniqueNonEmpty([
...(payload.gallery?.mainImage ?? []),
...(payload.gallery?.offerImgList ?? []),
...((payload.gallery?.wlImageInfos ?? []).map((item) => item.fullPathImageURI ?? '')),
]);
const detailUrl = offerId ? buildDetailUrl(offerId) : href;
const provenance = buildProvenance(href || detailUrl);
return {
offer_id: offerId,
member_id: memberId,
shop_id: shopId,
title: cleanText(payload.offerTitle) || stripAlibabaSuffix(payload.title) || firstNonEmptyLine(bodyText) || null,
item_url: detailUrl,
main_images: images,
price_text: priceRange.price_text || null,
price_tiers: priceTiers,
currency: priceRange.currency,
moq_text: moq.moq_text || null,
moq_value: moq.moq_value,
seller_name: sellerName || null,
seller_url: sellerUrl,
shop_name: sellerName || null,
origin_place: extractLocation(bodyText),
delivery_days_text: extractDeliveryDaysText(bodyText, services, payload.shipping),
customization_text: extractKeywordLine(bodyText, ['来样定制', '来图定制', '支持定制', '可定制', '定制']),
private_label_text: extractKeywordLine(bodyText, ['贴牌', '贴标', '定制logo', '打logo', 'OEM', 'ODM']),
visible_attributes: attributes,
sales_text: extractSalesText(bodyText),
service_badges: serviceBadges,
stock_quantity: extractStockQuantity(bodyText),
...provenance,
};
}
function normalizeVisibleAttributes(raw) {
if (!isRecord(raw))
return [];
return Object.entries(raw)
.filter(([key, value]) => key !== 'sellPointModel' && cleanText(key) && cleanText(String(value)))
.map(([key, value]) => ({ key: cleanText(key), value: cleanText(String(value)) }));
}
function uniqueServices(payload) {
const combined = [
...(Array.isArray(payload.services) ? payload.services : []),
...(Array.isArray(payload.shipping?.protectionInfos) ? payload.shipping.protectionInfos : []),
...(Array.isArray(payload.shipping?.buyerProtectionModel) ? payload.shipping.buyerProtectionModel : []),
];
const seen = new Set();
const result = [];
for (const service of combined) {
const key = cleanText(service.serviceName);
if (!key || seen.has(key))
continue;
seen.add(key);
result.push(service);
}
return result;
}
function stripAlibabaSuffix(title) {
return cleanText(title).replace(/\s*-\s*阿里巴巴$/, '').trim();
}
function firstNonEmptyLine(text) {
return text.split('\n').map((line) => cleanText(line)).find(Boolean) ?? '';
}
function extractMoqText(bodyText, beginAmount, unit) {
const lineMatch = bodyText.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/);
if (lineMatch)
return lineMatch[0];
const moqValue = toNumber(beginAmount);
if (moqValue !== null) {
return `${moqValue}${unit || ''}起批`;
}
return '';
}
function extractDeliveryDaysText(bodyText, services, shipping) {
const shippingText = cleanText(shipping?.deliveryLimitText) || cleanText(shipping?.logisticsText);
if (shippingText)
return shippingText;
const textMatch = bodyText.match(/\d+\s*(?:小时|天)(?:内)?发货/);
if (textMatch)
return textMatch[0];
const hourMatch = services.find((service) => typeof service.agreeDeliveryHours === 'number');
if (hourMatch && typeof hourMatch.agreeDeliveryHours === 'number') {
return `${hourMatch.agreeDeliveryHours}小时内发货`;
}
return null;
}
function extractKeywordLine(bodyText, keywords) {
const lines = bodyText.split('\n').map((line) => cleanText(line)).filter(Boolean);
for (const line of lines) {
if (keywords.some((keyword) => line.includes(keyword))) {
return line;
}
}
return null;
}
function extractSalesText(bodyText) {
const match = bodyText.match(/(?:全网销量|已售)\s*\d+(?:\.\d+)?\+?\s*[件套个单]?/);
return match ? cleanText(match[0]) : null;
}
function extractStockQuantity(bodyText) {
const match = bodyText.match(/库存\s*(\d+)/);
return match ? Number.parseInt(match[1], 10) : null;
}
async function readItemPayload(page, itemUrl) {
const state = await gotoAndReadState(page, itemUrl, 2500, 'item');
assertAuthenticatedState(state, 'item');
const payload = await page.evaluate(`
(() => {
const root = window.context ?? {};
const model = root.result?.global?.globalData?.model ?? null;
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
return {
href: window.location.href,
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
offerTitle: model?.offerTitleModel?.subject ?? '',
offerId: model?.tradeModel?.offerId ?? '',
seller: toJson(model?.sellerModel),
trade: toJson(model?.tradeModel),
gallery: toJson(root.result?.data?.gallery?.fields ?? null),
shipping: toJson(root.result?.data?.shippingServices?.fields ?? null),
services: toJson(root.result?.data?.shippingServices?.fields?.protectionInfos ?? []),
};
})()
`);
const resolvedOfferId = cleanText(String(payload.offerId ?? '')) || extractOfferId(cleanText(payload.href));
if (!resolvedOfferId) {
throw new CommandExecutionError('1688 item page did not expose product context', '当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试');
}
return payload;
}
cli({
site: '1688',
name: 'item',
access: 'read',
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 商品 URL 或 offer ID(如 887904326744',
},
],
columns: ['offer_id', 'title', 'price_text', 'moq_text', 'seller_name', 'origin_place'],
func: async (page, kwargs) => {
const itemUrl = buildDetailUrl(String(kwargs.input ?? ''));
const payload = await readItemPayload(page, itemUrl);
return [normalizeItemPayload(payload)];
},
});
export const __test__ = {
normalizeItemPayload,
normalizeVisibleAttributes,
stripAlibabaSuffix,
extractMoqText,
extractDeliveryDaysText,
extractKeywordLine,
extractSalesText,
extractStockQuantity,
};
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './item.js';
describe('1688 item normalization', () => {
it('normalizes public item payload into contract fields', () => {
const result = __test__.normalizeItemPayload({
href: 'https://detail.1688.com/offer/887904326744.html',
title: '法式春季长袖开衫连衣裙女新款大码女装碎花吊带裙套装142077 - 阿里巴巴',
bodyText: `
青岛沁澜衣品服装有限公司
入驻13年
主营:大码女装
店铺回头率
87%
山东青岛
3套起批
已售1600+套
支持定制logo
`,
offerTitle: '法式春季长袖开衫连衣裙女新款大码女装碎花吊带裙套装142077',
offerId: 887904326744,
seller: {
companyName: '青岛沁澜衣品服装有限公司',
memberId: 'b2b-1641351767',
winportUrl: 'https://yinuoweierfushi.1688.com/page/index.html?spm=a1',
},
trade: {
beginAmount: 3,
priceDisplay: '96.00-98.00',
unit: '套',
saleCount: 1655,
offerIDatacenterSellInfo: {
面料名称: '莫代尔',
主面料成分: '莫代尔纤维',
sellPointModel: '{"ignore":true}',
},
offerPriceModel: {
currentPrices: [
{ beginAmount: 3, price: '98.00' },
{ beginAmount: 50, price: '97.00' },
],
},
},
gallery: {
mainImage: ['https://example.com/1.jpg'],
offerImgList: ['https://example.com/2.jpg'],
wlImageInfos: [{ fullPathImageURI: 'https://example.com/3.jpg' }],
},
services: [
{ serviceName: '延期必赔', agreeDeliveryHours: 360 },
{ serviceName: '品质保障' },
],
});
expect(result.offer_id).toBe('887904326744');
expect(result.member_id).toBe('b2b-1641351767');
expect(result.shop_id).toBe('yinuoweierfushi');
expect(result.seller_url).toBe('https://yinuoweierfushi.1688.com');
expect(result.price_text).toBe('¥96.00-98.00');
expect(result.moq_text).toBe('3套起批');
expect(result.origin_place).toBe('山东青岛');
expect(result.delivery_days_text).toBe('360小时内发货');
expect(result.private_label_text).toBe('支持定制logo');
expect(result.visible_attributes).toEqual([
{ key: '面料名称', value: '莫代尔' },
{ key: '主面料成分', value: '莫代尔纤维' },
]);
});
});
-69
View File
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './item.js';
describe('1688 item normalization', () => {
it('normalizes public item payload into contract fields', () => {
const result = __test__.normalizeItemPayload({
href: 'https://detail.1688.com/offer/887904326744.html',
title: '法式春季长袖开衫连衣裙女新款大码女装碎花吊带裙套装142077 - 阿里巴巴',
bodyText: `
青岛沁澜衣品服装有限公司
入驻13年
主营:大码女装
店铺回头率
87%
山东青岛
3套起批
已售1600+套
支持定制logo
`,
offerTitle: '法式春季长袖开衫连衣裙女新款大码女装碎花吊带裙套装142077',
offerId: 887904326744,
seller: {
companyName: '青岛沁澜衣品服装有限公司',
memberId: 'b2b-1641351767',
winportUrl: 'https://yinuoweierfushi.1688.com/page/index.html?spm=a1',
},
trade: {
beginAmount: 3,
priceDisplay: '96.00-98.00',
unit: '套',
saleCount: 1655,
offerIDatacenterSellInfo: {
: '莫代尔',
: '莫代尔纤维',
sellPointModel: '{"ignore":true}',
},
offerPriceModel: {
currentPrices: [
{ beginAmount: 3, price: '98.00' },
{ beginAmount: 50, price: '97.00' },
],
},
},
gallery: {
mainImage: ['https://example.com/1.jpg'],
offerImgList: ['https://example.com/2.jpg'],
wlImageInfos: [{ fullPathImageURI: 'https://example.com/3.jpg' }],
},
services: [
{ serviceName: '延期必赔', agreeDeliveryHours: 360 },
{ serviceName: '品质保障' },
],
});
expect(result.offer_id).toBe('887904326744');
expect(result.member_id).toBe('b2b-1641351767');
expect(result.shop_id).toBe('yinuoweierfushi');
expect(result.seller_url).toBe('https://yinuoweierfushi.1688.com');
expect(result.price_text).toBe('¥96.00-98.00');
expect(result.moq_text).toBe('3套起批');
expect(result.origin_place).toBe('山东青岛');
expect(result.delivery_days_text).toBe('360小时内发货');
expect(result.private_label_text).toBe('支持定制logo');
expect(result.visible_attributes).toEqual([
{ key: '面料名称', value: '莫代尔' },
{ key: '主面料成分', value: '莫代尔纤维' },
]);
});
});
-282
View File
@@ -1,282 +0,0 @@
import { CommandExecutionError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { isRecord } from '../../utils.js';
import {
assertAuthenticatedState,
buildDetailUrl,
buildProvenance,
canonicalizeSellerUrl,
cleanMultilineText,
cleanText,
extractLocation,
extractMemberId,
extractOfferId,
extractShopId,
gotoAndReadState,
normalizePriceTiers,
parseMoqText,
parsePriceText,
toNumber,
uniqueNonEmpty,
} from './shared.js';
interface BuyerProtectionModel {
serviceName?: string;
shortBuyerDesc?: string;
packageBuyerDesc?: string;
textDesc?: string;
agreeDeliveryHours?: number;
}
interface ItemBrowserPayload {
href?: string;
title?: string;
bodyText?: string;
offerTitle?: string;
offerId?: string | number;
seller?: {
companyName?: string;
memberId?: string;
winportUrl?: string;
sellerWinportUrlMap?: Record<string, string>;
};
trade?: {
beginAmount?: string | number;
priceDisplay?: string;
unit?: string;
saleCount?: string | number;
offerIDatacenterSellInfo?: Record<string, unknown>;
offerPriceModel?: {
currentPrices?: Array<{ beginAmount?: string | number; price?: string | number }>;
};
};
gallery?: {
mainImage?: string[];
offerImgList?: string[];
wlImageInfos?: Array<{ fullPathImageURI?: string }>;
};
shipping?: {
deliveryLimitText?: string;
logisticsText?: string;
protectionInfos?: BuyerProtectionModel[];
buyerProtectionModel?: BuyerProtectionModel[];
};
services?: BuyerProtectionModel[];
}
interface VisibleAttribute {
key: string;
value: string;
}
function normalizeItemPayload(payload: ItemBrowserPayload): Record<string, unknown> {
const href = cleanText(payload.href);
const bodyText = cleanMultilineText(payload.bodyText);
const sellerName = cleanText(payload.seller?.companyName);
const sellerUrlRaw = cleanText(
payload.seller?.winportUrl
?? payload.seller?.sellerWinportUrlMap?.defaultUrl
?? payload.seller?.sellerWinportUrlMap?.indexUrl,
);
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw);
const offerId = cleanText(String(payload.offerId ?? '')) || extractOfferId(href) || null;
const memberId = cleanText(payload.seller?.memberId) || extractMemberId(sellerUrlRaw || href) || null;
const shopId = extractShopId(sellerUrl ?? href);
const unit = cleanText(payload.trade?.unit);
const priceDisplay = cleanText(payload.trade?.priceDisplay);
const priceRange = parsePriceText(priceDisplay ? `¥${priceDisplay}` : bodyText);
const moqText = extractMoqText(bodyText, payload.trade?.beginAmount, unit);
const moq = parseMoqText(moqText);
const services = uniqueServices(payload);
const serviceBadges = uniqueNonEmpty(services.map((service) => cleanText(service.serviceName)));
const attributes = normalizeVisibleAttributes(payload.trade?.offerIDatacenterSellInfo);
const priceTiers = normalizePriceTiers(payload.trade?.offerPriceModel?.currentPrices ?? [], unit || null);
const images = uniqueNonEmpty([
...(payload.gallery?.mainImage ?? []),
...(payload.gallery?.offerImgList ?? []),
...((payload.gallery?.wlImageInfos ?? []).map((item) => item.fullPathImageURI ?? '')),
]);
const detailUrl = offerId ? buildDetailUrl(offerId) : href;
const provenance = buildProvenance(href || detailUrl);
return {
offer_id: offerId,
member_id: memberId,
shop_id: shopId,
title: cleanText(payload.offerTitle) || stripAlibabaSuffix(payload.title) || firstNonEmptyLine(bodyText) || null,
item_url: detailUrl,
main_images: images,
price_text: priceRange.price_text || null,
price_tiers: priceTiers,
currency: priceRange.currency,
moq_text: moq.moq_text || null,
moq_value: moq.moq_value,
seller_name: sellerName || null,
seller_url: sellerUrl,
shop_name: sellerName || null,
origin_place: extractLocation(bodyText),
delivery_days_text: extractDeliveryDaysText(bodyText, services, payload.shipping),
customization_text: extractKeywordLine(bodyText, ['来样定制', '来图定制', '支持定制', '可定制', '定制']),
private_label_text: extractKeywordLine(bodyText, ['贴牌', '贴标', '定制logo', '打logo', 'OEM', 'ODM']),
visible_attributes: attributes,
sales_text: extractSalesText(bodyText),
service_badges: serviceBadges,
stock_quantity: extractStockQuantity(bodyText),
...provenance,
};
}
function normalizeVisibleAttributes(raw: unknown): VisibleAttribute[] {
if (!isRecord(raw)) return [];
return Object.entries(raw)
.filter(([key, value]) => key !== 'sellPointModel' && cleanText(key) && cleanText(String(value)))
.map(([key, value]) => ({ key: cleanText(key), value: cleanText(String(value)) }));
}
function uniqueServices(payload: ItemBrowserPayload): BuyerProtectionModel[] {
const combined = [
...(Array.isArray(payload.services) ? payload.services : []),
...(Array.isArray(payload.shipping?.protectionInfos) ? payload.shipping.protectionInfos : []),
...(Array.isArray(payload.shipping?.buyerProtectionModel) ? payload.shipping.buyerProtectionModel : []),
];
const seen = new Set<string>();
const result: BuyerProtectionModel[] = [];
for (const service of combined) {
const key = cleanText(service.serviceName);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(service);
}
return result;
}
function stripAlibabaSuffix(title: string | undefined): string {
return cleanText(title).replace(/\s*-\s*阿里巴巴$/, '').trim();
}
function firstNonEmptyLine(text: string): string {
return text.split('\n').map((line) => cleanText(line)).find(Boolean) ?? '';
}
function extractMoqText(bodyText: string, beginAmount: string | number | undefined, unit: string): string {
const lineMatch = bodyText.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/);
if (lineMatch) return lineMatch[0];
const moqValue = toNumber(beginAmount);
if (moqValue !== null) {
return `${moqValue}${unit || ''}起批`;
}
return '';
}
function extractDeliveryDaysText(
bodyText: string,
services: BuyerProtectionModel[],
shipping: ItemBrowserPayload['shipping'],
): string | null {
const shippingText = cleanText(shipping?.deliveryLimitText) || cleanText(shipping?.logisticsText);
if (shippingText) return shippingText;
const textMatch = bodyText.match(/\d+\s*(?:小时|天)(?:内)?发货/);
if (textMatch) return textMatch[0];
const hourMatch = services.find((service) => typeof service.agreeDeliveryHours === 'number');
if (hourMatch && typeof hourMatch.agreeDeliveryHours === 'number') {
return `${hourMatch.agreeDeliveryHours}小时内发货`;
}
return null;
}
function extractKeywordLine(bodyText: string, keywords: string[]): string | null {
const lines = bodyText.split('\n').map((line) => cleanText(line)).filter(Boolean);
for (const line of lines) {
if (keywords.some((keyword) => line.includes(keyword))) {
return line;
}
}
return null;
}
function extractSalesText(bodyText: string): string | null {
const match = bodyText.match(/(?:全网销量|已售)\s*\d+(?:\.\d+)?\+?\s*[件套个单]?/);
return match ? cleanText(match[0]) : null;
}
function extractStockQuantity(bodyText: string): number | null {
const match = bodyText.match(/库存\s*(\d+)/);
return match ? Number.parseInt(match[1], 10) : null;
}
async function readItemPayload(page: IPage, itemUrl: string): Promise<ItemBrowserPayload> {
const state = await gotoAndReadState(page, itemUrl, 2500, 'item');
assertAuthenticatedState(state, 'item');
const payload = await page.evaluate(`
(() => {
const root = window.context ?? {};
const model = root.result?.global?.globalData?.model ?? null;
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
return {
href: window.location.href,
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
offerTitle: model?.offerTitleModel?.subject ?? '',
offerId: model?.tradeModel?.offerId ?? '',
seller: toJson(model?.sellerModel),
trade: toJson(model?.tradeModel),
gallery: toJson(root.result?.data?.gallery?.fields ?? null),
shipping: toJson(root.result?.data?.shippingServices?.fields ?? null),
services: toJson(root.result?.data?.shippingServices?.fields?.protectionInfos ?? []),
};
})()
`) as ItemBrowserPayload;
const resolvedOfferId = cleanText(String(payload.offerId ?? '')) || extractOfferId(cleanText(payload.href));
if (!resolvedOfferId) {
throw new CommandExecutionError(
'1688 item page did not expose product context',
'当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试',
);
}
return payload;
}
cli({
site: '1688',
name: 'item',
description: '1688 商品详情(公开商品字段、价格阶梯、卖家基础信息)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 商品 URL 或 offer ID(如 887904326744',
},
],
columns: ['offer_id', 'title', 'price_text', 'moq_text', 'seller_name', 'origin_place'],
func: async (page, kwargs) => {
const itemUrl = buildDetailUrl(String(kwargs.input ?? ''));
const payload = await readItemPayload(page, itemUrl);
return [normalizeItemPayload(payload)];
},
});
export const __test__ = {
normalizeItemPayload,
normalizeVisibleAttributes,
stripAlibabaSuffix,
extractMoqText,
extractDeliveryDaysText,
extractKeywordLine,
extractSalesText,
extractStockQuantity,
};
+310
View File
@@ -0,0 +1,310 @@
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { FACTORY_BADGE_PATTERNS, SERVICE_BADGE_PATTERNS, assertAuthenticatedState, buildProvenance, buildSearchUrl, canonicalizeItemUrl, canonicalizeSellerUrl, cleanText, extractBadges, extractLocation, extractMemberId, extractOfferId, extractShopId, gotoAndReadState, parseMoqText, parsePriceText, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX, parseSearchLimit, uniqueNonEmpty, } from './shared.js';
const SEARCH_ITEM_URL_PATTERNS = [
'detail.1688.com/offer/',
'detail.m.1688.com/page/index.html?offerId=',
];
const MAX_SEARCH_PAGES = 12;
function normalizeSearchCandidate(candidate, sourceUrl) {
const canonicalItemUrl = canonicalizeItemUrl(cleanText(candidate.item_url));
const containerText = cleanText(candidate.container_text);
const priceText = firstNonEmpty([
normalizeInlineText(candidate.price_text),
normalizeInlineText(extractPriceText(candidate.hover_price_text)),
]);
const priceRange = parsePriceText(priceText || containerText);
const moq = parseMoqText(firstNonEmpty([
normalizeInlineText(candidate.moq_text),
normalizeInlineText(extractMoqText(containerText)),
]));
const canonicalSellerUrl = canonicalizeSellerUrl(cleanText(candidate.seller_url));
const evidenceText = uniqueNonEmpty([
containerText,
...(candidate.desc_rows ?? []),
...(candidate.tag_items ?? []),
...(candidate.hover_items ?? []),
]).join('\n');
const badges = extractBadges(evidenceText, [...FACTORY_BADGE_PATTERNS, ...SERVICE_BADGE_PATTERNS]);
const salesText = firstNonEmpty([
extractSalesText(candidate.sales_text),
extractSalesText(containerText),
]);
const returnRateText = extractReturnRateText([...(candidate.tag_items ?? []), ...(candidate.hover_items ?? [])]);
const provenance = buildProvenance(sourceUrl);
return {
rank: 0,
offer_id: extractOfferId(canonicalItemUrl ?? '') ?? null,
member_id: extractMemberId(canonicalSellerUrl ?? '') ?? null,
shop_id: extractShopId(canonicalSellerUrl ?? '') ?? null,
title: cleanText(candidate.title) || firstWord(containerText) || null,
item_url: canonicalItemUrl,
seller_name: cleanText(candidate.seller_name) || null,
seller_url: canonicalSellerUrl,
price_text: priceRange.price_text || null,
price_min: priceRange.price_min,
price_max: priceRange.price_max,
currency: priceRange.currency,
moq_text: moq.moq_text || null,
moq_value: moq.moq_value,
location: extractLocation(containerText),
badges,
sales_text: salesText || null,
return_rate_text: returnRateText,
source_url: provenance.source_url,
fetched_at: provenance.fetched_at,
strategy: provenance.strategy,
};
}
function extractMoqText(text) {
const normalized = normalizeInlineText(text);
return normalized.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/i)?.[0]
?? normalized.match(/≥\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)?/i)?.[0]
?? normalized.match(/\d+(?:\.\d+)?\s*(?:~|-|至|到)\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)/i)?.[0]
?? '';
}
function extractPriceText(text) {
const normalized = normalizeInlineText(text);
return normalized.match(/[¥$€]\s*\d+(?:\.\d+)?/)?.[0] ?? '';
}
function extractSalesText(text) {
const normalized = normalizeInlineText(text);
if (!normalized)
return '';
if (/^\d+(?:\.\d+)?\+?\s*(件|套|个|单)$/.test(normalized)) {
return normalized;
}
const match = normalized.match(/(?:已售|销量|售)\s*\d+(?:\.\d+)?\+?\s*(件|套|个|单)?/);
return match ? cleanText(match[0]) : '';
}
function firstWord(text) {
return text.split(/\s+/).find(Boolean) ?? '';
}
function firstNonEmpty(values) {
return values.map((value) => cleanText(value)).find(Boolean) ?? '';
}
function normalizeInlineText(text) {
return cleanText(text)
.replace(/([¥$€])\s+(?=\d)/g, '$1')
.replace(/(\d)\s*\.\s*(\d)/g, '$1.$2')
.replace(/\s*([~-])\s*/g, '$1')
.trim();
}
function extractReturnRateText(values) {
return uniqueNonEmpty(values.map((value) => normalizeInlineText(value)))
.find((value) => /^回头率\s*\d+(?:\.\d+)?%$/.test(value))
?? null;
}
function buildDedupeKey(row) {
if (row.offer_id)
return `offer:${row.offer_id}`;
if (row.item_url)
return `url:${row.item_url}`;
return null;
}
async function readSearchPayload(page, url) {
const state = await gotoAndReadState(page, url, 2500, 'search');
assertAuthenticatedState(state, 'search');
const payload = await page.evaluate(`
(() => {
const normalizeText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const normalizeUrl = (href) => {
if (!href) return '';
try {
return new URL(href, window.location.href).toString();
} catch {
return '';
}
};
const isItemHref = (href) => ${JSON.stringify(SEARCH_ITEM_URL_PATTERNS)}
.some((pattern) => (href || '').includes(pattern));
const uniqueTexts = (values) => [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))];
const collectTexts = (root, selector) => uniqueTexts(
Array.from(root.querySelectorAll(selector)).map((node) => node.innerText || node.textContent || ''),
);
const firstText = (root, selectors) => {
for (const selector of selectors) {
const node = root.querySelector(selector);
const value = normalizeText(node ? node.innerText || node.textContent || '' : '');
if (value) return value;
}
return '';
};
const findMoqText = (values, priceText) => {
const moqPattern = /(≥\\s*\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只)?)|(\\d+(?:\\.\\d+)?\\s*(?:~|-|至|到)\\s*\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只))|(\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只)\\s*起批)/i;
return values.find((value) => moqPattern.test(value))
|| normalizeText(priceText).match(moqPattern)?.[0]
|| '';
};
const isSellerHref = (href) => {
if (!href) return false;
try {
const url = new URL(href, window.location.href);
const host = url.hostname || '';
if (!host.endsWith('.1688.com')) return false;
if (
host === 's.1688.com'
|| host === 'r.1688.com'
|| host === 'air.1688.com'
|| host === 'detail.1688.com'
|| host === 'detail.m.1688.com'
|| host === 'dj.1688.com'
) {
return false;
}
return true;
} catch {
return false;
}
};
const pickContainer = (anchor) => {
let node = anchor;
while (node && node !== document.body) {
const text = normalizeText(node.innerText || node.textContent || '');
if (text.length >= 40 && text.length <= 2000) {
return node;
}
node = node.parentElement;
}
return anchor;
};
const collectCandidates = () => {
const anchors = Array.from(document.querySelectorAll('a')).filter((anchor) => isItemHref(anchor.href || ''));
const seen = new Set();
const items = [];
for (const anchor of anchors) {
const href = anchor.href || '';
if (!href || seen.has(href)) continue;
seen.add(href);
const container = pickContainer(anchor);
const tagItems = collectTexts(container, '.offer-tag-row .offer-desc-item');
const hoverItems = collectTexts(container, '.offer-hover-wrapper .offer-desc-item');
const sellerAnchor = Array.from(container.querySelectorAll('a'))
.find((link) => isSellerHref(link.href || ''));
const hoverPriceText = firstText(container, [
'.offer-hover-wrapper .hover-price-item',
'.offer-hover-wrapper .price-item',
]);
items.push({
item_url: href,
title: firstText(container, ['.offer-title-row .title-text', '.offer-title-row'])
|| normalizeText(anchor.innerText || anchor.textContent || ''),
container_text: normalizeText(container.innerText || container.textContent || ''),
desc_rows: collectTexts(container, '.offer-desc-row'),
price_text: firstText(container, ['.offer-price-row .price-item']),
sales_text: firstText(container, ['.offer-price-row .col-desc_after', '.offer-desc-row .col-desc_after']),
hover_price_text: hoverPriceText,
moq_text: findMoqText(hoverItems, hoverPriceText),
tag_items: tagItems,
hover_items: hoverItems,
seller_name: sellerAnchor ? normalizeText(sellerAnchor.innerText || sellerAnchor.textContent || '') : null,
seller_url: sellerAnchor ? sellerAnchor.href : null,
});
}
return items;
};
const findNextUrl = () => {
const selectors = [
'a.fui-next:not(.disabled)',
'a.next-pagination-item:not(.disabled)',
'a[rel="next"]:not(.disabled)',
'a[data-role="next"]:not(.disabled)',
];
for (const selector of selectors) {
const node = document.querySelector(selector);
if (!node) continue;
const href = normalizeUrl(node.getAttribute('href') || node.href || '');
if (href) return href;
}
const textBased = Array.from(document.querySelectorAll('a'))
.find((node) => /下一页|next/i.test(normalizeText(node.textContent || '')));
if (!textBased) return '';
return normalizeUrl(textBased.getAttribute('href') || textBased.href || '');
};
return {
href: window.location.href,
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
next_url: findNextUrl(),
candidates: collectCandidates(),
};
})()
`);
if (!payload || typeof payload !== 'object') {
throw new CommandExecutionError('1688 search page did not return a readable payload', 'Open the same query in Chrome and verify the page is fully loaded before retrying.');
}
return payload;
}
async function collectSearchRows(page, query, limit) {
const rowsByKey = new Map();
const seenPages = new Set();
let nextUrl = buildSearchUrl(query);
let pageCount = 0;
while (nextUrl && rowsByKey.size < limit && pageCount < MAX_SEARCH_PAGES) {
if (seenPages.has(nextUrl))
break;
seenPages.add(nextUrl);
pageCount += 1;
const payload = await readSearchPayload(page, nextUrl);
const sourceUrl = cleanText(payload.href) || nextUrl;
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
for (const candidate of candidates) {
const row = normalizeSearchCandidate(candidate, sourceUrl);
const dedupeKey = buildDedupeKey(row);
if (!dedupeKey || rowsByKey.has(dedupeKey))
continue;
rowsByKey.set(dedupeKey, row);
if (rowsByKey.size >= limit)
break;
}
const candidateNextUrl = cleanText(payload.next_url);
if (!candidateNextUrl || candidateNextUrl === sourceUrl)
break;
nextUrl = candidateNextUrl;
}
if (rowsByKey.size === 0) {
throw new EmptyResultError('1688 search', 'No visible results were extracted. Retry with a different query or open the same search page in Chrome first.');
}
return [...rowsByKey.values()]
.slice(0, limit)
.map((row, index) => ({ ...row, rank: index + 1 }));
}
cli({
site: '1688',
name: 'search',
access: 'read',
description: '1688 商品搜索(结果候选、卖家链接、价格/MOQ/销量文本)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'query',
required: true,
positional: true,
help: '搜索关键词,如 "置物架"',
},
{
name: 'limit',
type: 'int',
default: SEARCH_LIMIT_DEFAULT,
help: `结果数量上限(默认 ${SEARCH_LIMIT_DEFAULT},最大 ${SEARCH_LIMIT_MAX}`,
},
],
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);
return collectSearchRows(page, query, limit);
},
});
export const __test__ = {
normalizeSearchCandidate,
extractMoqText,
extractSalesText,
firstWord,
buildDedupeKey,
};
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './search.js';
describe('1688 search normalization', () => {
it('normalizes search candidates into structured result rows', () => {
const result = __test__.normalizeSearchCandidate({
item_url: 'https://detail.1688.com/offer/887904326744.html',
title: '宿舍置物架桌面加高架',
container_text: '宿舍置物架桌面加高架 ¥56.00 2套起批 山东青岛 已售300+套',
price_text: '¥ 56 .00',
sales_text: '300+套',
moq_text: '2套起批',
tag_items: ['退货包运费', '回头率52%'],
hover_items: ['验厂报告'],
seller_name: '青岛沁澜衣品服装有限公司',
seller_url: 'https://yinuoweierfushi.1688.com/page/index.html?spm=a123',
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=置物架');
expect(result.rank).toBe(0);
expect(result.offer_id).toBe('887904326744');
expect(result.shop_id).toBe('yinuoweierfushi');
expect(result.item_url).toBe('https://detail.1688.com/offer/887904326744.html');
expect(result.seller_url).toBe('https://yinuoweierfushi.1688.com');
expect(result.price_text).toBe('¥56.00');
expect(result.price_min).toBe(56);
expect(result.price_max).toBe(56);
expect(result.moq_value).toBe(2);
expect(result.location).toBe('山东青岛');
expect(result.sales_text).toBe('300+套');
expect(result.badges).toEqual(expect.arrayContaining(['退货包运费', '验厂报告']));
expect(result.return_rate_text).toBe('回头率52%');
});
it('does not use hover_price_text as MOQ source', () => {
const result = __test__.normalizeSearchCandidate({
item_url: 'https://detail.1688.com/offer/887904326744.html',
title: 'test',
container_text: 'test ¥56.00',
price_text: '¥ 56 .00',
hover_price_text: '¥56.00 3件起批',
moq_text: null,
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=test');
// hover_price_text should not be used for MOQ extraction
expect(result.moq_text).toBeNull();
expect(result.moq_value).toBeNull();
});
it('extracts offer id from mobile detail search links', () => {
const result = __test__.normalizeSearchCandidate({
item_url: 'http://detail.m.1688.com/page/index.html?offerId=910933345396&sortType=&pageId=',
title: '',
container_text: '桌面书桌办公室工位收纳展示新中式博古架多层茶具厨房摆放置物架 ¥24.3 已售20+件',
price_text: '¥ 14 .28',
sales_text: '1500+件',
moq_text: '≥2个',
seller_name: '泰商国际贸易(宁阳)有限公司',
seller_url: 'http://tsgjmy.1688.com/',
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=桌面置物架');
expect(result.offer_id).toBe('910933345396');
expect(result.shop_id).toBe('tsgjmy');
expect(result.item_url).toBe('https://detail.1688.com/offer/910933345396.html');
expect(result.title).toContain('桌面书桌办公室工位收纳展示');
expect(result.price_text).toBe('¥14.28');
expect(result.sales_text).toBe('1500+件');
expect(result.moq_text).toBe('≥2个');
expect(result.moq_value).toBe(2);
});
it('prefers offer id and falls back to item url for dedupe key', () => {
expect(__test__.buildDedupeKey({
offer_id: '123456',
item_url: 'https://detail.1688.com/offer/123456.html',
})).toBe('offer:123456');
expect(__test__.buildDedupeKey({
offer_id: null,
item_url: 'https://detail.1688.com/offer/123456.html',
})).toBe('url:https://detail.1688.com/offer/123456.html');
expect(__test__.buildDedupeKey({ offer_id: null, item_url: null })).toBeNull();
});
});
-81
View File
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './search.js';
describe('1688 search normalization', () => {
it('normalizes search candidates into structured result rows', () => {
const result = __test__.normalizeSearchCandidate({
item_url: 'https://detail.1688.com/offer/887904326744.html',
title: '宿舍置物架桌面加高架',
container_text: '宿舍置物架桌面加高架 ¥56.00 2套起批 山东青岛 已售300+套',
price_text: '¥ 56 .00',
sales_text: '300+套',
moq_text: '2套起批',
tag_items: ['退货包运费', '回头率52%'],
hover_items: ['验厂报告'],
seller_name: '青岛沁澜衣品服装有限公司',
seller_url: 'https://yinuoweierfushi.1688.com/page/index.html?spm=a123',
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=置物架');
expect(result.rank).toBe(0);
expect(result.offer_id).toBe('887904326744');
expect(result.shop_id).toBe('yinuoweierfushi');
expect(result.item_url).toBe('https://detail.1688.com/offer/887904326744.html');
expect(result.seller_url).toBe('https://yinuoweierfushi.1688.com');
expect(result.price_text).toBe('¥56.00');
expect(result.price_min).toBe(56);
expect(result.price_max).toBe(56);
expect(result.moq_value).toBe(2);
expect(result.location).toBe('山东青岛');
expect(result.sales_text).toBe('300+套');
expect(result.badges).toEqual(expect.arrayContaining(['退货包运费', '验厂报告']));
expect(result.return_rate_text).toBe('回头率52%');
});
it('does not use hover_price_text as MOQ source', () => {
const result = __test__.normalizeSearchCandidate({
item_url: 'https://detail.1688.com/offer/887904326744.html',
title: 'test',
container_text: 'test ¥56.00',
price_text: '¥ 56 .00',
hover_price_text: '¥56.00 3件起批',
moq_text: null,
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=test');
// hover_price_text should not be used for MOQ extraction
expect(result.moq_text).toBeNull();
expect(result.moq_value).toBeNull();
});
it('extracts offer id from mobile detail search links', () => {
const result = __test__.normalizeSearchCandidate({
item_url: 'http://detail.m.1688.com/page/index.html?offerId=910933345396&sortType=&pageId=',
title: '',
container_text: '桌面书桌办公室工位收纳展示新中式博古架多层茶具厨房摆放置物架 ¥24.3 已售20+件',
price_text: '¥ 14 .28',
sales_text: '1500+件',
moq_text: '≥2个',
seller_name: '泰商国际贸易(宁阳)有限公司',
seller_url: 'http://tsgjmy.1688.com/',
}, 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=桌面置物架');
expect(result.offer_id).toBe('910933345396');
expect(result.shop_id).toBe('tsgjmy');
expect(result.item_url).toBe('https://detail.1688.com/offer/910933345396.html');
expect(result.title).toContain('桌面书桌办公室工位收纳展示');
expect(result.price_text).toBe('¥14.28');
expect(result.sales_text).toBe('1500+件');
expect(result.moq_text).toBe('≥2个');
expect(result.moq_value).toBe(2);
});
it('prefers offer id and falls back to item url for dedupe key', () => {
expect(__test__.buildDedupeKey({
offer_id: '123456',
item_url: 'https://detail.1688.com/offer/123456.html',
})).toBe('offer:123456');
expect(__test__.buildDedupeKey({
offer_id: null,
item_url: 'https://detail.1688.com/offer/123456.html',
})).toBe('url:https://detail.1688.com/offer/123456.html');
expect(__test__.buildDedupeKey({ offer_id: null, item_url: null })).toBeNull();
});
});
-402
View File
@@ -1,402 +0,0 @@
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import {
FACTORY_BADGE_PATTERNS,
SERVICE_BADGE_PATTERNS,
assertAuthenticatedState,
buildProvenance,
buildSearchUrl,
canonicalizeItemUrl,
canonicalizeSellerUrl,
cleanText,
extractBadges,
extractLocation,
extractMemberId,
extractOfferId,
extractShopId,
gotoAndReadState,
parseMoqText,
parsePriceText,
SEARCH_LIMIT_DEFAULT,
SEARCH_LIMIT_MAX,
parseSearchLimit,
uniqueNonEmpty,
} from './shared.js';
interface SearchPayload {
href?: string;
title?: string;
bodyText?: string;
next_url?: string;
candidates?: Array<{
item_url?: string;
title?: string;
container_text?: string;
desc_rows?: string[];
price_text?: string | null;
sales_text?: string | null;
hover_price_text?: string | null;
moq_text?: string | null;
tag_items?: string[];
hover_items?: string[];
seller_name?: string | null;
seller_url?: string | null;
}>;
}
interface SearchRow {
rank: number;
offer_id: string | null;
member_id: string | null;
shop_id: string | null;
title: string | null;
item_url: string | null;
seller_name: string | null;
seller_url: string | null;
price_text: string | null;
price_min: number | null;
price_max: number | null;
currency: string | null;
moq_text: string | null;
moq_value: number | null;
location: string | null;
badges: string[];
sales_text: string | null;
return_rate_text: string | null;
source_url: string;
fetched_at: string;
strategy: string;
}
const SEARCH_ITEM_URL_PATTERNS = [
'detail.1688.com/offer/',
'detail.m.1688.com/page/index.html?offerId=',
];
const MAX_SEARCH_PAGES = 12;
function normalizeSearchCandidate(
candidate: NonNullable<SearchPayload['candidates']>[number],
sourceUrl: string,
): SearchRow {
const canonicalItemUrl = canonicalizeItemUrl(cleanText(candidate.item_url));
const containerText = cleanText(candidate.container_text);
const priceText = firstNonEmpty([
normalizeInlineText(candidate.price_text),
normalizeInlineText(extractPriceText(candidate.hover_price_text)),
]);
const priceRange = parsePriceText(priceText || containerText);
const moq = parseMoqText(firstNonEmpty([
normalizeInlineText(candidate.moq_text),
normalizeInlineText(extractMoqText(containerText)),
]));
const canonicalSellerUrl = canonicalizeSellerUrl(cleanText(candidate.seller_url));
const evidenceText = uniqueNonEmpty([
containerText,
...(candidate.desc_rows ?? []),
...(candidate.tag_items ?? []),
...(candidate.hover_items ?? []),
]).join('\n');
const badges = extractBadges(evidenceText, [...FACTORY_BADGE_PATTERNS, ...SERVICE_BADGE_PATTERNS]);
const salesText = firstNonEmpty([
extractSalesText(candidate.sales_text),
extractSalesText(containerText),
]);
const returnRateText = extractReturnRateText([...(candidate.tag_items ?? []), ...(candidate.hover_items ?? [])]);
const provenance = buildProvenance(sourceUrl);
return {
rank: 0,
offer_id: extractOfferId(canonicalItemUrl ?? '') ?? null,
member_id: extractMemberId(canonicalSellerUrl ?? '') ?? null,
shop_id: extractShopId(canonicalSellerUrl ?? '') ?? null,
title: cleanText(candidate.title) || firstWord(containerText) || null,
item_url: canonicalItemUrl,
seller_name: cleanText(candidate.seller_name) || null,
seller_url: canonicalSellerUrl,
price_text: priceRange.price_text || null,
price_min: priceRange.price_min,
price_max: priceRange.price_max,
currency: priceRange.currency,
moq_text: moq.moq_text || null,
moq_value: moq.moq_value,
location: extractLocation(containerText),
badges,
sales_text: salesText || null,
return_rate_text: returnRateText,
source_url: provenance.source_url,
fetched_at: provenance.fetched_at,
strategy: provenance.strategy,
};
}
function extractMoqText(text: string | null | undefined): string {
const normalized = normalizeInlineText(text);
return normalized.match(/\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)\s*起批/i)?.[0]
?? normalized.match(/≥\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)?/i)?.[0]
?? normalized.match(/\d+(?:\.\d+)?\s*(?:~|-|至|到)\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只)/i)?.[0]
?? '';
}
function extractPriceText(text: string | null | undefined): string {
const normalized = normalizeInlineText(text);
return normalized.match(/[¥$€]\s*\d+(?:\.\d+)?/)?.[0] ?? '';
}
function extractSalesText(text: string | null | undefined): string {
const normalized = normalizeInlineText(text);
if (!normalized) return '';
if (/^\d+(?:\.\d+)?\+?\s*(件|套|个|单)$/.test(normalized)) {
return normalized;
}
const match = normalized.match(/(?:已售|销量|售)\s*\d+(?:\.\d+)?\+?\s*(件|套|个|单)?/);
return match ? cleanText(match[0]) : '';
}
function firstWord(text: string): string {
return text.split(/\s+/).find(Boolean) ?? '';
}
function firstNonEmpty(values: Array<string | null | undefined>): string {
return values.map((value) => cleanText(value)).find(Boolean) ?? '';
}
function normalizeInlineText(text: string | null | undefined): string {
return cleanText(text)
.replace(/([¥$€])\s+(?=\d)/g, '$1')
.replace(/(\d)\s*\.\s*(\d)/g, '$1.$2')
.replace(/\s*([~-])\s*/g, '$1')
.trim();
}
function extractReturnRateText(values: string[]): string | null {
return uniqueNonEmpty(values.map((value) => normalizeInlineText(value)))
.find((value) => /^回头率\s*\d+(?:\.\d+)?%$/.test(value))
?? null;
}
function buildDedupeKey(row: Pick<SearchRow, 'offer_id' | 'item_url'>): string | null {
if (row.offer_id) return `offer:${row.offer_id}`;
if (row.item_url) return `url:${row.item_url}`;
return null;
}
async function readSearchPayload(page: IPage, url: string): Promise<SearchPayload> {
const state = await gotoAndReadState(page, url, 2500, 'search');
assertAuthenticatedState(state, 'search');
const payload = await page.evaluate(`
(() => {
const normalizeText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const normalizeUrl = (href) => {
if (!href) return '';
try {
return new URL(href, window.location.href).toString();
} catch {
return '';
}
};
const isItemHref = (href) => ${JSON.stringify(SEARCH_ITEM_URL_PATTERNS)}
.some((pattern) => (href || '').includes(pattern));
const uniqueTexts = (values) => [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))];
const collectTexts = (root, selector) => uniqueTexts(
Array.from(root.querySelectorAll(selector)).map((node) => node.innerText || node.textContent || ''),
);
const firstText = (root, selectors) => {
for (const selector of selectors) {
const node = root.querySelector(selector);
const value = normalizeText(node ? node.innerText || node.textContent || '' : '');
if (value) return value;
}
return '';
};
const findMoqText = (values, priceText) => {
const moqPattern = /(≥\\s*\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只)?)|(\\d+(?:\\.\\d+)?\\s*(?:~|-|至|到)\\s*\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只))|(\\d+(?:\\.\\d+)?\\s*(件|个|套|箱|包|双|台|把|只)\\s*起批)/i;
return values.find((value) => moqPattern.test(value))
|| normalizeText(priceText).match(moqPattern)?.[0]
|| '';
};
const isSellerHref = (href) => {
if (!href) return false;
try {
const url = new URL(href, window.location.href);
const host = url.hostname || '';
if (!host.endsWith('.1688.com')) return false;
if (
host === 's.1688.com'
|| host === 'r.1688.com'
|| host === 'air.1688.com'
|| host === 'detail.1688.com'
|| host === 'detail.m.1688.com'
|| host === 'dj.1688.com'
) {
return false;
}
return true;
} catch {
return false;
}
};
const pickContainer = (anchor) => {
let node = anchor;
while (node && node !== document.body) {
const text = normalizeText(node.innerText || node.textContent || '');
if (text.length >= 40 && text.length <= 2000) {
return node;
}
node = node.parentElement;
}
return anchor;
};
const collectCandidates = () => {
const anchors = Array.from(document.querySelectorAll('a')).filter((anchor) => isItemHref(anchor.href || ''));
const seen = new Set();
const items = [];
for (const anchor of anchors) {
const href = anchor.href || '';
if (!href || seen.has(href)) continue;
seen.add(href);
const container = pickContainer(anchor);
const tagItems = collectTexts(container, '.offer-tag-row .offer-desc-item');
const hoverItems = collectTexts(container, '.offer-hover-wrapper .offer-desc-item');
const sellerAnchor = Array.from(container.querySelectorAll('a'))
.find((link) => isSellerHref(link.href || ''));
const hoverPriceText = firstText(container, [
'.offer-hover-wrapper .hover-price-item',
'.offer-hover-wrapper .price-item',
]);
items.push({
item_url: href,
title: firstText(container, ['.offer-title-row .title-text', '.offer-title-row'])
|| normalizeText(anchor.innerText || anchor.textContent || ''),
container_text: normalizeText(container.innerText || container.textContent || ''),
desc_rows: collectTexts(container, '.offer-desc-row'),
price_text: firstText(container, ['.offer-price-row .price-item']),
sales_text: firstText(container, ['.offer-price-row .col-desc_after', '.offer-desc-row .col-desc_after']),
hover_price_text: hoverPriceText,
moq_text: findMoqText(hoverItems, hoverPriceText),
tag_items: tagItems,
hover_items: hoverItems,
seller_name: sellerAnchor ? normalizeText(sellerAnchor.innerText || sellerAnchor.textContent || '') : null,
seller_url: sellerAnchor ? sellerAnchor.href : null,
});
}
return items;
};
const findNextUrl = () => {
const selectors = [
'a.fui-next:not(.disabled)',
'a.next-pagination-item:not(.disabled)',
'a[rel="next"]:not(.disabled)',
'a[data-role="next"]:not(.disabled)',
];
for (const selector of selectors) {
const node = document.querySelector(selector);
if (!node) continue;
const href = normalizeUrl(node.getAttribute('href') || node.href || '');
if (href) return href;
}
const textBased = Array.from(document.querySelectorAll('a'))
.find((node) => /下一页|next/i.test(normalizeText(node.textContent || '')));
if (!textBased) return '';
return normalizeUrl(textBased.getAttribute('href') || textBased.href || '');
};
return {
href: window.location.href,
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
next_url: findNextUrl(),
candidates: collectCandidates(),
};
})()
`) as SearchPayload;
if (!payload || typeof payload !== 'object') {
throw new CommandExecutionError(
'1688 search page did not return a readable payload',
'Open the same query in Chrome and verify the page is fully loaded before retrying.',
);
}
return payload;
}
async function collectSearchRows(page: IPage, query: string, limit: number): Promise<SearchRow[]> {
const rowsByKey = new Map<string, SearchRow>();
const seenPages = new Set<string>();
let nextUrl = buildSearchUrl(query);
let pageCount = 0;
while (nextUrl && rowsByKey.size < limit && pageCount < MAX_SEARCH_PAGES) {
if (seenPages.has(nextUrl)) break;
seenPages.add(nextUrl);
pageCount += 1;
const payload = await readSearchPayload(page, nextUrl);
const sourceUrl = cleanText(payload.href) || nextUrl;
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
for (const candidate of candidates) {
const row = normalizeSearchCandidate(candidate, sourceUrl);
const dedupeKey = buildDedupeKey(row);
if (!dedupeKey || rowsByKey.has(dedupeKey)) continue;
rowsByKey.set(dedupeKey, row);
if (rowsByKey.size >= limit) break;
}
const candidateNextUrl = cleanText(payload.next_url);
if (!candidateNextUrl || candidateNextUrl === sourceUrl) break;
nextUrl = candidateNextUrl;
}
if (rowsByKey.size === 0) {
throw new EmptyResultError(
'1688 search',
'No visible results were extracted. Retry with a different query or open the same search page in Chrome first.',
);
}
return [...rowsByKey.values()]
.slice(0, limit)
.map((row, index) => ({ ...row, rank: index + 1 }));
}
cli({
site: '1688',
name: 'search',
description: '1688 商品搜索(结果候选、卖家链接、价格/MOQ/销量文本)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'query',
required: true,
positional: true,
help: '搜索关键词,如 "置物架"',
},
{
name: 'limit',
type: 'int',
default: SEARCH_LIMIT_DEFAULT,
help: `结果数量上限(默认 ${SEARCH_LIMIT_DEFAULT},最大 ${SEARCH_LIMIT_MAX}`,
},
],
columns: ['rank', 'title', 'price_text', 'moq_text', 'seller_name', 'location'],
func: async (page, kwargs) => {
const query = String(kwargs.query ?? '');
const limit = parseSearchLimit(kwargs.limit);
return collectSearchRows(page, query, limit);
},
});
export const __test__ = {
normalizeSearchCandidate,
extractMoqText,
extractSalesText,
firstWord,
buildDedupeKey,
};
+557
View File
@@ -0,0 +1,557 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
export const SITE = '1688';
export const HOME_URL = 'https://www.1688.com/';
export const SEARCH_URL_PREFIX = 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=';
export const DETAIL_URL_PREFIX = 'https://detail.1688.com/offer/';
export const STORE_MOBILE_URL_PREFIX = 'https://winport.m.1688.com/page/index.html?memberId=';
export const STRATEGY = 'cookie';
export const SEARCH_LIMIT_DEFAULT = 20;
export const SEARCH_LIMIT_MAX = 100;
const STORE_GENERIC_HOSTS = new Set(['www', 'detail', 's', 'winport', 'work', 'air', 'dj']);
const TRACKING_QUERY_KEYS = new Set([
'spm',
'tracelog',
'clickid',
'source',
'scene',
'from',
'src',
'ns',
'cna',
'pvid',
]);
const CAPTCHA_URL_MARKER = '/_____tmd_____/punish';
const CAPTCHA_TEXT_PATTERNS = [
'请拖动下方滑块完成验证',
'请按住滑块,拖动到最右边',
'通过验证以确保正常访问',
'验证码拦截',
'访问验证',
'滑动验证',
];
const LOGIN_TEXT_PATTERNS = [
'请登录',
'登录后',
'账号登录',
'手机登录',
'立即登录',
'扫码登录',
'请先完成登录',
'请先登录后查看',
];
const LOGIN_URL_PATTERNS = ['/member/login', 'passport', 'login.taobao.com', 'account.1688.com'];
export const FACTORY_BADGE_PATTERNS = [
'源头工厂',
'深度验厂',
'实力工厂',
'工厂档案',
'加工专区',
'验厂报告',
'厂家直销',
'生产厂家',
'工厂直供',
];
export const SERVICE_BADGE_PATTERNS = [
'延期必赔',
'品质保障',
'破损包赔',
'退货包运费',
'晚发必赔',
'7*24小时响应',
'48小时发货',
'72小时发货',
'后天达',
'包邮',
'闪电拿样',
];
const CHINA_LOCATIONS = [
'北京',
'天津',
'上海',
'重庆',
'河北',
'山西',
'辽宁',
'吉林',
'黑龙江',
'江苏',
'浙江',
'安徽',
'福建',
'江西',
'山东',
'河南',
'湖北',
'湖南',
'广东',
'海南',
'四川',
'贵州',
'云南',
'陕西',
'甘肃',
'青海',
'台湾',
'内蒙古',
'广西',
'西藏',
'宁夏',
'新疆',
'香港',
'澳门',
];
export function cleanText(value) {
return typeof value === 'string'
? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
: '';
}
export function cleanMultilineText(value) {
return typeof value === 'string'
? value
.replace(/\u00a0/g, ' ')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n')
: '';
}
export function uniqueNonEmpty(values) {
return [...new Set(values.map((value) => cleanText(value)).filter(Boolean))];
}
export function parseSearchLimit(input) {
const parsed = Number.parseInt(String(input ?? SEARCH_LIMIT_DEFAULT), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new ArgumentError('1688 search --limit must be a positive integer', 'Example: opencli 1688 search "桌面置物架" --limit 20');
}
return Math.min(SEARCH_LIMIT_MAX, parsed);
}
export function buildSearchUrl(query) {
const normalized = cleanText(query);
if (!normalized) {
throw new ArgumentError('1688 search query cannot be empty', 'Example: opencli 1688 search "桌面置物架" --limit 20');
}
return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`;
}
export function buildDetailUrl(input) {
const offerId = extractOfferId(input);
if (!offerId) {
throw new ArgumentError('1688 item expects an offer URL or offer ID', 'Example: opencli 1688 item 887904326744');
}
return `${DETAIL_URL_PREFIX}${offerId}.html`;
}
export function resolveStoreUrl(input) {
const normalized = cleanText(input);
if (!normalized) {
throw new ArgumentError('1688 store expects a store URL or member ID', 'Example: opencli 1688 store https://yinuoweierfushi.1688.com/');
}
const memberId = extractMemberId(normalized);
if (memberId) {
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
}
if (/^https?:\/\//i.test(normalized)) {
return canonicalizeStoreUrl(normalized);
}
if (normalized.endsWith('.1688.com')) {
return canonicalizeStoreUrl(`https://${normalized}`);
}
if (/^[a-z0-9-]+$/i.test(normalized)) {
return canonicalizeStoreUrl(`https://${normalized}.1688.com`);
}
throw new ArgumentError('1688 store expects a store URL or member ID', 'Example: opencli 1688 store b2b-22154705262941f196');
}
export function canonicalizeStoreUrl(input) {
const url = parse1688Url(input);
const memberId = extractMemberId(url.toString());
if (memberId) {
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
}
const host = normalizeStoreHost(url.hostname);
if (!host) {
throw new ArgumentError('Invalid 1688 store URL', 'Example: opencli 1688 store https://yinuoweierfushi.1688.com/');
}
return `https://${host}`;
}
export function canonicalizeItemUrl(input) {
const offerId = extractOfferId(input);
if (offerId) {
return `${DETAIL_URL_PREFIX}${offerId}.html`;
}
const url = parse1688UrlOrNull(input);
if (!url)
return null;
stripTrackingParams(url);
url.hash = '';
return url.toString();
}
export function canonicalizeSellerUrl(input) {
const memberId = extractMemberId(input);
if (memberId) {
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
}
const url = parse1688UrlOrNull(input);
if (!url)
return null;
const host = normalizeStoreHost(url.hostname);
if (!host)
return null;
return `https://${host}`;
}
export function extractOfferId(input) {
const normalized = cleanText(input);
if (!normalized)
return null;
const directId = normalized.match(/^\d{6,}$/)?.[0];
if (directId)
return directId;
const detailMatch = normalized.match(/\/offer\/(\d{6,})\.html/i);
if (detailMatch)
return detailMatch[1];
const queryMatch = normalized.match(/[?&]offerId=(\d{6,})/i);
if (queryMatch)
return queryMatch[1];
return null;
}
export function extractMemberId(input) {
const normalized = cleanText(input);
if (!normalized)
return null;
const direct = normalized.match(/\bb2b-[a-z0-9]+\b/i)?.[0];
if (direct)
return direct;
const queryMatch = normalized.match(/[?&]memberId=(b2b-[a-z0-9]+)/i);
if (queryMatch)
return queryMatch[1];
const mobileMatch = normalized.match(/\/winport\/(b2b-[a-z0-9]+)\.html/i);
if (mobileMatch)
return mobileMatch[1];
return null;
}
export function extractShopId(input) {
const normalized = cleanText(input);
if (!normalized)
return null;
try {
const url = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`);
const host = normalizeStoreHost(url.hostname);
if (!host)
return null;
return host.split('.')[0] ?? null;
}
catch {
return /^[a-z0-9-]+$/i.test(normalized) ? normalized : null;
}
}
export function buildProvenance(sourceUrl) {
return {
source_url: sourceUrl,
fetched_at: new Date().toISOString(),
strategy: STRATEGY,
};
}
export function parsePriceText(text) {
const normalized = normalizeNumericText(cleanText(text));
const matches = normalized.match(/\d+(?:,\d{3})*(?:\.\d+)?/g) ?? [];
const values = matches
.map((value) => Number.parseFloat(value.replace(/,/g, '')))
.filter((value) => Number.isFinite(value));
if (values.length === 0) {
return {
price_text: normalized,
price_min: null,
price_max: null,
currency: null,
};
}
return {
price_text: normalized,
price_min: values[0] ?? null,
price_max: values[values.length - 1] ?? values[0] ?? null,
currency: normalized.includes('¥') || normalized.includes('元') ? 'CNY' : null,
};
}
export function normalizePriceTiers(rawTiers, unit) {
return rawTiers
.map((tier) => {
const quantityMin = toNumber(tier.beginAmount);
const priceText = cleanText(tier.price);
const price = toNumber(tier.price);
return {
quantity_text: quantityMin !== null ? `${quantityMin}${unit ?? ''}` : '',
quantity_min: quantityMin,
price_text: priceText,
price,
currency: priceText ? 'CNY' : null,
};
})
.filter((tier) => tier.price_text);
}
export function parseMoqText(text) {
const normalized = normalizeNumericText(cleanText(text));
const match = normalized.match(/(\d+(?:\.\d+)?)\s*(件|个|套|箱|包|双|台|把|只|pcs|piece|pieces)?\s*起批/i)
?? normalized.match(/≥\s*(\d+(?:\.\d+)?)/);
const rangeMatch = normalized.match(/(\d+(?:\.\d+)?)\s*(?:~|-|至|到)\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只|pcs|piece|pieces)/i);
if (!match && !rangeMatch) {
return {
moq_text: normalized,
moq_value: null,
};
}
return {
moq_text: normalized,
moq_value: Number.parseFloat((match ?? rangeMatch)[1]),
};
}
export function extractLocation(text) {
const normalized = cleanMultilineText(text);
const primaryRegion = normalized.split(/送至|发往/)[0] ?? normalized;
const lines = primaryRegion.split('\n');
for (const line of lines) {
const compact = cleanText(line);
if (!compact || compact.length > 16)
continue;
if (CHINA_LOCATIONS.some((location) => compact.startsWith(location))) {
return compact;
}
}
const locationPattern = new RegExp(`(${CHINA_LOCATIONS.join('|')})[\\u4e00-\\u9fa5]{0,8}`);
return primaryRegion.match(locationPattern)?.[0] ?? null;
}
export function extractAddress(text) {
const normalized = cleanMultilineText(text);
const lineMatch = normalized.match(/地址[:]\s*([^\n]+)/);
if (lineMatch)
return cleanText(lineMatch[1]);
return normalized
.split('\n')
.map((line) => cleanText(line))
.find((line) => line.includes('省') || line.includes('市') || line.includes('区') || line.includes('县'))
?? null;
}
export function extractMetric(text, label) {
const normalized = cleanMultilineText(text);
const direct = normalized.match(new RegExp(`(?:^|\\n)\\s*${escapeForRegex(label)}[:]?\\s*([^\\n]+)`));
if (direct)
return cleanText(direct[1]);
const lineBased = normalized.match(new RegExp(`(?:^|\\n)\\s*${escapeForRegex(label)}\\n([^\\n]+)`));
return lineBased ? cleanText(lineBased[1]) : null;
}
export function extractYearsOnPlatform(text) {
return text.match(/入驻\d+年/)?.[0] ?? null;
}
export function extractMainBusiness(text) {
const value = extractMetric(text, '主营');
return value ? value.replace(/^/, '').trim() : null;
}
export function extractBadges(text, candidates) {
return uniqueNonEmpty(candidates.filter((candidate) => cleanMultilineText(text).includes(candidate)));
}
export function guessTopCategories(text) {
const mainBusiness = extractMainBusiness(text);
if (!mainBusiness)
return [];
return uniqueNonEmpty(mainBusiness.split(/[、,/|]/).map((value) => value.trim()));
}
export function isCaptchaState(state) {
const href = cleanText(state.href).toLowerCase();
const title = cleanText(state.title);
const bodyText = cleanMultilineText(state.body_text);
if (href.includes(CAPTCHA_URL_MARKER))
return true;
return CAPTCHA_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
}
export function isLoginState(state) {
const href = cleanText(state.href).toLowerCase();
const title = cleanText(state.title);
const bodyText = cleanMultilineText(state.body_text);
if (LOGIN_URL_PATTERNS.some((pattern) => href.includes(pattern)))
return true;
return LOGIN_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
}
export function buildCaptchaHint(action) {
return [
`Open a clean 1688 ${action} page in the shared Chrome profile and finish any slider challenge first.`,
'If you run opencli via CDP, set OPENCLI_CDP_TARGET=1688.com or a more specific 1688 host before retrying.',
].join(' ');
}
export async function readPageState(page) {
const result = await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
body_text: document.body ? document.body.innerText || '' : '',
}))()
`);
return {
href: cleanText(result.href),
title: cleanText(result.title),
body_text: cleanMultilineText(result.body_text),
};
}
export async function gotoAndReadState(page, url, settleMs = 2500, action = 'page') {
try {
await page.goto(url, { settleMs });
await page.wait(1.5);
return readPageState(page);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('Inspected target navigated or closed')
|| message.includes('Cannot find context with specified id')
|| message.includes('Target closed')) {
throw new CommandExecutionError(`1688 ${action} navigation lost the current browser target`, `${buildCaptchaHint(action)} If CDP is attached to a stale or blocked tab, open a fresh 1688 tab and point OPENCLI_CDP_TARGET at that tab.`);
}
throw error;
}
}
export async function ensure1688Session(page) {
const state = await gotoAndReadState(page, HOME_URL, 1500, 'homepage');
assertAuthenticatedState(state, 'homepage');
}
export function assertAuthenticatedState(state, action) {
if (!isCaptchaState(state) && !isLoginState(state))
return;
throw new AuthRequiredError('1688.com', `请先在共享 Chrome 完成 1688 登录/验证,再重试(${action}`);
}
export function assertNotCaptcha(state, action) {
assertAuthenticatedState(state, action);
}
export function toNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const normalized = value.replace(/,/g, '').trim();
if (!normalized)
return null;
const parsed = Number.parseFloat(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function limitCandidates(values, limit) {
const normalizedLimit = Math.max(1, Math.trunc(limit) || 1);
return values.slice(0, normalizedLimit);
}
export function normalizeMediaUrl(input) {
const raw = cleanText(input);
if (!raw)
return '';
let value = raw
.replace(/^url\((.*)\)$/i, '$1')
.replace(/^['"]|['"]$/g, '')
.replace(/\\u002F/g, '/')
.replace(/&amp;/g, '&')
.trim();
if (!value || value.startsWith('data:') || value.startsWith('blob:'))
return '';
if (value.startsWith('//'))
value = `https:${value}`;
try {
const url = new URL(value);
return url.toString();
}
catch {
return '';
}
}
export function uniqueMediaSources(values) {
const seen = new Set();
const result = [];
for (const value of values) {
const url = normalizeMediaUrl(value.url);
if (!url)
continue;
const key = `${value.type}:${url}`;
if (seen.has(key))
continue;
seen.add(key);
result.push({
...value,
url,
source: cleanText(value.source) || undefined,
});
}
return result;
}
function normalizeNumericText(value) {
return value
.replace(/([¥$€])\s+(?=\d)/g, '$1')
.replace(/(\d)\s*\.\s*(\d)/g, '$1.$2')
.replace(/\s*([~-])\s*/g, '$1')
.trim();
}
function escapeForRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parse1688Url(input) {
const normalized = cleanText(input);
try {
const url = new URL(normalized);
if (!url.hostname.endsWith('.1688.com') && url.hostname !== '1688.com' && url.hostname !== 'www.1688.com') {
throw new Error('invalid-host');
}
stripTrackingParams(url);
url.hash = '';
return url;
}
catch {
throw new ArgumentError('Invalid 1688 URL', 'Use a URL under 1688.com (for example: https://detail.1688.com/offer/887904326744.html)');
}
}
function parse1688UrlOrNull(input) {
try {
return parse1688Url(input);
}
catch {
return null;
}
}
function normalizeStoreHost(hostname) {
const lower = cleanText(hostname).toLowerCase();
if (!lower.endsWith('.1688.com'))
return null;
const [subdomain] = lower.split('.');
if (!subdomain || STORE_GENERIC_HOSTS.has(subdomain))
return null;
return lower;
}
function stripTrackingParams(url) {
const keys = [...url.searchParams.keys()];
for (const key of keys) {
if (TRACKING_QUERY_KEYS.has(key) || key.toLowerCase().startsWith('utm_')) {
url.searchParams.delete(key);
}
}
}
export const __test__ = {
SEARCH_LIMIT_DEFAULT,
SEARCH_LIMIT_MAX,
parseSearchLimit,
buildSearchUrl,
buildDetailUrl,
resolveStoreUrl,
canonicalizeStoreUrl,
canonicalizeItemUrl,
canonicalizeSellerUrl,
extractOfferId,
extractMemberId,
extractShopId,
parsePriceText,
normalizePriceTiers,
parseMoqText,
extractLocation,
extractAddress,
extractMetric,
extractYearsOnPlatform,
extractMainBusiness,
extractBadges,
guessTopCategories,
isCaptchaState,
isLoginState,
cleanText,
cleanMultilineText,
uniqueNonEmpty,
normalizeMediaUrl,
uniqueMediaSources,
limitCandidates,
};
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './shared.js';
describe('1688 shared helpers', () => {
it('builds encoded search URLs and validates limit', () => {
expect(__test__.buildSearchUrl('置物架')).toBe('https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=%E7%BD%AE%E7%89%A9%E6%9E%B6');
expect(() => __test__.buildSearchUrl(' ')).toThrowError(/cannot be empty/i);
expect(__test__.parseSearchLimit(3)).toBe(3);
expect(__test__.parseSearchLimit('1000')).toBe(__test__.SEARCH_LIMIT_MAX);
expect(() => __test__.parseSearchLimit('0')).toThrowError(/positive integer/i);
});
it('extracts IDs and canonicalizes urls', () => {
expect(__test__.extractOfferId('887904326744')).toBe('887904326744');
expect(__test__.extractOfferId('https://detail.1688.com/offer/887904326744.html')).toBe('887904326744');
expect(__test__.extractMemberId('https://winport.m.1688.com/page/index.html?memberId=b2b-1641351767')).toBe('b2b-1641351767');
expect(__test__.extractMemberId('b2b-22154705262941f196')).toBe('b2b-22154705262941f196');
expect(__test__.resolveStoreUrl('b2b-22154705262941f196')).toBe('https://winport.m.1688.com/page/index.html?memberId=b2b-22154705262941f196');
expect(__test__.canonicalizeStoreUrl('https://yinuoweierfushi.1688.com/page/index.html?spm=foo')).toBe('https://yinuoweierfushi.1688.com');
expect(__test__.canonicalizeItemUrl('http://detail.m.1688.com/page/index.html?offerId=910933345396&spm=x')).toBe('https://detail.1688.com/offer/910933345396.html');
expect(__test__.canonicalizeSellerUrl('https://yinuoweierfushi.1688.com/page/contactinfo.html?tracelog=1')).toBe('https://yinuoweierfushi.1688.com');
expect(__test__.extractShopId('https://yinuoweierfushi.1688.com/page/index.html')).toBe('yinuoweierfushi');
});
it('parses price ranges and moq text', () => {
expect(__test__.parsePriceText('¥96.00-98.00')).toEqual({
price_text: '¥96.00-98.00',
price_min: 96,
price_max: 98,
currency: 'CNY',
});
expect(__test__.parsePriceText('¥ 14 .28')).toEqual({
price_text: '¥14.28',
price_min: 14.28,
price_max: 14.28,
currency: 'CNY',
});
expect(__test__.parseMoqText('3套起批')).toEqual({
moq_text: '3套起批',
moq_value: 3,
});
expect(__test__.parseMoqText('2~999个')).toEqual({
moq_text: '2~999个',
moq_value: 2,
});
});
it('detects captcha and login states', () => {
expect(__test__.extractLocation('山东青岛 送至 江苏苏州')).toBe('山东青岛');
expect(__test__.isCaptchaState({
href: 'https://s.1688.com/_____tmd_____/punish',
title: '验证码拦截',
body_text: '请拖动下方滑块完成验证',
})).toBe(true);
expect(__test__.isLoginState({
href: 'https://login.taobao.com/member/login.jhtml',
title: '账号登录',
body_text: '请登录后继续',
})).toBe(true);
});
});
-75
View File
@@ -1,75 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './shared.js';
describe('1688 shared helpers', () => {
it('builds encoded search URLs and validates limit', () => {
expect(__test__.buildSearchUrl('置物架')).toBe(
'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=%E7%BD%AE%E7%89%A9%E6%9E%B6',
);
expect(() => __test__.buildSearchUrl(' ')).toThrowError(/cannot be empty/i);
expect(__test__.parseSearchLimit(3)).toBe(3);
expect(__test__.parseSearchLimit('1000')).toBe(__test__.SEARCH_LIMIT_MAX);
expect(() => __test__.parseSearchLimit('0')).toThrowError(/positive integer/i);
});
it('extracts IDs and canonicalizes urls', () => {
expect(__test__.extractOfferId('887904326744')).toBe('887904326744');
expect(__test__.extractOfferId('https://detail.1688.com/offer/887904326744.html')).toBe('887904326744');
expect(__test__.extractMemberId('https://winport.m.1688.com/page/index.html?memberId=b2b-1641351767')).toBe('b2b-1641351767');
expect(__test__.extractMemberId('b2b-22154705262941f196')).toBe('b2b-22154705262941f196');
expect(__test__.resolveStoreUrl('b2b-22154705262941f196')).toBe(
'https://winport.m.1688.com/page/index.html?memberId=b2b-22154705262941f196',
);
expect(__test__.canonicalizeStoreUrl('https://yinuoweierfushi.1688.com/page/index.html?spm=foo')).toBe(
'https://yinuoweierfushi.1688.com',
);
expect(__test__.canonicalizeItemUrl('http://detail.m.1688.com/page/index.html?offerId=910933345396&spm=x')).toBe(
'https://detail.1688.com/offer/910933345396.html',
);
expect(__test__.canonicalizeSellerUrl('https://yinuoweierfushi.1688.com/page/contactinfo.html?tracelog=1')).toBe(
'https://yinuoweierfushi.1688.com',
);
expect(__test__.extractShopId('https://yinuoweierfushi.1688.com/page/index.html')).toBe('yinuoweierfushi');
});
it('parses price ranges and moq text', () => {
expect(__test__.parsePriceText('¥96.00-98.00')).toEqual({
price_text: '¥96.00-98.00',
price_min: 96,
price_max: 98,
currency: 'CNY',
});
expect(__test__.parsePriceText('¥ 14 .28')).toEqual({
price_text: '¥14.28',
price_min: 14.28,
price_max: 14.28,
currency: 'CNY',
});
expect(__test__.parseMoqText('3套起批')).toEqual({
moq_text: '3套起批',
moq_value: 3,
});
expect(__test__.parseMoqText('2~999个')).toEqual({
moq_text: '2~999个',
moq_value: 2,
});
});
it('detects captcha and login states', () => {
expect(__test__.extractLocation('山东青岛 送至 江苏苏州')).toBe('山东青岛');
expect(__test__.isCaptchaState({
href: 'https://s.1688.com/_____tmd_____/punish',
title: '验证码拦截',
body_text: '请拖动下方滑块完成验证',
})).toBe(true);
expect(__test__.isLoginState({
href: 'https://login.taobao.com/member/login.jhtml',
title: '账号登录',
body_text: '请登录后继续',
})).toBe(true);
});
});
-623
View File
@@ -1,623 +0,0 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
export const SITE = '1688';
export const HOME_URL = 'https://www.1688.com/';
export const SEARCH_URL_PREFIX = 'https://s.1688.com/selloffer/offer_search.htm?charset=utf8&keywords=';
export const DETAIL_URL_PREFIX = 'https://detail.1688.com/offer/';
export const STORE_MOBILE_URL_PREFIX = 'https://winport.m.1688.com/page/index.html?memberId=';
export const STRATEGY = 'cookie';
export const SEARCH_LIMIT_DEFAULT = 20;
export const SEARCH_LIMIT_MAX = 100;
const STORE_GENERIC_HOSTS = new Set(['www', 'detail', 's', 'winport', 'work', 'air', 'dj']);
const TRACKING_QUERY_KEYS = new Set([
'spm',
'tracelog',
'clickid',
'source',
'scene',
'from',
'src',
'ns',
'cna',
'pvid',
]);
const CAPTCHA_URL_MARKER = '/_____tmd_____/punish';
const CAPTCHA_TEXT_PATTERNS = [
'请拖动下方滑块完成验证',
'请按住滑块,拖动到最右边',
'通过验证以确保正常访问',
'验证码拦截',
'访问验证',
'滑动验证',
];
const LOGIN_TEXT_PATTERNS = [
'请登录',
'登录后',
'账号登录',
'手机登录',
'立即登录',
'扫码登录',
'请先完成登录',
'请先登录后查看',
];
const LOGIN_URL_PATTERNS = ['/member/login', 'passport', 'login.taobao.com', 'account.1688.com'];
export const FACTORY_BADGE_PATTERNS = [
'源头工厂',
'深度验厂',
'实力工厂',
'工厂档案',
'加工专区',
'验厂报告',
'厂家直销',
'生产厂家',
'工厂直供',
];
export const SERVICE_BADGE_PATTERNS = [
'延期必赔',
'品质保障',
'破损包赔',
'退货包运费',
'晚发必赔',
'7*24小时响应',
'48小时发货',
'72小时发货',
'后天达',
'包邮',
'闪电拿样',
];
const CHINA_LOCATIONS = [
'北京',
'天津',
'上海',
'重庆',
'河北',
'山西',
'辽宁',
'吉林',
'黑龙江',
'江苏',
'浙江',
'安徽',
'福建',
'江西',
'山东',
'河南',
'湖北',
'湖南',
'广东',
'海南',
'四川',
'贵州',
'云南',
'陕西',
'甘肃',
'青海',
'台湾',
'内蒙古',
'广西',
'西藏',
'宁夏',
'新疆',
'香港',
'澳门',
];
export interface ProvenanceFields {
source_url: string;
fetched_at: string;
strategy: string;
}
export interface PageState {
href: string;
title: string;
body_text: string;
}
export interface PriceRange {
price_text: string;
price_min: number | null;
price_max: number | null;
currency: string | null;
}
export interface MoqValue {
moq_text: string;
moq_value: number | null;
}
export interface PriceTier {
quantity_text: string;
quantity_min: number | null;
price_text: string;
price: number | null;
currency: string | null;
}
export interface SearchCandidate {
item_url: string;
title: string;
container_text: string;
seller_name: string | null;
seller_url: string | null;
}
export function cleanText(value: unknown): string {
return typeof value === 'string'
? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
: '';
}
export function cleanMultilineText(value: unknown): string {
return typeof value === 'string'
? value
.replace(/\u00a0/g, ' ')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n')
: '';
}
export function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
return [...new Set(values.map((value) => cleanText(value)).filter(Boolean))];
}
export function parseSearchLimit(input: unknown): number {
const parsed = Number.parseInt(String(input ?? SEARCH_LIMIT_DEFAULT), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new ArgumentError(
'1688 search --limit must be a positive integer',
'Example: opencli 1688 search "桌面置物架" --limit 20',
);
}
return Math.min(SEARCH_LIMIT_MAX, parsed);
}
export function buildSearchUrl(query: string): string {
const normalized = cleanText(query);
if (!normalized) {
throw new ArgumentError(
'1688 search query cannot be empty',
'Example: opencli 1688 search "桌面置物架" --limit 20',
);
}
return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`;
}
export function buildDetailUrl(input: string): string {
const offerId = extractOfferId(input);
if (!offerId) {
throw new ArgumentError(
'1688 item expects an offer URL or offer ID',
'Example: opencli 1688 item 887904326744',
);
}
return `${DETAIL_URL_PREFIX}${offerId}.html`;
}
export function resolveStoreUrl(input: string): string {
const normalized = cleanText(input);
if (!normalized) {
throw new ArgumentError(
'1688 store expects a store URL or member ID',
'Example: opencli 1688 store https://yinuoweierfushi.1688.com/',
);
}
const memberId = extractMemberId(normalized);
if (memberId) {
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
}
if (/^https?:\/\//i.test(normalized)) {
return canonicalizeStoreUrl(normalized);
}
if (normalized.endsWith('.1688.com')) {
return canonicalizeStoreUrl(`https://${normalized}`);
}
if (/^[a-z0-9-]+$/i.test(normalized)) {
return canonicalizeStoreUrl(`https://${normalized}.1688.com`);
}
throw new ArgumentError(
'1688 store expects a store URL or member ID',
'Example: opencli 1688 store b2b-22154705262941f196',
);
}
export function canonicalizeStoreUrl(input: string): string {
const url = parse1688Url(input);
const memberId = extractMemberId(url.toString());
if (memberId) {
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
}
const host = normalizeStoreHost(url.hostname);
if (!host) {
throw new ArgumentError(
'Invalid 1688 store URL',
'Example: opencli 1688 store https://yinuoweierfushi.1688.com/',
);
}
return `https://${host}`;
}
export function canonicalizeItemUrl(input: string): string | null {
const offerId = extractOfferId(input);
if (offerId) {
return `${DETAIL_URL_PREFIX}${offerId}.html`;
}
const url = parse1688UrlOrNull(input);
if (!url) return null;
stripTrackingParams(url);
url.hash = '';
return url.toString();
}
export function canonicalizeSellerUrl(input: string): string | null {
const memberId = extractMemberId(input);
if (memberId) {
return `${STORE_MOBILE_URL_PREFIX}${memberId}`;
}
const url = parse1688UrlOrNull(input);
if (!url) return null;
const host = normalizeStoreHost(url.hostname);
if (!host) return null;
return `https://${host}`;
}
export function extractOfferId(input: string): string | null {
const normalized = cleanText(input);
if (!normalized) return null;
const directId = normalized.match(/^\d{6,}$/)?.[0];
if (directId) return directId;
const detailMatch = normalized.match(/\/offer\/(\d{6,})\.html/i);
if (detailMatch) return detailMatch[1];
const queryMatch = normalized.match(/[?&]offerId=(\d{6,})/i);
if (queryMatch) return queryMatch[1];
return null;
}
export function extractMemberId(input: string): string | null {
const normalized = cleanText(input);
if (!normalized) return null;
const direct = normalized.match(/\bb2b-[a-z0-9]+\b/i)?.[0];
if (direct) return direct;
const queryMatch = normalized.match(/[?&]memberId=(b2b-[a-z0-9]+)/i);
if (queryMatch) return queryMatch[1];
const mobileMatch = normalized.match(/\/winport\/(b2b-[a-z0-9]+)\.html/i);
if (mobileMatch) return mobileMatch[1];
return null;
}
export function extractShopId(input: string): string | null {
const normalized = cleanText(input);
if (!normalized) return null;
try {
const url = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`);
const host = normalizeStoreHost(url.hostname);
if (!host) return null;
return host.split('.')[0] ?? null;
} catch {
return /^[a-z0-9-]+$/i.test(normalized) ? normalized : null;
}
}
export function buildProvenance(sourceUrl: string): ProvenanceFields {
return {
source_url: sourceUrl,
fetched_at: new Date().toISOString(),
strategy: STRATEGY,
};
}
export function parsePriceText(text: string): PriceRange {
const normalized = normalizeNumericText(cleanText(text));
const matches = normalized.match(/\d+(?:,\d{3})*(?:\.\d+)?/g) ?? [];
const values = matches
.map((value) => Number.parseFloat(value.replace(/,/g, '')))
.filter((value) => Number.isFinite(value));
if (values.length === 0) {
return {
price_text: normalized,
price_min: null,
price_max: null,
currency: null,
};
}
return {
price_text: normalized,
price_min: values[0] ?? null,
price_max: values[values.length - 1] ?? values[0] ?? null,
currency: normalized.includes('¥') || normalized.includes('元') ? 'CNY' : null,
};
}
export function normalizePriceTiers(
rawTiers: Array<{ beginAmount?: unknown; price?: unknown }>,
unit: string | null,
): PriceTier[] {
return rawTiers
.map((tier) => {
const quantityMin = toNumber(tier.beginAmount);
const priceText = cleanText(tier.price);
const price = toNumber(tier.price);
return {
quantity_text: quantityMin !== null ? `${quantityMin}${unit ?? ''}` : '',
quantity_min: quantityMin,
price_text: priceText,
price,
currency: priceText ? 'CNY' : null,
};
})
.filter((tier) => tier.price_text);
}
export function parseMoqText(text: string): MoqValue {
const normalized = normalizeNumericText(cleanText(text));
const match = normalized.match(/(\d+(?:\.\d+)?)\s*(件|个|套|箱|包|双|台|把|只|pcs|piece|pieces)?\s*起批/i)
?? normalized.match(/≥\s*(\d+(?:\.\d+)?)/);
const rangeMatch = normalized.match(
/(\d+(?:\.\d+)?)\s*(?:~|-|至|到)\s*\d+(?:\.\d+)?\s*(件|个|套|箱|包|双|台|把|只|pcs|piece|pieces)/i,
);
if (!match && !rangeMatch) {
return {
moq_text: normalized,
moq_value: null,
};
}
return {
moq_text: normalized,
moq_value: Number.parseFloat((match ?? rangeMatch)![1]),
};
}
export function extractLocation(text: string): string | null {
const normalized = cleanMultilineText(text);
const primaryRegion = normalized.split(/送至|发往/)[0] ?? normalized;
const lines = primaryRegion.split('\n');
for (const line of lines) {
const compact = cleanText(line);
if (!compact || compact.length > 16) continue;
if (CHINA_LOCATIONS.some((location) => compact.startsWith(location))) {
return compact;
}
}
const locationPattern = new RegExp(`(${CHINA_LOCATIONS.join('|')})[\\u4e00-\\u9fa5]{0,8}`);
return primaryRegion.match(locationPattern)?.[0] ?? null;
}
export function extractAddress(text: string): string | null {
const normalized = cleanMultilineText(text);
const lineMatch = normalized.match(/地址[:]\s*([^\n]+)/);
if (lineMatch) return cleanText(lineMatch[1]);
return normalized
.split('\n')
.map((line) => cleanText(line))
.find((line) => line.includes('省') || line.includes('市') || line.includes('区') || line.includes('县'))
?? null;
}
export function extractMetric(text: string, label: string): string | null {
const normalized = cleanMultilineText(text);
const direct = normalized.match(new RegExp(`(?:^|\\n)\\s*${escapeForRegex(label)}[:]?\\s*([^\\n]+)`));
if (direct) return cleanText(direct[1]);
const lineBased = normalized.match(new RegExp(`(?:^|\\n)\\s*${escapeForRegex(label)}\\n([^\\n]+)`));
return lineBased ? cleanText(lineBased[1]) : null;
}
export function extractYearsOnPlatform(text: string): string | null {
return text.match(/入驻\d+年/)?.[0] ?? null;
}
export function extractMainBusiness(text: string): string | null {
const value = extractMetric(text, '主营');
return value ? value.replace(/^/, '').trim() : null;
}
export function extractBadges(text: string, candidates: string[]): string[] {
return uniqueNonEmpty(candidates.filter((candidate) => cleanMultilineText(text).includes(candidate)));
}
export function guessTopCategories(text: string): string[] {
const mainBusiness = extractMainBusiness(text);
if (!mainBusiness) return [];
return uniqueNonEmpty(mainBusiness.split(/[、,/|]/).map((value) => value.trim()));
}
export function isCaptchaState(state: Partial<PageState>): boolean {
const href = cleanText(state.href).toLowerCase();
const title = cleanText(state.title);
const bodyText = cleanMultilineText(state.body_text);
if (href.includes(CAPTCHA_URL_MARKER)) return true;
return CAPTCHA_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
}
export function isLoginState(state: Partial<PageState>): boolean {
const href = cleanText(state.href).toLowerCase();
const title = cleanText(state.title);
const bodyText = cleanMultilineText(state.body_text);
if (LOGIN_URL_PATTERNS.some((pattern) => href.includes(pattern))) return true;
return LOGIN_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern));
}
export function buildCaptchaHint(action: string): string {
return [
`Open a clean 1688 ${action} page in the shared Chrome profile and finish any slider challenge first.`,
'If you run opencli via CDP, set OPENCLI_CDP_TARGET=1688.com or a more specific 1688 host before retrying.',
].join(' ');
}
export async function readPageState(page: IPage): Promise<PageState> {
const result = await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
body_text: document.body ? document.body.innerText || '' : '',
}))()
`) as Partial<PageState>;
return {
href: cleanText(result.href),
title: cleanText(result.title),
body_text: cleanMultilineText(result.body_text),
};
}
export async function gotoAndReadState(
page: IPage,
url: string,
settleMs: number = 2500,
action: string = 'page',
): Promise<PageState> {
try {
await page.goto(url, { settleMs });
await page.wait(1.5);
return readPageState(page);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
message.includes('Inspected target navigated or closed')
|| message.includes('Cannot find context with specified id')
|| message.includes('Target closed')
) {
throw new CommandExecutionError(
`1688 ${action} navigation lost the current browser target`,
`${buildCaptchaHint(action)} If CDP is attached to a stale or blocked tab, open a fresh 1688 tab and point OPENCLI_CDP_TARGET at that tab.`,
);
}
throw error;
}
}
export async function ensure1688Session(page: IPage): Promise<void> {
const state = await gotoAndReadState(page, HOME_URL, 1500, 'homepage');
assertAuthenticatedState(state, 'homepage');
}
export function assertAuthenticatedState(state: PageState, action: string): void {
if (!isCaptchaState(state) && !isLoginState(state)) return;
throw new AuthRequiredError('1688.com', `请先在共享 Chrome 完成 1688 登录/验证,再重试(${action}`);
}
export function assertNotCaptcha(state: PageState, action: string): void {
assertAuthenticatedState(state, action);
}
export function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const normalized = value.replace(/,/g, '').trim();
if (!normalized) return null;
const parsed = Number.parseFloat(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function limitCandidates<T>(values: T[], limit: number): T[] {
const normalizedLimit = Math.max(1, Math.trunc(limit) || 1);
return values.slice(0, normalizedLimit);
}
function normalizeNumericText(value: string): string {
return value
.replace(/([¥$€])\s+(?=\d)/g, '$1')
.replace(/(\d)\s*\.\s*(\d)/g, '$1.$2')
.replace(/\s*([~-])\s*/g, '$1')
.trim();
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parse1688Url(input: string): URL {
const normalized = cleanText(input);
try {
const url = new URL(normalized);
if (!url.hostname.endsWith('.1688.com') && url.hostname !== '1688.com' && url.hostname !== 'www.1688.com') {
throw new Error('invalid-host');
}
stripTrackingParams(url);
url.hash = '';
return url;
} catch {
throw new ArgumentError(
'Invalid 1688 URL',
'Use a URL under 1688.com (for example: https://detail.1688.com/offer/887904326744.html)',
);
}
}
function parse1688UrlOrNull(input: string): URL | null {
try {
return parse1688Url(input);
} catch {
return null;
}
}
function normalizeStoreHost(hostname: string): string | null {
const lower = cleanText(hostname).toLowerCase();
if (!lower.endsWith('.1688.com')) return null;
const [subdomain] = lower.split('.');
if (!subdomain || STORE_GENERIC_HOSTS.has(subdomain)) return null;
return lower;
}
function stripTrackingParams(url: URL): void {
const keys = [...url.searchParams.keys()];
for (const key of keys) {
if (TRACKING_QUERY_KEYS.has(key) || key.toLowerCase().startsWith('utm_')) {
url.searchParams.delete(key);
}
}
}
export const __test__ = {
SEARCH_LIMIT_DEFAULT,
SEARCH_LIMIT_MAX,
parseSearchLimit,
buildSearchUrl,
buildDetailUrl,
resolveStoreUrl,
canonicalizeStoreUrl,
canonicalizeItemUrl,
canonicalizeSellerUrl,
extractOfferId,
extractMemberId,
extractShopId,
parsePriceText,
normalizePriceTiers,
parseMoqText,
extractLocation,
extractAddress,
extractMetric,
extractYearsOnPlatform,
extractMainBusiness,
extractBadges,
guessTopCategories,
isCaptchaState,
isLoginState,
cleanText,
cleanMultilineText,
uniqueNonEmpty,
limitCandidates,
};
+227
View File
@@ -0,0 +1,227 @@
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { FACTORY_BADGE_PATTERNS, SERVICE_BADGE_PATTERNS, assertAuthenticatedState, buildDetailUrl, buildProvenance, canonicalizeSellerUrl, canonicalizeStoreUrl, cleanMultilineText, cleanText, extractAddress, extractBadges, extractMemberId, extractMetric, extractOfferId, extractShopId, extractYearsOnPlatform, gotoAndReadState, guessTopCategories, resolveStoreUrl, uniqueNonEmpty, } from './shared.js';
function normalizeStorePayload(input) {
const storePayload = input.storePayload;
const contactPayload = input.contactPayload;
const seed = input.seed;
const contactText = cleanMultilineText(contactPayload?.bodyText);
const storeText = cleanMultilineText(storePayload?.bodyText);
const seedText = cleanMultilineText(seed?.bodyText);
const combinedText = [contactText, storeText, seedText].filter(Boolean).join('\n');
const sellerUrlRaw = cleanText(seed?.seller?.winportUrl
?? seed?.seller?.sellerWinportUrlMap?.defaultUrl
?? storePayload?.href
?? input.resolvedUrl);
const storeUrl = safeCanonicalStoreUrl(sellerUrlRaw || input.resolvedUrl) ?? input.resolvedUrl;
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw) ?? storeUrl;
const companyUrl = pickCompanyUrl(contactPayload?.href, storeUrl);
const memberId = cleanText(seed?.seller?.memberId)
|| input.explicitMemberId
|| extractMemberId(input.resolvedUrl)
|| extractMemberId(storePayload?.href ?? '')
|| null;
const shopId = extractShopId(sellerUrl) ?? extractShopId(storeUrl);
const companyName = cleanText(seed?.seller?.companyName)
|| firstNamedLine(contactText)
|| firstNamedLine(storeText)
|| null;
const serviceBadges = uniqueNonEmpty([
...extractBadges(combinedText, SERVICE_BADGE_PATTERNS),
...((seed?.services ?? []).map((service) => cleanText(service.serviceName))),
]);
const factoryBadges = extractBadges(combinedText, FACTORY_BADGE_PATTERNS);
return {
member_id: memberId,
shop_id: shopId,
store_name: companyName,
store_url: storeUrl,
company_name: companyName,
company_url: companyUrl,
business_model_text: firstMetric(combinedText, ['经营模式', '生产加工', '主营产品']),
years_on_platform_text: extractYearsOnPlatform(combinedText),
location: extractAddress(contactText) ?? extractAddress(storeText),
staff_size_text: firstMetric(combinedText, ['员工人数', '员工总数']),
factory_badges: factoryBadges,
service_badges: serviceBadges,
response_rate_text: firstMetric(combinedText, ['响应率', '回复率', '响应速度']),
return_rate_text: extractReturnRate(combinedText),
top_categories: guessTopCategories(combinedText),
phone_text: extractMetric(contactText, '电话'),
mobile_text: extractMetric(contactText, '手机'),
...buildProvenance(cleanText(contactPayload?.href) || cleanText(storePayload?.href) || input.resolvedUrl),
};
}
function safeCanonicalStoreUrl(url) {
try {
return canonicalizeStoreUrl(url);
}
catch {
return null;
}
}
function pickCompanyUrl(contactHref, storeUrl) {
const fromPage = cleanText(contactHref);
if (fromPage) {
const normalized = buildContactUrl(fromPage);
if (normalized)
return normalized;
}
return buildContactUrl(storeUrl);
}
function buildContactUrl(storeUrl) {
try {
const parsed = new URL(storeUrl);
if (!parsed.hostname.endsWith('.1688.com'))
return null;
return `${parsed.protocol}//${parsed.hostname}/page/contactinfo.html`;
}
catch {
return null;
}
}
function firstNamedLine(text) {
return text
.split('\n')
.map((line) => cleanText(line))
.find((line) => line.includes('有限公司') || line.includes('商行') || line.includes('工厂'))
?? null;
}
function firstMetric(text, labels) {
for (const label of labels) {
const value = extractMetric(text, label);
if (value)
return value;
}
return null;
}
function extractReturnRate(text) {
const inline = text.match(/回头率\s*([0-9.]+%)/);
if (inline)
return cleanText(inline[0]);
const multiline = text.match(/回头率\s*\n\s*([0-9.]+%)/);
if (!multiline)
return null;
return `回头率${cleanText(multiline[1])}`;
}
function firstOfferId(links) {
for (const link of links) {
const offerId = extractOfferId(link);
if (offerId)
return offerId;
}
return null;
}
function firstContactUrl(links) {
for (const link of links) {
const url = buildContactUrl(link);
if (url)
return url;
}
return null;
}
async function readStorePayload(page, url, action) {
const state = await gotoAndReadState(page, url, 2500, action);
assertAuthenticatedState(state, action);
return await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
offerLinks: Array.from(document.querySelectorAll('a[href*="detail.1688.com/offer/"], a[href*="offerId="]'))
.map((anchor) => anchor.href)
.filter(Boolean),
contactLinks: Array.from(document.querySelectorAll('a[href*="contactinfo"]'))
.map((anchor) => anchor.href)
.filter(Boolean),
}))()
`);
}
async function readItemSeed(page, offerId) {
const itemUrl = buildDetailUrl(offerId);
const state = await gotoAndReadState(page, itemUrl, 2500, 'store seed item');
assertAuthenticatedState(state, 'store seed item');
const seed = await page.evaluate(`
(() => {
const model = window.context?.result?.global?.globalData?.model ?? null;
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
return {
href: window.location.href,
bodyText: document.body ? document.body.innerText || '' : '',
seller: toJson(model?.sellerModel),
services: toJson(model?.shippingServices?.fields?.buyerProtectionModel ?? []),
};
})()
`);
const hasSellerContext = !!cleanText(seed?.seller?.memberId) || !!cleanText(seed?.seller?.winportUrl);
if (!hasSellerContext) {
throw new CommandExecutionError('1688 store seed item did not expose seller context', '当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试');
}
return seed;
}
function hasAnyEvidence(storePayload, contactPayload, seed) {
return !!cleanText(storePayload?.bodyText)
|| !!cleanText(contactPayload?.bodyText)
|| !!cleanText(seed?.bodyText);
}
cli({
site: '1688',
name: 'store',
access: 'read',
description: '1688 店铺/供应商公开信息(联系方式、主营、入驻年限、公开服务信号)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 店铺 URL 或 member ID(如 b2b-22154705262941f196',
},
],
columns: ['store_name', 'years_on_platform_text', 'location', 'return_rate_text'],
func: async (page, kwargs) => {
const rawInput = String(kwargs.input ?? '');
const resolvedUrl = resolveStoreUrl(rawInput);
const explicitMemberId = extractMemberId(rawInput);
const storePayload = await readStorePayload(page, resolvedUrl, 'store');
const contactUrl = firstContactUrl(storePayload.contactLinks ?? []) || buildContactUrl(storePayload.href || resolvedUrl);
const contactPayload = contactUrl ? await readStorePayload(page, contactUrl, 'store contact') : null;
const offerId = extractOfferId(rawInput)
|| firstOfferId(storePayload.offerLinks ?? [])
|| firstOfferId(contactPayload?.offerLinks ?? []);
let seed = null;
if (offerId) {
try {
seed = await readItemSeed(page, offerId);
}
catch (error) {
if (!(error instanceof CommandExecutionError))
throw error;
}
}
if (!hasAnyEvidence(storePayload, contactPayload, seed)) {
throw new EmptyResultError('1688 store', 'Store page is reachable but no visible fields were extracted. Open the store page in Chrome and retry.');
}
return [
normalizeStorePayload({
resolvedUrl,
storePayload,
contactPayload,
seed,
explicitMemberId,
}),
];
},
});
export const __test__ = {
normalizeStorePayload,
safeCanonicalStoreUrl,
buildContactUrl,
firstNamedLine,
firstMetric,
extractReturnRate,
firstOfferId,
firstContactUrl,
};
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './store.js';
describe('1688 store normalization', () => {
it('merges store contact text with seller seed data', () => {
const result = __test__.normalizeStorePayload({
resolvedUrl: 'https://yinuoweierfushi.1688.com/?offerId=887904326744',
explicitMemberId: null,
storePayload: {
href: 'https://yinuoweierfushi.1688.com/page/index.html',
bodyText: `
青岛沁澜衣品服装有限公司
联系方式
地址:山东省青岛市即墨区环秀街道办事处湘江二路97号甲
`,
offerLinks: ['https://detail.1688.com/offer/887904326744.html'],
},
contactPayload: {
href: 'https://yinuoweierfushi.1688.com/page/contactinfo.html',
bodyText: `
青岛沁澜衣品服装有限公司
电话:86 0532 86655366
手机:15963238678
地址:山东省青岛市即墨区环秀街道办事处湘江二路97号甲
`,
},
seed: {
bodyText: `
入驻13年
主营:大码女装
店铺回头率
87%
延期必赔
品质保障
`,
seller: {
companyName: '青岛沁澜衣品服装有限公司',
memberId: 'b2b-1641351767',
winportUrl: 'https://yinuoweierfushi.1688.com/page/index.html?spm=abc',
},
services: [{ serviceName: '延期必赔' }, { serviceName: '品质保障' }],
},
});
expect(result.member_id).toBe('b2b-1641351767');
expect(result.store_url).toBe('https://yinuoweierfushi.1688.com');
expect(result.company_url).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
expect(result.years_on_platform_text).toBe('入驻13年');
expect(result.location).toBe('山东省青岛市即墨区环秀街道办事处湘江二路97号甲');
expect(result.return_rate_text).toContain('87%');
expect(result.top_categories).toEqual(['大码女装']);
expect(result.service_badges).toEqual(['延期必赔', '品质保障']);
});
it('builds contact urls and extracts offer ids', () => {
expect(__test__.safeCanonicalStoreUrl('https://yinuoweierfushi.1688.com/page/index.html?spm=foo')).toBe('https://yinuoweierfushi.1688.com');
expect(__test__.buildContactUrl('https://yinuoweierfushi.1688.com')).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
expect(__test__.firstOfferId([
'https://detail.1688.com/offer/887904326744.html',
])).toBe('887904326744');
expect(__test__.firstContactUrl([
'https://yinuoweierfushi.1688.com/page/contactinfo.html?spm=1',
])).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
});
});
-69
View File
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './store.js';
describe('1688 store normalization', () => {
it('merges store contact text with seller seed data', () => {
const result = __test__.normalizeStorePayload({
resolvedUrl: 'https://yinuoweierfushi.1688.com/?offerId=887904326744',
explicitMemberId: null,
storePayload: {
href: 'https://yinuoweierfushi.1688.com/page/index.html',
bodyText: `
青岛沁澜衣品服装有限公司
联系方式
地址:山东省青岛市即墨区环秀街道办事处湘江二路97号甲
`,
offerLinks: ['https://detail.1688.com/offer/887904326744.html'],
},
contactPayload: {
href: 'https://yinuoweierfushi.1688.com/page/contactinfo.html',
bodyText: `
青岛沁澜衣品服装有限公司
电话:86 0532 86655366
手机:15963238678
地址:山东省青岛市即墨区环秀街道办事处湘江二路97号甲
`,
},
seed: {
bodyText: `
入驻13年
主营:大码女装
店铺回头率
87%
延期必赔
品质保障
`,
seller: {
companyName: '青岛沁澜衣品服装有限公司',
memberId: 'b2b-1641351767',
winportUrl: 'https://yinuoweierfushi.1688.com/page/index.html?spm=abc',
},
services: [{ serviceName: '延期必赔' }, { serviceName: '品质保障' }],
},
});
expect(result.member_id).toBe('b2b-1641351767');
expect(result.store_url).toBe('https://yinuoweierfushi.1688.com');
expect(result.company_url).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
expect(result.years_on_platform_text).toBe('入驻13年');
expect(result.location).toBe('山东省青岛市即墨区环秀街道办事处湘江二路97号甲');
expect(result.return_rate_text).toContain('87%');
expect(result.top_categories).toEqual(['大码女装']);
expect(result.service_badges).toEqual(['延期必赔', '品质保障']);
});
it('builds contact urls and extracts offer ids', () => {
expect(__test__.safeCanonicalStoreUrl('https://yinuoweierfushi.1688.com/page/index.html?spm=foo')).toBe(
'https://yinuoweierfushi.1688.com',
);
expect(__test__.buildContactUrl('https://yinuoweierfushi.1688.com')).toBe(
'https://yinuoweierfushi.1688.com/page/contactinfo.html',
);
expect(__test__.firstOfferId([
'https://detail.1688.com/offer/887904326744.html',
])).toBe('887904326744');
expect(__test__.firstContactUrl([
'https://yinuoweierfushi.1688.com/page/contactinfo.html?spm=1',
])).toBe('https://yinuoweierfushi.1688.com/page/contactinfo.html');
});
});
-300
View File
@@ -1,300 +0,0 @@
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import {
FACTORY_BADGE_PATTERNS,
SERVICE_BADGE_PATTERNS,
assertAuthenticatedState,
buildDetailUrl,
buildProvenance,
canonicalizeSellerUrl,
canonicalizeStoreUrl,
cleanMultilineText,
cleanText,
extractAddress,
extractBadges,
extractMemberId,
extractMetric,
extractOfferId,
extractShopId,
extractYearsOnPlatform,
gotoAndReadState,
guessTopCategories,
resolveStoreUrl,
uniqueNonEmpty,
} from './shared.js';
interface StoreBrowserPayload {
href?: string;
title?: string;
bodyText?: string;
offerLinks?: string[];
contactLinks?: string[];
}
interface StoreItemSeed {
href?: string;
bodyText?: string;
seller?: {
companyName?: string;
memberId?: string;
winportUrl?: string;
sellerWinportUrlMap?: Record<string, string>;
};
services?: Array<{ serviceName?: string }>;
}
function normalizeStorePayload(input: {
resolvedUrl: string;
storePayload: StoreBrowserPayload | null;
contactPayload: StoreBrowserPayload | null;
seed: StoreItemSeed | null;
explicitMemberId: string | null;
}): Record<string, unknown> {
const storePayload = input.storePayload;
const contactPayload = input.contactPayload;
const seed = input.seed;
const contactText = cleanMultilineText(contactPayload?.bodyText);
const storeText = cleanMultilineText(storePayload?.bodyText);
const seedText = cleanMultilineText(seed?.bodyText);
const combinedText = [contactText, storeText, seedText].filter(Boolean).join('\n');
const sellerUrlRaw = cleanText(
seed?.seller?.winportUrl
?? seed?.seller?.sellerWinportUrlMap?.defaultUrl
?? storePayload?.href
?? input.resolvedUrl,
);
const storeUrl = safeCanonicalStoreUrl(sellerUrlRaw || input.resolvedUrl) ?? input.resolvedUrl;
const sellerUrl = canonicalizeSellerUrl(sellerUrlRaw) ?? storeUrl;
const companyUrl = pickCompanyUrl(contactPayload?.href, storeUrl);
const memberId = cleanText(seed?.seller?.memberId)
|| input.explicitMemberId
|| extractMemberId(input.resolvedUrl)
|| extractMemberId(storePayload?.href ?? '')
|| null;
const shopId = extractShopId(sellerUrl) ?? extractShopId(storeUrl);
const companyName = cleanText(seed?.seller?.companyName)
|| firstNamedLine(contactText)
|| firstNamedLine(storeText)
|| null;
const serviceBadges = uniqueNonEmpty([
...extractBadges(combinedText, SERVICE_BADGE_PATTERNS),
...((seed?.services ?? []).map((service) => cleanText(service.serviceName))),
]);
const factoryBadges = extractBadges(combinedText, FACTORY_BADGE_PATTERNS);
return {
member_id: memberId,
shop_id: shopId,
store_name: companyName,
store_url: storeUrl,
company_name: companyName,
company_url: companyUrl,
business_model_text: firstMetric(combinedText, ['经营模式', '生产加工', '主营产品']),
years_on_platform_text: extractYearsOnPlatform(combinedText),
location: extractAddress(contactText) ?? extractAddress(storeText),
staff_size_text: firstMetric(combinedText, ['员工人数', '员工总数']),
factory_badges: factoryBadges,
service_badges: serviceBadges,
response_rate_text: firstMetric(combinedText, ['响应率', '回复率', '响应速度']),
return_rate_text: extractReturnRate(combinedText),
top_categories: guessTopCategories(combinedText),
phone_text: extractMetric(contactText, '电话'),
mobile_text: extractMetric(contactText, '手机'),
...buildProvenance(cleanText(contactPayload?.href) || cleanText(storePayload?.href) || input.resolvedUrl),
};
}
function safeCanonicalStoreUrl(url: string): string | null {
try {
return canonicalizeStoreUrl(url);
} catch {
return null;
}
}
function pickCompanyUrl(contactHref: string | undefined, storeUrl: string): string | null {
const fromPage = cleanText(contactHref);
if (fromPage) {
const normalized = buildContactUrl(fromPage);
if (normalized) return normalized;
}
return buildContactUrl(storeUrl);
}
function buildContactUrl(storeUrl: string): string | null {
try {
const parsed = new URL(storeUrl);
if (!parsed.hostname.endsWith('.1688.com')) return null;
return `${parsed.protocol}//${parsed.hostname}/page/contactinfo.html`;
} catch {
return null;
}
}
function firstNamedLine(text: string): string | null {
return text
.split('\n')
.map((line) => cleanText(line))
.find((line) => line.includes('有限公司') || line.includes('商行') || line.includes('工厂'))
?? null;
}
function firstMetric(text: string, labels: string[]): string | null {
for (const label of labels) {
const value = extractMetric(text, label);
if (value) return value;
}
return null;
}
function extractReturnRate(text: string): string | null {
const inline = text.match(/回头率\s*([0-9.]+%)/);
if (inline) return cleanText(inline[0]);
const multiline = text.match(/回头率\s*\n\s*([0-9.]+%)/);
if (!multiline) return null;
return `回头率${cleanText(multiline[1])}`;
}
function firstOfferId(links: string[]): string | null {
for (const link of links) {
const offerId = extractOfferId(link);
if (offerId) return offerId;
}
return null;
}
function firstContactUrl(links: string[]): string | null {
for (const link of links) {
const url = buildContactUrl(link);
if (url) return url;
}
return null;
}
async function readStorePayload(page: IPage, url: string, action: string): Promise<StoreBrowserPayload> {
const state = await gotoAndReadState(page, url, 2500, action);
assertAuthenticatedState(state, action);
return await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
offerLinks: Array.from(document.querySelectorAll('a[href*="detail.1688.com/offer/"], a[href*="offerId="]'))
.map((anchor) => anchor.href)
.filter(Boolean),
contactLinks: Array.from(document.querySelectorAll('a[href*="contactinfo"]'))
.map((anchor) => anchor.href)
.filter(Boolean),
}))()
`) as StoreBrowserPayload;
}
async function readItemSeed(page: IPage, offerId: string): Promise<StoreItemSeed> {
const itemUrl = buildDetailUrl(offerId);
const state = await gotoAndReadState(page, itemUrl, 2500, 'store seed item');
assertAuthenticatedState(state, 'store seed item');
const seed = await page.evaluate(`
(() => {
const model = window.context?.result?.global?.globalData?.model ?? null;
const toJson = (value) => JSON.parse(JSON.stringify(value ?? null));
return {
href: window.location.href,
bodyText: document.body ? document.body.innerText || '' : '',
seller: toJson(model?.sellerModel),
services: toJson(model?.shippingServices?.fields?.buyerProtectionModel ?? []),
};
})()
`) as StoreItemSeed;
const hasSellerContext = !!cleanText(seed?.seller?.memberId) || !!cleanText(seed?.seller?.winportUrl);
if (!hasSellerContext) {
throw new CommandExecutionError(
'1688 store seed item did not expose seller context',
'当前 tab 非商品详情上下文,请切到 detail.1688.com 商品页并重试',
);
}
return seed;
}
function hasAnyEvidence(
storePayload: StoreBrowserPayload | null,
contactPayload: StoreBrowserPayload | null,
seed: StoreItemSeed | null,
): boolean {
return !!cleanText(storePayload?.bodyText)
|| !!cleanText(contactPayload?.bodyText)
|| !!cleanText(seed?.bodyText);
}
cli({
site: '1688',
name: 'store',
description: '1688 店铺/供应商公开信息(联系方式、主营、入驻年限、公开服务信号)',
domain: 'www.1688.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: '1688 店铺 URL 或 member ID(如 b2b-22154705262941f196',
},
],
columns: ['store_name', 'years_on_platform_text', 'location', 'return_rate_text'],
func: async (page, kwargs) => {
const rawInput = String(kwargs.input ?? '');
const resolvedUrl = resolveStoreUrl(rawInput);
const explicitMemberId = extractMemberId(rawInput);
const storePayload = await readStorePayload(page, resolvedUrl, 'store');
const contactUrl = firstContactUrl(storePayload.contactLinks ?? []) || buildContactUrl(storePayload.href || resolvedUrl);
const contactPayload = contactUrl ? await readStorePayload(page, contactUrl, 'store contact') : null;
const offerId = extractOfferId(rawInput)
|| firstOfferId(storePayload.offerLinks ?? [])
|| firstOfferId(contactPayload?.offerLinks ?? []);
let seed: StoreItemSeed | null = null;
if (offerId) {
try {
seed = await readItemSeed(page, offerId);
} catch (error) {
if (!(error instanceof CommandExecutionError)) throw error;
}
}
if (!hasAnyEvidence(storePayload, contactPayload, seed)) {
throw new EmptyResultError(
'1688 store',
'Store page is reachable but no visible fields were extracted. Open the store page in Chrome and retry.',
);
}
return [
normalizeStorePayload({
resolvedUrl,
storePayload,
contactPayload,
seed,
explicitMemberId,
}),
];
},
});
export const __test__ = {
normalizeStorePayload,
safeCanonicalStoreUrl,
buildContactUrl,
firstNamedLine,
firstMetric,
extractReturnRate,
firstOfferId,
firstContactUrl,
};
+35
View File
@@ -0,0 +1,35 @@
/**
* 一亩三分地 精华帖 — Discuz guide=digest view.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'digest',
access: 'read',
description: '一亩三分地 精华帖(编辑推荐 / 加精)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
func: async (args) => {
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=digest`);
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
lastReplyTime: t.lastReplyTime,
url: t.url,
}));
},
});
+51
View File
@@ -0,0 +1,51 @@
/**
* 一亩三分地 版块帖子列表 — /bbs/forum-<fid>-<page>.html
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { fetchHtml, parseThreadList, parseThreadRows, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'forum',
access: 'read',
description: '浏览一亩三分地某个版块的帖子列表(按 fid)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'fid', required: true, positional: true, help: '版块 ID,例如 145(海外面经)、198(海外职位内推)、27(研究生申请)' },
{ name: 'page', type: 'int', default: 1, help: '页码(默认 1' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'kind', 'title', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
func: async (args) => {
const fid = String(args.fid || '').trim();
if (!/^\d+$/.test(fid)) {
throw new ArgumentError('fid must be a numeric forum id', 'e.g. 145 for 海外面经');
}
const pageNum = Number(args.page ?? 1);
if (!Number.isInteger(pageNum) || pageNum <= 0) {
throw new ArgumentError('page must be a positive integer');
}
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum-${fid}-${pageNum}.html`);
const rows = parseThreadRows(html);
if (rows.length === 0) {
// Forum may be sub-category-only — surface gracefully as empty with hint.
return [];
}
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
kind: t.kind === 'stickthread' ? '置顶' : '普通',
title: t.title,
author: t.author,
replies: t.replies,
views: t.views,
lastReplyTime: t.lastReplyTime,
url: t.url,
}));
},
});
+44
View File
@@ -0,0 +1,44 @@
/**
* 一亩三分地 所有版块清单 — parsed from /bbs/forum.php
*
* Each forum card has:
* <a href="forum-<fid>-1.html" ... class="... overflow-hidden whitespace-nowrap hidden desktop:block">版块名</a>
* and an adjacent description element. We dedupe by fid and return name + url.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, decodeEntities, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'forums',
access: 'read',
description: '一亩三分地 所有版块(fid + 版块名)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'filter', type: 'string', default: '', help: '按版块名关键字过滤(子串匹配,中英文)' },
],
columns: ['fid', 'name', 'url'],
func: async (args) => {
const html = await fetchHtml(`${BASE}/forum.php`);
const seen = new Map();
const re = /<a href="forum-(\d+)-1\.html"[^>]*class="[^"]*overflow-hidden[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/g;
let m;
while ((m = re.exec(html))) {
const fid = m[1];
let name = decodeEntities(m[2].trim());
// Some subforum labels are wrapped in brackets — unwrap for display parity.
name = name.replace(/^\[(.+)\]$/, '$1').trim();
if (!name || seen.has(fid)) continue;
seen.set(fid, name);
}
const filter = String(args.filter || '').toLowerCase().trim();
const out = [];
for (const [fid, name] of seen) {
if (filter && !name.toLowerCase().includes(filter)) continue;
out.push({ fid, name, url: `${BASE}/forum-${fid}-1.html` });
}
return out;
},
});
+35
View File
@@ -0,0 +1,35 @@
/**
* 一亩三分地 热门帖子 — Discuz guide=hot view.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'hot',
access: 'read',
description: '一亩三分地 今日热门帖子(按热度排序,约 50 条)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'lastReplyTime', 'url'],
func: async (args) => {
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=hot`);
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
lastReplyTime: t.lastReplyTime,
url: t.url,
}));
},
});
+35
View File
@@ -0,0 +1,35 @@
/**
* 一亩三分地 最新帖子 — Discuz guide=new view.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchHtml, parseThreadList, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'latest',
access: 'read',
description: '一亩三分地 最新发帖(按发帖时间倒序)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'postTime', 'url'],
func: async (args) => {
const limit = normalizeLimit(args.limit, 20, 50);
const html = await fetchHtml(`${BASE}/forum.php?mod=guide&view=new`);
const items = parseThreadList(html);
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
postTime: t.postTime,
url: t.url,
}));
},
});
+64
View File
@@ -0,0 +1,64 @@
/**
* 一亩三分地 我的通知 — 坛友互动 / 点评 / @我 等
*
* /bbs/home.php?mod=space&do=notice&view=interactive needs login cookie.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, decodeEntities, getCookie, stripHtml, truncate, normalizePositiveInteger, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'notifications',
access: 'read',
description: '一亩三分地 站内通知(互动 / 点评 / @ 我;需要登录)',
domain: 'www.1point3acres.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'kind', type: 'string', default: 'mypost',
help: '通知类型:mypost(我的帖子) / interactive(互动) / system(系统) / app(应用)' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数' },
],
columns: ['index', 'from', 'summary', 'time', 'threadUrl'],
func: async (page, args) => {
const kind = String(args.kind || 'mypost').trim();
const cookie = await getCookie(page);
const url = `${BASE}/home.php?mod=space&do=notice&view=${encodeURIComponent(kind)}`;
const html = await fetchHtml(url, { cookie, headers: { Referer: `${BASE}/` } });
if (/<title>提示信息/.test(html) && /请登录/.test(html)) {
throw new AuthRequiredError('www.1point3acres.com', '请先登录一亩三分地');
}
// "No notifications" is a real empty result, not a synthetic data row.
if (/暂时没有提醒内容/.test(html)) {
throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');
}
const rows = [];
const limit = normalizePositiveInteger(args.limit, 20, 'limit');
// Pattern 1: standard Discuz <dl class="cl">…</dl> block per notice.
const dlRe = /<dl class="[^"]*cl[^"]*"[^>]*>([\s\S]*?)<\/dl>/g;
let m;
let i = 0;
while ((m = dlRe.exec(html)) && rows.length < limit) {
const block = m[1];
const from = decodeEntities((block.match(/<dt>([\s\S]*?)<\/dt>/) || [, ''])[1])
.replace(/<[^>]+>/g, '').trim();
const summaryRaw = (block.match(/<dd class="ntc_body">([\s\S]*?)<\/dd>/) ||
block.match(/<dd>([\s\S]*?)<\/dd>/) || [, ''])[1];
const summary = truncate(stripHtml(summaryRaw), 200);
const time = ((block.match(/<dd class="[^"]*xg1[^"]*"[^>]*>([\s\S]*?)<\/dd>/) || [, ''])[1] || '')
.replace(/<[^>]+>/g, '').trim();
const linkMatch = summaryRaw.match(/href="([^"]*thread-\d+[^"]*)"/);
const threadUrl = linkMatch ? (linkMatch[1].startsWith('http') ? linkMatch[1] : `${BASE}/${linkMatch[1]}`) : '';
i += 1;
if (!from && !summary) continue;
rows.push({ index: i, from, summary, time, threadUrl });
}
return rows;
},
});
+71
View File
@@ -0,0 +1,71 @@
/**
* 一亩三分地 站内搜索 — /bbs/search.php?mod=forum
*
* Guests get a "请登录" alert page, so this command needs the live browser
* session's cookie. Discuz routes search through a 302 redirect to
* search.php?searchid=<ID>. Node fetch follows redirects automatically as
* long as we pass the session cookie along.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, parseSearchList, assertNotGuestAlert, getCookie, decodeEntities, normalizeLimit, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'search',
access: 'read',
description: '一亩三分地 站内关键字搜索(需要登录)',
domain: 'www.1point3acres.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'query', required: true, positional: true, help: '搜索关键字' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(默认 20,最多 50' },
{ name: 'fid', type: 'string', default: '', help: '限定版块 ID(可选)' },
],
columns: ['rank', 'tid', 'title', 'forum', 'author', 'replies', 'views', 'postTime', 'url'],
func: async (page, args) => {
const query = String(args.query || '').trim();
if (!query) throw new ArgumentError('query 不能为空');
const limit = normalizeLimit(args.limit, 20, 50);
const fid = String(args.fid || '').trim();
const cookie = await getCookie(page);
const qs = new URLSearchParams({
mod: 'forum',
srchtxt: query,
searchsubmit: 'yes',
...(fid ? { srchfid: fid } : {}),
});
const url = `${BASE}/search.php?${qs.toString()}`;
// Node fetch with the session cookie — Discuz's 302 to search.php?searchid=…
// is followed by default.
const html = await fetchHtml(url, {
cookie,
headers: { Referer: `${BASE}/` },
});
assertNotGuestAlert(html);
const items = parseSearchList(html);
if (items.length === 0) {
const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\/p>/);
if (hint) {
throw new EmptyResultError('1point3acres search', decodeEntities(hint[1].trim()));
}
throw new EmptyResultError('1point3acres search', `No results for "${query}"`);
}
return items.slice(0, limit).map((t, i) => ({
rank: i + 1,
tid: t.tid,
title: t.title,
forum: t.forum,
author: t.author,
replies: t.replies,
views: t.views,
postTime: t.postTime,
url: t.url,
}));
},
});
+117
View File
@@ -0,0 +1,117 @@
/**
* 一亩三分地 帖子详情 — /bbs/thread-<tid>-<page>-1.html
*
* Returns one row per post on the requested page. First row (floor=1) is the
* main post; the rest are replies. Columns are shaped so `--limit 1` gives
* just the main post, and larger limits walk down the thread.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, decodeEntities, stripHtml, truncate, normalizePositiveInteger, BASE } from './utils.js';
function extract(html, regex, group = 1) {
const m = html.match(regex);
return m ? m[group] : '';
}
cli({
site: '1point3acres',
name: 'thread',
access: 'read',
description: '一亩三分地 帖子详情 + 楼层(主楼 + 回复)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'tid', required: true, positional: true, help: '帖子 ID(数字,见 `hot`/`latest` 返回的 tid' },
{ name: 'page', type: 'int', default: 1, help: '楼层分页页码(默认 1' },
{ name: 'limit', type: 'int', default: 10, help: '返回楼层条数(默认 10,含主楼)' },
{ name: 'contentLimit', type: 'int', default: 400, help: '每楼正文截断长度(默认 400 字符,最少 50)' },
],
columns: ['floor', 'pid', 'author', 'postTime', 'content', 'url'],
func: async (args) => {
const tid = String(args.tid || '').trim();
if (!/^\d+$/.test(tid)) {
throw new ArgumentError('tid must be a numeric thread id');
}
const page = normalizePositiveInteger(args.page, 1, 'page');
const limit = normalizePositiveInteger(args.limit, 10, 'limit');
const contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });
const url = `${BASE}/thread-${tid}-${page}-1.html`;
const html = await fetchHtml(url);
// Sanity: real thread page will contain postlist + at least one post div.
if (!/id="postlist"/.test(html) && !/id="post_\d+"/.test(html)) {
throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在或被删除`);
}
// Split posts: each post block is bounded by <div id="post_<PID>">…</div> next post or postlist end.
// NOTE: intermediate objects intentionally use postId/body/offset (not pid/html/start) to
// avoid being mistaken for row-shaped objects by the silent-column-drop audit.
const postBlocks = [];
const re = /<div id="post_(\d+)"[^>]*>/g;
const offsets = [];
let m;
while ((m = re.exec(html))) offsets.push({ postId: m[1], offset: m.index });
for (let i = 0; i < offsets.length; i++) {
const segStart = offsets[i].offset;
const segEnd = i + 1 < offsets.length ? offsets[i + 1].offset : html.length;
postBlocks.push({ postId: offsets[i].postId, body: html.slice(segStart, segEnd) });
}
const rows = [];
for (let i = 0; i < postBlocks.length && rows.length < limit; i++) {
const { postId: pid, body: block } = postBlocks[i];
// Discuz authi block holds the author link + post time metadata.
const authiMatch = block.match(/<div class="authi"[\s\S]*?<\/div>/);
const authiBlock = authiMatch ? authiMatch[0] : '';
const authorCandidates = [
/<a [^>]*class="[^"]*\bxi2\b[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/,
/<a [^>]*href="space-uid-\d+\.html"[^>]*>\s*([^<]+?)\s*<\/a>/,
/<a [^>]*class="[^"]*\bxw1\b[^"]*"[^>]*>\s*([^<]+?)\s*<\/a>/,
];
let author = '';
for (const re of authorCandidates) {
const v = decodeEntities(extract(authiBlock || block, re));
if (v && !/匿名卡|变色卡|关贴卡/.test(v)) { author = v; break; }
}
// Time: prefer <span title="YYYY-MM-DD HH:MM:SS"> (per-post, precise).
// <meta itemprop="datePublished"> is the *thread* publish time on this site — avoid.
const postTime = extract(authiBlock, /<span title="([^"]+)">/) ||
extract(block, /id="authorposton\d+"[^>]*>\s*<span title="([^"]+)">/) ||
extract(block, /id="authorposton\d+"[^>]*>\s*([^<]+?)\s*</) ||
extract(block, /<meta itemprop="datePublished" content="([^"]+)"/);
// Floor: first post on page 1 is the 楼主, subsequent posts carry <em>N#</em>.
const floorEm = extract(block, /<em>(\d+)<\/em>\s*#?\s*<\/a>/) ||
extract(block, /id="postnum\d+"[^>]*>\s*<em>(\d+)<\/em>/);
const isMainPost = page === 1 && i === 0;
const floor = floorEm ? Number(floorEm) : (isMainPost ? 1 : (page - 1) * 10 + i + 1);
const contentMatch = block.match(/id="postmessage_\d+"[^>]*>([\s\S]*?)<\/td>/);
const content = truncate(stripHtml(contentMatch ? contentMatch[1] : ''), contentLimit);
rows.push({
floor,
pid,
author,
postTime: postTime.trim(),
content,
url: `${BASE}/forum.php?mod=redirect&goto=findpost&ptid=${tid}&pid=${pid}`,
});
}
// Attach the thread title + forum name as a leading synthetic row only when rows exist
// and only for page 1, so agents get the title without needing a separate call.
if (page === 1 && rows.length > 0) {
const title = decodeEntities(
extract(html, /<span id="thread_subject">([^<]+)<\/span>/).trim() ||
extract(html, /<title>([^<]+?)\s*[-|]/).trim()
);
rows[0].content = title ? `${title}\n${rows[0].content}` : rows[0].content;
}
if (!rows.length) {
throw new EmptyResultError('1point3acres thread', `帖子 ${tid}${page} 页没有可读取楼层`);
}
return rows;
},
});
+77
View File
@@ -0,0 +1,77 @@
/**
* 一亩三分地 用户资料 — /bbs/space-uid-<uid>.html or /bbs/space-username-<name>.html
*
* Guest-visible fields: username, uid, user group, register/last-access times,
* post/thread/digest counts, credits, rice (大米 — site currency), profile URL.
* Users can be queried by numeric uid or by username (both routes are public).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { fetchHtml, decodeEntities, BASE } from './utils.js';
cli({
site: '1point3acres',
name: 'user',
access: 'read',
description: '一亩三分地 用户空间(用户组 / 积分 / 大米 / 帖子数 等)',
domain: 'www.1point3acres.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'who', required: true, positional: true, help: '用户名或 uid(纯数字按 uid 查,否则按用户名)' },
],
columns: [
'uid', 'username', 'group', 'credits', 'rice',
'posts', 'threads', 'digests', 'registerTime', 'lastAccess', 'profileUrl',
],
func: async (args) => {
const who = String(args.who || '').trim();
if (!who) throw new ArgumentError('who 不能为空', '传用户名或数字 uid');
const url = /^\d+$/.test(who)
? `${BASE}/space-uid-${who}.html`
: `${BASE}/space-username-${encodeURIComponent(who)}.html`;
const html = await fetchHtml(url);
if (/<title>提示信息/.test(html) && /(没有找到|不存在)/.test(html)) {
throw new EmptyResultError('1point3acres user', `用户 "${who}" 不存在`);
}
const pick = (re) => {
const m = html.match(re);
return m ? decodeEntities(m[1].trim()) : '';
};
// <li>KEY: VAL</li> — tolerant of optional <span>, colons fullwidth/半角, 颗/根/粒 suffixes.
const pickLi = (label) => {
const re = new RegExp(`<li>\\s*${label}[:\\s]*(?:<[^>]+>)?\\s*([^<]+?)\\s*(?:<|$)`);
const m = html.match(re);
return m ? decodeEntities(m[1].trim()) : '';
};
const username =
pick(/<p class="mtm[^"]*"[^>]*>\s*<a [^>]*>([^<]+?)<\/a>/) ||
pick(/<title>([^<]+?)的个人资料/);
const uid = pick(/uid=(\d+)/) || pick(/space-uid-(\d+)\.html/);
const group = pickLi('用户组');
const credits = pickLi('积分');
const rice = pickLi('大米');
const posts = pickLi('帖子数');
const threads = pickLi('主题数');
const digests = pickLi('精华数');
const registerTime = pickLi('注册时间');
const lastAccess = pickLi('最后访问');
return [{
uid,
username,
group,
credits,
rice,
posts,
threads,
digests,
registerTime,
lastAccess,
profileUrl: uid ? `${BASE}/space-uid-${uid}.html` : url,
}];
},
});
+247
View File
@@ -0,0 +1,247 @@
/**
* Shared helpers for 一亩三分地 (1point3acres.com) adapters.
*
* Site is a Discuz!X PHP BBS that serves GBK-encoded HTML.
* - Thread listings: /bbs/forum.php?mod=guide&view={hot|new|digest|newthread}
* - Forum: /bbs/forum-<fid>-<page>.html
* - Thread detail: /bbs/thread-<tid>-<page>-1.html
* - User profile: /bbs/space-uid-<uid>.html or /bbs/space-username-<name>.html
* - Search: /bbs/search.php?mod=forum (COOKIE — guests get an alert page)
*/
import { AuthRequiredError, ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const BASE = 'https://www.1point3acres.com/bbs';
/**
* Validate `limit` per typed-fail-fast convention (no silent clamp).
* Throws ArgumentError on non-positive / non-integer / out-of-range input.
*/
export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {
const limit = normalizePositiveInteger(value, defaultValue, label);
if (limit > maxValue) {
throw new ArgumentError(`${label} must be <= ${maxValue}`);
}
return limit;
}
/** Validate a positive integer argument without silently flooring/clamping. */
export function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {
const raw = value ?? defaultValue;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError(`${label} must be a positive integer`);
}
if (limit < min) {
throw new ArgumentError(`${label} must be >= ${min}`);
}
return limit;
}
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0 Safari/537.36';
/** Fetch a GBK-encoded Discuz page and return decoded UTF-8 HTML. */
export async function fetchHtml(url, { headers = {}, cookie = '' } = {}) {
let res;
try {
res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
...(cookie ? { Cookie: cookie } : {}),
...headers,
},
redirect: 'follow',
});
} catch (error) {
throw new CommandExecutionError(`1point3acres request failed: ${error?.message || error}`);
}
if (!res.ok) {
throw new CommandExecutionError(`1point3acres request failed: HTTP ${res.status} ${res.statusText} from ${url}`);
}
const buf = await res.arrayBuffer();
return new TextDecoder('gbk').decode(buf);
}
/** Pull cookie string from the live browser session for this domain.
* Discuz auth cookies (4Oaf_61d6_*, session) are HttpOnly and set on the
* root domain `.1point3acres.com`, so we need `getCookies` (not document.cookie)
* AND we need to query both host + root domain and merge.
*/
export async function getCookie(page) {
if (!page) return '';
const seen = new Map();
if (typeof page.getCookies === 'function') {
for (const opts of [{ domain: 'www.1point3acres.com' }, { domain: '.1point3acres.com' }]) {
try {
const cookies = await page.getCookies(opts);
for (const c of cookies || []) {
if (!seen.has(c.name)) seen.set(c.name, c.value);
}
} catch { /* try next */ }
}
}
if (seen.size > 0) {
return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');
}
try {
const result = await page.evaluate('document.cookie');
return typeof result === 'string' ? result : '';
} catch {
return '';
}
}
/** Detect the "you are a guest" alert page that Discuz returns for protected actions. */
export function assertNotGuestAlert(html, domain = 'www.1point3acres.com') {
if (/<title>提示信息 \| 一亩三分地<\/title>/.test(html) && /无法进行此操作/.test(html)) {
throw new AuthRequiredError(domain, '需要登录一亩三分地后再使用该命令');
}
}
const ENTITY_MAP = {
'&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>',
'&quot;': '"', '&#39;': "'", '&apos;': "'",
};
/** Decode HTML entities (numeric + common named). */
export function decodeEntities(s) {
if (!s) return '';
return s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, m => ENTITY_MAP[m] || m);
}
/** Strip HTML tags and collapse whitespace, returning plain text. */
export function stripHtml(html) {
if (!html) return '';
return decodeEntities(
String(html)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr)>/gi, '\n')
.replace(/<[^>]+>/g, '')
).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
/** Truncate text to n characters with ellipsis. */
export function truncate(s, n = 300) {
if (!s) return '';
return s.length > n ? s.slice(0, n) + '…' : s;
}
/** Extract all <tbody id="normalthread_*"> blocks from a forum/guide page. */
export function parseThreadRows(html) {
const rows = [];
const re = /<tbody id="(normalthread|stickthread)_(\d+)"[^>]*>([\s\S]*?)<\/tbody>/g;
let m;
while ((m = re.exec(html))) {
const [, kind, tid, inner] = m;
rows.push({ kind, tid, inner });
}
return rows;
}
/** Parse a single Discuz thread row (inner HTML of the tbody). */
export function parseThreadRow({ kind, tid, inner }) {
const titleMatches = [...inner.matchAll(/<a [^>]*class="[^"]*\bxst\b[^"]*"[^>]*>([^<]+)<\/a>/g)];
const title = titleMatches.length
? decodeEntities(titleMatches[titleMatches.length - 1][1].trim())
: '';
const forumMatch = inner.match(/<a href="forum-(\d+)-1\.html"[^>]*target="_blank"[^>]*>([^<]+)<\/a>/);
const fid = forumMatch ? forumMatch[1] : '';
const forumName = forumMatch ? decodeEntities(forumMatch[2].trim()) : '';
// <td class="by"> blocks; first with <cite> = author, last with <cite> = last reply
const byBlocks = [...inner.matchAll(/<td class="by"[^>]*>([\s\S]*?)<\/td>/g)].map(m => m[1]);
const readCite = (block) => {
const m = block.match(/<cite[^>]*>([\s\S]*?)<\/cite>/);
if (!m) return '';
return decodeEntities(m[1].replace(/<[^>]+>/g, '').trim());
};
const readTime = (block) => {
const titleM = block.match(/<span [^>]*title="([^"]+)"[^>]*>/);
if (titleM) return titleM[1].trim();
const plainA = block.match(/<em>[\s\S]*?<a [^>]*>\s*([^<]+?)\s*<\/a>/);
if (plainA) return decodeEntities(plainA[1].trim());
const plainSpan = block.match(/<em>[\s\S]*?<span[^>]*>\s*([^<]+?)\s*<\/span>/);
if (plainSpan) return decodeEntities(plainSpan[1].trim());
const bare = block.match(/<em>\s*([^<]+?)\s*<\/em>/);
return bare ? decodeEntities(bare[1].trim()) : '';
};
let authorBlock = '';
let lastBlock = '';
for (const b of byBlocks) {
if (/<cite/.test(b)) {
if (!authorBlock) authorBlock = b;
lastBlock = b;
}
}
const author = authorBlock ? readCite(authorBlock) : '';
const postTime = authorBlock ? readTime(authorBlock) : '';
const lastReplyUser = lastBlock && lastBlock !== authorBlock ? readCite(lastBlock) : '';
const lastReplyTime = lastBlock && lastBlock !== authorBlock ? readTime(lastBlock) : '';
const numMatch = inner.match(/<td class="num"[^>]*>\s*<a[^>]*class="xi2"[^>]*>(\d+)<\/a>(?:\s*<em>(\d+)<\/em>)?/);
const replies = numMatch ? Number(numMatch[1]) : 0;
const views = numMatch && numMatch[2] ? Number(numMatch[2]) : 0;
return {
tid,
kind,
title,
author,
forum: forumName,
fid,
replies,
views,
postTime,
lastReplyUser,
lastReplyTime,
url: `${BASE}/thread-${tid}-1-1.html`,
};
}
/** Quick one-shot listing parser used by hot/latest/digest/forum. */
export function parseThreadList(html) {
return parseThreadRows(html).map(parseThreadRow).filter(t => t.title);
}
/**
* Parse Discuz search results page (different HTML shape than forum listings).
* Each hit is <li class="pbw" id="TID"> containing h3 > a[href*="tid=TID"],
* <p class="xg1">N 个回复 - M 次查看</p>, and a time/author/forum <p>.
*/
export function parseSearchList(html) {
const items = [];
const re = /<li class="pbw" id="(\d+)">([\s\S]*?)<\/li>/g;
let m;
while ((m = re.exec(html))) {
const [, tid, inner] = m;
const titleMatch = inner.match(/<h3[^>]*>\s*<a [^>]*>([\s\S]*?)<\/a>/);
const titleRaw = titleMatch ? titleMatch[1] : '';
const title = decodeEntities(titleRaw.replace(/<[^>]+>/g, '')).trim();
if (!title) continue;
const statsMatch = inner.match(/<p class="xg1">\s*([\d,]+)\s*个回复\s*-\s*([\d,]+)\s*次查看\s*<\/p>/);
const replies = statsMatch ? Number(statsMatch[1].replace(/,/g, '')) : 0;
const views = statsMatch ? Number(statsMatch[2].replace(/,/g, '')) : 0;
const metaMatch = inner.match(/<p>\s*<span>([^<]+)<\/span>[\s\S]*?<a [^>]*space-uid-\d+[^>]*>([^<]+?)<\/a>[\s\S]*?<a [^>]*href="forum-(\d+)-[^"]*"[^>]*>([^<]+?)<\/a>/);
const postTime = metaMatch ? decodeEntities(metaMatch[1].trim()) : '';
const author = metaMatch ? decodeEntities(metaMatch[2].trim()) : '';
const fid = metaMatch ? metaMatch[3] : '';
const forumName = metaMatch ? decodeEntities(metaMatch[4].trim()) : '';
items.push({
tid, title, author, forum: forumName, fid,
replies, views, postTime,
// Search pages don't show lastReplyTime separately — surface postTime instead.
lastReplyUser: '', lastReplyTime: postTime,
url: `${BASE}/thread-${tid}-1-1.html`,
});
}
return items;
}
export { UA };
+63
View File
@@ -0,0 +1,63 @@
/**
* 36kr article detail — INTERCEPT strategy.
*
* Fetches the full content of a 36kr article given its ID or URL.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
/** Extract article ID from a full URL or a bare numeric ID string */
function parseArticleId(input) {
const m = input.match(/\/p\/(\d+)/);
return m ? m[1] : input.replace(/\D/g, '');
}
cli({
site: '36kr',
name: 'article',
access: 'read',
description: '获取36氪文章正文内容',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
args: [
{ name: 'id', positional: true, required: true, help: 'Article ID or full 36kr article URL' },
],
columns: ['field', 'value'],
func: async (page, args) => {
const articleId = parseArticleId(String(args.id ?? ''));
if (!articleId) {
throw new CliError('INVALID_ARGUMENT', 'Invalid article ID or URL');
}
await page.installInterceptor('36kr.com/api');
await page.goto(`https://www.36kr.com/p/${articleId}`);
await page.wait(5);
const data = await page.evaluate(`
(() => {
// Title: 36kr uses class "article-title" on h1
const title = document.querySelector('.article-title, h1')?.textContent?.trim() || '';
// Author: second .author-name (first is empty nav link, second has real name)
const authorEls = document.querySelectorAll('.author-name');
const author = Array.from(authorEls).map(el => el.textContent?.trim()).filter(Boolean)[0] || '';
// Date: 36kr uses class "title-icon-item item-time" for the publish date
const dateRaw = document.querySelector('.item-time')?.textContent?.trim() || '';
const date = dateRaw.replace(/^[·\s]+/, '').trim();
// Article body paragraphs
const bodyEls = document.querySelectorAll('[class*="article-content"] p, [class*="rich-text"] p, .article p');
const body = Array.from(bodyEls)
.map(el => el.textContent?.trim())
.filter(t => t && t.length > 10)
.join(' ')
.slice(0, 800);
return { title, author, date, body };
})()
`);
if (!data?.title) {
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
}
return [
{ field: 'title', value: data.title },
{ field: 'author', value: data.author || '-' },
{ field: 'date', value: data.date || '-' },
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
{ field: 'body', value: data.body || '-' },
];
},
});
-69
View File
@@ -1,69 +0,0 @@
/**
* 36kr article detail — INTERCEPT strategy.
*
* Fetches the full content of a 36kr article given its ID or URL.
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
/** Extract article ID from a full URL or a bare numeric ID string */
function parseArticleId(input: string): string {
const m = input.match(/\/p\/(\d+)/);
return m ? m[1] : input.replace(/\D/g, '');
}
cli({
site: '36kr',
name: 'article',
description: '获取36氪文章正文内容',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
args: [
{ name: 'id', positional: true, required: true, help: 'Article ID or full 36kr article URL' },
],
columns: ['field', 'value'],
func: async (page: IPage, args) => {
const articleId = parseArticleId(String(args.id ?? ''));
if (!articleId) {
throw new CliError('INVALID_ARGUMENT', 'Invalid article ID or URL');
}
await page.installInterceptor('36kr.com/api');
await page.goto(`https://www.36kr.com/p/${articleId}`);
await page.wait(5);
const data: any = await page.evaluate(`
(() => {
// Title: 36kr uses class "article-title" on h1
const title = document.querySelector('.article-title, h1')?.textContent?.trim() || '';
// Author: second .author-name (first is empty nav link, second has real name)
const authorEls = document.querySelectorAll('.author-name');
const author = Array.from(authorEls).map(el => el.textContent?.trim()).filter(Boolean)[0] || '';
// Date: 36kr uses class "title-icon-item item-time" for the publish date
const dateRaw = document.querySelector('.item-time')?.textContent?.trim() || '';
const date = dateRaw.replace(/^[·\s]+/, '').trim();
// Article body paragraphs
const bodyEls = document.querySelectorAll('[class*="article-content"] p, [class*="rich-text"] p, .article p');
const body = Array.from(bodyEls)
.map(el => el.textContent?.trim())
.filter(t => t && t.length > 10)
.join(' ')
.slice(0, 800);
return { title, author, date, body };
})()
`);
if (!data?.title) {
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
}
return [
{ field: 'title', value: data.title },
{ field: 'author', value: data.author || '-' },
{ field: 'date', value: data.date || '-' },
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
{ field: 'body', value: data.body || '-' },
];
},
});
+87
View File
@@ -0,0 +1,87 @@
/**
* 36kr hot-list — DOM scraping.
*
* Navigates to the 36kr hot-list page and scrapes rendered article links.
* Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
const TYPE_MAP = {
renqi: '人气榜',
zonghe: '综合榜',
shoucang: '收藏榜',
catalog: '热门资讯',
};
function getShanghaiDate(date = new Date()) {
// Shanghai stays on UTC+8 year-round, so a fixed offset is sufficient here
// and avoids the slow Intl timezone path that timed out on Windows CI.
return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
function buildHotListUrl(listType, date = new Date()) {
if (listType === 'catalog') {
return 'https://www.36kr.com/hot-list/catalog';
}
return `https://www.36kr.com/hot-list/${listType}/${getShanghaiDate(date)}/1`;
}
cli({
site: '36kr',
name: 'hot',
access: 'read',
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
{
name: 'type',
type: 'string',
default: 'catalog',
help: 'List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)',
},
],
columns: ['rank', 'title', 'url'],
func: async (page, args) => {
const count = Math.min(Number(args.limit) || 20, 50);
const listType = String(args.type ?? 'catalog');
if (!TYPE_MAP[listType]) {
throw new CliError('INVALID_ARGUMENT', `Unknown type "${listType}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`);
}
const url = buildHotListUrl(listType);
await page.goto(url);
// Poll DOM until article links appear (36kr renders client-side)
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (await page.evaluate('document.querySelectorAll("a[href*=\\"/p/\\"]").length'))
break;
await new Promise(r => setTimeout(r, 300));
}
// Scrape rendered article links from DOM (deduplicated)
const domItems = await page.evaluate(`
(() => {
const seen = new Set();
const results = [];
const links = document.querySelectorAll('a[href*="/p/"]');
for (const el of links) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || title.length < 5 || seen.has(href) || seen.has(title)) continue;
seen.add(href);
seen.add(title);
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href });
}
return results;
})()
`);
const items = Array.isArray(domItems) ? domItems : [];
if (items.length === 0) {
throw new CliError('NO_DATA', 'Could not retrieve 36kr hot list', '36kr may have changed its DOM structure');
}
return items.slice(0, count).map((item, i) => ({
rank: i + 1,
title: item.title,
url: item.url,
}));
},
});
export { buildHotListUrl, getShanghaiDate };
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { buildHotListUrl, getShanghaiDate } from './hot.js';
describe('36kr/hot date routing', () => {
it('formats dates in Asia/Shanghai instead of UTC', () => {
const date = new Date('2026-03-25T18:30:00.000Z');
expect(getShanghaiDate(date)).toBe('2026-03-26');
});
it('builds dated hot-list routes with Shanghai-local date', () => {
const date = new Date('2026-03-25T18:30:00.000Z');
expect(buildHotListUrl('renqi', date)).toBe('https://www.36kr.com/hot-list/renqi/2026-03-26/1');
});
it('keeps catalog on the static route', () => {
expect(buildHotListUrl('catalog')).toBe('https://www.36kr.com/hot-list/catalog');
});
});
-19
View File
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildHotListUrl, getShanghaiDate } from './hot.js';
describe('36kr/hot date routing', () => {
it('formats dates in Asia/Shanghai instead of UTC', () => {
const date = new Date('2026-03-25T18:30:00.000Z');
expect(getShanghaiDate(date)).toBe('2026-03-26');
});
it('builds dated hot-list routes with Shanghai-local date', () => {
const date = new Date('2026-03-25T18:30:00.000Z');
expect(buildHotListUrl('renqi', date)).toBe('https://www.36kr.com/hot-list/renqi/2026-03-26/1');
});
it('keeps catalog on the static route', () => {
expect(buildHotListUrl('catalog')).toBe('https://www.36kr.com/hot-list/catalog');
});
});
-105
View File
@@ -1,105 +0,0 @@
/**
* 36kr hot-list — DOM scraping.
*
* Navigates to the 36kr hot-list page and scrapes rendered article links.
* Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
const TYPE_MAP: Record<string, string> = {
renqi: '人气榜',
zonghe: '综合榜',
shoucang: '收藏榜',
catalog: '热门资讯',
};
function getShanghaiDate(date = new Date()): string {
// Shanghai stays on UTC+8 year-round, so a fixed offset is sufficient here
// and avoids the slow Intl timezone path that timed out on Windows CI.
return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
function buildHotListUrl(listType: string, date = new Date()): string {
if (listType === 'catalog') {
return 'https://www.36kr.com/hot-list/catalog';
}
return `https://www.36kr.com/hot-list/${listType}/${getShanghaiDate(date)}/1`;
}
cli({
site: '36kr',
name: 'hot',
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
{
name: 'type',
type: 'string',
default: 'catalog',
help: 'List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)',
},
],
columns: ['rank', 'title', 'url'],
func: async (page: IPage, args) => {
const count = Math.min(Number(args.limit) || 20, 50);
const listType = String(args.type ?? 'catalog');
if (!TYPE_MAP[listType]) {
throw new CliError(
'INVALID_ARGUMENT',
`Unknown type "${listType}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`,
);
}
const url = buildHotListUrl(listType);
await page.goto(url);
// Poll DOM until article links appear (36kr renders client-side)
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (await page.evaluate('document.querySelectorAll("a[href*=\\"/p/\\"]").length')) break;
await new Promise(r => setTimeout(r, 300));
}
// Scrape rendered article links from DOM (deduplicated)
const domItems: any = await page.evaluate(`
(() => {
const seen = new Set();
const results = [];
const links = document.querySelectorAll('a[href*="/p/"]');
for (const el of links) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || title.length < 5 || seen.has(href) || seen.has(title)) continue;
seen.add(href);
seen.add(title);
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href });
}
return results;
})()
`);
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
if (items.length === 0) {
throw new CliError(
'NO_DATA',
'Could not retrieve 36kr hot list',
'36kr may have changed its DOM structure',
);
}
return items.slice(0, count).map((item: any, i: number) => ({
rank: i + 1,
title: item.title,
url: item.url,
}));
},
});
export { buildHotListUrl, getShanghaiDate };
+52
View File
@@ -0,0 +1,52 @@
/**
* 36kr latest news — public RSS feed, no browser needed.
*/
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,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
],
columns: ['rank', 'title', 'summary', 'date', 'url'],
func: async (kwargs) => {
const count = Math.min(kwargs.limit || 20, 50);
const resp = await fetch('https://www.36kr.com/feed', {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
});
if (!resp.ok)
return [];
const xml = await resp.text();
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < count) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
block.match(/<link>(.*?)<\/link>/)?.[1] ??
'';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
// Extract plain-text summary from HTML description (first ~120 chars)
const rawDesc = block.match(/<description><!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? '';
const summary = rawDesc
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
if (title) {
items.push({ rank: items.length + 1, title, summary, date, url: url.trim() });
}
}
return items;
},
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
const SAMPLE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>36氪</title>
<item>
<title>红杉中国领投AI公司「示例」,金额近2亿元</title>
<link><![CDATA[https://36kr.com/p/1111111111111111?f=rss]]></link>
<pubDate>2026-03-26 10:00:00 +0800</pubDate>
</item>
<item>
<title>马斯克旗下xAI估值突破1000亿美元</title>
<link><![CDATA[https://36kr.com/p/2222222222222222?f=rss]]></link>
<pubDate>2026-03-26 09:00:00 +0800</pubDate>
</item>
<item>
<title>OpenAI发布GPT-5,多模态能力大幅提升</title>
<link><![CDATA[https://36kr.com/p/3333333333333333?f=rss]]></link>
<pubDate>2026-03-25 20:00:00 +0800</pubDate>
</item>
</channel></rss>`;
afterEach(() => {
vi.restoreAllMocks();
});
describe('36kr/news RSS parsing', () => {
it('parses RSS feed into ranked news items', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
text: async () => SAMPLE_RSS,
});
// Direct RSS parse test using the same regex logic as news.ts
const xml = SAMPLE_RSS;
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < 10) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
block.match(/<link>(.*?)<\/link>/)?.[1] ??
'';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
if (title)
items.push({ rank: items.length + 1, title, date, url: url.trim() });
}
expect(items).toHaveLength(3);
expect(items[0].rank).toBe(1);
expect(items[0].title).toBe('红杉中国领投AI公司「示例」,金额近2亿元');
expect(items[0].date).toBe('2026-03-26');
expect(items[0].url).toBe('https://36kr.com/p/1111111111111111?f=rss');
});
it('respects limit — returns at most N items', async () => {
const xml = SAMPLE_RSS;
const limit = 2;
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < limit) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ?? '';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
if (title)
items.push({ rank: items.length + 1, title, date, url: url.trim() });
}
expect(items).toHaveLength(2);
});
it('skips items with empty title', async () => {
const xml = `<rss><channel>
<item><title></title><link>https://36kr.com/p/0</link><pubDate>2026-01-01</pubDate></item>
<item><title>有标题的文章</title><link>https://36kr.com/p/1</link><pubDate>2026-01-01</pubDate></item>
</channel></rss>`;
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml))) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
if (title)
items.push({ title });
}
expect(items).toHaveLength(1);
expect(items[0].title).toBe('有标题的文章');
});
});
-90
View File
@@ -1,90 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
const SAMPLE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>36氪</title>
<item>
<title>红杉中国领投AI公司「示例」,金额近2亿元</title>
<link><![CDATA[https://36kr.com/p/1111111111111111?f=rss]]></link>
<pubDate>2026-03-26 10:00:00 +0800</pubDate>
</item>
<item>
<title>马斯克旗下xAI估值突破1000亿美元</title>
<link><![CDATA[https://36kr.com/p/2222222222222222?f=rss]]></link>
<pubDate>2026-03-26 09:00:00 +0800</pubDate>
</item>
<item>
<title>OpenAI发布GPT-5,多模态能力大幅提升</title>
<link><![CDATA[https://36kr.com/p/3333333333333333?f=rss]]></link>
<pubDate>2026-03-25 20:00:00 +0800</pubDate>
</item>
</channel></rss>`;
afterEach(() => {
vi.restoreAllMocks();
});
describe('36kr/news RSS parsing', () => {
it('parses RSS feed into ranked news items', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
text: async () => SAMPLE_RSS,
} as Response);
// Direct RSS parse test using the same regex logic as news.ts
const xml = SAMPLE_RSS;
const items: { rank: number; title: string; date: string; url: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < 10) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url =
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
block.match(/<link>(.*?)<\/link>/)?.[1] ??
'';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
}
expect(items).toHaveLength(3);
expect(items[0].rank).toBe(1);
expect(items[0].title).toBe('红杉中国领投AI公司「示例」,金额近2亿元');
expect(items[0].date).toBe('2026-03-26');
expect(items[0].url).toBe('https://36kr.com/p/1111111111111111?f=rss');
});
it('respects limit — returns at most N items', async () => {
const xml = SAMPLE_RSS;
const limit = 2;
const items: { rank: number; title: string; date: string; url: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < limit) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ?? '';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
}
expect(items).toHaveLength(2);
});
it('skips items with empty title', async () => {
const xml = `<rss><channel>
<item><title></title><link>https://36kr.com/p/0</link><pubDate>2026-01-01</pubDate></item>
<item><title>有标题的文章</title><link>https://36kr.com/p/1</link><pubDate>2026-01-01</pubDate></item>
</channel></rss>`;
const items: any[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml))) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
if (title) items.push({ title });
}
expect(items).toHaveLength(1);
expect(items[0].title).toBe('有标题的文章');
});
});
-54
View File
@@ -1,54 +0,0 @@
/**
* 36kr latest news — public RSS feed, no browser needed.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: '36kr',
name: 'news',
description: 'Latest tech/startup news from 36kr (36氪)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
],
columns: ['rank', 'title', 'summary', 'date', 'url'],
func: async (_page, kwargs) => {
const count = Math.min(kwargs.limit || 20, 50);
const resp = await fetch('https://www.36kr.com/feed', {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
});
if (!resp.ok) return [];
const xml = await resp.text();
const items: { rank: number; title: string; summary: string; date: string; url: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < count) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url =
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
block.match(/<link>(.*?)<\/link>/)?.[1] ??
'';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
// Extract plain-text summary from HTML description (first ~120 chars)
const rawDesc = block.match(/<description><!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? '';
const summary = rawDesc
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
if (title) {
items.push({ rank: items.length + 1, title, summary, date, url: url.trim() });
}
}
return items;
},
});
+79
View File
@@ -0,0 +1,79 @@
/**
* 36kr article search — DOM scraping.
*
* Navigates to the 36kr search results page and scrapes rendered articles.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
cli({
site: '36kr',
name: 'search',
access: 'read',
description: '搜索36氪文章',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
],
columns: ['rank', 'title', 'date', 'url'],
func: async (page, args) => {
const count = Math.min(Number(args.limit) || 20, 50);
const query = encodeURIComponent(String(args.query ?? ''));
await page.goto(`https://www.36kr.com/search/articles/${query}`);
// Poll DOM until article links appear (36kr renders client-side)
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (await page.evaluate('document.querySelectorAll("a[href*=\\"/p/\\"]").length'))
break;
await new Promise(r => setTimeout(r, 300));
}
const domItems = await page.evaluate(`
(() => {
const seen = new Set();
const results = [];
// article-item-title contains the clickable title link
const titleEls = document.querySelectorAll('.article-item-title a[href*="/p/"], .article-item-title[href*="/p/"]');
for (const el of titleEls) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || seen.has(href)) continue;
seen.add(href);
// Look for date near the article item
const item = el.closest('[class*="article-item"]') || el.parentElement;
const dateEl = item?.querySelector('[class*="time"], [class*="date"], time');
const date = dateEl?.textContent?.trim() || '';
results.push({
title,
url: href.startsWith('http') ? href : 'https://36kr.com' + href,
date,
});
}
// Fallback: generic /p/ links with meaningful text
if (results.length === 0) {
const links = document.querySelectorAll('a[href*="/p/"]');
for (const el of links) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || title.length < 8 || seen.has(href) || seen.has(title)) continue;
seen.add(href);
seen.add(title);
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href, date: '' });
}
}
return results;
})()
`);
const items = Array.isArray(domItems) ? domItems : [];
if (items.length === 0) {
throw new CliError('NO_DATA', 'No results found', `Try a different query or check your keyword`);
}
return items.slice(0, count).map((item, i) => ({
rank: i + 1,
title: item.title,
date: item.date,
url: item.url,
}));
},
});
-83
View File
@@ -1,83 +0,0 @@
/**
* 36kr article search — DOM scraping.
*
* Navigates to the 36kr search results page and scrapes rendered articles.
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
site: '36kr',
name: 'search',
description: '搜索36氪文章',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
],
columns: ['rank', 'title', 'date', 'url'],
func: async (page: IPage, args) => {
const count = Math.min(Number(args.limit) || 20, 50);
const query = encodeURIComponent(String(args.query ?? ''));
await page.goto(`https://www.36kr.com/search/articles/${query}`);
// Poll DOM until article links appear (36kr renders client-side)
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (await page.evaluate('document.querySelectorAll("a[href*=\\"/p/\\"]").length')) break;
await new Promise(r => setTimeout(r, 300));
}
const domItems: any = await page.evaluate(`
(() => {
const seen = new Set();
const results = [];
// article-item-title contains the clickable title link
const titleEls = document.querySelectorAll('.article-item-title a[href*="/p/"], .article-item-title[href*="/p/"]');
for (const el of titleEls) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || seen.has(href)) continue;
seen.add(href);
// Look for date near the article item
const item = el.closest('[class*="article-item"]') || el.parentElement;
const dateEl = item?.querySelector('[class*="time"], [class*="date"], time');
const date = dateEl?.textContent?.trim() || '';
results.push({
title,
url: href.startsWith('http') ? href : 'https://36kr.com' + href,
date,
});
}
// Fallback: generic /p/ links with meaningful text
if (results.length === 0) {
const links = document.querySelectorAll('a[href*="/p/"]');
for (const el of links) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || title.length < 8 || seen.has(href) || seen.has(title)) continue;
seen.add(href);
seen.add(title);
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href, date: '' });
}
}
return results;
})()
`);
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
if (items.length === 0) {
throw new CliError('NO_DATA', 'No results found', `Try a different query or check your keyword`);
}
return items.slice(0, count).map((item: any, i: number) => ({
rank: i + 1,
title: item.title,
date: item.date,
url: item.url,
}));
},
});
+126
View File
@@ -0,0 +1,126 @@
/**
* 51job company jobs + basic info by encCoId.
*
* Navigates to `jobs.51job.com/all/co<encCoId>.html`. Each job card is an
* `<a sensorsdata="…">` whose attribute is a JSON blob with jobId, title,
* salary, area, year, degree — so parsing is just JSON, not DOM-text fragile.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { JOBS_ORIGIN, requirePage, navigateTo, parseCompanyJobCard } from './utils.js';
cli({
site: '51job',
name: 'company',
access: 'read',
description: '51job 公司简介 + 在招职位(按 encCoId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'encCoId', type: 'string', required: true, positional: true, help: '加密公司 IDsearch 返回的 encCoId' },
{ name: 'limit', type: 'int', default: 20, help: '返回职位数(1-50' },
],
columns: [
'rank', 'jobId', 'title', 'salary', 'city', 'workYear', 'degree',
'funcType', 'issueDate', 'url',
'companyName', 'companyType', 'companySize', 'companyIndustry',
'companyIntro', 'companyUrl',
],
func: async (page, kwargs) => {
requirePage(page);
const encCoId = String(kwargs.encCoId ?? '').trim();
if (!encCoId) throw new CliError('INVALID_ARGUMENT', 'encCoId is required');
if (!/^[A-Za-z0-9_]+$/.test(encCoId)) {
throw new CliError('INVALID_ARGUMENT', `encCoId must be alphanumeric/underscore, got "${encCoId}"`);
}
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
const url = `${JOBS_ORIGIN}/all/co${encCoId}.html`;
await navigateTo(page, url, 2);
const script = `(() => {
const sel = s => document.querySelector(s)?.innerText?.trim() || '';
const bodyText = (document.body.innerText || '').slice(0, 400);
if (/公司不存在|页面不存在|账号状态异常/.test(bodyText)) {
return { error: 'NOT_FOUND', bodyText };
}
const companyName = sel('h1') || sel('.cname');
// Company introduction block
const introEl = document.querySelector('#companyIntroRef, .c-intro');
const companyIntro = introEl ? (introEl.innerText || '').trim() : '';
// Info sidebar (type / size / industry) — labels sit in .com-info dl or .coinfo
const sidebarText = sel('.ci-content, .company-info, .coinfo, .com-info');
const links = [...document.querySelectorAll('a[sensorsdata]')]
.filter(a => /\\/\\d{6,}\\.html/.test(a.href || ''))
.slice(0, 60)
.map(a => {
return {
href: a.href,
sensorsdata: a.getAttribute('sensorsdata') || '',
text: (a.innerText || '').trim(),
};
});
// Company meta is three inline spans under .c-info.ellipsis
// (title/size/industry) — extract them by position.
const cInfo = document.querySelector('.c-info.ellipsis');
const cInfoParts = cInfo
? [...cInfo.querySelectorAll('span')].map(s => (s.innerText || '').trim()).filter(Boolean)
: [];
return {
companyName,
companyIntro,
links,
cInfoParts,
sidebarText: sidebarText.slice(0, 400),
};
})()`;
const data = await page.evaluate(script);
if (data.error === 'NOT_FOUND') {
throw new CliError('NO_DATA', `Company ${encCoId} not found`);
}
if (!data.companyName) {
throw new CliError('NO_DATA', `Could not parse company page ${encCoId}; layout may have changed`);
}
const companyUrl = url;
const [companyType = '', companySize = '', companyIndustry = ''] = data.cInfoParts || [];
const seen = new Set();
const rows = [];
for (const link of data.links || []) {
const job = parseCompanyJobCard(link);
if (!job) continue;
if (seen.has(job.jobId)) continue;
seen.add(job.jobId);
rows.push({
rank: rows.length + 1,
...job,
companyName: data.companyName,
companyType,
companySize,
companyIndustry,
companyIntro: data.companyIntro || '',
companyUrl,
});
if (rows.length >= limit) break;
}
if (rows.length === 0) {
// Still return a sentinel row with the company info so caller isn't left with [].
return [{
rank: 0,
jobId: '',
title: '(no active jobs)',
salary: '', city: '', workYear: '', degree: '',
funcType: '', issueDate: '', url: '',
companyName: data.companyName,
companyType, companySize, companyIndustry,
companyIntro: data.companyIntro || '',
companyUrl,
}];
}
return rows;
},
});
+109
View File
@@ -0,0 +1,109 @@
/**
* 51job job detail by jobId.
*
* Navigates to `jobs.51job.com/x/<jobId>.html` (SSR page — the generic `/x/`
* area slug always resolves) and scrapes the structured blocks. No API
* surface returns the full detail page, so DOM scraping is the only path.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { JOBS_ORIGIN, requirePage, navigateTo } from './utils.js';
cli({
site: '51job',
name: 'detail',
access: 'read',
description: '51job 职位详情(按 jobId',
domain: 'jobs.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'jobId', type: 'string', required: true, positional: true, help: '职位 IDsearch 返回的 jobId' },
],
columns: [
'jobId', 'title', 'salary', 'location', 'workYear', 'degree',
'category', 'address', 'ageRequirement',
'description', 'welfare',
'company', 'companyType', 'companySize', 'companyIndustry',
'companyUrl', 'url',
],
func: async (page, kwargs) => {
requirePage(page);
const jobId = String(kwargs.jobId ?? '').trim();
if (!jobId) throw new CliError('INVALID_ARGUMENT', 'jobId is required');
if (!/^\d{6,12}$/.test(jobId)) throw new CliError('INVALID_ARGUMENT', `jobId must be a 6-12 digit number, got "${jobId}"`);
const url = `${JOBS_ORIGIN}/x/${jobId}.html`;
await navigateTo(page, url, 2);
const script = `(() => {
const sel = s => document.querySelector(s)?.innerText?.trim() || '';
const all = s => [...document.querySelectorAll(s)].map(e => e.innerText.trim()).filter(Boolean);
const finalUrl = window.location.href;
const bodyText = (document.body.innerText || '').slice(0, 400);
if (/职位已下线|该职位已删除|页面不存在/.test(bodyText)) {
return { error: 'EXPIRED', bodyText };
}
const companyA = document.querySelector('.cname a, .tCompany_sidebar .com_msg a');
const funcs = all('.bmsg .fp');
const pick = (prefix) => {
const row = funcs.find(f => f.startsWith(prefix));
return row ? row.slice(prefix.length).replace(/^[:\\s\\n]+/, '').trim() : '';
};
return {
finalUrl,
title: sel('h1') || sel('.cn .name'),
salary: sel('.cn strong') || sel('strong'),
meta: sel('.cn .msg.ltype') || sel('.msg.ltype'),
description: (() => {
const box = document.querySelector('.bmsg.job_msg') || document.querySelector('.job_msg');
if (!box) return '';
const clone = box.cloneNode(true);
clone.querySelectorAll('.fp, .mt10, script, style').forEach(n => n.remove());
return (clone.innerText || '').trim();
})(),
welfare: all('.t1 span, .jtag .t1 span'),
category: pick('职能类别'),
address: pick('上班地址'),
ageRequirement: pick('年龄要求'),
company: companyA?.innerText?.trim() || '',
companyUrl: companyA?.href || '',
companyTag: sel('.com_tag'),
};
})()`;
const data = await page.evaluate(script);
if (data.error === 'EXPIRED') {
throw new CliError('NO_DATA', `Job ${jobId} is offline or removed`);
}
if (!data.title) {
throw new CliError('NO_DATA', `Could not parse job detail for ${jobId}; page may have changed layout`);
}
// meta looks like "北京-丰台区 | 3年及以上 | 本科"
const [locRaw, workYear, degree] = (data.meta || '').split('|').map(s => s.trim());
// companyTag looks like "国企\n\n150-500人\n\n电子技术/半导体/集成电路"
const tagParts = (data.companyTag || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
return [{
jobId,
title: data.title,
salary: data.salary || '',
location: locRaw || '',
workYear: workYear || '',
degree: degree || '',
category: data.category || '',
address: data.address || '',
ageRequirement: data.ageRequirement || '',
description: data.description || '',
welfare: (data.welfare || []).join(','),
company: data.company || '',
companyType: tagParts[0] || '',
companySize: tagParts[1] || '',
companyIndustry: tagParts.slice(2).join(' / '),
companyUrl: data.companyUrl || '',
url: data.finalUrl || url,
}];
},
});
+56
View File
@@ -0,0 +1,56 @@
/**
* 51job hot / recommended feed.
*
* Same endpoint as `search`, but with empty keyword — 51job returns its
* own ranked recommendation list (up to ~999 for most regions).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import {
WE_ORIGIN, SEARCH_COLUMNS, SORT_CODES,
requirePage, navigateTo, pageFetchJson,
buildSearchUrl, mapJobItem, resolveCity, resolveCode,
} from './utils.js';
cli({
site: '51job',
name: 'hot',
access: 'read',
description: '51job 推荐职位(按城市/行业/排序浏览)',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'area', type: 'string', default: '全国', help: '城市名或 6 位城市码(默认 "全国"' },
{ name: 'sort', type: 'string', default: '综合', help: '排序:综合 / 最新 / 薪资 / 距离' },
{ name: 'page', type: 'int', default: 1, help: '页码(1-based' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50' },
],
columns: SEARCH_COLUMNS,
func: async (page, kwargs) => {
requirePage(page);
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
const pageNum = Math.max(1, Number(kwargs.page) || 1);
const jobArea = resolveCity(kwargs.area);
const sortType = resolveCode(kwargs.sort, SORT_CODES, '0');
const currentUrl = await page.evaluate(`(() => window.location.href)()`);
if (!String(currentUrl).startsWith(WE_ORIGIN)) {
await navigateTo(page, `${WE_ORIGIN}/pc/search?searchType=2`, 2);
}
const url = buildSearchUrl({
keyword: '', jobArea, sortType,
pageNum, pageSize: Math.min(limit, 50),
});
const data = await pageFetchJson(page, url);
if (data.status !== '1' && data.status !== 1) {
throw new CliError('API_ERROR', `51job hot failed: ${data.message ?? 'unknown'}`);
}
const items = data?.resultbody?.job?.items ?? [];
if (items.length === 0) throw new CliError('NO_DATA', 'No recommended jobs returned');
return items.slice(0, limit).map((it, i) => mapJobItem(it, (pageNum - 1) * limit + i + 1));
},
});
+80
View File
@@ -0,0 +1,80 @@
/**
* 51job keyword search.
*
* Backed by `we.51job.com/api/job/search-pc`, which returns a job list with
* the full `jobDescribe` embedded. Needs the browser session because the
* Aliyun WAF in front of `we.51job.com` challenges bare fetches; the
* `pageFetchJson` helper runs inside the page so the WAF sees a real browser.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import {
WE_ORIGIN, SEARCH_COLUMNS,
SALARY_CODES, WORKYEAR_CODES, DEGREE_CODES,
COMPANY_TYPE_CODES, COMPANY_SIZE_CODES, SORT_CODES,
requirePage, navigateTo, pageFetchJson,
buildSearchUrl, mapJobItem, resolveCity, resolveCode,
} from './utils.js';
cli({
site: '51job',
name: 'search',
access: 'read',
description: '51job 前程无忧关键词职位搜索',
domain: 'we.51job.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'keyword', type: 'string', required: true, positional: true, help: '搜索关键词(岗位名 / 技能 / 公司)' },
{ name: 'area', type: 'string', default: '全国', help: '城市名或 6 位城市码(如 "杭州" / "080200" / "全国"' },
{ name: 'salary', type: 'string', default: '', help: '薪资区间(如 "10-15k" / "1-1.5万" / "20-30k"' },
{ name: 'experience', type: 'string', default: '', help: '工作年限(如 "应届" / "1-3年" / "3-5年" / "5-7年"' },
{ name: 'degree', type: 'string', default: '', help: '学历要求(如 "本科" / "大专" / "硕士"' },
{ name: 'companyType', type: 'string', default: '', help: '公司性质(如 "外资" / "国企" / "民营"' },
{ name: 'companySize', type: 'string', default: '', help: '公司规模(如 "50-150" / "1000-5000"' },
{ name: 'sort', type: 'string', default: '综合', help: '排序:综合 / 最新 / 薪资 / 距离' },
{ name: 'page', type: 'int', default: 1, help: '页码(1-based' },
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50' },
],
columns: SEARCH_COLUMNS,
func: async (page, kwargs) => {
requirePage(page);
const keyword = String(kwargs.keyword ?? '').trim();
if (!keyword) throw new CliError('INVALID_ARGUMENT', 'keyword is required');
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
const pageNum = Math.max(1, Number(kwargs.page) || 1);
const jobArea = resolveCity(kwargs.area);
const salary = resolveCode(kwargs.salary, SALARY_CODES);
const workYear = resolveCode(kwargs.experience, WORKYEAR_CODES);
const degree = resolveCode(kwargs.degree, DEGREE_CODES);
const companyType = resolveCode(kwargs.companyType, COMPANY_TYPE_CODES);
const companySize = resolveCode(kwargs.companySize, COMPANY_SIZE_CODES);
const sortType = resolveCode(kwargs.sort, SORT_CODES, '0');
// Establish WAF-clean origin. Reusing the same tab avoids the slider
// challenge fire every call.
const currentUrl = await page.evaluate(`(() => window.location.href)()`);
if (!String(currentUrl).startsWith(WE_ORIGIN)) {
await navigateTo(page, `${WE_ORIGIN}/pc/search?keyword=${encodeURIComponent(keyword)}&searchType=2`, 2);
}
const url = buildSearchUrl({
keyword, jobArea, salary, workYear, degree,
companyType, companySize, sortType,
pageNum, pageSize: Math.min(limit, 50),
});
const data = await pageFetchJson(page, url);
if (data.status !== '1' && data.status !== 1) {
throw new CliError('API_ERROR', `51job search failed: ${data.message ?? 'unknown'}`);
}
const items = data?.resultbody?.job?.items ?? [];
if (items.length === 0) {
throw new CliError('NO_DATA', `No jobs matched "${keyword}"`);
}
return items.slice(0, limit).map((it, i) => mapJobItem(it, (pageNum - 1) * limit + i + 1));
},
});
+302
View File
@@ -0,0 +1,302 @@
/**
* 51job shared utilities.
*
* Key design points:
* - we.51job.com is protected by Aliyun WAF — bare `curl` / Node-side fetch
* gets a slider CAPTCHA HTML page. Only browser-context fetch (page.evaluate)
* with the session's cookies survives the challenge.
* - `document.cookie` exposes the anti-bot cookies (`acw_sc__v2`, `ssxmod_itna`
* etc.) — no HttpOnly/login needed for public pages.
* - API (`we.51job.com/api/job/search-pc`) is same-origin when we've navigated
* to `https://we.51job.com/...`, so fetch inside page.evaluate works.
* - Detail / company pages live on `jobs.51job.com` and render data into the
* DOM (SSR), so adapters for those navigate and scrape.
*/
import { CliError } from '@jackwener/opencli/errors';
export const WE_ORIGIN = 'https://we.51job.com';
export const JOBS_ORIGIN = 'https://jobs.51job.com';
/**
* City name / alias → 6-digit jobArea code. `000000` is the national bucket.
* Covers the 40 largest cities the search UI surfaces. Unknown input passed
* as-is if it's already 6 digits; otherwise fall back to `000000` (all).
*/
export const CITY_CODES = {
'全国': '000000', 'all': '000000',
'北京': '010000', 'beijing': '010000',
'上海': '020000', 'shanghai': '020000',
'广州': '030200', 'guangzhou': '030200',
'深圳': '040000', 'shenzhen': '040000',
'武汉': '180200', 'wuhan': '180200',
'西安': '200200', "xi'an": '200200', 'xian': '200200',
'杭州': '080200', 'hangzhou': '080200',
'南京': '070200', 'nanjing': '070200',
'成都': '090200', 'chengdu': '090200',
'苏州': '070300', 'suzhou': '070300',
'重庆': '060000', 'chongqing': '060000',
'天津': '050000', 'tianjin': '050000',
'长沙': '190200', 'changsha': '190200',
'郑州': '170200', 'zhengzhou': '170200',
'青岛': '120300', 'qingdao': '120300',
'合肥': '150200', 'hefei': '150200',
'厦门': '110300', 'xiamen': '110300',
'无锡': '070400', 'wuxi': '070400',
'济南': '120200', 'jinan': '120200',
'佛山': '030700', 'foshan': '030700',
'东莞': '030800', 'dongguan': '030800',
'宁波': '080300', 'ningbo': '080300',
'福州': '110200', 'fuzhou': '110200',
'昆明': '250200', 'kunming': '250200',
'大连': '230300', 'dalian': '230300',
'沈阳': '230200', 'shenyang': '230200',
'哈尔滨': '220200', 'haerbin': '220200', 'harbin': '220200',
'石家庄': '160200', 'shijiazhuang': '160200',
'贵阳': '260200', 'guiyang': '260200',
'南宁': '100200', 'nanning': '100200',
'南昌': '130200', 'nanchang': '130200',
'长春': '240200', 'changchun': '240200',
'太原': '210200', 'taiyuan': '210200',
'兰州': '280200', 'lanzhou': '280200',
'乌鲁木齐': '310200', 'urumqi': '310200',
'海口': '270200', 'haikou': '270200',
'香港': '330000', 'hongkong': '330000', 'hk': '330000',
};
/** Salary bucket code (matches 51job's `salary` filter). */
export const SALARY_CODES = {
'不限': '',
'2千以下': '01', '2-3千': '02', '3-4.5千': '03',
'4.5-6千': '04', '6-8千': '05', '8k-1万': '06', '8-10k': '06',
'1-1.5万': '07', '10-15k': '07',
'1.5-2万': '08', '15-20k': '08',
'2-3万': '09', '20-30k': '09',
'3-5万': '10', '30-50k': '10',
'5万以上': '11', '50k以上': '11',
};
/** Work experience bucket. */
export const WORKYEAR_CODES = {
'不限': '',
'在校生': '01', '应届': '02', '1年以下': '03',
'1-3年': '04', '3-5年': '05', '5-7年': '06',
'7-10年': '07', '10年以上': '08',
};
/** Degree bucket. */
export const DEGREE_CODES = {
'不限': '',
'初中及以下': '01', '高中/中技/中专': '02', '高中': '02',
'大专': '03', '本科': '04', '硕士': '05', '博士': '06',
};
/** Company ownership type. */
export const COMPANY_TYPE_CODES = {
'不限': '',
'外资': '01', '欧美': '0101', '日韩': '0102',
'合资': '02', '国企': '03', '民营': '04',
'上市公司': '05', '创业公司': '06', '事业单位': '07',
'非营利': '08', '政府': '09',
};
/** Company headcount bucket. */
export const COMPANY_SIZE_CODES = {
'不限': '',
'少于50': '01', '50以下': '01',
'50-150': '02', '150-500': '03',
'500-1000': '04', '1000-5000': '05',
'5000-10000': '06', '10000以上': '07',
};
/** Sort strategy. */
export const SORT_CODES = {
'综合': '0', 'relevance': '0', 'default': '0',
'最新': '1', 'new': '1', 'newest': '1',
'薪资': '2', 'salary': '2', 'pay': '2',
'距离': '9', 'distance': '9',
};
export function resolveCity(input) {
if (!input) return '000000';
const s = String(input).trim();
if (!s || s === '全国' || s.toLowerCase() === 'all') return '000000';
if (/^\d{6}$/.test(s)) return s;
const key = s.toLowerCase();
if (CITY_CODES[s] !== undefined) return CITY_CODES[s];
if (CITY_CODES[key] !== undefined) return CITY_CODES[key];
for (const [name, code] of Object.entries(CITY_CODES)) {
if (typeof name === 'string' && name.includes(s)) return code;
}
throw new CliError('INVALID_ARGUMENT', `Unknown city/area "${s}"`, 'Use a supported city name like "杭州" or a 6-digit city code');
}
export function resolveCode(input, table, fallback = '') {
if (input === undefined || input === null || input === '') return fallback;
const s = String(input).trim();
if (table[s] !== undefined) return table[s];
const key = s.toLowerCase();
if (table[key] !== undefined) return table[key];
if (Object.values(table).includes(s)) return s;
for (const [k, v] of Object.entries(table)) {
if (typeof k === 'string' && k.includes(s)) return v;
}
return fallback;
}
export function requirePage(page) {
if (!page) throw new CliError('INTERNAL_ERROR', 'Browser page required (adapter must set browser: true)');
}
/**
* Navigate the page to a URL and give the SPA a moment to settle. Reuses
* existing session cookies — first call on a fresh browser may trigger the
* Aliyun WAF interstitial, which the headless Chromium solves automatically
* because the JS that sets `acw_sc__v2` runs in the page.
*/
export async function navigateTo(page, url, waitSeconds = 2) {
await page.goto(url);
await page.wait({ time: waitSeconds });
}
/**
* Browser-context fetch: execute `fetch(url, { credentials: 'include' })`
* inside the page so cookies apply and WAF sees a real browser. Returns
* parsed JSON; throws on network / parse / status failure.
*/
export async function pageFetchJson(page, url, opts = {}) {
const method = opts.method ?? 'GET';
const body = opts.body ?? null;
const timeout = opts.timeout ?? 15000;
const headers = opts.headers ?? {};
const script = `
async () => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ${timeout});
try {
const resp = await fetch(${JSON.stringify(url)}, {
method: ${JSON.stringify(method)},
credentials: 'include',
headers: ${JSON.stringify({ Accept: 'application/json', ...headers })},
${body !== null ? `body: ${JSON.stringify(body)},` : ''}
signal: ctrl.signal,
});
const text = await resp.text();
return { ok: resp.ok, status: resp.status, text };
} catch (e) {
return { ok: false, status: 0, text: '', error: String(e && e.message || e) };
} finally {
clearTimeout(timer);
}
}
`;
const res = await page.evaluate(script);
if (res.error) throw new CliError('HTTP_ERROR', `51job fetch failed: ${res.error}`);
if (!res.ok) throw new CliError('HTTP_ERROR', `51job HTTP ${res.status}`);
if (res.text.trim().startsWith('<')) {
throw new CliError('ANTI_BOT', '51job returned HTML (likely Aliyun WAF slider). Refresh browser session.');
}
try {
return JSON.parse(res.text);
} catch (e) {
throw new CliError('API_ERROR', `51job invalid JSON: ${res.text.slice(0, 200)}`);
}
}
/**
* Build the canonical search-pc URL. All optional filters default to empty
* (no constraint). `scene=7` + `source=1` match what the real SPA sends.
*/
export function buildSearchUrl(params) {
const qs = new URLSearchParams();
qs.set('api_key', '51job');
qs.set('timestamp', String(Date.now()));
qs.set('keyword', params.keyword ?? '');
qs.set('searchType', '2');
qs.set('function', params.function ?? '');
qs.set('industry', params.industry ?? '');
qs.set('jobArea', params.jobArea ?? '000000');
qs.set('jobArea2', params.jobArea2 ?? '');
qs.set('landmark', params.landmark ?? '');
qs.set('metro', params.metro ?? '');
qs.set('salary', params.salary ?? '');
qs.set('workYear', params.workYear ?? '');
qs.set('degree', params.degree ?? '');
qs.set('companyType', params.companyType ?? '');
qs.set('companySize', params.companySize ?? '');
qs.set('jobType', params.jobType ?? '');
qs.set('issueDate', params.issueDate ?? '');
qs.set('sortType', params.sortType ?? '0');
qs.set('pageNum', String(params.pageNum ?? 1));
qs.set('pageSize', String(params.pageSize ?? 20));
qs.set('source', '1');
qs.set('scene', '7');
return `${WE_ORIGIN}/api/job/search-pc?${qs.toString()}`;
}
/**
* Map a raw search-pc `resultbody.job.items[i]` into the canonical row shape
* we expose to the user. Kept here so `search` and `hot` stay aligned.
*/
export function mapJobItem(it, rank) {
const area = it.jobAreaLevelDetail || {};
return {
rank,
jobId: String(it.jobId ?? ''),
title: it.jobName ?? '',
salary: it.provideSalaryString ?? '',
salaryMin: Number(it.jobSalaryMin ?? 0) || 0,
salaryMax: Number(it.jobSalaryMax ?? 0) || 0,
city: area.cityString ?? it.jobAreaString ?? '',
district: area.districtString ?? '',
workYear: it.workYearString ?? '',
degree: it.degreeString ?? '',
tags: Array.isArray(it.jobTags) ? it.jobTags.join(',') : '',
company: it.companyName ?? '',
companyFull: it.fullCompanyName ?? '',
companyType: it.companyTypeString ?? '',
companySize: it.companySizeString ?? '',
industry: it.industryType1Str ?? '',
hr: it.hrName ? `${it.hrName}·${it.hrPosition ?? ''}` : '',
issueDate: it.issueDateString ?? '',
url: it.jobHref ?? '',
companyUrl: it.companyHref ?? '',
encCoId: it.encCoId ?? '',
};
}
export const SEARCH_COLUMNS = [
'rank', 'jobId', 'title', 'salary', 'salaryMin', 'salaryMax',
'city', 'district', 'workYear', 'degree', 'tags',
'company', 'companyFull', 'companyType', 'companySize', 'industry',
'hr', 'issueDate', 'url', 'companyUrl', 'encCoId',
];
/**
* Parse a 51job company-page `<a sensorsdata="...">` payload into a stable
* row fragment. Returns null when the attribute is absent or malformed.
*/
export function parseCompanyJobCard(raw) {
if (!raw || typeof raw !== 'object') return null;
const href = typeof raw.href === 'string' ? raw.href : '';
const sensorsdata = typeof raw.sensorsdata === 'string' ? raw.sensorsdata : '';
if (!href || !sensorsdata) return null;
let data;
try {
data = JSON.parse(sensorsdata);
} catch {
return null;
}
if (!data || !data.jobId) return null;
return {
jobId: String(data.jobId),
title: data.jobTitle || '',
salary: data.jobSalary || '',
city: data.jobArea || '',
workYear: data.jobYear || '',
degree: data.jobDegree || '',
funcType: data.funcType || '',
issueDate: data.jobTime || '',
url: href,
};
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest';
import { CliError } from '@jackwener/opencli/errors';
import { parseCompanyJobCard, pageFetchJson, resolveCity } from './utils.js';
describe('51job resolveCity', () => {
it('maps known city names and explicit national scope', () => {
expect(resolveCity('杭州')).toBe('080200');
expect(resolveCity('all')).toBe('000000');
expect(resolveCity('000000')).toBe('000000');
});
it('rejects unknown non-empty inputs instead of silently widening to 全国', () => {
expect(() => resolveCity('杭州z')).toThrowError(CliError);
expect(() => resolveCity('杭州z')).toThrow(/Unknown city\/area/);
});
});
describe('51job pageFetchJson', () => {
it('detects WAF challenge HTML and throws ANTI_BOT', async () => {
const page = {
evaluate: vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: '<html><title>slider</title></html>',
}),
};
await expect(pageFetchJson(page, 'https://we.51job.com/api/job/search-pc')).rejects.toMatchObject({
code: 'ANTI_BOT',
});
});
});
describe('51job parseCompanyJobCard', () => {
it('parses sensorsdata JSON into a stable row fragment', () => {
const row = parseCompanyJobCard({
href: 'https://jobs.51job.com/shanghai/123456789.html',
sensorsdata: JSON.stringify({
jobId: '123456789',
jobTitle: 'Senior Engineer',
jobSalary: '20-30K',
jobArea: '上海',
jobYear: '3-5年',
jobDegree: '本科',
funcType: '后端开发',
jobTime: '04-22',
}),
});
expect(row).toEqual({
jobId: '123456789',
title: 'Senior Engineer',
salary: '20-30K',
city: '上海',
workYear: '3-5年',
degree: '本科',
funcType: '后端开发',
issueDate: '04-22',
url: 'https://jobs.51job.com/shanghai/123456789.html',
});
});
it('returns null on malformed sensorsdata', () => {
expect(parseCompanyJobCard({
href: 'https://jobs.51job.com/shanghai/123456789.html',
sensorsdata: '{bad json}',
})).toBeNull();
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared utilities for CLI adapters.
*/
import { ArgumentError } from '@jackwener/opencli/errors';
/**
* Clamp a numeric value to [min, max].
* Matches the signature of lodash.clamp and Rust's clamp.
*/
export function clamp(value, min, max) {
return Math.max(min, Math.min(value, max));
}
export function clampInt(raw, fallback, min, max) {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return fallback;
}
return clamp(Math.floor(parsed), min, max);
}
export function normalizeNumericId(value, label, example) {
const normalized = String(value ?? '').trim();
if (!/^\d+$/.test(normalized)) {
throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`);
}
return normalized;
}
export function requireNonEmptyQuery(value, label = 'query') {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw new ArgumentError(`${label} cannot be empty`);
}
return normalized;
}
-11
View File
@@ -1,11 +0,0 @@
/**
* Shared utilities for CLI adapters.
*/
/**
* Clamp a numeric value to [min, max].
* Matches the signature of lodash.clamp and Rust's clamp.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Shared command factories for Electron/desktop app adapters.
* Eliminates duplicate screenshot/status/new/dump implementations
* across cursor, codex, chatwise, etc.
*/
import * as fs from 'node:fs';
import { cli, Strategy } from '@jackwener/opencli/registry';
/**
* Factory: capture DOM HTML + accessibility snapshot.
*/
export function makeScreenshotCommand(site, displayName, extra = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'screenshot',
access: 'read',
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
],
columns: ['Status', 'File'],
func: async (page, kwargs) => {
const outputPath = kwargs.output || `/tmp/${site}-snapshot.txt`;
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
}
/**
* Factory: check CDP connection status.
*/
export function makeStatusCommand(site, displayName, extra = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'status',
access: 'read',
description: `Check active CDP connection to ${label}`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status', 'Url', 'Title'],
func: async (page) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [{ Status: 'Connected', Url: url, Title: title }];
},
});
}
/**
* Factory: start a new session via Cmd/Ctrl+N.
*/
export function makeNewCommand(site, displayName, extra = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'new',
access: 'write',
description: `Start a new ${label} session`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status'],
func: async (page) => {
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
}
/**
* Factory: dump DOM + snapshot for reverse-engineering.
*/
export function makeDumpCommand(site) {
return cli({
site,
name: 'dump',
access: 'read',
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page) => {
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
},
];
},
});
}
-121
View File
@@ -1,121 +0,0 @@
/**
* Shared command factories for Electron/desktop app adapters.
* Eliminates duplicate screenshot/status/new/dump implementations
* across cursor, codex, chatwise, etc.
*/
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import type { CliOptions } from '../../registry.js';
/**
* Factory: capture DOM HTML + accessibility snapshot.
*/
export function makeScreenshotCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'screenshot',
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
}
/**
* Factory: check CDP connection status.
*/
export function makeStatusCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'status',
description: `Check active CDP connection to ${label}`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status', 'Url', 'Title'],
func: async (page: IPage) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [{ Status: 'Connected', Url: url, Title: title }];
},
});
}
/**
* Factory: start a new session via Cmd/Ctrl+N.
*/
export function makeNewCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'new',
description: `Start a new ${label} session`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status'],
func: async (page: IPage) => {
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
}
/**
* Factory: dump DOM + snapshot for reverse-engineering.
*/
export function makeDumpCommand(site: string) {
return cli({
site,
name: 'dump',
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page: IPage) => {
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
},
];
},
});
}
+70
View File
@@ -0,0 +1,70 @@
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export function requireSearchQuery(value, label = 'keyword') {
const query = String(value ?? '').trim();
if (!query) {
throw new ArgumentError(`${label} cannot be empty`);
}
return query;
}
export function requireBoundedInteger(value, defaultValue, min, max, label) {
const raw = value ?? defaultValue;
const parsed = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(parsed)) {
throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
}
if (parsed < min || parsed > max) {
throw new ArgumentError(`${label} must be between ${min} and ${max}, got ${parsed}`);
}
return parsed;
}
export function requireNonNegativeInteger(value, defaultValue, label) {
const raw = value ?? defaultValue;
const parsed = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new ArgumentError(`${label} must be a non-negative integer, got ${JSON.stringify(value)}`);
}
return parsed;
}
export function unwrapBrowserResult(value) {
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
return value.data;
}
return value;
}
export function requireRows(value, label) {
const rows = unwrapBrowserResult(value);
if (!Array.isArray(rows)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array of result rows.`);
}
return rows;
}
export function toHttpsUrl(value, baseUrl) {
const raw = String(value ?? '').trim();
if (!raw) return '';
try {
const url = new URL(raw, baseUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
return url.href;
} catch {
return '';
}
}
export function emptySearchResults(site, query) {
return new EmptyResultError(`${site} search`, `No ${site} results matched "${query}".`);
}
export async function runBrowserStep(label, fn) {
try {
return await fn();
} catch (error) {
if (error?.code || error?.name === 'ArgumentError') throw error;
throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`);
}
}
+110
View File
@@ -0,0 +1,110 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError, getErrorMessage } from '@jackwener/opencli/errors';
const AIBASE_DAILY_URL = 'https://www.aibase.com/zh/daily';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
function normalizeLimit(value) {
const raw = value ?? DEFAULT_LIMIT;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError('limit must be a positive integer', `Example: opencli aibase news --limit ${DEFAULT_LIMIT}`);
}
if (limit > MAX_LIMIT) {
throw new ArgumentError(`limit must be <= ${MAX_LIMIT}`, `Example: opencli aibase news --limit ${MAX_LIMIT}`);
}
return limit;
}
function normalizeText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function buildExtractAibaseNewsJs() {
return `
(() => {
const anchors = Array.from(document.querySelectorAll('.bg-white .grid a[href], a[href*="/zh/daily/"]'))
.filter((anchor) => {
const href = anchor.getAttribute('href') || '';
const text = (anchor.innerText || anchor.textContent || '').trim();
return text && href && !href.endsWith('/zh/daily') && !href.endsWith('/zh/daily/');
});
if (anchors.length === 0) {
return {
ok: false,
reason: 'selector-missing',
title: document.title || '',
bodyText: (document.body?.innerText || document.body?.textContent || '').slice(0, 500),
};
}
const seen = new Set();
const rows = [];
for (const anchor of anchors) {
const url = new URL(anchor.getAttribute('href'), location.href).href;
if (seen.has(url)) continue;
seen.add(url);
rows.push({
rank: rows.length + 1,
title: anchor.innerText || anchor.textContent || '',
url,
});
}
return { ok: true, rows };
})()
`;
}
function toRows(payload, limit) {
if (!payload || typeof payload !== 'object') {
throw new CommandExecutionError('AIbase daily page returned an unreadable payload');
}
if (!payload.ok) {
const reason = typeof payload.reason === 'string' && payload.reason.trim() ? payload.reason.trim() : 'selector-drift';
throw new CommandExecutionError(
`AIbase daily selector drift: ${reason}`,
payload.title ? `Page title: ${payload.title}` : undefined,
);
}
const rows = (Array.isArray(payload.rows) ? payload.rows : [])
.map((row, index) => ({
rank: index + 1,
title: normalizeText(row.title),
url: normalizeText(row.url),
}))
.filter((row) => row.title && row.url);
if (rows.length === 0) {
throw new EmptyResultError('aibase news', 'AIbase daily page loaded, but no article rows with title and URL were extracted.');
}
return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
}
async function loadAibaseNews(page, args) {
const limit = normalizeLimit(args.limit);
await page.goto(AIBASE_DAILY_URL, { waitUntil: 'load', settleMs: 3000 });
const payload = await page.evaluate(buildExtractAibaseNewsJs()).catch((error) => {
throw new CommandExecutionError(`Failed to extract AIbase daily news: ${getErrorMessage(error)}`);
});
return toRows(payload, limit);
}
export const aibaseNewsCommand = cli({
site: 'aibase',
name: 'news',
access: 'read',
description: 'AIbase 日报 - 每天三分钟关注AI行业趋势',
domain: 'www.aibase.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of news items to return (max ${MAX_LIMIT})` },
],
columns: ['rank', 'title', 'url'],
func: loadAibaseNews,
});
export const __test__ = {
buildExtractAibaseNewsJs,
normalizeLimit,
toRows,
};
+59
View File
@@ -0,0 +1,59 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { aibaseNewsCommand, __test__ } from './news.js';
function runBrowserScript(html, script, url = 'https://www.aibase.com/zh/daily') {
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
return dom.window.eval(script);
}
function makePage(evaluateResult) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(evaluateResult),
};
}
describe('aibase/news', () => {
it('registers stable URL in columns', () => {
expect(aibaseNewsCommand.access).toBe('read');
expect(aibaseNewsCommand.columns).toEqual(['rank', 'title', 'url']);
});
it('validates limit before browser navigation', async () => {
const page = makePage({ ok: true, rows: [] });
await expect(aibaseNewsCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(aibaseNewsCommand.func(page, { limit: 51 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('extracts and deduplicates AIbase daily rows', async () => {
const html = `
<div class="bg-white">
<div class="grid">
<a href="/zh/daily/123"> First AI daily item </a>
<a href="/zh/daily/123"> First AI daily item duplicate </a>
<a href="/zh/daily/456"> Second AI daily item </a>
</div>
</div>
`;
const payload = runBrowserScript(html, __test__.buildExtractAibaseNewsJs());
const page = makePage(payload);
const rows = await aibaseNewsCommand.func(page, { limit: 2 });
expect(page.goto).toHaveBeenCalledWith('https://www.aibase.com/zh/daily', { waitUntil: 'load', settleMs: 3000 });
expect(rows).toEqual([
{ rank: 1, title: 'First AI daily item', url: 'https://www.aibase.com/zh/daily/123' },
{ rank: 2, title: 'Second AI daily item', url: 'https://www.aibase.com/zh/daily/456' },
]);
});
it('maps selector drift and empty rows to typed errors', async () => {
await expect(aibaseNewsCommand.func(makePage({ ok: false, reason: 'selector-missing' }), { limit: 1 }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(aibaseNewsCommand.func(makePage({ ok: true, rows: [{ title: '', url: '' }] }), { limit: 1 }))
.rejects.toBeInstanceOf(EmptyResultError);
});
});
+8
View File
@@ -0,0 +1,8 @@
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',
}));
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './rankings.js';
describe('amazon bestsellers normalization', () => {
it('normalizes bestseller cards and infers review counts from card text', () => {
const result = __test__.normalizeRankingCandidate({
asin: 'B0DR31GC3D',
title: '',
href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs',
price_text: '$25.92',
rating_text: '4.3 out of 5 stars',
review_count_text: '',
card_text: 'Desk Shelves Desktop Organizer Shlef\n4.3 out of 5 stars\n435\n$25.92',
}, {
listType: 'bestsellers',
rankFallback: 2,
listTitle: 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves',
sourceUrl: 'https://www.amazon.com/example',
categoryTitle: null,
categoryUrl: 'https://www.amazon.com/example',
categoryPath: [],
visibleCategoryLinks: [],
});
expect(result.rank).toBe(2);
expect(result.asin).toBe('B0DR31GC3D');
expect(result.title).toBe('Desk Shelves Desktop Organizer Shlef');
expect(result.review_count).toBe(435);
expect(result.list_title).toBe('Amazon Best Sellers: Best Desktop & Off-Surface Shelves');
});
});
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './rankings.js';
describe('amazon bestsellers normalization', () => {
it('normalizes bestseller cards and infers review counts from card text', () => {
const result = __test__.normalizeRankingCandidate({
asin: 'B0DR31GC3D',
title: '',
href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs',
price_text: '$25.92',
rating_text: '4.3 out of 5 stars',
review_count_text: '',
card_text: 'Desk Shelves Desktop Organizer Shlef\n4.3 out of 5 stars\n435\n$25.92',
}, {
listType: 'bestsellers',
rankFallback: 2,
listTitle: 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves',
sourceUrl: 'https://www.amazon.com/example',
categoryTitle: null,
categoryUrl: 'https://www.amazon.com/example',
categoryPath: [],
visibleCategoryLinks: [],
});
expect(result.rank).toBe(2);
expect(result.asin).toBe('B0DR31GC3D');
expect(result.title).toBe('Desk Shelves Desktop Organizer Shlef');
expect(result.review_count).toBe(435);
expect(result.list_title).toBe('Amazon Best Sellers: Best Desktop & Off-Surface Shelves');
});
});

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