Files
jackwener__opencli/docs/adapters/browser/xiaoe.md
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

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_urlentry.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

Notes

  • courses starts from study.xiaoe-tech.com and matches purchased course cards back to Vue data to recover shop names and course URLs
  • catalog supports normal courses, columns, and big columns by reading Vuex / Vue component state after the course page loads
  • play-url uses 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 courses and pass it directly into detail, catalog, play-url, or content
  • catalog, courses, and content extract pure helpers (typeLabel, buildItemUrl, chapterUrlPath, buildCourseUrl, pickContentText, countXiaoeImages) that are unit-tested directly in clis/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