wp2txt 2.2.0 → 2.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e54ea4f824be0f060036923759b1c6755b20ee1e721245db58a23665b9efaab
4
- data.tar.gz: ca0a5b1ae0efae6eb4c485a6319569ea9995c662054e7e697a70429632613a25
3
+ metadata.gz: 4062c47480d0801e7a07d8e3bec7c65be8efeeda2eae30d398f9872810d321cd
4
+ data.tar.gz: 25bb3aa8a4d5baefc4616a8a4b754ec63e92d3e0b86b22ccc0ead2929a39739e
5
5
  SHA512:
6
- metadata.gz: 34b61e5ee404431e521e77bc1223b9b7e1e0c9f2082ae4898e6e4b9fdee9de593c9ca45d661723f8d14f5ebffd62360dfb27d3a7b0d87d2a37691bb127c9ab6f
7
- data.tar.gz: ca002ba2fc0c5a9bc8b2ae55dbc736f154e54452af681aad6f1b9ed30ca616535db94ee8cccb069defa56aacc4feb96b306d6fdb1a0102ee20611c98a213d5d0
6
+ metadata.gz: ff61afbb989613e286af8bea784e4494c8f71b0dd52d24d3d038e72c5d77f9196d899039d9ff8c5e4b16461c044670d8cca6bc3feda55aa8cdbc31cbc30e012b
7
+ data.tar.gz: ddffdfb2452a3710d527bc7deeae52818cb9c2b4dedd433b2c7ecb06db74e6e389edf127976567a3269583531acf0aabc2ea9058d7422b5b675de9bae2e227fa
data/.dockerignore CHANGED
@@ -6,11 +6,12 @@ pkg
6
6
  spec
7
7
  coverage
8
8
  tmp
9
+ .bundle
10
+ research-notes
9
11
  benchmark_results
10
12
  data/output_samples
11
13
  scripts
12
14
  .dockerignore
13
- .gitignore
14
15
  .solargraph.yml
15
16
  .rubocop.yml
16
17
  Gemfile.lock
data/.gitignore CHANGED
@@ -23,6 +23,9 @@ tags
23
23
  # Claude Code development files
24
24
  CLAUDE.md
25
25
 
26
+ # Private design/research notes (not for publication)
27
+ research-notes/
28
+
26
29
  # Error logs
27
30
  error_log.txt
28
31
  spec/examples.txt
