wp2txt 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.dockerignore +2 -1
- data/.gitignore +1 -0
- data/CHANGELOG.md +81 -275
- data/DEVELOPMENT.md +10 -1
- data/DEVELOPMENT_ja.md +9 -1
- data/Dockerfile +7 -1
- data/README.md +13 -14
- data/README_ja.md +33 -1
- data/Rakefile +28 -5
- data/bin/wp2txt-mcp +1 -1
- data/docs/{RESEARCH.md → INDEXES.md} +64 -63
- data/lib/wp2txt/corpus.rb +27 -0
- data/lib/wp2txt/regex.rb +7 -0
- data/lib/wp2txt/text_processing.rb +8 -0
- data/lib/wp2txt/utils.rb +17 -1
- data/lib/wp2txt/version.rb +1 -1
- data/spec/corpus_spec.rb +25 -0
- data/spec/docs_sync_spec.rb +61 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/utils_spec.rb +129 -0
- metadata +4 -2
data/README.md
CHANGED
|
@@ -84,7 +84,7 @@ docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
|
|
|
84
84
|
|
|
85
85
|
The `wp2txt` command is available inside the container. Use `/data` for input/output files.
|
|
86
86
|
|
|
87
|
-
Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`)
|
|
87
|
+
Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`). The former Docker Hub repository (`yohasebe/wp2txt`) is no longer updated as of 2.3.1.
|
|
88
88
|
|
|
89
89
|
**MCP server (no Ruby required on the host):**
|
|
90
90
|
|
|
@@ -255,12 +255,12 @@ By default, citation templates are removed. Use `--extract-citations` to extract
|
|
|
255
255
|
|
|
256
256
|
Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`, `{{Citation}}`, etc.
|
|
257
257
|
|
|
258
|
-
##
|
|
258
|
+
## Offline Indexes, Queries, and the MCP Server
|
|
259
259
|
|
|
260
|
-
Beyond text extraction, wp2txt can turn a dump into a **local, version-pinned
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
260
|
+
Beyond text extraction, wp2txt can turn a dump into a **local, version-pinned database**:
|
|
261
|
+
SQLite indexes over categories, section headings, redirects, and (optionally) the full
|
|
262
|
+
article text, plus interlanguage links for cross-edition comparison — all queryable
|
|
263
|
+
offline, and usable from an LLM client via an MCP server.
|
|
264
264
|
|
|
265
265
|
```console
|
|
266
266
|
$ wp2txt --build-index --fulltext --lang=ja # build the indexes
|
|
@@ -270,13 +270,12 @@ $ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
|
|
|
270
270
|
$ wp2txt-mcp --lang=ja # stdio MCP server for LLM agents
|
|
271
271
|
```
|
|
272
272
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
These queries scan every article — `0 matches` means the term is absent from that dump
|
|
274
|
+
version — and extractions record the dump version and query in a `.meta.json` sidecar,
|
|
275
|
+
so results can be reproduced later.
|
|
276
276
|
|
|
277
|
-
**→ See
|
|
278
|
-
|
|
279
|
-
MCP tool list, and design principles.
|
|
277
|
+
**→ See [docs/INDEXES.md](docs/INDEXES.md)** for index building, offline queries,
|
|
278
|
+
full-text search, interlanguage links, cross-language SQL, and the full MCP tool list.
|
|
280
279
|
|
|
281
280
|
## Command Line Options
|
|
282
281
|
|
|
@@ -354,11 +353,11 @@ MCP tool list, and design principles.
|
|
|
354
353
|
### Research infrastructure options
|
|
355
354
|
|
|
356
355
|
--build-index Build the metadata index (add --fulltext for FTS)
|
|
357
|
-
--find-articles / --search
|
|
356
|
+
--find-articles / --search Offline queries over a whole edition (see docs/INDEXES.md)
|
|
358
357
|
--import-langlinks Import interlanguage links (version-matched)
|
|
359
358
|
--fts-optimize Optimize an existing full-text index
|
|
360
359
|
|
|
361
|
-
See
|
|
360
|
+
See [docs/INDEXES.md](docs/INDEXES.md) for details.
|
|
362
361
|
|
|
363
362
|
## Configuration File
|
|
364
363
|
|
data/README_ja.md
CHANGED
|
@@ -34,6 +34,8 @@ WP2TXTはWikipediaダンプファイルからプレーンテキストとカテ
|
|
|
34
34
|
- **多言語対応** - 350以上のWikipedia言語でカテゴリ・リダイレクトを検出
|
|
35
35
|
- **ストリーミング処理** - 中間ファイルなしで大規模ダンプを処理
|
|
36
36
|
- **JSON出力** - データパイプライン向けの機械可読JSONL形式
|
|
37
|
+
- **オフライン研究索引** - カテゴリ・節見出し・全文に対する悉皆的・版固定クエリ、言語間リンクによる言語版横断SQL
|
|
38
|
+
- **MCPサーバー** - ローカルダンプをLLMエージェント(Claude、ChatGPT、Gemini、ローカルモデル)に公開
|
|
37
39
|
|
|
38
40
|
## ユースケース
|
|
39
41
|
|
|
@@ -43,6 +45,8 @@ wp2txtは以下の用途に適しています:
|
|
|
43
45
|
- トピック領域を横断した比較言語研究
|
|
44
46
|
- NLPタスク向けのメタデータ付きWikipediaテキスト抽出
|
|
45
47
|
- 並行カテゴリ構造を利用した対照言語研究
|
|
48
|
+
- 版固定のRAG知識ベース・LLM評価データセット
|
|
49
|
+
- web検索では原理的に不可能な悉皆的言明(「このカテゴリのどの記事にもXへの言及がない」)
|
|
46
50
|
|
|
47
51
|
## データアクセス
|
|
48
52
|
|
|
@@ -71,11 +75,13 @@ Windows:[Bzip2 for Windows](http://gnuwin32.sourceforge.net/packages/bzip2.htm
|
|
|
71
75
|
### Docker(代替方法)
|
|
72
76
|
|
|
73
77
|
```shell
|
|
74
|
-
docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
|
|
78
|
+
docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
|
|
75
79
|
```
|
|
76
80
|
|
|
77
81
|
`wp2txt`コマンドはコンテナ内で使用可能です。入出力には`/data`ディレクトリを使用してください。
|
|
78
82
|
|
|
83
|
+
イメージはGitHub Container Registry(`ghcr.io/yohasebe/wp2txt`)で公開されています。旧 Docker Hub リポジトリ(`yohasebe/wp2txt`)は 2.3.1 以降更新されません。
|
|
84
|
+
|
|
79
85
|
## 基本的な使い方
|
|
80
86
|
|
|
81
87
|
### 自動ダウンロードと処理(推奨)
|
|
@@ -327,6 +333,28 @@ defaults:
|
|
|
327
333
|
|
|
328
334
|
コマンドラインオプションは設定ファイルの設定を上書きします。
|
|
329
335
|
|
|
336
|
+
## オフライン索引・クエリ・MCPサーバー
|
|
337
|
+
|
|
338
|
+
テキスト抽出に加えて、wp2txtはダンプを**ローカルな版固定データベース**に変換できます。
|
|
339
|
+
カテゴリ・節見出し・リダイレクト・(オプションで)記事全文のSQLite索引、言語版横断比較のための
|
|
340
|
+
言語間リンク(langlinks)— すべてオフラインで悉皆的にクエリでき、MCPサーバー経由で
|
|
341
|
+
LLMエージェントにも公開できます。
|
|
342
|
+
|
|
343
|
+
```console
|
|
344
|
+
$ wp2txt --build-index --fulltext --lang=ja # 索引の構築
|
|
345
|
+
$ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
|
|
346
|
+
$ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
|
|
347
|
+
$ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
|
|
348
|
+
$ wp2txt-mcp --lang=ja # LLMエージェント向けMCPサーバー
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
これらのクエリは全記事を走査します(`0件` は当該ダンプ版にその語が存在しないことを
|
|
352
|
+
意味します)。抽出結果には dump 版とクエリを記録した `.meta.json` サイドカーが付き、
|
|
353
|
+
あとから再現できます。
|
|
354
|
+
|
|
355
|
+
**→ 詳細は [docs/INDEXES.md](docs/INDEXES.md)(英語)を参照**:
|
|
356
|
+
索引構築、オフラインクエリ、全文検索、言語間リンク、言語版横断SQL、MCPツール一覧。
|
|
357
|
+
|
|
330
358
|
## パフォーマンス
|
|
331
359
|
|
|
332
360
|
MacBook Air M4でのベンチマーク結果(7並列プロセス、ターボモード、ダウンロード時間除く):
|
|
@@ -347,6 +375,10 @@ MacBook Air M4でのベンチマーク結果(7並列プロセス、ターボ
|
|
|
347
375
|
|
|
348
376
|
詳細なリリースノートは[CHANGELOG.md](CHANGELOG.md)を参照してください。
|
|
349
377
|
|
|
378
|
+
**v2.3.0(2026年7月)**: 言語間リンク取り込み、言語版横断SQL(multi-dump ATTACH)、明示タイトル集合の抽出、SQL結果のファイル出力と再現性サイドカー、GHCRイメージ公開。
|
|
379
|
+
|
|
380
|
+
**v2.2.0(2026年7月)**: オフラインメタデータ索引、FTS5全文検索、MCPサーバー、query_sql、抽出ジョブ。
|
|
381
|
+
|
|
350
382
|
**v2.1.0(2026年2月)**: SQLiteキャッシュ、Ractor並列処理(Ruby 4.0+)、テンプレート展開、コンテンツマーカー、Dockerイメージ更新。
|
|
351
383
|
|
|
352
384
|
**v2.0.0(2026年1月)**: 自動ダウンロードモード、カテゴリベース抽出、タイトル指定抽出、JSON出力、ストリーミング処理、Ruby 4.0サポート。
|
data/Rakefile
CHANGED
|
@@ -32,16 +32,39 @@ Rake::Task["build"].enhance([:normalize_permissions])
|
|
|
32
32
|
# Docker
|
|
33
33
|
# =============================================================================
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# Paths that must never reach a published image. The image is built from the
|
|
36
|
+
# working tree, so anything ignored locally (private notes, scratch files)
|
|
37
|
+
# would otherwise ride along.
|
|
38
|
+
IMAGE_FORBIDDEN_PATHS = %w[/wp2txt/research-notes /wp2txt/tmp /wp2txt/.git /wp2txt/CLAUDE.md /wp2txt/.claude].freeze
|
|
39
|
+
|
|
40
|
+
desc "Verify a built image contains no private material (run before pushing)"
|
|
41
|
+
task :verify_image, [:tag] do |_t, args|
|
|
42
|
+
tag = args[:tag] || "wp2txt-verify:local"
|
|
43
|
+
checks = IMAGE_FORBIDDEN_PATHS.map { |p| "test -e #{p} && echo LEAK:#{p}" }.join("; ")
|
|
44
|
+
out = `docker run --rm #{tag} sh -c '#{checks}; true' 2>&1`
|
|
45
|
+
leaks = out.lines.grep(/^LEAK:/).map(&:strip)
|
|
46
|
+
abort "Image #{tag} contains private paths:\n #{leaks.join("\n ")}" unless leaks.empty?
|
|
47
|
+
|
|
48
|
+
puts "OK: #{tag} contains none of #{IMAGE_FORBIDDEN_PATHS.join(', ')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "Build the image locally and verify it, without pushing"
|
|
52
|
+
task :check_image do
|
|
53
|
+
sh "docker build -t wp2txt-verify:local ."
|
|
54
|
+
Rake::Task[:verify_image].invoke
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc "Build and push Docker images to GHCR (verifies a local build first)"
|
|
58
|
+
task push: :check_image do
|
|
59
|
+
# Docker Hub was retired after 2.3.0; GHCR (ghcr.io/yohasebe/wp2txt) is the
|
|
60
|
+
# only registry. Requires `docker buildx use multiarch` and a ghcr.io login.
|
|
37
61
|
sh <<-SCRIPT.strip_heredoc, { verbose: false }
|
|
38
62
|
/bin/bash -xeu <<'BASH'
|
|
39
|
-
# docker buildx create --name
|
|
40
|
-
# docker buildx use
|
|
63
|
+
# docker buildx create --name multiarch
|
|
64
|
+
# docker buildx use multiarch
|
|
41
65
|
# docker buildx inspect --bootstrap
|
|
42
66
|
docker buildx build --platform linux/amd64,linux/arm64 \
|
|
43
67
|
-t ghcr.io/yohasebe/wp2txt:#{Wp2txt::VERSION} -t ghcr.io/yohasebe/wp2txt:latest \
|
|
44
|
-
-t yohasebe/wp2txt:#{Wp2txt::VERSION} -t yohasebe/wp2txt:latest \
|
|
45
68
|
. --push
|
|
46
69
|
BASH
|
|
47
70
|
SCRIPT
|
data/bin/wp2txt-mcp
CHANGED
|
@@ -254,7 +254,7 @@ end
|
|
|
254
254
|
|
|
255
255
|
server.define_tool(
|
|
256
256
|
name: "save_alias_set",
|
|
257
|
-
description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility.",
|
|
257
|
+
description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility. A refused save is signaled by saved:false in the RESULT (not an exception); verify persistence with list_alias_sets.",
|
|
258
258
|
input_schema: {
|
|
259
259
|
properties: {
|
|
260
260
|
name: { type: "string" },
|
|
@@ -1,40 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Offline Indexes, Queries, and the MCP Server
|
|
2
2
|
|
|
3
|
-
This guide covers
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
This guide covers wp2txt's index-based features: local indexes over Wikipedia dumps,
|
|
4
|
+
offline queries across a whole edition, full-text search, interlanguage links,
|
|
5
|
+
cross-language SQL, and the MCP server for connecting an LLM client.
|
|
6
6
|
|
|
7
7
|
For plain-text extraction (the classic wp2txt), see the [README](../README.md).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
wp2txt takes the opposite approach: build local indexes over an official dump file, so that
|
|
17
|
-
every query is
|
|
18
|
-
|
|
19
|
-
- **exhaustive** — it scans every article, not search-ranked results;
|
|
20
|
-
- **version-pinned** — results are tied to one dump (e.g. `jawiki-20260701`) and
|
|
21
|
-
reproducible later;
|
|
22
|
-
- **agent-operable** — the MCP server exposes the whole layer to LLM agents, with
|
|
23
|
-
guardrails and provenance records designed for autonomous use.
|
|
9
|
+
Everything below runs against a downloaded dump file: queries cover every article of the
|
|
10
|
+
edition rather than a page of search results, results are tied to one dump (e.g.
|
|
11
|
+
`jawiki-20260701`) and can be reproduced later, and nothing goes over the network once
|
|
12
|
+
the dump is downloaded. Typical uses: "which of the 1.5M articles have a plot section",
|
|
13
|
+
"how many film articles mention X, and which ones don't", "how do the section structures
|
|
14
|
+
of the same article differ between the English and Japanese editions".
|
|
24
15
|
|
|
25
16
|
## 1. Building the indexes
|
|
26
17
|
|
|
27
18
|
```console
|
|
28
|
-
#
|
|
19
|
+
# Metadata index (categories, section headings, redirects, category hierarchy)
|
|
29
20
|
$ wp2txt --build-index --lang=ja
|
|
30
21
|
|
|
31
|
-
#
|
|
22
|
+
# Metadata index + FTS5 full-text index over the cleaned article text
|
|
32
23
|
$ wp2txt --build-index --fulltext --lang=ja
|
|
33
24
|
```
|
|
34
25
|
|
|
35
26
|
The dump is downloaded automatically if needed and everything is cached under
|
|
36
27
|
`~/.wp2txt/cache/`. Ballpark figures (Apple Silicon laptop): Japanese Wikipedia ~15 min /
|
|
37
|
-
~1.7 GB for the metadata index, ~1.5 h / ~12 GB with full text; English roughly
|
|
28
|
+
~1.7 GB for the metadata index, ~1.5 h / ~12 GB with full text; English roughly 2.5–3× the
|
|
38
29
|
time, ~10 GB / ~12 GB respectively.
|
|
39
30
|
|
|
40
31
|
The full-text tokenizer is selected per language: character trigrams for Japanese,
|
|
@@ -58,8 +49,8 @@ $ wp2txt --find-articles --in-category "Films" -D 2 -j json --limit 100 --lang=e
|
|
|
58
49
|
$ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
|
|
59
50
|
```
|
|
60
51
|
|
|
61
|
-
Search totals
|
|
62
|
-
that dump version —
|
|
52
|
+
Search totals count every match in the edition, so `0 matches` means the term is absent
|
|
53
|
+
from that dump version — a result you can state and re-verify later.
|
|
63
54
|
|
|
64
55
|
## 3. Interlanguage links (langlinks)
|
|
65
56
|
|
|
@@ -114,7 +105,7 @@ $ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yo
|
|
|
114
105
|
|
|
115
106
|
| Tool | Purpose |
|
|
116
107
|
|------|---------|
|
|
117
|
-
| `dump_info` | Dump identity,
|
|
108
|
+
| `dump_info` | Dump identity, installed indexes, corpus statistics, langlinks provenance |
|
|
118
109
|
| `get_article` / `get_sections` / `list_headings` / `get_categories` | Single-article access (redirect-aware) |
|
|
119
110
|
| `find_articles` | Exhaustive filtered listing (category recursion, category AND / pattern match, section headings, title match) |
|
|
120
111
|
| `category_tree` / `section_stats` | Scope exploration and heading-frequency discovery |
|
|
@@ -126,17 +117,19 @@ $ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yo
|
|
|
126
117
|
| `extract_corpus` | Filtered or explicit-title extraction to JSONL + reproducibility sidecar; optional RAG chunking |
|
|
127
118
|
| `start_extract_job` / `job_status` / `cancel_job` / `list_jobs` | Background jobs for large extractions |
|
|
128
119
|
|
|
129
|
-
###
|
|
120
|
+
### What happens when your assistant uses these tools
|
|
130
121
|
|
|
131
|
-
- **
|
|
132
|
-
|
|
133
|
-
- **
|
|
134
|
-
|
|
135
|
-
- **
|
|
136
|
-
the query, and any alias sets
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
122
|
+
- **Filtering and counting run over the whole dump**, and your assistant reads the result
|
|
123
|
+
rather than tallying articles itself.
|
|
124
|
+
- **Large results are written to a file**; the reply carries a summary and a short sample.
|
|
125
|
+
Your corpus lands on disk intact instead of being paraphrased through the chat.
|
|
126
|
+
- **Extractions are traceable.** Extractions and file-writing queries leave a `.meta.json`
|
|
127
|
+
next to the output recording the dump version, the query, and any alias sets used, so
|
|
128
|
+
you can reproduce or cite the result later.
|
|
129
|
+
- **The tools cannot change your data.** Queries run read-only and are stopped after 30
|
|
130
|
+
seconds, saved alias sets are re-checked before being stored, and files can only be
|
|
131
|
+
written under the server's output directory — worth knowing if you plan to let an
|
|
132
|
+
assistant work unattended.
|
|
140
133
|
|
|
141
134
|
## 5. Cross-language SQL
|
|
142
135
|
|
|
@@ -157,12 +150,12 @@ query_sql(
|
|
|
157
150
|
```
|
|
158
151
|
|
|
159
152
|
Attached databases appear as `{lang}_meta` (and `{lang}_fts` when that language has a
|
|
160
|
-
full-text
|
|
161
|
-
|
|
162
|
-
|
|
153
|
+
full-text index) and share the main database's schema. You pass language codes, not paths,
|
|
154
|
+
and the SQL you write cannot attach anything itself. The response lists what was attached,
|
|
155
|
+
including each edition's dump name, and flags it when the dates differ.
|
|
163
156
|
|
|
164
|
-
|
|
165
|
-
|
|
157
|
+
This runs entirely offline against pinned dump versions, so a cross-edition comparison can
|
|
158
|
+
be re-run later and produce the same numbers.
|
|
166
159
|
|
|
167
160
|
## 6. Large results, explicit sets, and reproducibility
|
|
168
161
|
|
|
@@ -182,26 +175,34 @@ extract_corpus(titles: ["東京物語", "羅生門", ...], content: "summary",
|
|
|
182
175
|
redirect hop, and reports unmatched titles in `not_found` — closing the loop
|
|
183
176
|
*SQL decides the set → the tool materializes it → the LLM reads it*.
|
|
184
177
|
|
|
185
|
-
## 7.
|
|
186
|
-
|
|
187
|
-
Section headings vary ("Plot" vs "Synopsis"; 「あらすじ」 vs
|
|
188
|
-
no per-language dictionaries.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
-
|
|
207
|
-
|
|
178
|
+
## 7. Section alias sets
|
|
179
|
+
|
|
180
|
+
Section headings vary by article and by language ("Plot" vs "Synopsis"; 「あらすじ」 vs
|
|
181
|
+
「ストーリー」), and wp2txt ships no per-language dictionaries. Three tools manage named
|
|
182
|
+
groups of equivalent headings instead:
|
|
183
|
+
|
|
184
|
+
- `section_stats` lists the headings actually used in a scope, with counts.
|
|
185
|
+
- `section_cooccurrence` reports how often two headings appear in the same article.
|
|
186
|
+
Headings that mean the same thing rarely co-occur, so a high ratio is evidence that
|
|
187
|
+
they are *different* sections (「概要」 and 「あらすじ」 co-occur often — not synonyms).
|
|
188
|
+
- `save_alias_set` stores a named group. The group is re-checked against co-occurrence
|
|
189
|
+
before saving; a failing group is not stored and the call returns `saved: false`
|
|
190
|
+
rather than an error. Pass `force` to override, and use `list_alias_sets` to see
|
|
191
|
+
what is stored.
|
|
192
|
+
|
|
193
|
+
Queries then accept `alias_set: "name"` in place of a heading list, and extractions
|
|
194
|
+
record the group's exact contents in their `.meta.json`. The bundled `discover_aliases`
|
|
195
|
+
prompt walks an LLM client through building and saving a set.
|
|
196
|
+
|
|
197
|
+
## 8. Limitations to keep in mind
|
|
198
|
+
|
|
199
|
+
- Searches run over the **cleaned text**: content that extraction replaces with a marker
|
|
200
|
+
(`[MATH]`, `[CODE]`, `[TABLE]`, …) cannot be matched. A count is a count of the cleaned
|
|
201
|
+
text, not of the raw wikitext.
|
|
202
|
+
- Japanese, Chinese, and Korean indexes cannot match queries shorter than 3 characters.
|
|
203
|
+
Word-based languages match exact forms only — `run` does not find `running`. Plan your
|
|
204
|
+
search terms accordingly, especially when you intend to report a zero result.
|
|
205
|
+
- Categories and interlanguage links are read from the dump as editors wrote them.
|
|
206
|
+
Categories added by a template rather than written in the article text are not visible
|
|
207
|
+
to wp2txt, which can make a category look much smaller than it is on the website —
|
|
208
|
+
check against the article text if a count looks wrong.
|
data/lib/wp2txt/corpus.rb
CHANGED
|
@@ -482,6 +482,31 @@ module Wp2txt
|
|
|
482
482
|
SQL_ROW_LIMIT = 200
|
|
483
483
|
SQL_CELL_LIMIT = 2000
|
|
484
484
|
SQL_TIMEOUT_SECONDS = 30
|
|
485
|
+
|
|
486
|
+
# Grace added to the child's own CPU limit: the parent's IO.select deadline
|
|
487
|
+
# should normally fire first; this is the fallback for when it cannot.
|
|
488
|
+
SQL_CHILD_CPU_GRACE = 5
|
|
489
|
+
|
|
490
|
+
# Self-imposed deadline for a query child, applied inside the fork.
|
|
491
|
+
#
|
|
492
|
+
# The parent kills the child on timeout, but a child ORPHANED by the
|
|
493
|
+
# parent's death (interrupted test run, closed terminal, crashed server)
|
|
494
|
+
# would otherwise spin forever: sqlite3 holds the GVL inside sqlite3_step,
|
|
495
|
+
# so Ruby's deferred signal handling never reaches a safe point and even
|
|
496
|
+
# SIGTERM is ignored — only SIGKILL or the kernel can stop it. A CPU
|
|
497
|
+
# rlimit is enforced by the kernel regardless of the GVL (SIGXCPU at the
|
|
498
|
+
# soft limit, SIGKILL at the hard one), so the child always dies on its own.
|
|
499
|
+
def self.apply_child_cpu_limit(timeout)
|
|
500
|
+
return unless Process.respond_to?(:setrlimit) && defined?(Process::RLIMIT_CPU)
|
|
501
|
+
|
|
502
|
+
seconds = timeout.to_f.ceil
|
|
503
|
+
Process.setrlimit(Process::RLIMIT_CPU,
|
|
504
|
+
seconds + SQL_CHILD_CPU_GRACE,
|
|
505
|
+
seconds + (SQL_CHILD_CPU_GRACE * 2))
|
|
506
|
+
rescue StandardError
|
|
507
|
+
# A platform without CPU rlimits keeps the previous behaviour (parent-only kill)
|
|
508
|
+
nil
|
|
509
|
+
end
|
|
485
510
|
SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
|
|
486
511
|
|
|
487
512
|
# File-output mode (query_sql output_path:): hard row cap and per-cell
|
|
@@ -656,6 +681,7 @@ module Wp2txt
|
|
|
656
681
|
fts_path = fts.db_path
|
|
657
682
|
reader_io, writer_io = IO.pipe
|
|
658
683
|
pid = Process.fork do
|
|
684
|
+
self.class.apply_child_cpu_limit(timeout)
|
|
659
685
|
reader_io.close
|
|
660
686
|
outcome = begin
|
|
661
687
|
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
|
@@ -797,6 +823,7 @@ module Wp2txt
|
|
|
797
823
|
fts_path = fts.db_path
|
|
798
824
|
reader_io, writer_io = IO.pipe
|
|
799
825
|
pid = Process.fork do
|
|
826
|
+
self.class.apply_child_cpu_limit(timeout)
|
|
800
827
|
reader_io.close
|
|
801
828
|
outcome = begin
|
|
802
829
|
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
data/lib/wp2txt/regex.rb
CHANGED
|
@@ -210,6 +210,13 @@ module Wp2txt
|
|
|
210
210
|
REMOVE_HR_REGEX = Regexp.new('^\s*\-{4,}\s*$')
|
|
211
211
|
MAKE_REFERENCE_REGEX_A = Regexp.new('<br ?\/>', Regexp::IGNORECASE)
|
|
212
212
|
MAKE_REFERENCE_REGEX_B = Regexp.new('<ref[^>]*\/>', Regexp::IGNORECASE)
|
|
213
|
+
# A reference carrying no content adds nothing in either mode
|
|
214
|
+
MAKE_REFERENCE_REGEX_EMPTY = Regexp.new('<ref[^>]*>\s*<\/ref>', Regexp::MULTILINE | Regexp::IGNORECASE)
|
|
215
|
+
# One whole reference span, used to normalize newlines inside it.
|
|
216
|
+
# The span must not contain another opening <ref: an unclosed <ref> would
|
|
217
|
+
# otherwise pair with a later </ref> and swallow the paragraphs in between.
|
|
218
|
+
MAKE_REFERENCE_REGEX_SPAN = Regexp.new('<ref[^>]*>(?:(?!<ref[\s>]).)*?<\/ref>',
|
|
219
|
+
Regexp::MULTILINE | Regexp::IGNORECASE)
|
|
213
220
|
MAKE_REFERENCE_REGEX_C = Regexp.new('<ref[^>]*>', Regexp::IGNORECASE)
|
|
214
221
|
MAKE_REFERENCE_REGEX_D = Regexp.new('<\/ref>', Regexp::IGNORECASE)
|
|
215
222
|
FORMAT_REF_REGEX = Regexp.new('\[ref\](.*?)\[\/ref\]', Regexp::MULTILINE)
|
|
@@ -308,6 +308,14 @@ module Wp2txt
|
|
|
308
308
|
result = +str.to_s
|
|
309
309
|
result.gsub!(MAKE_REFERENCE_REGEX_A, "\n")
|
|
310
310
|
result.gsub!(MAKE_REFERENCE_REGEX_B, "")
|
|
311
|
+
# Element splitting (Article#parse) breaks paragraphs at newlines, so a
|
|
312
|
+
# reference written across lines would land in separate elements and its
|
|
313
|
+
# [ref]/[/ref] pair would never be visible to remove_ref at the same time.
|
|
314
|
+
# Drop empty references outright, and flatten the rest onto one line so
|
|
315
|
+
# multi-line references behave exactly like single-line ones (this is also
|
|
316
|
+
# what makes --extract-citations work on multi-line cite templates).
|
|
317
|
+
result.gsub!(MAKE_REFERENCE_REGEX_EMPTY, "")
|
|
318
|
+
result.gsub!(MAKE_REFERENCE_REGEX_SPAN) { |span| span.gsub(/\s*\n\s*/, " ") }
|
|
311
319
|
result.gsub!(MAKE_REFERENCE_REGEX_C, "[ref]")
|
|
312
320
|
result.gsub!(MAKE_REFERENCE_REGEX_D, "[/ref]")
|
|
313
321
|
result
|
data/lib/wp2txt/utils.rb
CHANGED
|
@@ -415,11 +415,24 @@ module Wp2txt
|
|
|
415
415
|
result.strip
|
|
416
416
|
end
|
|
417
417
|
|
|
418
|
+
# Reference markers are already in [ref] form when external links are
|
|
419
|
+
# processed, and the scanner would strip their brackets (making remove_ref
|
|
420
|
+
# fail). Hide them behind placeholders for the duration of the scan.
|
|
421
|
+
# NOTE: do not special-case them inside the block instead — returning
|
|
422
|
+
# "[ref]" from the block makes process_nested_single_pass re-detect the same
|
|
423
|
+
# spot forever, burning MAX_NESTING_ITERATIONS and leaving the whole string
|
|
424
|
+
# unprocessed (measured: 478x slower, no links processed).
|
|
425
|
+
REF_OPEN_PLACEHOLDER = "«REFOPEN»"
|
|
426
|
+
REF_CLOSE_PLACEHOLDER = "«REFCLOSE»"
|
|
427
|
+
|
|
418
428
|
def process_external_links(str)
|
|
419
429
|
# Early exit if no external links present
|
|
420
430
|
return str unless str.include?("[")
|
|
421
431
|
|
|
422
|
-
|
|
432
|
+
protected_str = str.gsub("[ref]", REF_OPEN_PLACEHOLDER)
|
|
433
|
+
.gsub("[/ref]", REF_CLOSE_PLACEHOLDER)
|
|
434
|
+
|
|
435
|
+
processed = process_nested_single_pass(protected_str, "[", "]") do |contents|
|
|
423
436
|
if /\A\s.+\s\z/ =~ contents
|
|
424
437
|
" (#{contents.strip}) "
|
|
425
438
|
else
|
|
@@ -432,6 +445,9 @@ module Wp2txt
|
|
|
432
445
|
end
|
|
433
446
|
end
|
|
434
447
|
end
|
|
448
|
+
|
|
449
|
+
processed.gsub(REF_OPEN_PLACEHOLDER, "[ref]")
|
|
450
|
+
.gsub(REF_CLOSE_PLACEHOLDER, "[/ref]")
|
|
435
451
|
end
|
|
436
452
|
|
|
437
453
|
#################### template processing ####################
|
data/lib/wp2txt/version.rb
CHANGED
data/spec/corpus_spec.rb
CHANGED
|
@@ -199,6 +199,31 @@ RSpec.describe Wp2txt::Corpus do
|
|
|
199
199
|
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 5
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
it "gives the query child its own kernel-enforced CPU limit" do
|
|
203
|
+
# A child orphaned by the parent's death (interrupted test run, closed
|
|
204
|
+
# terminal) must still die on its own: sqlite3 holds the GVL inside
|
|
205
|
+
# sqlite3_step, so signals are never processed and only the kernel can
|
|
206
|
+
# stop it. Verified here by reading the limit the child actually gets.
|
|
207
|
+
skip "no CPU rlimits on this platform" unless Process.respond_to?(:setrlimit)
|
|
208
|
+
|
|
209
|
+
reader, writer = IO.pipe
|
|
210
|
+
pid = Process.fork do
|
|
211
|
+
reader.close
|
|
212
|
+
Wp2txt::Corpus.apply_child_cpu_limit(30)
|
|
213
|
+
writer.puts(Process.getrlimit(Process::RLIMIT_CPU).inspect)
|
|
214
|
+
writer.close
|
|
215
|
+
exit!(0)
|
|
216
|
+
end
|
|
217
|
+
writer.close
|
|
218
|
+
limits = reader.read
|
|
219
|
+
reader.close
|
|
220
|
+
Process.waitpid(pid)
|
|
221
|
+
|
|
222
|
+
# 30s query timeout => soft 35s / hard 40s of CPU time. Both exceed the
|
|
223
|
+
# parent's own wall-clock deadline, so no legitimate query is affected.
|
|
224
|
+
expect(limits.strip).to eq("[35, 40]")
|
|
225
|
+
end
|
|
226
|
+
|
|
202
227
|
it "keeps serving queries after a timeout" do
|
|
203
228
|
expect do
|
|
204
229
|
@corpus.query_sql("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT COUNT(*) FROM c", timeout: 1)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# The MCP tool surface and its public documentation drift apart easily
|
|
6
|
+
# (the 2.2.0 README shipped with a tool table missing four tools). This spec
|
|
7
|
+
# pins them together: every tool defined in bin/wp2txt-mcp must appear in the
|
|
8
|
+
# docs/INDEXES.md tool table, and the table must not list phantom tools.
|
|
9
|
+
RSpec.describe "documentation surface sync" do
|
|
10
|
+
repo_root = File.expand_path("..", __dir__)
|
|
11
|
+
|
|
12
|
+
define_method(:defined_tools) do
|
|
13
|
+
src = File.read(File.join(repo_root, "bin", "wp2txt-mcp"))
|
|
14
|
+
src.scan(/server\.define_tool\(\s*name:\s*"([a-z_]+)"/).flatten
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
define_method(:documented_tools) do
|
|
18
|
+
doc = File.read(File.join(repo_root, "docs", "INDEXES.md"))
|
|
19
|
+
table = doc[/^### Tools\n(.*?)\n\n/m, 1]
|
|
20
|
+
raise "Tools table not found in docs/INDEXES.md" unless table
|
|
21
|
+
|
|
22
|
+
# Tool names live in the first column only (the purpose column may
|
|
23
|
+
# backtick argument names like `attach`)
|
|
24
|
+
table.lines.filter_map { |line| line.split("|")[1] }
|
|
25
|
+
.flat_map { |cell| cell.scan(/`([a-z_]+)`/).flatten }
|
|
26
|
+
.uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "defines a non-trivial number of MCP tools" do
|
|
30
|
+
expect(defined_tools.size).to be >= 15
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "documents every MCP tool in docs/INDEXES.md, with no phantom entries" do
|
|
34
|
+
missing = defined_tools - documented_tools
|
|
35
|
+
phantom = documented_tools - defined_tools
|
|
36
|
+
expect(missing).to be_empty, "tools not documented in docs/INDEXES.md: #{missing.join(', ')}"
|
|
37
|
+
expect(phantom).to be_empty, "documented tools that do not exist: #{phantom.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Tripwire: tracked files must not contain tokens listed in .private-doc-tokens,
|
|
41
|
+
# an untracked, machine-local file (one substring per line; # starts a comment).
|
|
42
|
+
# The file exists only on machines that maintain such a list; everywhere else
|
|
43
|
+
# (CI, other contributors) this example skips — loudly, so a silently dead
|
|
44
|
+
# check cannot be mistaken for a passing one.
|
|
45
|
+
it "keeps machine-local private tokens out of tracked files" do
|
|
46
|
+
token_file = File.join(repo_root, ".private-doc-tokens")
|
|
47
|
+
skip "SKIPPED: no .private-doc-tokens on this machine — tripwire not checked" unless File.exist?(token_file)
|
|
48
|
+
|
|
49
|
+
tokens = File.readlines(token_file, encoding: "UTF-8")
|
|
50
|
+
.map(&:strip).reject { |t| t.empty? || t.start_with?("#") }
|
|
51
|
+
tracked = `git -C #{repo_root} ls-files -z`.split("\x0")
|
|
52
|
+
hits = tracked.flat_map do |f|
|
|
53
|
+
path = File.join(repo_root, f)
|
|
54
|
+
next [] unless File.file?(path)
|
|
55
|
+
|
|
56
|
+
content = File.read(path, encoding: "BINARY")
|
|
57
|
+
tokens.filter_map { |t| "#{f}: #{t}" if content.include?(t.b) }
|
|
58
|
+
end
|
|
59
|
+
expect(hits).to be_empty, "private tokens found in tracked files:\n #{hits.join("\n ")}"
|
|
60
|
+
end
|
|
61
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -59,6 +59,28 @@ RSpec.configure do |config|
|
|
|
59
59
|
mocks.verify_partial_doubles = true
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Per-example wall-clock guard. The suite deliberately exercises runaway
|
|
63
|
+
# queries and forked workers; an interrupted or wedged example must not turn
|
|
64
|
+
# into a process spinning at 100% CPU for days (observed 2026-07-24).
|
|
65
|
+
# Slowest legitimate example is ~14s, so 120s is pure headroom. Override with
|
|
66
|
+
# WP2TXT_SPEC_TIMEOUT=0 to disable, or tag an example :no_timeout.
|
|
67
|
+
spec_timeout = (ENV["WP2TXT_SPEC_TIMEOUT"] || 120).to_i
|
|
68
|
+
if spec_timeout.positive?
|
|
69
|
+
require "timeout"
|
|
70
|
+
config.around(:each) do |example|
|
|
71
|
+
if example.metadata[:no_timeout]
|
|
72
|
+
example.run
|
|
73
|
+
else
|
|
74
|
+
begin
|
|
75
|
+
Timeout.timeout(spec_timeout) { example.run }
|
|
76
|
+
rescue Timeout::Error
|
|
77
|
+
raise "example exceeded the #{spec_timeout}s spec timeout (possible hang; " \
|
|
78
|
+
"set WP2TXT_SPEC_TIMEOUT=0 to disable this guard)"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
62
84
|
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
63
85
|
config.filter_run_when_matching :focus
|
|
64
86
|
config.example_status_persistence_file_path = "spec/examples.txt"
|