mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
6f7eb6a76a
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 路径完全不同)
4.7 KiB
4.7 KiB
Xiaoe (小鹅通)
Mode: 🔐 Browser · Domain: study.xiaoe-tech.com / *.h5.xet.citv.cn
Commands
| Command | Description |
|---|---|
opencli xiaoe courses |
List purchased courses with course URLs and shop names |
opencli xiaoe detail <url> |
Read course metadata such as title, price, student count, and shop |
opencli xiaoe catalog <url> |
Read the full course outline for normal courses, columns, and big columns |
opencli xiaoe play-url <url> |
Resolve the M3U8 playback URL for video lessons or live replays |
opencli xiaoe content <url> |
Extract rich-text lesson or page content as plain text |
Usage Examples
# List purchased courses
opencli xiaoe courses
# Read course metadata
opencli xiaoe detail "https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx"
# Read the course outline
opencli xiaoe catalog "https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx"
# Resolve a lesson M3U8 URL
opencli xiaoe play-url "https://appxxxx.h5.xet.citv.cn/v1/course/video/v_xxxxx?product_id=p_xxxxx" -f json
# Extract page content
opencli xiaoe content "https://appxxxx.h5.xet.citv.cn/v1/course/text/t_xxxxx"
Output
content
| Column | Type | Notes |
|---|---|---|
title |
string | document.title of the rich-text page |
content |
string | Trimmed innerText from the first matching content selector (.rich-text-wrap, .content-wrap, .article-content, .text-content, .course-detail, .detail-content, [class*="richtext"], [class*="rich-text"], .ql-editor); falls through to <main> → #app → <body> if no selector matches. No silent truncation — full extracted text is returned. |
content_length |
int | content.length — useful for caller-side truncation decisions |
image_count |
int | Count of <img> whose src is xiaoe-hosted and not a data: URI. No silent slice — counts the entire page. |
Empty content (e.g. login expired, page renders an empty shell) raises
EmptyResultError instead of returning a row with content=''.
catalog
| Column | Type | Notes |
|---|---|---|
ch |
int | 1-based chapter index |
chapter |
string | Chapter title (or course name for column resources) |
no |
int | 1-based section index within the chapter |
title |
string | Section title |
type |
string | Resource label: 图文 / 直播 / 音频 / 视频 / 专栏 / 大专栏; unknown types pass through as the raw String(t) rather than being silently swallowed |
resource_id |
string | Xiaoe resource identifier (p_xxx / v_xxx / l_xxx / a_xxx / i_xxx) |
url |
string | Canonical playback / reading URL — '' when xiaoe did not expose enough fields to construct one (no synthetic URL) |
status |
string | 已完成 / <n>% / 未学 for normal courses; 已完成 / <n>节 / '' for column resources |
courses
| Column | Type | Notes |
|---|---|---|
title |
string | Course title from the card |
shop |
string | Shop name (shop_name) or app name (app_name); empty when the Vue tree did not expose either |
url |
string | Canonical course URL: entry.h5_url → entry.url → built from app_id + resource_id (column courses get /v1/course/column/<id>?type=3, everything else gets /p/course/ecourse/<id>); returns '' when none of the three are available — never a synthetic partial URL |
Validation
Empty results raise EmptyResultError, never silent [] — for browser
adapters this almost always indicates the cookie has expired or the URL
is not a course page. The positional url argument is required for content /
catalog / detail / play-url; missing url raises
ArgumentError before any browser navigation happens.
Prerequisites
- Chrome running and logged into the target Xiaoe shop
- Browser Bridge extension installed
Notes
coursesstarts fromstudy.xiaoe-tech.comand matches purchased course cards back to Vue data to recover shop names and course URLscatalogsupports normal courses, columns, and big columns by reading Vuex / Vue component state after the course page loadsplay-urluses a direct API path for video lessons and falls back to runtime resource inspection for live replays- Cross-shop course URLs are preserved, so you can take a URL from
coursesand pass it directly intodetail,catalog,play-url, orcontent catalog,courses, andcontentextract pure helpers (typeLabel,buildItemUrl,chapterUrlPath,buildCourseUrl,pickContentText,countXiaoeImages) that are unit-tested directly inclis/xiaoe/xiaoe.test.js— the in-page IIFEs embed those same functions via${fn.toString()}so the live and the test paths share one source of truth