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

Merge pull request #2434 from visuddhinanda/development

Development
visuddhinanda 1 неделя назад
Родитель
Сommit
a9064aa7f0

+ 81 - 0
api-v13/app/Console/Commands/ClearAppCache.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+
+class ClearAppCache extends Command
+{
+    /**
+     * 名字里不用 cache:clear / cache:forget —— 那两个是 Laravel 内置命令,会撞。
+     *
+     * @var string
+     */
+    protected $signature = 'cache:app.clear {name? : 缓存项名称,不传则列出可清理的项} {--all : 清理全部}';
+
+    /**
+     * @var string
+     */
+    protected $description = '清理由应用代码显式写入的缓存(如书目清单),不影响框架自身的缓存';
+
+    /**
+     * 可清理的缓存项:名称 => 实际的 cache key 列表。
+     *
+     * 新增带缓存的接口时在这里登记一行,命令与提示会自动跟上。
+     *
+     * @var array<string, array{keys: string[], desc: string}>
+     */
+    private const CACHES = [
+        'book-titles' => [
+            'keys' => ['book-titles/with-tags'],
+            'desc' => '书目清单(含 toc 与 tag),TTL 24 小时',
+        ],
+    ];
+
+    public function handle(): int
+    {
+        $name = $this->argument('name');
+
+        if (! $name && ! $this->option('all')) {
+            $this->line('可清理的缓存项:');
+            foreach (self::CACHES as $key => $item) {
+                $cached = collect($item['keys'])->filter(fn ($k) => Cache::has($k))->count();
+                $state = $cached > 0 ? "已缓存 {$cached}/".count($item['keys']) : '未缓存';
+                $this->line(sprintf('  %-14s %-34s [%s]', $key, $item['desc'], $state));
+            }
+            $this->newLine();
+            $this->line('用法:php artisan cache:app.clear <名称>   或   --all');
+
+            return self::SUCCESS;
+        }
+
+        if ($name && ! isset(self::CACHES[$name])) {
+            $this->error("未知的缓存项:{$name}");
+            $this->line('可选:'.implode(' / ', array_keys(self::CACHES)));
+
+            return self::FAILURE;
+        }
+
+        $targets = $name ? [$name => self::CACHES[$name]] : self::CACHES;
+        $cleared = 0;
+        foreach ($targets as $key => $item) {
+            foreach ($item['keys'] as $cacheKey) {
+                // forget 对不存在的 key 也返回 true,所以先问一次才能如实报告清了几条
+                $existed = Cache::has($cacheKey);
+                Cache::forget($cacheKey);
+                if ($existed) {
+                    $cleared++;
+                    $this->info("已清理 {$key}:{$cacheKey}");
+                } else {
+                    $this->line("跳过 {$key}:{$cacheKey}(本来就没有缓存)");
+                }
+            }
+        }
+
+        $this->newLine();
+        $this->info("共清理 {$cleared} 条缓存。下次请求会重新构建。");
+
+        return self::SUCCESS;
+    }
+}

+ 132 - 13
api-v13/app/Http/Controllers/BookTitleController.php

@@ -2,29 +2,152 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Resources\BookTitleResource;
 use App\Models\BookTitle;
 use Illuminate\Http\Request;
