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.
* 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
* 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>
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.
* 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>
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.
- 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)
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.
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.
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.
* 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.
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).
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.
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.
* 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
Adds opencli convention-audit for batch convention scanning, with structured output, strict mode, docs, and startup isolation from local user/plugin discovery.
* 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
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.
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/`.
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.
* 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
* 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
* 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
* 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
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.