Merges ~/ck3-knowledge and ~/kcd-knowledge, which were 90% duplicate code and
neither of which had ever been committed anywhere.
Works on any wiki, not just MediaWiki:
- sources.py splits the engine-specific parts (enumerate, fetch) behind a Source
protocol. MediaWikiSource is preferred whenever an api.php exists;
SitemapHtmlSource handles custom engines with no API (verified on Fextralife).
Everything downstream was already source-agnostic.
- scripts/detect_wiki.py probes /api.php, /w/api.php then /sitemap.xml and prints
the config to use. The API path and article URL vary independently — Fandom
serves /api.php but /wiki/{Title}.
- Profiles are plain env files, since config.py was already fully env-driven.
Per-wiki behaviour that must be measured rather than assumed:
- Subpage policy. On the CK3 wiki "A/B" is a mod; on the KCD wiki it is the
sequel, and rejecting subpages deletes 441 pages of wanted content.
- Noise sections. "Other languages" tables were 7% of KCD chunks and matched
English queries by repeating every title in eight languages.
- Variant tagging for wikis covering two editions, with scopes that exclude the
other edition but keep both/unknown pages.
Build performance: length bucketing in embed_efficiently is 2.7x (fastembed pads
each batch to its longest member; 184-char median vs 2000-char max). With noise
sections that is 61 min -> 20 min on a 6,140-page wiki at half the workers.
Fandom portable-infoboxes are transformed to tables before markdownify —
otherwise stat values survive but detach from their labels.
100% branch coverage, ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
6.4 KiB
Python
182 lines
6.4 KiB
Python
"""Tests for HTML -> markdown conversion."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from wikikb import html2md
|
|
|
|
_HTML = """
|
|
<div class="mw-parser-output">
|
|
<div class="mw-editsection">[edit]</div>
|
|
<p>A <b>count</b> can hold up to 4 counties.</p>
|
|
<table><tr><th>Trait</th><th>Diplomacy</th></tr>
|
|
<tr><td>Genius</td><td>+5</td></tr></table>
|
|
<div class="navbox">navigation</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
def test_clean_html_strips_chrome_keeps_content() -> None:
|
|
cleaned = html2md.clean_html(_HTML)
|
|
assert "editsection" not in cleaned
|
|
assert "navbox" not in cleaned
|
|
assert "count" in cleaned
|
|
|
|
|
|
def test_clean_html_without_parser_output_wrapper() -> None:
|
|
cleaned = html2md.clean_html("<p>bare</p>")
|
|
assert "bare" in cleaned
|
|
|
|
|
|
def test_html_to_markdown_preserves_table_numbers() -> None:
|
|
md = html2md.html_to_markdown(_HTML)
|
|
assert "Genius" in md
|
|
assert "+5" in md
|
|
assert "\n\n\n" not in md # blank lines collapsed
|
|
|
|
|
|
def test_build_document_frontmatter_and_quote_escaping() -> None:
|
|
doc = html2md.build_document(
|
|
title='War of the "Roses"',
|
|
url="https://wiki.example/War",
|
|
revid=42,
|
|
categories=["Warfare", "1.19"],
|
|
html="<p>hello</p>",
|
|
)
|
|
assert doc.startswith("---\n")
|
|
assert 'title: "War of the \\"Roses\\""' in doc
|
|
assert "revid: 42" in doc
|
|
assert 'categories: ["Warfare", "1.19"]' in doc
|
|
assert "# War of the" in doc
|
|
assert "hello" in doc
|
|
|
|
|
|
# A cut-down Fandom PortableInfobox: labels are <h3> siblings of their values, not
|
|
# table cells, and the tier variants are inlined behind tabs.
|
|
_INFOBOX = """
|
|
<div class="mw-parser-output">
|
|
<aside class="portable-infobox">
|
|
<h2 class="pi-item pi-title">Common longsword</h2>
|
|
<section class="pi-item pi-group">
|
|
<h2 class="pi-item pi-header">Information</h2>
|
|
<div class="pi-item pi-data"><h3 class="pi-data-label">Classification</h3>
|
|
<div class="pi-data-value">Weapons</div></div>
|
|
</section>
|
|
<h2 class="pi-item pi-header">Attributes</h2>
|
|
<section class="pi-item pi-panel wds-tabber">
|
|
<div class="wds-tabs__wrapper"><ul class="wds-tabs">
|
|
<li class="wds-tabs__tab"><div class="wds-tabs__tab-label">I</div></li>
|
|
<li class="wds-tabs__tab"><div class="wds-tabs__tab-label">II</div></li>
|
|
</ul></div>
|
|
<div class="wds-tab__content">
|
|
<section class="pi-item pi-smart-group">
|
|
<section class="pi-smart-group-head">
|
|
<h3 class="pi-smart-data-label">Stab dmg.</h3>
|
|
<h3 class="pi-smart-data-label">Slash dmg.</h3>
|
|
</section>
|
|
<section class="pi-smart-group-body">
|
|
<div class="pi-smart-data-value">74</div>
|
|
<div class="pi-smart-data-value">71</div>
|
|
</section>
|
|
</section>
|
|
</div>
|
|
<div class="wds-tab__content">
|
|
<section class="pi-item pi-smart-group">
|
|
<section class="pi-smart-group-head">
|
|
<h3 class="pi-smart-data-label">Stab dmg.</h3>
|
|
<h3 class="pi-smart-data-label">Slash dmg.</h3>
|
|
</section>
|
|
<section class="pi-smart-group-body">
|
|
<div class="pi-smart-data-value">92</div>
|
|
<div class="pi-smart-data-value">88</div>
|
|
</section>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
<p>The lead sentence.</p>
|
|
</div>
|
|
"""
|
|
|
|
|
|
def test_infobox_becomes_a_table_with_labels_bound_to_values() -> None:
|
|
md = html2md.html_to_markdown(_INFOBOX)
|
|
# The point of the transform: not merely that "74" survives, but that it stays
|
|
# attached to "Stab dmg." Plain markdownify emits headings then orphaned numbers.
|
|
assert "| Stab dmg. | 74 |" in md
|
|
assert "| Slash dmg. | 71 |" in md
|
|
assert "| Classification | Weapons |" in md
|
|
|
|
|
|
def test_infobox_tier_tabs_are_labelled() -> None:
|
|
md = html2md.html_to_markdown(_INFOBOX)
|
|
# Without the tab label, both tiers' stat blocks are indistinguishable.
|
|
assert "## Infobox: Attributes (I)" in md
|
|
assert "## Infobox: Attributes (II)" in md
|
|
assert "| Stab dmg. | 92 |" in md
|
|
|
|
|
|
def test_infobox_lead_text_gets_its_own_section() -> None:
|
|
md = html2md.html_to_markdown(_INFOBOX)
|
|
# The lead paragraph must not be filed under the last infobox group.
|
|
assert md.index("## Overview") < md.index("The lead sentence.")
|
|
|
|
|
|
def test_infobox_without_a_group_header_is_named_plainly() -> None:
|
|
html = (
|
|
'<aside class="portable-infobox"><div class="pi-item pi-data">'
|
|
'<h3 class="pi-data-label">ID</h3><div class="pi-data-value">4</div></div></aside>'
|
|
)
|
|
md = html2md.html_to_markdown(html)
|
|
assert "## Infobox\n" in md
|
|
assert "| ID | 4 |" in md
|
|
|
|
|
|
def test_empty_infobox_is_dropped() -> None:
|
|
# Theme/dummy infoboxes hold no data and would otherwise leave a stray heading.
|
|
md = html2md.html_to_markdown(
|
|
'<div><aside class="portable-infobox pi-theme-dummy"></aside><p>body</p></div>'
|
|
)
|
|
assert "Infobox" not in md
|
|
assert "body" in md
|
|
|
|
|
|
def test_mismatched_smart_group_keeps_the_pairs_that_line_up() -> None:
|
|
html = (
|
|
'<aside class="portable-infobox"><section class="pi-smart-group">'
|
|
'<section class="pi-smart-group-head"><h3 class="pi-smart-data-label">A</h3>'
|
|
'<h3 class="pi-smart-data-label">B</h3></section>'
|
|
'<section class="pi-smart-group-body"><div class="pi-smart-data-value">1</div>'
|
|
"</section></section></aside>"
|
|
)
|
|
md = html2md.html_to_markdown(html)
|
|
assert "| A | 1 |" in md
|
|
assert "| B |" not in md
|
|
|
|
|
|
def test_incomplete_pi_data_is_skipped_without_losing_the_rest() -> None:
|
|
# A hand-edited infobox row can carry a label with no value. Skip that row and
|
|
# keep going rather than emitting a label bound to nothing.
|
|
html = (
|
|
'<aside class="portable-infobox">'
|
|
'<div class="pi-item pi-data"><h3 class="pi-data-label">Orphan</h3></div>'
|
|
'<div class="pi-item pi-data"><h3 class="pi-data-label">Weight</h3>'
|
|
'<div class="pi-data-value">3.9</div></div>'
|
|
"</aside>"
|
|
)
|
|
md = html2md.html_to_markdown(html)
|
|
assert "| Weight | 3.9 |" in md
|
|
assert "Orphan" not in md
|
|
|
|
|
|
def test_page_tabber_switcher_is_stripped() -> None:
|
|
# It links to the other game's page; keeping it would put a bare "KCD" link at
|
|
# the top of every dual-game article.
|
|
html = (
|
|
'<div class="mw-parser-output">'
|
|
'<div class="pagetabber-absolute"><a href="/wiki/Alchemy">KCD</a></div>'
|
|
"<p>real content</p></div>"
|
|
)
|
|
md = html2md.html_to_markdown(html)
|
|
assert "real content" in md
|
|
assert "/wiki/Alchemy" not in md
|