Просмотр исходного кода

feat(plugin): 0.7.0 —— books 分类目录、chapter 改走 chapter-content

books:「长部的复注有哪些」一条命令答出(--tags dīghanikāya,ṭīkā → 5 部),
输出直接接 toc 看章节。靠上游刚扩展的 book-title 实现,不需要功能清单里
标 ⬜ 的 tag 端点。281 条整表本地缓存,按 tag 筛是本地操作;--tag-list 列出
全部 211 个 tag 及书数供发现。对旧版服务端明确报「分类目录功能尚未上线」,
而不是筛出 0 条让人以为语料里没这类书。

chapter --fetch 改走 /v2/chapter-content:一次请求拿回整章,服务端已按
wordStart/wordEnd 对齐,比原来的「palitext 报体量 + sentence 逐段取」少一次
往返。取回后只保留每句的 id 与正文,服务端原始返回 24–86 KB → 3.3–9.0 KB
(14% / 10%)。句子 id 就是引用坐标 book-para-wordStart-wordEnd。

两个实测出来、必须在客户端处理的服务端行为,已写进 references/api-read.md:

- **content 与 html 按 channel 类型互补**。original 的 content 是空的、正文在
  html 里(带 <strong> 黑体);nissaya 的 content 是 markdown「巴利词= 释义。」,
  html 是同内容的渲染且体积十几倍。取值规则改为「优先 content,为空才回退
  html」。此前我曾把 nissaya html 里 <MdTpl props="<base64>"> 的 base64 当成
  冗余删掉——那是错的:base64 解出来是 {"pali":…,"meaning":…},把巴利词与
  释义分开标注,而渲染出的 span 把两者拼在了一起,那个区分正是逐词解析的
  核心。用 content 天然保留了它。
- **请求的 channel 无内容时返回等量空占位**(content 与 html 均为空串)。照直
  显示会让人以为「有译文只是没渲染」。客户端滤掉并明确报「该译本在本章无
  文本」,与同坐标下 sentence 端点返回 count: 0 口径一致。

另记:/v2/chapter/{id} 与 /v2/palitext/{id} 返回完全一致,无需二选一。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 1 неделя назад
Родитель
Сommit
1d1a4f0c5c

+ 1 - 0
plugins/wikipali/lib/cli.py

@@ -88,6 +88,7 @@ def build_parser():
     p.add_argument('--fetch', action='store_true', help='确认要读全文时加这个;不加只报体量')
     p.add_argument('--channel', action='append', help='channel uid,可重复;缺省取巴利原文')
     p.add_argument('--warn-at', type=int, default=8000, help='超过多少字符就提示,默认 8000')
+    p.add_argument('--text', action='store_true', help='输出纯文本而非 html(黑体转成 **)')
     p.add_argument('--limit', type=int, default=200)
     p.set_defaults(func=cmd_read.cmd_chapter)
 

+ 95 - 3
plugins/wikipali/lib/cmd_read.py

@@ -398,9 +398,101 @@ def cmd_chapter(args):
     if strlen > args.warn_at:
         note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。')
 