-use App\Http\Resources\BookTitleResource;
+use Illuminate\Http\Response;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
 
 class BookTitleController extends Controller
 {
+    /**
+     * 书目清单基本不变,而每次都要连 pali_texts / tag_maps / tags 三张表,
+     * 所以整份结果缓存 24 小时。
+     */
+    private const CACHE_KEY = 'book-titles/with-tags';
+
+    private const CACHE_TTL = 60 * 60 * 24;
+
     /**
      * Display a listing of the resource.
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function index()
     {
         //
-        $result = BookTitle::orderBy('sn')->get();
-        return $this->ok(["rows"=>BookTitleResource::collection($result),"count"=>count($result)]);
+        $data = Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
+            $result = BookTitle::orderBy('sn')->get();
+            $meta = $this->paliTextMeta($result);
+            $relatedNames = $this->relatedNames($result);
+            foreach ($result as $row) {
+                $key = "{$row->book}-{$row->paragraph}";
+                $row->toc = $meta[$key]['toc'] ?? null;
+                $row->tags = $meta[$key]['tags'] ?? [];
+                $row->related_name = $relatedNames[$key] ?? null;
+            }
+
+            return [
+                'rows' => BookTitleResource::collection($result)->resolve(),
+                'count' => count($result),
+            ];
+        });
+
+        return $this->ok($data);
+    }
+
+    /**
+     * 取每条书目在 pali_texts 里对应的 toc 与 tag 名。
+     *
+     * book_titles 的 (book, paragraph) 指向 pali_texts 的同名字段;toc 直接取自
+     * pali_texts,tag 则再经 pali_texts.uid → tag_maps.anchor_id → tags 一跳。
+     *
+     * 两条查询都按 (book, paragraph) 的取值范围收窄,一次查完在内存里归组,
+     * 避免 281 条书目各查一次。whereIn 取的是两个维度的笛卡尔超集,最后按精确的
+     * "{book}-{paragraph}" 键取用,多出来的行不会被匹配到。
+     *
+     * @param  Collection  $bookTitles
+     * @return array<string, array{toc: ?string, tags: string[]}> 键是 "{book}-{paragraph}"
+     */
+    private function paliTextMeta($bookTitles): array
+    {
+        if ($bookTitles->isEmpty()) {
+            return [];
+        }
+
+        $books = $bookTitles->pluck('book')->unique()->all();
+        $paragraphs = $bookTitles->pluck('paragraph')->unique()->all();
+
+        $texts = DB::table('pali_texts')
+            ->whereIn('book', $books)
+            ->whereIn('paragraph', $paragraphs)
+            ->select('uid', 'book', 'paragraph', 'toc')
+            ->get();
+
+        $meta = [];
+        $keyByUid = [];
+        foreach ($texts as $text) {
+            $key = "{$text->book}-{$text->paragraph}";
+            $meta[$key] = ['toc' => $text->toc, 'tags' => []];
+            $keyByUid[$text->uid] = $key;
+        }
+        if (empty($keyByUid)) {
+            return $meta;
+        }
+
+        $tags = DB::table('tag_maps')
+            ->join('tags', 'tags.id', '=', 'tag_maps.tag_id')
+            ->where('tag_maps.table_name', 'pali_texts')
+            ->whereIn('tag_maps.anchor_id', array_keys($keyByUid))
+            ->select('tag_maps.anchor_id', 'tags.name')
+            ->get();
+
+        foreach ($tags as $tag) {
+            $key = $keyByUid[$tag->anchor_id] ?? null;
+            if ($key === null || in_array($tag->name, $meta[$key]['tags'], true)) {
+                continue;
+            }
+            $meta[$key]['tags'][] = $tag->name;
+        }
+        foreach ($meta as &$item) {
+            sort($item['tags']);
+        }
+
+        return $meta;
+    }
+
+    /**
+     * 取每条书目对应的 CST 书名(related_paragraphs.book_name)。
+     *
+     * 按 (book, para) 配对,而不是 related_paragraphs.book_id = book_titles.sn。
+     * 两者实测差异:book_id 给的是「这本书横跨的全部 CST 书」(pācityādiyojanā →
+     * vin2..vin5),(book, para) 给的是「起始段所在的那一本」(→ vin2),且后者能查到
+     * book_id 归错地方的 samantapāsādikā(sn=280) 与 Bhikkhunīvibhaṅga(sn=281)。
+     *
+     * 实测每个 (book, para) 至多对应一个非空 book_name,故返回标量。
+     *
+     * @param  Collection  $bookTitles
+     * @return array<string, string> 键是 "{book}-{paragraph}"
+     */
+    private function relatedNames($bookTitles): array
+    {
+        if ($bookTitles->isEmpty()) {
+            return [];
+        }
+
+        $rows = DB::table('related_paragraphs')
+            ->whereIn('book', $bookTitles->pluck('book')->unique()->all())
+            ->whereIn('para', $bookTitles->pluck('paragraph')->unique()->all())
+            ->whereNotNull('book_name')
+            ->where('book_name', '<>', '')
+            ->select('book', 'para', 'book_name')
+            ->distinct()
+            ->get();
+
+        $map = [];
+        foreach ($rows as $row) {
+            $map["{$row->book}-{$row->para}"] = $row->book_name;
+        }
+
+        return $map;
     }
 
     /**
      * Store a newly created resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function store(Request $request)
     {
@@ -34,8 +157,7 @@ class BookTitleController extends Controller
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\BookTitle  $bookTitle
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function show(BookTitle $bookTitle)
     {
@@ -45,9 +167,7 @@ class BookTitleController extends Controller
     /**
      * Update the specified resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \App\Models\BookTitle  $bookTitle
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function update(Request $request, BookTitle $bookTitle)
     {
@@ -57,8 +177,7 @@ class BookTitleController extends Controller
     /**
      * Remove the specified resource from storage.
      *
-     * @param  \App\Models\BookTitle  $bookTitle
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function destroy(BookTitle $bookTitle)
     {

+ 49 - 11
docs/wikipali-feature-coverage.md

@@ -6,7 +6,9 @@
 > 用途:逐项落实的工作清单。**标 ⬜ 的需要提供一个能跑通的完整 API URL**——
 > 照着反推参数比读控制器快,也不会猜错。
 >
-> 插件版本基准:0.5.0(2026-08-08)
+> 插件版本基准:**0.7.0**(2026-08-09)
+
+**当前进度**:✅ 13 项 · ⬜ 9 项(其中 7 项需要 API URL,2 项是写入侧的文章/文集)
 
 **图例**
 
@@ -39,7 +41,7 @@
 
 | 功能 | 状态 | 实现 / 端点 | 备注 |
 |---|---|---|---|
-| 分类目录(如「长部的复注有哪些」)| ⬜ | `tag` / `tags-in-chapter` / `tag-map`? | `GET /v2/tag?view=public` → 500。`GET /v2/book-title?view=public` 可用(200)。**缺参数** |
+| 分类目录(如「长部的复注有哪些」)| ✅ | `wikipali books --tags dīghanikāya,ṭīkā` → `GET /v2/book-title` | **服务端已扩展**:book-title 现在返回 toc / tags / related_name 并缓存 24 小时。不再需要 `tag` 端点。⚠ 需要服务端部署这一版 |
 | 某本书的目录 | ✅ | `wikipali toc` → `GET /v2/palitext?view=book-toc&book=&para=` | 返回整套丛书,客户端按 book 过滤 |
 | 章节内容 · 查有哪些版本 | ⚠️ | `wikipali versions` → `GET /v2/channel?view=paragraphs&book_id=&para=` | **只能按段落查**;按章节查目前用章节起始段近似 |
 | 章节内容 · 读某一版本 | ✅ | `wikipali chapter <坐标> --fetch --channel <uid>` | 先报体量(`chapter_strlen`)再取 |
@@ -51,14 +53,14 @@
 
 | 功能 | 状态 | 实现 / 端点 | 备注 |
 |---|---|---|---|
-| 文章 | 🔧 | `GET /v2/article?view=public&limit=` | 实测 200 可用,未封装 |
-| 文集 | 🔧 | `GET /v2/anthology?view=public&limit=` | 实测 200 可用,未封装(控制器是 `CollectionController`)|
+| 文章 | ✅ | `wikipali articles [关键词]` / `wikipali article <uid>` | 列表支持 `search=`;单篇返回 markdown 正文 |
+| 文集 | ✅ | `wikipali anthology [uid]` | 不给 uid 列表,给 uid 看其文章目录 |
 
 ### 5. 相关经文(根本 ↔ 义注 ↔ 复注)
 
 | 功能 | 状态 | 实现 / 端点 | 备注 |
 |---|---|---|---|
-| 相关段落 | 🔧 | `GET /v2/related-paragraph?book=&para=` | 已评估:63 万行 / 217 部书,段落覆盖率 97.9–99.9%,双向可用,带 tags 可标层次。服务端「无关联时 500」已修复。**命令待做(0.6.0)** |
+| 相关段落 | ✅ | `wikipali related <坐标>` | 63 万行 / 217 部书,覆盖率 97.9–99.9%,双向可用,按 mūla→aṭṭhakathā→ṭīkā 排序标层次。⚠ 服务端「无关联时 500」的修复**已合并但线上未部署**,客户端已兜住这个窗口 |
 | 相关章节 | ⬜ | ? | 路由里没找到 |
 | 相关书 | ⬜ | ? | 路由里没找到;`related-paragraph` 的返回里有书级信息,但不确定是否等价 |
 
@@ -80,17 +82,17 @@
 | 术语 | ⬜ | `POST /v2/terms`(`DhammaTermController`)? | `GET /v2/terms?view=public&key=` → 500。**缺参数** |
 | 评论 · 句子 | ⬜ | `POST /v2/discussion`? | **缺参数** |
 | 修改建议 | ⬜ | ? | 路由里没找到独立端点;`SentResource` 里有 `suggestionCount`,代码里有 `SuggestionApi` |
-| 文章 | 🔧 | `POST /v2/article` | 端点在,未封装 |
-| 文集 | 🔧 | `POST /v2/anthology` | 端点在,未封装 |
+| 文章 | ⬜ | `POST /v2/article` | 端点在,写入未封装(读已封装)|
+| 文集 | ⬜ | `POST /v2/anthology` | 端点在,写入未封装(读已封装)|
 
 ---
 
-## 三、需要提供 URL 的清单(共 8 项)
+## 三、需要提供 URL 的清单(共 7 项)
 
 按对研究流程的价值排序:
 
 1. **相似句** `sent-sim` —— 对读与校勘的核心能力,数据量最大(3.6 GB)
-2. **分类目录** `tag` 系列 —— 「长部的复注有哪些」这类浏览,是研究的起
+2. ~~**分类目录**~~ —— **已解决**:扩展 `book-title` 返回 tags 即可,不需要新端
 3. **单个术语查询** `system-term/{lang}/{word}` —— 现在只能靠全表缓存过滤
 4. **相关章节** —— 有了相关段落,章节级对应能省大量往返
 5. **相关书** —— 同上
@@ -106,14 +108,50 @@
 
 | 版本 | 内容 | 依赖 |
 |---|---|---|
-| 0.6.0 | `related`(相关段落)· `article` / `anthology`(文章与文集读取) | 无,可立即开工 |
-| 待定 | 上面 8 项,收到 URL 后按价值排 | 用户提供 URL |
+| 0.6.0 ✅ | `related`(相关段落)· `articles` / `article` / `anthology`(文章与文集读取)—— **已发布 2026-08-09** | — |
+| 0.7.0 ✅ | `books`(分类目录,按 tag 找书)—— 配套服务端扩展 `book-title` 的返回 | — |
+| 待定 | 上面 7 项,收到 URL 后按价值排 | 用户提供 URL |
 | 待定 | 按章节聚合分布(`dist --by chapter`),方案见 `wikipali-research-agent-design.md` §3.7 | 方案待定 |
 | 待定 | 短语检索改走 `/v3/search`(OpenSearch),见 §3.6 | v3 调试完成 |
 | 待定 | `versions` 支持按章节查(现在只能按段落,章节用起始段近似) | 可能需要服务端支持 |
 
 ---
 
+## 四之二、待用户决定的规范问题(TODO)
+
+这两项不阻塞开发,但会影响产出质量,定案后要改 `references/conventions.md`。
+
+### ⬜ 引用格式:是否采用 `{{book-para-start-end}}`
+
+**发现(2026-08-09)**:平台自己就有引用格式。用户所写文章《表24:三种别住(Parivāsa)》
+引用巴利原文用的是:
+
+```
+{{141-120-17-40}}      =  book 141 · paragraph 120 · word_start 17 · word_end 40
+```
+
+**精确到句**,实测该坐标正是义注里讲 `odhānasamodhāna` 的那一句。
+
+采用它的好处:这是平台原生格式,写成这样的引用**在 wikipali 上能直接解析定位**,
+读者点开就能看到原文。比目前临时用的
+`Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)` 强。
+
+待定的点:
+- 是否全面采用?还是巴利原文引用用它、书名章节仍用可读格式(两者并存)?
+- 论文里给人读的场合,纯坐标可读性差,是否需要「可读书名 + `{{坐标}}`」的组合写法?
+
+定案后:改 `conventions.md` 的「引用格式」一节,`research` 规程要求产出中的原文引用一律用它。
+
+### ⬜ 译名分歧:术语表 vs 实际使用
+
+`samodhānaparivāsa` 在术语表(`term-vocabulary`)里是「**合并**别住」,
+而用户所写文章里用的是「**合一**别住」。
+
+规程现在写的是「产出译名应与术语表一致,不一致要说明理由」。若实际研究中术语表并非
+唯一权威,这条要改写——比如改成「优先用术语表,与既有文献用词不一致时并列标出」。
+
+---
+
 ## 五、两个记录在案的判断
 
 **多版本不做并排对照。** 一度考虑把巴利原文、缅文 nissaya、汉译按 `(book, paragraph,

+ 1 - 1
plugins/wikipali/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "wikipali",
   "description": "WikiPali 巴利三藏平台的客户端:检索与阅读语料做研究(词形展开、全文检索、出处分布、按坐标取原文与译本),以及以 AI 模型身份写入句子。",
-  "version": "0.6.0",
+  "version": "0.7.0",
   "author": {
     "name": "visuddhinanda",
     "url": "https://github.com/visuddhinanda"

+ 10 - 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)
 
@@ -131,6 +132,15 @@ def build_parser():
     p.add_argument('--offset', type=int, default=0)
     p.set_defaults(func=cmd_read.cmd_anthology)
 
+    p = add('books', '分类目录:按 tag 找书,如「长部的复注有哪些」')
+    p.add_argument('keyword', nargs='?', help='按书名/toc 过滤')
+    p.add_argument('--tags', help='按 tag 筛,逗号分隔是**且**,如 dīghanikāya,ṭīkā')
+    p.add_argument('--tag-list', action='store_true', help='列出全部 tag 及各自的书数')
+    p.add_argument('--show-tags', action='store_true', help='每本书都列出它的 tag')
+    p.add_argument('--limit', type=int, default=40)
+    p.add_argument('--refresh', action='store_true', help='强制重新拉取书目清单(有本地缓存)')
+    p.set_defaults(func=cmd_read.cmd_books)
+
     # -- 写 ----------------------------------------------------------------
     p = add('ensure-model', '幂等地建立模型记录并取模型身份 token', needs_json=False)
     p.add_argument('--name', help='模型标识,如 claude-opus-5(会成为句子作者署名)')

+ 189 - 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 ''  
 
 
 # ---------------------------------------------------------------------------
@@ -699,3 +791,97 @@ def cmd_anthology(args):
 
     emit(args, rows, render)
     return 0
+
+
+# ---------------------------------------------------------------------------
+# books —— 分类目录:按 tag 找书
+# ---------------------------------------------------------------------------
+
+
+def books_cache_path():
+    import os
+    from creds import CREDS_DIR
+    return os.path.join(CREDS_DIR, 'cache', 'book-titles.json')
+
+
+def fetch_books(client, refresh=False):
+    """书目清单整表拉一次缓存在本地。服务端也缓存 24 小时,这里再缓存一层是为了
+    让按 tag 筛选变成本地操作——281 条全量在手,筛什么都不用再请求。"""
+    import os
+    path = books_cache_path()
+    if os.path.exists(path) and not refresh:
+        try:
+            with open(path, encoding='utf-8') as fh:
+                return json.load(fh)
+        except (OSError, ValueError):
+            pass
+    try:
+        data = client.call('GET', 'v2/book-title', timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '取书目清单')
+    rows = (data or {}).get('rows') or []
+    if rows and 'tags' not in rows[0]:
+        raise WpError(
+            '该站点返回的书目清单里没有 tags/toc 字段——服务端版本较旧,'
+            '分类目录功能尚未上线。\n'
+            '可以换最新版试试:wikipali --api next books …'
+        )
+    os.makedirs(os.path.dirname(path), exist_ok=True)
+    with open(path, 'w', encoding='utf-8') as fh:
+        json.dump(rows, fh, ensure_ascii=False)
+    return rows
+
+
+def cmd_books(args):
+    client = make_client(args)
+    rows = fetch_books(client, refresh=args.refresh)
+
+    if args.tag_list:
+        counter = {}
+        for r in rows:
+            for t in r.get('tags') or []:
+                counter[t] = counter.get(t, 0) + 1
+
+        def render_tags():
+            print(f'{len(counter)} 个 tag(后面是有该 tag 的书数):\n')
+            for name, n in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[: args.limit]:
+                print(f'  {n:>4}  {name}')
+            print('\n多个 tag 用逗号连接是**且**的关系:'
+                  'wikipali books --tags dīghanikāya,ṭīkā')
+        emit(args, counter, render_tags)
+        return 0
+
+    hits = rows
+    if args.tags:
+        want = [t.strip() for t in args.tags.split(',') if t.strip()]
+        hits = [r for r in hits if all(t in (r.get('tags') or []) for t in want)]
+    if args.keyword:
+        kw = args.keyword.lower()
+        hits = [r for r in hits
+                if kw in str(r.get('title', '')).lower() or kw in str(r.get('toc', '')).lower()]
+
+    def render():
+        scope = []
+        if args.tags:
+            scope.append(f'tags={args.tags}')
+        if args.keyword:
+            scope.append(f'关键词={args.keyword}')
+        print(f'{len(hits)} 部书(全部 {len(rows)} 部)'
+              + (f'  [{" ".join(scope)}]' if scope else ''))
+        if not hits:
+            print('\n没有匹配的书。用 --tag-list 看有哪些 tag;多个 tag 之间是「且」。')
+            return
+        print()
+        for r in hits[: args.limit]:
+            cs = f'  {r["related_name"]}' if r.get('related_name') else ''
+            print(f'  {r.get("book")}:{r.get("paragraph"):<6} {str(r.get("toc"))[:38]:<40}{cs}')
+            if args.show_tags:
+                print(f'      {" ".join(r.get("tags") or [])}')
+        if len(hits) > args.limit:
+            print(f'  …… 其余 {len(hits) - args.limit} 部(--limit 调整)')
+        if hits:
+            first = hits[0]
+            print(f'\n看某本书的章节:wikipali toc {first.get("book")}:{first.get("paragraph")}')
+
+    emit(args, hits, render)
+    return 0

+ 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` 亦可。
+
 ## 已知故障
 
 | 端点 | 现象 |

+ 23 - 1
plugins/wikipali/skills/research/SKILL.md

@@ -28,7 +28,21 @@ skill 共用的规矩,必须遵守。** 端点细节见 `references/api-read.m
 
 ## 流程
 
-### 0. 先看有没有人写过
+### 0a. 需要浏览语料结构时:先找书,再看章节
+
+```bash
+wikipali books --tag-list                      # 有哪些分类 tag
+wikipali books --tags dīghanikāya,ṭīkā         # 长部的复注有哪些(多个 tag 是「且」)
+wikipali toc 185:3                             # 那本书的章节目录
+```
+
+`books` 按 tag 筛(`mūla` / `aṭṭhakathā` / `ṭīkā` / 各部尼柯耶 / 各种论书),
+输出直接接 `toc` 看章节。适合「我想知道某类文献有哪些」这种起步阶段,
+而不是已经有明确关键词的检索。
+
+⚠ 该功能需要服务端较新版本;旧版会明确报「分类目录功能尚未上线」。
+
+### 0b. 先看有没有人写过
 
 ```bash
 wikipali articles 别住          # 平台上的二手研究
@@ -149,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