data/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.3.1] - 2026-08-12
9
+
10
+ - **Container images no longer carry private files**: the image is built from the working tree with `git init && git add -A`, but the build context excluded `.gitignore`, so files ignored locally were tracked inside the container and shipped in the image — 2.3.0's images contained the maintainer's private research notes under `/wp2txt/research-notes`. The context now keeps `.gitignore` (so the container's file list matches a local `gem build`), explicitly excludes `research-notes` and `.bundle`, and the throwaway `.git` (a blob copy of every added file) is deleted after the build. `rake check_image` builds and inspects an image for private paths, and `rake push` refuses to publish without that check. The published gems were never affected; 2.3.0 images have been withdrawn
11
+ - **Docker Hub retired**: images are published to GitHub Container Registry only (`ghcr.io/yohasebe/wp2txt`). The Docker Hub repository is no longer updated
12
+ - **Fix: multi-line `<ref>` references broken by element splitting — also fixes `--extract-citations` on multi-line cite templates**: `Article#parse` splits elements at newlines, so any reference written across lines (very common for multi-line `{{cite …}}` templates) had its `[ref]` and `[/ref]` markers land in separate elements, invisible to `remove_ref`. The markers and raw reference markup leaked into the output, and — more importantly — `--extract-citations` silently never fired for multi-line cite templates, producing inconsistent output versus their single-line equivalents. `make_reference` now drops empty references outright and flattens the rest onto a single line before element splitting, so multi-line references behave exactly like single-line ones. Ordinary paragraph breaks outside references are unaffected
13
+ - **Fix: `[ref]` markers destroyed by external-link processing**: `process_external_links` stripped the brackets of the `[ref]`/`[/ref]` markers produced by `make_reference` (their contents took the single-word branch), so `remove_ref` could no longer find them and tag names plus reference bodies leaked into extracted text — even with `--ref`, the kept markers came out broken. The markers are now hidden behind placeholders for the duration of the bracket scan and restored afterwards. Downstream corpora no longer contain `ref…/ref` residue, which broke tokenization/sentence splitting and mixed bibliographic text into body prose
14
+ - **Runaway-query hardening**: a `query_sql` child process now sets its own kernel-enforced CPU limit (`RLIMIT_CPU`, the query timeout plus a small grace) in addition to the parent's wall-clock kill. Previously a child orphaned by the parent's death — an interrupted test run, a closed terminal, a crashed server — kept executing forever: sqlite3 holds the GVL inside `sqlite3_step`, so Ruby never reaches a signal-safe point and even SIGTERM is ignored. Observed in the wild as two processes spinning at 99% CPU for over five days. The CPU limit is strictly more permissive than the existing wall-clock deadline, so no query that would otherwise succeed is affected
15
+ - **Test-suite hang guard**: each example now runs under a wall-clock timeout (120s default; `WP2TXT_SPEC_TIMEOUT=0` disables it, the `:no_timeout` tag exempts an example). The suite deliberately exercises runaway queries, so a wedged example must fail rather than spin
16
+
17
+ ## [2.3.0] - 2026-07-24
18
+
19
+ - **Documentation split**: README now focuses on text extraction; the research layer (indexes, exhaustive queries, full-text search, langlinks, cross-language SQL, MCP server) is documented in the new [Research Infrastructure Guide](docs/RESEARCH.md), including the complete MCP tool table
20
+ - **Container images on GHCR**: images are now published to `ghcr.io/yohasebe/wp2txt` (Docker Hub `yohasebe/wp2txt` is maintained as a mirror)
21
+
22
+ - **`extract_corpus` `titles:` argument**: Extract an explicit set of article titles (e.g. a set determined via `query_sql`) — titles are normalized (MediaWiki rules), deduplicated preserving input order, and one redirect hop is resolved; missing titles (including redirects to nowhere) are skipped and reported as `not_found` (count + 20-title sample, also in the `.meta.json` sidecar). Mutually exclusive with the filter arguments (set operations belong in SQL); capped at 10,000 titles. The sidecar records `titles_count` + `titles_sha256` (order-independent) for reproducibility, enumerating the full list when ≤100 titles. Also available via `start_extract_job`
23
+ - **`query_sql` `output_path:` argument**: Write ALL rows of a large result to a JSONL file (with a `.meta.json` sidecar recording the SQL, dump version, attach configuration, row count, and tool version) and return only a summary + 3-row sample — the extract_corpus D4 pattern generalized to SQL. Writes happen in the forked child (so the 30s SIGKILL deadline covers them) to a `.partial` file that the parent atomically renames on success and removes on every failure path (child crash, timeout kill, query error). Hard cap `SQL_FILE_ROW_LIMIT` = 5M rows (`truncated` flag), cells clipped at 64KB (`cells_clipped` count), duplicate column names are made unique (`_2` suffix), `limit` is ignored in this mode, and existing files require `overwrite: true`. The MCP layer's output-dir confinement is now a shared helper (`Wp2txt::OutputPath.confine`) used by extract_corpus, start_extract_job, and query_sql alike
24
+ - **Interlanguage links (`--import-langlinks`)**: Import the official `{lang}wiki-{date}-langlinks.sql.gz` dump into the Tier 1 metadata index as a `langlinks` table (`ll_from` = source page_id, `ll_lang` = target language, `ll_title` = normalized target title). Version pinning is enforced: the langlinks file's dump name must equal the built index's dump version — a mismatch is rejected with no override. Streams the MySQL dump without loading it whole (escape-safe tuple parser, 10k-row transaction batches, indexes created after the load). `--langlinks-langs` restricts imported target languages (e.g. `en,de,fr,zh,ko`); re-import requires `-U` (otherwise a no-op reporting the previous import time). Provenance (source file, size, import time, wp2txt version, language filter, row count) is stamped into the index and reported by `dump_info`; a post-import sanity check samples titles per language and warns when the join rate against a locally installed target edition falls below 90%
25
+ - **Multi-dump ATTACH in `query_sql`**: The MCP `query_sql` tool gains an optional `attach` argument (language codes only, never paths): `attach: ["en"]` read-only ATTACHes that language's locally installed metadata DB as `en_meta` and its FTS DB (when built) as `en_fts`, both sharing the main DB's schema. Codes are validated, only installed indexes are accepted, path resolution is server-side (same-date dump preferred; otherwise the latest build with a `dump_mismatch` note in the response's `attached` metadata). User SQL still cannot contain ATTACH/DETACH — attachments are issued by server code only, via `mode=ro` URIs on a read-only connection. Combined with the langlinks table, this enables single-query cross-language comparisons (e.g. section-structure diffs of article pairs)
26
+
8
27
  ## [2.2.0] - 2026-07-22
9
28
 
10
29
  - **Index hardening (design review follow-up)**: `ord` now has identical semantics in `page_sections` and `fts_map` (lead = 0, first heading = 1; schema v2 — rebuild indexes with `--build-index -U`); section headings are normalized identically in both indexes (decorated headings like `== '''X''' ==` now match section filters); indexes record the wp2txt version (and the FTS index a rendering-config digest) so `dump_info` can flag indexes built by code whose text cleaning differs; rebuilds are atomic (built alongside, renamed on completion — a failed rebuild no longer destroys the working index)
data/DEVELOPMENT.md CHANGED
@@ -395,7 +395,8 @@ info = Wp2txt::Bz2Validator.file_info("/path/to/file.bz2")
395
395
  Build and push Docker images:
396
396
 
397
397
  ```bash
398
- rake push # Builds multi-arch and pushes to Docker Hub
398
+ rake check_image # Builds the image locally and verifies it carries no private files
399
+ rake push # Verifies, then builds multi-arch and pushes to GHCR
399
400
  ```
400
401
 
401
402
  ## Release Process
data/DEVELOPMENT_ja.md CHANGED
@@ -395,7 +395,8 @@ info = Wp2txt::Bz2Validator.file_info("/path/to/file.bz2")
395
395
  Dockerイメージのビルドとプッシュ:
396
396
 
397
397
  ```bash
398
- rake push # マルチアーキテクチャでビルドしDocker Hubにプッシュ
398
+ rake check_image # ローカルでイメージをビルドし、私的ファイルの混入がないか検証
399
+ rake push # 検証したうえでマルチアーキテクチャでビルドしGHCRにプッシュ
399
400
  ```
400
401
 
401
402
  ## リリースプロセス
data/Dockerfile CHANGED
@@ -1,10 +1,18 @@
1
1
  FROM ruby:4.0.1-alpine3.23
2
2
 
3
+ # Link the GHCR package to the source repository
4
+ LABEL org.opencontainers.image.source="https://github.com/yohasebe/wp2txt"
5
+
3
6
  WORKDIR /wp2txt
4
7
  COPY . ./
5
8
  RUN rm -f Gemfile.lock
6
9
 
7
- # Install dependencies (git is required by gemspec's `git ls-files`)
10
+ # Install dependencies (git is required by gemspec's `git ls-files`).
11
+ # The repository's .gitignore is copied in deliberately: `git add -A` must
12
+ # honour it so the file list here matches a local `gem build` — without it,
13
+ # ignored material (private notes, scratch files) would land in the image.
14
+ # The throwaway .git is removed afterwards: it holds a blob copy of every
15
+ # added file and is dead weight in the published image.
8
16
  RUN apk update && \
9
17
  apk upgrade && \
10
18
  apk add --no-cache \
@@ -14,6 +22,7 @@ RUN apk update && \
14
22
  build-base curl-dev wget && \
15
23
  git init && git add -A && \
16
24
  bundle install -j4 && \
25
+ rm -rf /wp2txt/.git && \
17
26
  apk del .build-packages
18
27
 
19
28
  # lbzip2 is not available as an Alpine package; build from source
data/README.md CHANGED
@@ -34,7 +34,7 @@ WP2TXT extracts plain text and category information from Wikipedia dump files. I
34
34
  - **Multilingual support** - Category and redirect detection for 350+ Wikipedia languages
35
35
  - **Streaming processing** - Process large dumps without intermediate files
36
36
  - **JSON output** - Machine-readable JSONL format for data pipelines
37
- - **Offline metadata index** - Exhaustive, version-pinned queries over categories and section headings, no API required
37
+ - **Offline research indexes** - Exhaustive, version-pinned queries over categories, section headings, and full text; interlanguage links for cross-edition SQL
38
38
  - **MCP server** - Expose a local dump to LLM agents (Claude, ChatGPT, Gemini, local models) for grounded, reproducible corpus work
39
39
 
40
40
  ## Use Cases
@@ -79,19 +79,21 @@ The Docker image bundles everything — Ruby, decompression tools, and the MCP s
79
79
  **Interactive / batch CLI:**
80
80
 
81
81
  ```shell
82
- docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
82
+ docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
83
83
  ```
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`). The former Docker Hub repository (`yohasebe/wp2txt`) is no longer updated as of 2.3.1.
88
+
87
89
  **MCP server (no Ruby required on the host):**
88
90
 
89
91
  ```shell
90
92
  # Build the indexes once (cached in a named volume so they persist)
91
- docker run -it -v wp2txt:/root/.wp2txt yohasebe/wp2txt wp2txt --build-index --fulltext -L ja
93
+ docker run -it -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt --build-index --fulltext -L ja
92
94
 
93
95
  # Register with an MCP client, e.g. Claude Code
94
- claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt yohasebe/wp2txt wp2txt-mcp -L ja
96
+ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yohasebe/wp2txt wp2txt-mcp -L ja
95
97
  ```
96
98
 
97
99
  Note: use `-i` (not `-it`) when running the MCP server — a TTY would corrupt the JSON-RPC stream. The named volume (`wp2txt:`) holds downloaded dumps and indexes; without it they are lost when the container exits.
@@ -253,63 +255,28 @@ By default, citation templates are removed. Use `--extract-citations` to extract
253
255
 
254
256
  Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`, `{{Citation}}`, etc.
255
257
 
256
- ## Offline Metadata Index
257
-
258
- Build a local SQLite index of a dump — per-article categories, section headings, redirects, and the category hierarchy — extracted from the dump itself (no API access, no rate limits, version-pinned for reproducibility):
259
-
260
- $ wp2txt --build-index --lang=ja # downloads dump if needed; ja: ~15 min, ~1.7 GB index
261
-
262
- Then run exhaustive queries entirely offline:
263
-
264
- # All film articles (recursing 3 levels of subcategories) that have a plot section
265
- $ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
266
-
267
- # Machine-readable output with total count
268
- $ wp2txt --find-articles --in-category "Films" -D 2 -j json --limit 100 --lang=en
269
-
270
- Unlike web/API access, these queries are *exhaustive* (they scan every article, not search-ranked results) and *reproducible* (pinned to a specific dump version). Answering "which of the 1.5M articles have X" takes milliseconds once the index is built.
271
-
272
- ### Full-Text Search
273
-
274
- Add `--fulltext` to also build an FTS5 index over the article text itself (tokenizer auto-selected: character-trigram for Japanese/Chinese/Korean, word-based for space-delimited languages):
275
-
276
- $ wp2txt --build-index --fulltext --lang=ja
277
- $ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
278
-
279
- Search hits report the article, section, and a snippet; totals are exhaustive counts, so `0 matches` is a verifiable absence claim for that dump version. The index is contentless (stores only the inverted index; snippets are re-rendered from the dump), keeping disk overhead moderate.
258
+ ## Research Infrastructure (Indexes, Exhaustive Queries, MCP)
280
259
 
281
- ## MCP Server (LLM Integration)
260
+ Beyond text extraction, wp2txt can turn a dump into a **local, version-pinned research
261
+ database**: SQLite indexes over categories, section headings, redirects, and (optionally)
262
+ the full article text, plus interlanguage links for cross-edition comparison — all
263
+ queryable offline, exhaustively, and exposed to LLM agents via an MCP server.
282
264
 
283
- `wp2txt-mcp` exposes a local dump to LLM agents (Claude, ChatGPT, Gemini, local models — any MCP-capable client) via the Model Context Protocol:
284
-
285
- $ gem install mcp # optional dependency, needed only for the server
286
- $ wp2txt --build-index --lang=ja # prerequisite
287
- $ wp2txt-mcp --lang=ja # stdio MCP server
288
-
289
- Example client configuration (e.g., Claude Desktop / Claude Code):
290
-
291
- ```json
292
- {
293
- "mcpServers": {
294
- "wp2txt": { "command": "wp2txt-mcp", "args": ["--lang", "ja"] }
295
- }
296
- }
265
+ ```console
266
+ $ wp2txt --build-index --fulltext --lang=ja # build the indexes
267
+ $ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
268
+ $ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
269
+ $ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
270
+ $ wp2txt-mcp --lang=ja # stdio MCP server for LLM agents
297
271
  ```
298
272
 
299
- Tools provided:
300
-
301
- | Tool | Purpose |
302
- |------|---------|
303
- | `dump_info` | Dump version, index tiers, corpus statistics |
304
- | `get_article` / `get_sections` / `list_headings` | Single-article access (redirect-aware) |
305
- | `find_articles` | Exhaustive filtered listing (category recursion, section headings, title match) |
306
- | `category_tree` / `section_stats` | Scope exploration and heading frequency discovery |
307
- | `section_cooccurrence` | Verify section-alias hypotheses (synonyms rarely co-occur in one article) |
308
- | `save_alias_set` etc. | Persist verified per-dump alias groups (server-side co-occurrence guardrail) |
309
- | `extract_corpus` | Filtered extraction to JSONL + reproducibility sidecar; optional RAG chunking |
310
- | `start_extract_job` / `job_status` / `cancel_job` | Background jobs for large extractions |
273
+ Unlike web/API access, these queries scan every article (a `0 matches` result is a
274
+ verifiable absence claim for that dump version) and are reproducible: extractions record
275
+ the dump version and query in a `.meta.json` sidecar.
311
276
 
312
- Design principles: large results go to disk (the model receives a summary plus a 3-record sample, never the full corpus); every output records the dump version and query in a `.meta.json` sidecar; section aliases are discovered from real dump statistics by the LLM and mechanically verified — no hand-maintained per-language dictionaries. The bundled `discover_aliases` prompt walks any agent through the verification protocol.
277
+ **→ See the [Research Infrastructure Guide](docs/RESEARCH.md)** for index building,
278
+ exhaustive queries, full-text search, interlanguage links, cross-language SQL, the full
279
+ MCP tool list, and design principles.
313
280
 
314
281
  ## Command Line Options
315
282
 
@@ -384,6 +351,15 @@ Design principles: large results go to disk (the model receives a summary plus a
384
351
  -v, --version Print version
385
352
  -h, --help Show help
386
353
 
354
+ ### Research infrastructure options
355
+
356
+ --build-index Build the metadata index (add --fulltext for FTS)
357
+ --find-articles / --search Exhaustive offline queries (see docs/RESEARCH.md)
358
+ --import-langlinks Import interlanguage links (version-matched)
359
+ --fts-optimize Optimize an existing full-text index
360
+
361
+ See the [Research Infrastructure Guide](docs/RESEARCH.md) for details.
362
+
387
363
  ## Configuration File
388
364
 
389
365
  Create persistent settings with:
@@ -425,6 +401,10 @@ Turbo mode (default) splits bz2 into XML chunks first, then processes in paralle
425
401
 
426
402
  See [CHANGELOG.md](CHANGELOG.md) for detailed release notes.
427
403
 
404
+ **v2.3.0 (July 2026)**: Interlanguage links import, cross-language SQL (multi-dump ATTACH), explicit-title extraction, SQL results to file with reproducibility sidecars, GHCR image publishing.
405
+
406
+ **v2.2.0 (July 2026)**: Offline metadata index, FTS5 full-text search, MCP server, query_sql escape hatch, extraction jobs.
407
+
428
408
  **v2.1.0 (February 2026)**: SQLite caching, Ractor parallelism (Ruby 4.0+), template expansion, content markers, Docker image update.
429
409
 
430
410
  **v2.0.0 (January 2026)**: Auto-download mode, category-based extraction, article extraction by title, JSON output, streaming processing, Ruby 4.0 support.
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
+ web/API アクセスと異なり、これらのクエリは全記事を走査します(`0件` は当該ダンプ版に対する
352
+ 検証可能な不在の言明になります)。抽出結果には dump 版とクエリを記録した `.meta.json`
353
+ サイドカーが付き、再現可能です。
354
+
355
+ **→ 詳細は [Research Infrastructure Guide](docs/RESEARCH.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,14 +32,40 @@ Rake::Task["build"].enhance([:normalize_permissions])
32
32
  # Docker
33
33
  # =============================================================================
34
34
 
35
- desc "Push Docker images"
36
- task :push do
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; 2.3.0's images shipped research-notes/ this way.
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 mybuilder
40
- # docker buildx use mybuilder
63
+ # docker buildx create --name multiarch
64
+ # docker buildx use multiarch
41
65
  # docker buildx inspect --bootstrap
42
- docker buildx build --platform linux/amd64,linux/arm64 -t yohasebe/wp2txt:#{Wp2txt::VERSION} -t yohasebe/wp2txt:latest . --push
66
+ docker buildx build --platform linux/amd64,linux/arm64 \
67
+ -t ghcr.io/yohasebe/wp2txt:#{Wp2txt::VERSION} -t ghcr.io/yohasebe/wp2txt:latest \
68
+ . --push
43
69
  BASH
44
70
  SCRIPT
45
71
  end
data/bin/wp2txt CHANGED
@@ -711,6 +711,7 @@ class WpApp
711
711
  return run_find_articles(opts) if opts[:find_articles]
712
712
  return run_search(opts) if opts[:search]
713
713
  return run_fts_optimize(opts) if opts[:fts_optimize]
714
+ return run_import_langlinks(opts) if opts[:import_langlinks]
714
715
 
715
716
  # Determine input source
716
717
  if opts[:from_category] && opts[:lang]
data/bin/wp2txt-mcp CHANGED
@@ -24,6 +24,7 @@ require "optimist"
24
24
  require "json"
25
25
  require_relative "../lib/wp2txt/corpus"
26
26
  require_relative "../lib/wp2txt/corpus_jobs"
27
+ require_relative "../lib/wp2txt/output_path"
27
28
  require_relative "../lib/wp2txt/version"
28
29
 
29
30
  opts = Optimist.options do
@@ -159,16 +160,23 @@ end
159
160
 
160
161
  server.define_tool(
161
162
  name: "query_sql",
162
- description: "Read-only SQL (SELECT/WITH only) against the index databases — the escape hatch for questions the fixed tools cannot express (multi-way intersections, aggregations, joins). Call describe_schema first to see the tables. Main DB: pages(page_id, title, namespace, redirect_to, text_length), page_categories(page_id, category), page_sections(page_id, heading, level, ord), category_hierarchy(child, parent), alias_sets. When the fulltext tier exists, the FTS DB is attached as `fts` (fts.fts_sections MATCH ..., fts.fts_map). Filter articles with namespace = 0 AND redirect_to IS NULL. Rows are capped by `limit` (default 200, max 1000) with truncated flag; long cells are clipped. Queries are killed after 30 seconds (the error explains the likely cause); the tables are large (millions of rows), so constrain joins with indexed keys (page_id) and add WHERE filters.",
163
+ description: "Read-only SQL (SELECT/WITH only) against the index databases — the escape hatch for questions the fixed tools cannot express (multi-way intersections, aggregations, joins). Call describe_schema first to see the tables. Main DB: pages(page_id, title, namespace, redirect_to, text_length), page_categories(page_id, category), page_sections(page_id, heading, level, ord), category_hierarchy(child, parent), alias_sets; a langlinks table (ll_from = source page_id, ll_lang, ll_title) is also present when interlanguage links were imported via wp2txt --import-langlinks. When the fulltext tier exists, the FTS DB is attached as `fts` (fts.fts_sections MATCH ..., fts.fts_map). Cross-language: pass attach: [\"en\",\"de\"] to read-only ATTACH other locally installed languages' indexes, referenced as {lang}_meta.pages / {lang}_meta.page_sections / ... and {lang}_fts when that language has a fulltext tier — attached DBs share the main DB's schema. Hyphenated language codes map to underscored aliases (e.g. zh-yue → zh_yue_meta). Only installed languages are accepted (the main language itself cannot be attached); the response's `attached` field lists what was attached (dump_name, fts, dump_mismatch when the dates differ). Join langlinks (ll_from = page_id) to map articles across languages. Filter articles with namespace = 0 AND redirect_to IS NULL. Rows are capped by `limit` (default 200, max 1000) with truncated flag; long cells are clipped. output_path: write ALL rows to a JSONL file (with a .meta.json recording the SQL, dump versions, and attach configuration) and return only a summary + 3-row sample — use this whenever the result is large; `limit` is ignored in this mode. Queries are killed after 30 seconds (the error explains the likely cause); the tables are large (millions of rows), so constrain joins with indexed keys (page_id) and add WHERE filters.",
163
164
  input_schema: {
164
165
  properties: {
165
166
  sql: { type: "string" },
166
- limit: { type: "integer", minimum: 1, maximum: 1000 }
167
+ limit: { type: "integer", minimum: 1, maximum: 1000 },
168
+ attach: { type: "array", items: { type: "string" },
169
+ description: "Language codes of other locally installed dumps to ATTACH read-only as {lang}_meta / {lang}_fts (default: none)" },
170
+ output_path: { type: "string", description: "JSONL output path for ALL rows; relative paths resolve under the server's output dir" },
171
+ overwrite: { type: "boolean", description: "Replace an existing output file (default: refuse)" }
167
172
  },
168
173
  required: ["sql"]
169
174
  }
170
- ) do |sql:, limit: 200, server_context:|
171
- respond { corpus.query_sql(sql, limit: limit) }
175
+ ) do |sql:, limit: 200, attach: [], output_path: nil, overwrite: false, server_context:|
176
+ respond do
177
+ path = output_path && Wp2txt::OutputPath.confine(output_path, OUTPUT_DIR, overwrite: overwrite)
178
+ corpus.query_sql(sql, limit: limit, attach: attach, output_path: path, overwrite: overwrite)
179
+ end
172
180
  end
173
181
 
174
182
  server.define_tool(
@@ -246,7 +254,7 @@ end
246
254
 
247
255
  server.define_tool(
248
256
  name: "save_alias_set",
249
- 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.",
250
258
  input_schema: {
251
259
  properties: {
252
260
  name: { type: "string" },
@@ -279,6 +287,8 @@ EXTRACT_SCHEMA = {
279
287
  properties: {
280
288
  output_path: { type: "string", description: "JSONL output path; relative paths resolve under the server's output dir" },
281
289
  content: { type: "string", enum: ["sections", "summary", "full", "wikitext"] },
290
+ titles: { type: "array", items: { type: "string" }, maxItems: 10_000,
291
+ description: "Explicit article titles to extract (mutually exclusive with the filter arguments)" },
282
292
  sections: { type: "array", items: { type: "string" } },
283
293
  alias_set: { type: "string" },
284
294
  category: { type: "string" },
@@ -294,23 +304,12 @@ EXTRACT_SCHEMA = {
294
304
  required: ["output_path", "content"]
295
305
  }.freeze
296
306
 
297
- def extract_params(output_path:, content:, sections: nil, alias_set: nil, category: nil,
307
+ def extract_params(output_path:, content:, titles: nil, sections: nil, alias_set: nil, category: nil,
298
308
  depth: 0, categories: nil, category_match: nil,
299
309
  title_match: nil, limit: 0, chunk_size: nil, chunk_overlap: 0,
300
310
  overwrite: false)
301
- # Confine writes to the server's output directory (an agent mixing up paths
302
- # must not be able to clobber arbitrary user files), and refuse to silently
303
- # replace existing outputs
304
- path = File.expand_path(output_path, OUTPUT_DIR)
305
- base = File.expand_path(OUTPUT_DIR)
306
- unless path == base || path.start_with?(base + File::SEPARATOR)
307
- raise ArgumentError, "output_path must stay within the server output directory (#{base})"
308
- end
309
- if File.exist?(path) && !overwrite
310
- raise ArgumentError, "output file already exists: #{path} (pass overwrite: true to replace it)"
311
- end
312
-
313
- { output_path: path, content: content,
311
+ { output_path: Wp2txt::OutputPath.confine(output_path, OUTPUT_DIR, overwrite: overwrite),
312
+ content: content, titles: titles,
314
313
  sections: sections, alias_set: alias_set, category: category, depth: depth,
315
314
  categories: categories, category_match: category_match,
316
315
  title_match: title_match, limit: limit, chunk_size: chunk_size, chunk_overlap: chunk_overlap }
@@ -318,7 +317,7 @@ end
318
317
 
319
318
  server.define_tool(
320
319
  name: "extract_corpus",
321
- description: "Extract matching articles to a JSONL file on disk (with a .meta.json sidecar recording dump version and query for reproducibility). Returns a summary and the first 3 records only — the full corpus stays on disk; process it with your own tools. content: 'sections' (named sections only), 'summary' (lead text), 'full' (whole cleaned article), or 'wikitext' (raw markup, for mining infoboxes/templates that cleaning replaces with markers). Filters are the same as find_articles (category/depth, categories, category_match, title_match). Optional chunk_size/chunk_overlap produce one RAG-ready record per chunk with section_path and chunk_index (not with wikitext). Synchronous, capped at 5000 articles (result flags truncation) — use start_extract_job for larger extractions. Dry-run the filters with find_articles first.",
320
+ description: "Extract matching articles to a JSONL file on disk (with a .meta.json sidecar recording dump version and query for reproducibility). Returns a summary and the first 3 records only — the full corpus stays on disk; process it with your own tools. content: 'sections' (named sections only), 'summary' (lead text), 'full' (whole cleaned article), or 'wikitext' (raw markup, for mining infoboxes/templates that cleaning replaces with markers). Filters are the same as find_articles (category/depth, categories, category_match, title_match). titles: extract an explicit list of articles (e.g. a set determined via query_sql); mutually exclusive with the filter arguments; redirects are resolved; missing titles are reported in not_found. Optional chunk_size/chunk_overlap produce one RAG-ready record per chunk with section_path and chunk_index (not with wikitext). Synchronous, capped at 5000 articles (result flags truncation) — use start_extract_job for larger extractions. Dry-run the filters with find_articles first.",
322
321
  input_schema: EXTRACT_SCHEMA
323
322
  ) do |server_context:, **args|
324
323
  respond { corpus.extract_corpus(**extract_params(**args)) }