-    args.coords = [f'{book}:{p}' for p in range(start, end + 1)]
-    args.limit = max(args.limit, length * 20)
-    return cmd_get(args)
+    return fetch_chapter_content(client, book, start, args)
+
+
+def fetch_chapter_content(client, book, para, args):
+    """整章取文:走 chapter-content,一次拿回全章并按句对齐。
+
+    服务端返回的结构极厚(每句都带 channel / studio / editor / 各类计数,
+    24–36 KB),直接丢给模型是浪费。这里只留每句的 id 与 html——id 本身就是
+    可引用的坐标(book-para-wordStart-wordEnd),html 保留了 <strong> 黑体,
+    那是判断「这句是不是词条解释」的依据,不能丢。
+    """
+    query = {}
+    if args.channel:
+        query['channels'] = ','.join(args.channel)
+    try:
+        data = client.call('GET', f'v2/chapter-content/{book}-{para}', query=query,
+                           timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'取 {book}:{para} 的整章内容')
+
+    raw = (data or {}).get('content') or '[]'
+    try:
+        paragraphs = json.loads(raw) if isinstance(raw, str) else raw
+    except ValueError:
+        raise WpError('整章内容不是合法 JSON,服务端返回形状可能变了。')
+
+    wanted = set(args.channel or [])
+    out = []
+    placeholders = 0
+    for item in paragraphs:
+        sentences = []
+        for child in item.get('children') or []:
+            body = pick_body(child, wanted)
+            if body is None:
+                continue
+            if not body.strip():
+                # 请求的 channel 在这一句没有内容时,服务端仍返回等量的空占位条目。
+                # 照直输出会让人以为「有译文只是没显示」,必须滤掉并单独报数。
+                placeholders += 1
+                continue
+            sentences.append({
+                'id': child.get('id'),
+                'text': strip_markup(body) if args.text else body,
+            })
+        if sentences:
+            out.append({'para': int(item.get('para')), 'sentences': sentences})
+
+    def render():
+        total = sum(len(x['sentences']) for x in out)
+        src = f'channel {",".join(args.channel)}' if args.channel else '巴利原文'
+        if not total:
+            print(f'\n该 channel 在本章**没有任何内容**'
+                  + (f'(服务端返回了 {placeholders} 条空占位)' if placeholders else '') + '。')
+            print('如实报告「该译本在本章无文本」,不要拿别的版本或相邻章节顶替。')
+            print('用 wikipali versions <坐标> 看这一段实际有哪些译本。')
+            return
+        print(f'\n{len(out)} 段 / {total} 句({src})'
+              + (f' ⚠ 另有 {placeholders} 句该 channel 无内容,已略去' if placeholders else ''))
+        for item in out:
+            print(f'\n## {book}:{item["para"]}')
+            for sent in item['sentences']:
+                print(f'  {sent["id"]}  {sent["text"]}')
+        print('\n句子 id 就是引用坐标(book-para-wordStart-wordEnd)。')
+
+    emit(args, out, render)
+    return 0
+
+
+def pick_body(child, wanted_channels):
+    """取这一句要展示的正文:指定了 channel 就取该 channel 的译文,否则取原文。
+
+    **优先 content,为空才回退 html**——两者按 channel 类型互补:
+    - original(巴利原文)的 content 是空的,正文在 html 里(带 <strong> 黑体);
+    - nissaya 的 content 是 markdown 源码「巴利词= 缅文释义。」,既紧凑又保住了
+      「哪部分是巴利、哪部分是释义」这个区分;其 html 是同样内容的渲染结果,
+      体积十几倍且把两者拼在了一起。
+    """
+    sources = []
+    if wanted_channels:
+        for tran in child.get('translation') or []:
+            if ((tran.get('channel') or {}).get('id')) in wanted_channels:
+                sources.append(tran)
+        if not sources:
+            return None
+    else:
+        sources = child.get('origin') or []
+
+    for src in sources:
+        body = (src.get('content') or '').strip()
+        if body:
+            return body
+        html = (src.get('html') or '').strip()
+        if html:
+            return html
+    return ''  
 
 
 # ---------------------------------------------------------------------------

+ 54 - 0
plugins/wikipali/references/api-read.md

