2 Commits

Author SHA1 Message Date
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
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