cmd_read.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """检索与阅读的子命令:forms / word / search / dist / get。
  2. 全部只读,不需要凭据。
  3. """
  4. import html as html_mod
  5. import json
  6. import re
  7. import sys
  8. from client import make_client, note
  9. from coords import fmt_coord, fmt_path, parse_coords, text_layer
  10. from errors import ApiError, WpError, explain_api_error
  11. # 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析
  12. # 是同一个调用换 channel。
  13. PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6'
  14. # 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。
  15. READ_TIMEOUT = 60
  16. def strip_markup(raw, hl='【】', bold='**'):
  17. """把服务端返回的 HTML 压成纯文本,保留命中高亮与黑体两种信息。
  18. 命中词用 <span class='hl'> 包,黑体是原文的 <span class="bld">——后者是注释书
  19. 标出词条的地方,对判断「这段是不是定义」很关键,不能丢。
  20. """
  21. if not raw:
  22. return ''
  23. text = raw
  24. text = re.sub(r"<span class='hl'>(.*?)</span>", hl[0] + r'\1' + hl[1], text, flags=re.S)
  25. text = re.sub(r'<span class="bld">(.*?)</span>', bold + r'\1' + bold, text, flags=re.S)
  26. text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
  27. text = re.sub(r'<[^>]+>', '', text)
  28. text = html_mod.unescape(text)
  29. return re.sub(r'\s+', ' ', text).strip()
  30. def snippet(text, width, around=None):
  31. """截断文本;给了 around 就尽量把它所在的位置露出来。"""
  32. if len(text) <= width:
  33. return text
  34. if around:
  35. pos = text.find(around)
  36. if pos > width // 2:
  37. start = pos - width // 3
  38. return '…' + text[start:start + width] + '…'
  39. return text[:width] + '…'
  40. def emit(args, payload, render):
  41. if getattr(args, 'json', False):
  42. print(json.dumps(payload, ensure_ascii=False, indent=2))
  43. else:
  44. render()
  45. # ---------------------------------------------------------------------------
  46. # forms —— 词形展开,一切检索的前置
  47. # ---------------------------------------------------------------------------
  48. def fetch_forms(client, word):
  49. try:
  50. data = client.call('GET', f'v2/case/{word}', timeout=READ_TIMEOUT)
  51. except ApiError as exc:
  52. raise explain_api_error(exc, f'展开词形 {word}')
  53. return (data or {}).get('rows') or []
  54. def cmd_forms(args):
  55. client = make_client(args)
  56. rows = fetch_forms(client, args.word)
  57. if not rows:
  58. raise WpError(
  59. f'「{args.word}」在语料里找不到任何词形。检查拼写(变音符号是否正确),'
  60. '或换一个可能的词根再试。'
  61. )
  62. def render():
  63. for idx, row in enumerate(rows[: args.limit], 1):
  64. forms = row.get('case') or []
  65. total = sum(int(f.get('count') or 0) for f in forms)
  66. bold = sum(int(f.get('bold') or 0) for f in forms)
  67. mark = ' ← 可能性最高' if idx == 1 else ''
  68. print(f'[{idx}] {row.get("word")} {len(forms)} 形 / 共 {total} 次(黑体 {bold}){mark}')
  69. for f in sorted(forms, key=lambda x: -int(x.get('count') or 0)):
  70. print(f' {f.get("word"):<20} {f.get("count"):>5} 次 黑体 {f.get("bold")}')
  71. print()
  72. print('检索用(第一候选的全部词形):')
  73. print(' ' + forms_arg(rows[0]))
  74. if len(rows) > 1:
  75. print('注意:还有其他候选词根。若目标概念同时有名词与动词两条线,两条都要展开。')
  76. emit(args, rows, render)
  77. return 0
  78. def forms_arg(row):
  79. """把一个候选的全部词形拼成 search 要的逗号串。"""
  80. return ','.join(f.get('word') for f in (row.get('case') or []) if f.get('word'))
  81. # ---------------------------------------------------------------------------
  82. # word —— 词典释义与形态分析,用来确认选对了词根
  83. # ---------------------------------------------------------------------------
  84. def cmd_word(args):
  85. client = make_client(args)
  86. try:
  87. data = client.call('GET', 'v2/dict', query={'word': args.word, 'lang': args.lang},
  88. timeout=READ_TIMEOUT)
  89. except ApiError as exc:
  90. raise explain_api_error(exc, f'查词典 {args.word}')
  91. groups = (data or {}).get('words') or []
  92. if not groups:
  93. raise WpError(f'词典里没有「{args.word}」。')
  94. def render():
  95. for grp in groups:
  96. for w in (grp.get('words') or [])[: args.limit]:
  97. print(f'■ {w.get("word")}')
  98. for g in (w.get('grammar') or [])[:6]:
  99. print(f' ← {g.get("parent")} {g.get("type")} {g.get("grammar")}'
  100. f' ({g.get("factors")})')
  101. for d in (w.get('dict') or [])[: args.dicts]:
  102. # 释义在 note;description 是词典本身的介绍,不是词条内容
  103. meaning = strip_markup(d.get('note') or '')
  104. if not meaning:
  105. continue
  106. print(f' 〔{d.get("shortname")}·{d.get("lang")}〕{snippet(meaning, 220)}')
  107. print()
  108. emit(args, groups, render)
  109. return 0
  110. # ---------------------------------------------------------------------------
  111. # search —— 按词形检索段落
  112. # ---------------------------------------------------------------------------
  113. def resolve_key(client, args):
  114. """确定检索用的词形串。--lemma 会先跑一次 forms,并把展开结果打出来。"""
  115. if args.lemma:
  116. rows = fetch_forms(client, args.lemma)
  117. if not rows:
  118. raise WpError(f'「{args.lemma}」展不出任何词形。')
  119. key = forms_arg(rows[0])
  120. note(f'⚠ 已把词根「{args.lemma}」展开为 {len(key.split(","))} 个词形:{key}')
  121. return key
  122. key = ','.join(part.strip() for item in args.forms for part in item.split(',') if part.strip())
  123. if not key:
  124. raise WpError('没有给出词形。用 --lemma <词根> 自动展开,或直接给逗号分隔的词形。')
  125. return key
  126. def cmd_search(args):
  127. client = make_client(args)
  128. key = resolve_key(client, args)
  129. query = {'key': key, 'limit': args.limit, 'offset': args.offset}
  130. if args.bold:
  131. query['bold'] = 'on'
  132. if args.book:
  133. query['book'] = args.book
  134. if args.tags:
  135. query['tags'] = args.tags
  136. try:
  137. data = client.call('GET', 'v2/search-pali-wbw', query=query, timeout=READ_TIMEOUT)
  138. except ApiError as exc:
  139. raise explain_api_error(exc, '检索')
  140. rows = (data or {}).get('rows') or []
  141. total = (data or {}).get('count', 0)
  142. def render():
  143. scope = []
  144. if args.bold:
  145. scope.append('仅黑体')
  146. if args.book:
  147. scope.append(f'book={args.book}')
  148. if args.tags:
  149. scope.append(f'tags={args.tags}')
  150. print(f'命中 {total} 段,本页 {len(rows)}(offset {args.offset})'
  151. + (f' [{" ".join(scope)}]' if scope else ''))
  152. if not rows:
  153. print('\n0 条。依次怀疑:词形没展开(用 --lemma)→ 词根选错 → 范围限太窄。')
  154. return
  155. print()
  156. for idx, r in enumerate(rows, 1 + args.offset):
  157. coord = fmt_coord(r.get('book'), r.get('paragraph'))
  158. print(f'[{idx}] {coord} {fmt_path(r.get("path"))} rank {r.get("rank")}')
  159. print(f' {snippet(strip_markup(r.get("highlight")), args.width, "【")}')
  160. print(f'\n引用时用坐标 book:paragraph,取原文用:wikipali get {rows[0].get("book")}:'
  161. f'{rows[0].get("paragraph")}')
  162. emit(args, {'count': total, 'rows': rows}, render)
  163. return 0
  164. # ---------------------------------------------------------------------------
  165. # dist —— 出处分布
  166. # ---------------------------------------------------------------------------
  167. def cmd_dist(args):
  168. client = make_client(args)
  169. key = resolve_key(client, args)
  170. query = {'key': key}
  171. if args.tags:
  172. query['tags'] = args.tags
  173. try:
  174. data = client.call('GET', 'v2/search-pali-wbw-books', query=query, timeout=READ_TIMEOUT)
  175. except ApiError as exc:
  176. raise explain_api_error(exc, '统计出处分布')
  177. rows = (data or {}).get('rows') or []
  178. def render():
  179. total = sum(int(r.get('count') or 0) for r in rows)
  180. print(f'{len(rows)} 部书,共 {total} 次词命中\n'
  181. '(注意:这里数的是词次,不是段落数。段落数用 search 的 count,'
  182. '两者不相等——同一段里出现多次只算一段。)\n')
  183. by_layer = {}
  184. for r in sorted(rows, key=lambda x: -int(x.get('count') or 0))[: args.limit]:
  185. layer = text_layer(r.get('tags'))
  186. by_layer[layer] = by_layer.get(layer, 0) + int(r.get('count') or 0)
  187. tags = ' '.join(t.get('name') for t in (r.get('tags') or []) if t.get('name'))
  188. print(f'{r.get("count"):>5} {str(r.get("paliTitle"))[:38]:<40} '
  189. f'--book {r.get("pcdBookId")} [{tags}]')
  190. print('\n按文献层次:', end='')
  191. for layer in ('mūla', 'aṭṭhakathā', 'ṭīkā', ''):
  192. if layer in by_layer:
  193. print(f' {layer or "未标层次"} {by_layer[layer]}', end='')
  194. print('\n引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
  195. emit(args, {'rows': rows}, render)
  196. return 0
  197. # ---------------------------------------------------------------------------
  198. # get —— 按坐标取原文/译文
  199. # ---------------------------------------------------------------------------
  200. def cmd_get(args):
  201. client = make_client(args)
  202. grouped = parse_coords(args.coords)
  203. channels = ','.join(args.channel) if args.channel else PALI_CHANNEL
  204. collected = []
  205. for book, paras in grouped.items():
  206. # 服务端不带 channels 会 500,所以 channels 永远要给
  207. query = {'view': 'paragraph', 'book': book, 'para': ','.join(str(p) for p in paras),
  208. 'channels': channels, 'limit': args.limit}
  209. try:
  210. data = client.call('GET', 'v2/sentence', query=query, timeout=READ_TIMEOUT)
  211. except ApiError as exc:
  212. raise explain_api_error(exc, f'取 {book} 的段落')
  213. collected.extend((data or {}).get('rows') or [])
  214. def render():
  215. if not collected:
  216. print('这些坐标在指定 channel 下没有内容。')
  217. print('注意:这是「该 channel 在此处没有文本」,不是「查询失败」——'
  218. '如实报告,不要拿相邻段落或别的译本凑。')
  219. return
  220. current = None
  221. for r in collected:
  222. ch = (r.get('channel') or {})
  223. head = (r.get('book'), r.get('paragraph'), ch.get('uid'))
  224. if head != current:
  225. current = head
  226. editor = (r.get('editor') or {})
  227. who = editor.get('nickName') or editor.get('name') or ''
  228. print(f'\n=== {fmt_coord(r.get("book"), r.get("paragraph"))} '
  229. f'{ch.get("name")}({ch.get("lang")})'
  230. + (f' 作者:{who}' if who else '') + ' ===')
  231. text = strip_markup(r.get('content'))
  232. print(f' [{r.get("word_start")}-{r.get("word_end")}] {text}')
  233. print(f'\n共 {len(collected)} 句。')
  234. emit(args, collected, render)
  235. return 0