@@ -130,6 +130,60 @@ word_start, word_end, editor, channel, updated_at}`。按 `word_start` 排序拼
 
 `status` 不止 10/30:实测 5(610 个,basic 用户新建的)、30(559,公开)、10(495)、0、1 都在用。
 
+## 11. 整章内容 —— `GET /v2/chapter-content/{book}-{para}`
+
+一次调用返回整章,带 `?channels={uid,…}` 时把译文一并返回,**服务端已按
+`wordStart/wordEnd` 与原文对齐**。比「`palitext` 报体量 + `sentence` 逐段取」少一次
+往返,也省了客户端自己配对。
+
+`data.content` 是**双重编码的 JSON 字符串**(`content_type: "json"`),要二次解析:
+
+```
+data.content → [ {book, para, channels, sentences: [[139,861,2,8], …], mode, children: [
+    { id: "139-861-2-8", book, para, wordStart, wordEnd,
+      origin: [...], translation: [...], commentaries: [...],
+      tranNum, nissayaNum, commNum, originNum, simNum }
+  ]} ]
+```
+
+`children[].id` 就是 `book-para-wordStart-wordEnd`,与平台文章里的引用格式
+`{{141-120-17-40}}` 一致——读到什么就能直接引用什么。
+
+不带 `channels` 时也返回 `tranNum` / `nissayaNum` / `commNum` / `simNum`,等于**免费给出
+章节级的「有哪些资源」**(`versions` 只能按段落查,这里补上了章节粒度)。
+
+### ⚠ content 与 html 按 channel 类型互补,不能只取一个
+
+| channel 类型 | `content` | `html` | 该用哪个 |
+|---|---|---|---|
+| `original`(巴利原文) | **空** | 正文在这里,带 `<strong>` 黑体 | `html` |
+| `nissaya`(缅文逐词) | markdown 源码 `巴利词= 缅文释义。` | 同内容的渲染,**体积十几倍** | **`content`** |
+
+所以取值规则是「**优先 `content`,为空才回退 `html`**」。
+
+nissaya 的 `html` 里每条 gloss 包在 `<MdTpl props="<base64>">` 里,base64 解出来是
+`{"pali": "…", "meaning": ["…"], "lang": "my"}`——**它把巴利词与释义分开标注**,而渲染
+出的 span 只是把两者拼接。这个区分是逐词解析的核心,**不要以为 props 是冗余而删掉**
+(本项目曾犯过这个错)。用 `content` 就天然保留了这个区分,`=` 左右分别是巴利与释义。
+
+### ⚠ 请求的 channel 无内容时会返回等量空占位
+
+指定 `channels=X` 而 X 在本章没有内容时,服务端**仍为每一句返回一条 `content` 与
+`html` 都是空字符串的条目**。照直显示会让人以为「有译文只是没渲染出来」。客户端必须
+滤掉空条目,并明确报告「该译本在本章无文本」——实测同一坐标下 `sentence` 端点返回
+`count: 0`,两处口径一致。
+
+### 体积
+
+整章原始返回 24 KB(仅原文)到 86 KB(带 nissaya)。只保留每句的 `id` 与正文后分别是
+3.3 KB 与 9.0 KB(**14% 与 10%**)。整章直接喂给模型是浪费,务必先过滤。
+
+## 12. 章节元信息的两个等价端点
+
+`GET /v2/chapter/{book}-{para}` 与 `GET /v2/palitext/{book}-{para}` **返回完全一致**
+(实测字段与取值逐一相同,两个版本的站点上都是 200)。本项目用 `palitext`,没有偏好上的
+理由,换用 `chapter` 亦可。
+
 ## 已知故障
 
 | 端点 | 现象 |

+ 8 - 0
plugins/wikipali/skills/research/SKILL.md

@@ -163,11 +163,19 @@ wikipali get 216:35 216:36 216:41       # 按坐标精确取,缺省是巴利
 wikipali toc 216:512                    # 看这本书的章节结构
 wikipali chapter 216:512                # 只报体量:章节范围、段数、字符数
 wikipali chapter 216:512 --fetch        # 确认要读全章时才加 --fetch
+wikipali chapter 216:512 --fetch --channel <uid>   # 读某一个译本
+wikipali chapter 216:512 --fetch --text            # 纯文本,更省
 ```
 
 `chapter` 给正文段也行,会自动向上找到所属章节。**不加 `--fetch` 就只报体量**——
 这是上下文预算的闸门,先看清多大再决定读不读。
 
+取文时每句只保留 **id + 正文**,服务端原始返回的十分之一左右。**句子 id 就是引用坐标**
+(`139-861-9-12` = book-para-wordStart-wordEnd),读到什么就能直接引用什么。
+
+若指定的 channel 在本章没有内容,命令会明确报「该译本在本章无文本」而不是显示一堆
+空行——服务端在这种情况下会返回等量的空占位条目。
+
 ### 5b. 从本文跳到义注与复注
 
 ```bash