duden-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # required for PyPI trusted publishing (OIDC)
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,61 @@
1
+ name: Publish to MCP Registry
2
+
3
+ # Official MCP Registry (registry.modelcontextprotocol.io) via GitHub OIDC.
4
+ # No secret, no interactive login: the runner's OIDC token proves ownership
5
+ # of the io.github.mohammad-abbasi-sh/* namespace.
6
+ # Refs: https://github.com/modelcontextprotocol/registry
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ id-token: write # required for OIDC -> MCP Registry
18
+ contents: read
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Sync server.json version to tag
23
+ # Only sync when running on a version tag (v*). On workflow_dispatch
24
+ # (ref = refs/heads/main) keep the version committed in server.json.
25
+ run: |
26
+ VER=""
27
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
28
+ TAG="${GITHUB_REF#refs/tags/}"
29
+ VER="${TAG#v}"
30
+ fi
31
+ if [ -n "$VER" ]; then
32
+ python3 - "$VER" << 'EOF'
33
+ import json, sys
34
+ ver = sys.argv[1]
35
+ d = json.load(open('server.json'))
36
+ d['version'] = ver
37
+ for pkg in d.get('packages', []):
38
+ pkg['version'] = ver
39
+ json.dump(d, open('server.json', 'w'), indent=2, ensure_ascii=False)
40
+ print('server.json version ->', ver)
41
+ EOF
42
+ else
43
+ echo "No version tag ($GITHUB_REF); keeping server.json version as-is"
44
+ fi
45
+
46
+ - name: Install mcp-publisher
47
+ run: |
48
+ curl -sL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.7/mcp-publisher_linux_amd64.tar.gz" -o /tmp/mcp-publisher.tar.gz
49
+ tar -xzf /tmp/mcp-publisher.tar.gz -C /tmp
50
+ sudo mv /tmp/mcp-publisher /usr/local/bin/mcp-publisher
51
+ mcp-publisher --version
52
+
53
+ - name: Validate server.json
54
+ run: mcp-publisher validate server.json
55
+
56
+ - name: Publish to MCP Registry (OIDC)
57
+ run: |
58
+ mcp-publisher login github-oidc
59
+ mcp-publisher publish server.json
60
+ # A registry outage must never break the release; re-run from Actions if needed.
61
+ continue-on-error: true
@@ -0,0 +1,21 @@
1
+ name: Daily live smoke
2
+
3
+ # One fixed probe per day: catches fingerprint rot / bot-wall changes
4
+ # before users hit them. Fails visibly on 403/timeout.
5
+ on:
6
+ schedule:
7
+ - cron: "0 6 * * *"
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ smoke:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - name: Install
19
+ run: pip install -e .
20
+ - name: Smoke probe
21
+ run: python tests/smoke_live.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammad Abbasi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: duden-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for Duden (duden.de) — dictionary lookup, synonyms, spelling & verb conjugation
5
+ Author: Mohammad Abbasi
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: mcp<2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # duden-mcp 📖
14
+
15
+ mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
16
+
17
+ Unofficial **read-only** MCP server for **[duden.de](https://www.duden.de)** — Duden dictionary lookup from duden.de (free, no key, no login): word meanings, synonyms, spelling with hyphenation, grammar notes and full verb conjugation. No key, no login.
18
+
19
+ > Built by Mohammad Abbasi as part of the "Germany MCP factory". Not affiliated with duden.de.
20
+
21
+ ## For AI agents
22
+
23
+ Add to your MCP client config (stdio):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "duden": {
29
+ "command": "uvx",
30
+ "args": ["duden-mcp==0.1.0"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ Or run locally: `python -m duden_mcp.server` (needs `mcp<2`, `httpx`).
37
+
38
+ ## Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `duden_lookup_word` | Look up a German word in Duden: meanings, word type, frequency, hyphenation & grammar — `word` (e.g. Haus, laufen) |
43
+ | `duden_synonyms` | Synonyms of a German word from Duden — `word` |
44
+ | `duden_spelling` | Spelling of a German word: hyphenation and spelling notes — `word` |
45
+ | `duden_grammar` | Grammar notes for a German word: declension/conjugation summary — `word` |
46
+ | `duden_conjugate` | Full conjugation table of a German verb: present, past, perfect, participles, imperative — `verb` |
47
+ | `duden_search` | Search Duden headwords matching a query — `query`, `limit` 1..20 |
48
+ | `duden_health` | Reachability probe (ok / blocked / error) — one polite request |
49
+
50
+ ## Notes
51
+
52
+ - All data comes from **Duden's public word pages** (free, no key, no login).
53
+ - Meanings are the numbered definitions from the word entry; synonyms are the ones listed on the entry (the dedicated `/synonyme/<word>` overview page only lists alphabetical navigation, so it is linked, not parsed).
54
+ - Word URLs use Duden's transliterated slug (ä→ae, ö→oe, ü→ue, ß→ss); lookup tries the transliterated form first, then the raw spelling.
55
+ - Ambiguous headwords redirect: `sein` → `sein_Hilfsverb`, `haben` → `haben_Hilfsverb` / `haben_Vollverb`. The tools follow Duden's own redirects, and `duden_conjugate` resolves the suffixed conjugation page through the word entry when the bare `/konjugation/<verb>` URL 404s.
56
+ - Hyphenation marks syllable breaks with `·` (Duden itself writes a soft hyphen or pipe).
57
+ - Conjugation tables include the reflexive pronouns Duden lists (e.g. `laufe (mich/mir)` for `laufen`).
58
+
59
+ Tests: 37/37 green (28 offline + 9 live, 2026-11-10).
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
65
+ .venv/bin/pip install -e .
66
+ .venv/bin/python -m pytest tests/ -q
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT
72
+
73
+ ---
74
+
75
+ Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
@@ -0,0 +1,52 @@
1
+ # duden-mcp 📖
2
+
3
+ mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
4
+
5
+ Inoffizieller **nur lesender** MCP-Server für **[duden.de](https://www.duden.de)** — Duden-Nachschlagen auf duden.de (kostenlos, kein Schlüssel, kein Login): Bedeutungen, Synonyme, Rechtschreibung mit Worttrennung, Grammatik und vollständige Verbkonjugation. Kein Schlüssel, keine Anmeldung.
6
+
7
+ > Gebaut von Mohammad Abbasi als Teil der "Germany MCP factory". Nicht mit duden.de verbunden.
8
+
9
+ ## Für KI-Agenten
10
+
11
+ Zur MCP-Client-Konfiguration hinzufügen (stdio):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "duden": {
17
+ "command": "uvx",
18
+ "args": ["duden-mcp==0.1.0"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Oder lokal starten: `python -m duden_mcp.server` (benötigt `mcp<2`, `httpx`).
25
+
26
+ ## Tools
27
+
28
+ | Tool | Beschreibung |
29
+ |------|-------------|
30
+ | `duden_lookup_word` | Ein deutsches Wort im Duden nachschlagen: Bedeutungen, Wortart & Grammatik |
31
+ | `duden_synonyms` | Synonyme eines deutschen Wortes |
32
+ | `duden_spelling` | Rechtschreibung & Worttrennung eines deutschen Wortes |
33
+ | `duden_grammar` | Grammatiknotizen zu einem deutschen Wort |
34
+ | `duden_conjugate` | Vollständige Konjugationstabelle eines deutschen Verbs |
35
+ | `duden_search` | Duden-Stichwörter suchen |
36
+ | `duden_health` | Erreichbarkeitsprüfung (ok / blocked / error) — eine höfliche Anfrage |
37
+
38
+ ## Entwicklung
39
+
40
+ ```bash
41
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
42
+ .venv/bin/pip install -e .
43
+ .venv/bin/python -m pytest tests/ -q
44
+ ```
45
+
46
+ ## Lizenz
47
+
48
+ MIT
49
+
50
+ ---
51
+
52
+ Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
@@ -0,0 +1,52 @@
1
+ # duden-mcp 📖
2
+
3
+ mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
4
+
5
+ سرور MCP غیررسمی و **فقط‌خواندنی** برای **[duden.de](https://www.duden.de)** — جست‌وجو در فرهنگ لغت دودن از duden.de (رایگان، بدون کلید): معانی واژه، مترادف‌ها، املا با جداسازی هجاها، نکات دستوری و صرف کامل فعل. بدون کلید، بدون ورود.
6
+
7
+ > ساختهٔ محمد عباسی، بخشی از «کارخانهٔ MCP آلمان». وابسته به duden.de نیست.
8
+
9
+ ## برای ایجنت‌های هوش مصنوعی
10
+
11
+ به پیکربندی MCP کلاینت اضافه کنید (stdio):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "duden": {
17
+ "command": "uvx",
18
+ "args": ["duden-mcp==0.1.0"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ یا به‌صورت محلی اجرا کنید: `python -m duden_mcp.server` (نیازمند `mcp<2` و `httpx`).
25
+
26
+ ## ابزارها
27
+
28
+ | ابزار | توضیح |
29
+ |------|-------|
30
+ | `duden_lookup_word` | جست‌وجوی واژهٔ آلمانی در دودن: معانی، نوع کلمه و دستور زبان |
31
+ | `duden_synonyms` | مترادف‌های یک واژهٔ آلمانی |
32
+ | `duden_spelling` | املا و جداسازی هجاهای یک واژهٔ آلمانی |
33
+ | `duden_grammar` | نکات دستوری یک واژهٔ آلمانی |
34
+ | `duden_conjugate` | جدول کامل صرف یک فعل آلمانی |
35
+ | `duden_search` | جست‌وجوی سرخط‌های دودن |
36
+ | `duden_health` | بررسی دسترس‌پذیری (ok / blocked / error) — یک درخواست مؤدبانه |
37
+
38
+ ## توسعه
39
+
40
+ ```bash
41
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
42
+ .venv/bin/pip install -e .
43
+ .venv/bin/python -m pytest tests/ -q
44
+ ```
45
+
46
+ ## لایسنس
47
+
48
+ MIT
49
+
50
+ ---
51
+
52
+ Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
@@ -0,0 +1,63 @@
1
+ # duden-mcp 📖
2
+
3
+ mcp-name: io.github.mohammad-abbasi-sh/duden-mcp
4
+
5
+ Unofficial **read-only** MCP server for **[duden.de](https://www.duden.de)** — Duden dictionary lookup from duden.de (free, no key, no login): word meanings, synonyms, spelling with hyphenation, grammar notes and full verb conjugation. No key, no login.
6
+
7
+ > Built by Mohammad Abbasi as part of the "Germany MCP factory". Not affiliated with duden.de.
8
+
9
+ ## For AI agents
10
+
11
+ Add to your MCP client config (stdio):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "duden": {
17
+ "command": "uvx",
18
+ "args": ["duden-mcp==0.1.0"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Or run locally: `python -m duden_mcp.server` (needs `mcp<2`, `httpx`).
25
+
26
+ ## Tools
27
+
28
+ | Tool | Description |
29
+ |------|-------------|
30
+ | `duden_lookup_word` | Look up a German word in Duden: meanings, word type, frequency, hyphenation & grammar — `word` (e.g. Haus, laufen) |
31
+ | `duden_synonyms` | Synonyms of a German word from Duden — `word` |
32
+ | `duden_spelling` | Spelling of a German word: hyphenation and spelling notes — `word` |
33
+ | `duden_grammar` | Grammar notes for a German word: declension/conjugation summary — `word` |
34
+ | `duden_conjugate` | Full conjugation table of a German verb: present, past, perfect, participles, imperative — `verb` |
35
+ | `duden_search` | Search Duden headwords matching a query — `query`, `limit` 1..20 |
36
+ | `duden_health` | Reachability probe (ok / blocked / error) — one polite request |
37
+
38
+ ## Notes
39
+
40
+ - All data comes from **Duden's public word pages** (free, no key, no login).
41
+ - Meanings are the numbered definitions from the word entry; synonyms are the ones listed on the entry (the dedicated `/synonyme/<word>` overview page only lists alphabetical navigation, so it is linked, not parsed).
42
+ - Word URLs use Duden's transliterated slug (ä→ae, ö→oe, ü→ue, ß→ss); lookup tries the transliterated form first, then the raw spelling.
43
+ - Ambiguous headwords redirect: `sein` → `sein_Hilfsverb`, `haben` → `haben_Hilfsverb` / `haben_Vollverb`. The tools follow Duden's own redirects, and `duden_conjugate` resolves the suffixed conjugation page through the word entry when the bare `/konjugation/<verb>` URL 404s.
44
+ - Hyphenation marks syllable breaks with `·` (Duden itself writes a soft hyphen or pipe).
45
+ - Conjugation tables include the reflexive pronouns Duden lists (e.g. `laufe (mich/mir)` for `laufen`).
46
+
47
+ Tests: 37/37 green (28 offline + 9 live, 2026-11-10).
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
53
+ .venv/bin/pip install -e .
54
+ .venv/bin/python -m pytest tests/ -q
55
+ ```
56
+
57
+ ## License
58
+
59
+ MIT
60
+
61
+ ---
62
+
63
+ Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "duden-mcp"
7
+ version = "0.1.0"
8
+ description = "Unofficial read-only MCP server for Duden (duden.de) — dictionary lookup, synonyms, spelling & verb conjugation"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "mcp<2",
14
+ "httpx>=0.27",
15
+ ]
16
+ authors = [{ name = "Mohammad Abbasi" }]
17
+
18
+ [project.scripts]
19
+ duden-mcp = "duden_mcp.server:main"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/duden_mcp"]
23
+
24
+ [tool.pytest.ini_options]
25
+ markers = ["live: tests that hit the real https://www.duden.de site"]
@@ -0,0 +1 @@
1
+ {"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "description": "Unofficial read-only MCP server for Duden — meanings, synonyms, spelling & conjugation.", "name": "io.github.mohammad-abbasi-sh/duden-mcp", "packages": [{"identifier": "duden-mcp", "registryBaseUrl": "https://pypi.org", "registryType": "pypi", "transport": {"type": "stdio"}, "version": "0.1.0"}], "repository": {"source": "github", "url": "https://github.com/mohammad-abbasi-sh/duden-mcp"}, "title": "Duden — Germany's standard dictionary: word meanings, synonyms, spelling & grammar", "version": "0.1.0", "websiteUrl": "https://github.com/mohammad-abbasi-sh/duden-mcp"}
@@ -0,0 +1,7 @@
1
+ """duden_mcp — unofficial read-only MCP server for duden.de.
2
+
3
+ Duden dictionary lookup from duden.de (free, no key, no login): word meanings, synonyms, spelling with hyphenation, grammar notes and full verb conjugation.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,232 @@
1
+ """Domain logic: shape Duden word / conjugation / search pages into
2
+ compact dicts (stdlib only).
3
+
4
+ Data source: duden.de public word pages
5
+ (https://www.duden.de/rechtschreibung/<word>) — free, no key, no login.
6
+
7
+ Page structure (server-rendered HTML, verified 2026-11-10):
8
+ - lemma in <h1>; word type / frequency in <dl class="tuple"> blocks
9
+ (Wortart, Häufigkeit with a shaft aria-label).
10
+ - meanings: <div class="enumeration__text">…</div> inside the
11
+ #bedeutungen division (one per numbered meaning).
12
+ - spelling: #rechtschreibung division (Worttrennung line + notes).
13
+ - synonyms: links with data-duden-ref-type="lexeme" inside the
14
+ #synonyme division; the dedicated /synonyme/<word> overview page
15
+ only lists alphabetical navigation, so the word page's list is used.
16
+ - grammar: first <p> of the #grammatik division
17
+ ("das Haus; Genitiv: des Hauses, Häuser"); verbs link to
18
+ /konjugation/<verb>.
19
+ - conjugation page: <ul class="accordion__list content-column"> lists
20
+ in document order — first 6 are Indikativ (Präsens, Präteritum,
21
+ Perfekt, Plusquamperfekt, Futur I, Futur II), then Konjunktiv I/II,
22
+ then the Imperativ list (2 items, no label) and the infinite forms
23
+ (Infinitiv mit zu / Partizip I / Partizip II, one form each).
24
+ Footnote <sup> markers are stripped before parsing.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import html as _html
29
+ import re
30
+ from urllib.parse import quote
31
+
32
+ BASE = "https://www.duden.de"
33
+
34
+ PERSONS = ["ich", "du", "er/sie/es", "wir", "ihr", "sie"]
35
+
36
+ TENSES = ["Präsens", "Präteritum", "Perfekt", "Plusquamperfekt",
37
+ "Futur I", "Futur II"]
38
+ TENSE_KEYS = ["praesens", "praeteritum", "perfekt", "plusquamperfekt",
39
+ "futur_i", "futur_ii"]
40
+
41
+ _TAG_RE = re.compile(r"<[^>]+>")
42
+ _SUP_RE = re.compile(r"<sup[^>]*>.*?</sup>", re.S)
43
+ _WS_RE = re.compile(r"\s+")
44
+
45
+ _TRANSLIT = str.maketrans({
46
+ "ä": "ae", "ö": "oe", "ü": "ue",
47
+ "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss",
48
+ })
49
+
50
+
51
+ def validate_word(word: str) -> str:
52
+ w = (word or "").strip()
53
+ if not w:
54
+ raise ValueError("word must not be empty")
55
+ if len(w) > 80:
56
+ raise ValueError("word too long (max 80 characters)")
57
+ return w
58
+
59
+
60
+ def validate_limit(limit: int) -> int:
61
+ if not 1 <= limit <= 20:
62
+ raise ValueError(f"limit out of range: {limit} (expected 1..20)")
63
+ return limit
64
+
65
+
66
+ def word_slug(word: str) -> str:
67
+ """Duden URL slug: umlauts transliterated (ä→ae …), spaces → _."""
68
+ return word.translate(_TRANSLIT).replace(" ", "_")
69
+
70
+
71
+ def word_url(word: str) -> str:
72
+ return f"{BASE}/rechtschreibung/{word_slug(word)}"
73
+
74
+
75
+ def conjugation_url(verb: str) -> str:
76
+ return f"{BASE}/konjugation/{word_slug(verb)}"
77
+
78
+
79
+ def search_url(query: str) -> str:
80
+ return f"{BASE}/suchen/dudenonline/{quote(query.strip())}"
81
+
82
+
83
+ def _clean(text: str) -> str:
84
+ return _WS_RE.sub(" ", _html.unescape(_TAG_RE.sub(" ", text or ""))).strip()
85
+
86
+
87
+ def _division(html: str, div_id: str) -> str:
88
+ """Raw HTML of the <div class="division" id="<div_id>"> block."""
89
+ m = re.search(rf'id="{re.escape(div_id)}"', html)
90
+ if not m:
91
+ return ""
92
+ start = html.rfind("<div", 0, m.start())
93
+ if start < 0:
94
+ start = m.start()
95
+ nxt = html.find('<div class="division ', start + 10)
96
+ return html[start:] if nxt < 0 else html[start:nxt]
97
+
98
+
99
+ def _first_paragraph(division_html: str) -> str:
100
+ m = re.search(r"<p[^>]*>(.*?)</p>", division_html, re.S)
101
+ return _clean(m.group(1)) if m else ""
102
+
103
+
104
+ def parse_word_page(html: str) -> dict:
105
+ """{lemma, word_type, frequency, meanings, hyphenation, grammar,
106
+ origin, synonyms, conjugation_url, url} — empty strings/lists
107
+ when a section is absent (not every word has every section)."""
108
+ m = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S)
109
+ lemma = _clean(m.group(1)) if m else ""
110
+ lemma = lemma.replace("­", "").replace(" ,", ",")
111
+ m = re.search(
112
+ r'<dt class="tuple__key">Wortart:.*?</dt>\s*'
113
+ r'<dd class="tuple__val">(.*?)</dd>', html, re.S)
114
+ word_type = _clean(m.group(1)) if m else ""
115
+ m = re.search(
116
+ r'<dt class="tuple__key">Häufigkeit:.*?'
117
+ r'<div class="shaft" aria-label="([^"]+)"', html, re.S)
118
+ frequency = _html.unescape(m.group(1)) if m else ""
119
+
120
+ meanings = []
121
+ for raw in re.findall(r'enumeration__text">(.*?)</div>', html, re.S):
122
+ t = _clean(raw)
123
+ if t and t not in meanings:
124
+ meanings.append(t)
125
+
126
+ rechtschreibung = _division(html, "rechtschreibung")
127
+ hyphenation = ""
128
+ if rechtschreibung:
129
+ lines = [_clean(ln) for ln in _TAG_RE.sub("\n", rechtschreibung).split("\n")]
130
+ lines = [ln for ln in lines if ln]
131
+ for i, ln in enumerate(lines):
132
+ if ln == "Worttrennung" and i + 1 < len(lines):
133
+ # Duden marks syllable breaks with a soft hyphen or
134
+ # a pipe; normalise both to · for display
135
+ hyphenation = (lines[i + 1].replace("­", "·")
136
+ .replace("|", "·"))
137
+ break
138
+
139
+ grammar = _first_paragraph(_division(html, "grammatik"))
140
+ origin = _first_paragraph(_division(html, "herkunft"))
141
+ syn_div = _division(html, "synonyme")
142
+ synonyms = []
143
+ for raw in re.findall(
144
+ r'data-duden-ref-type="lexeme"[^>]*>([^<]+)</a>', syn_div):
145
+ w = _clean(raw)
146
+ if w and w not in synonyms:
147
+ synonyms.append(w)
148
+ m = re.search(r'href="(/konjugation/[^"]+)"', html)
149
+ conj_url = f"{BASE}{m.group(1)}" if m else ""
150
+ return {
151
+ "lemma": lemma,
152
+ "word_type": word_type,
153
+ "frequency": frequency,
154
+ "meanings": meanings,
155
+ "hyphenation": hyphenation,
156
+ "grammar": grammar,
157
+ "origin": origin,
158
+ "synonyms": synonyms,
159
+ "conjugation_url": conj_url,
160
+ }
161
+
162
+
163
+ def parse_spelling(html: str) -> dict:
164
+ """{hyphenation, notes} from the #rechtschreibung division."""
165
+ word = parse_word_page(html)
166
+ div = _division(html, "rechtschreibung")
167
+ notes = _clean(div)[:1500]
168
+ # drop the leading "Rechtschreibung" heading + info icon
169
+ notes = re.sub(r"^Rechtschreibung\s*ⓘ\s*", "", notes)
170
+ return {"hyphenation": word["hyphenation"], "notes": notes}
171
+
172
+
173
+ def parse_conjugation(html: str) -> dict:
174
+ """{indikativ: {tense_key: {person: form}}, imperative:
175
+ {singular, plural}, infinitiv_mit_zu, partizip_i, partizip_ii}."""
176
+ clean_html = _SUP_RE.sub("", html)
177
+ uls = re.findall(
178
+ r'<ul class="accordion__list content-column">(.*?)</ul>',
179
+ clean_html, re.S)
180
+ tables = []
181
+ for u in uls:
182
+ items = [_clean(li) for li in re.findall(
183
+ r'<li class="accordion__item">(.*?)</li>', u, re.S)]
184
+ items = [it for it in items if it]
185
+ if items:
186
+ tables.append(items)
187
+
188
+ indikativ: dict[str, dict[str, str]] = {}
189
+ imperative = {"singular": "", "plural": ""}
190
+ infinitiv_mit_zu = ""
191
+ partizip_i = ""
192
+ partizip_ii = ""
193
+ for items in tables:
194
+ label, forms = items[0], items[1:]
195
+ if label in TENSES and len(forms) == 6:
196
+ key = TENSE_KEYS[TENSES.index(label)]
197
+ if key not in indikativ:
198
+ indikativ[key] = dict(zip(PERSONS, forms))
199
+ elif label == "Infinitiv mit zu" and forms:
200
+ infinitiv_mit_zu = forms[0]
201
+ elif label == "Partizip I" and forms:
202
+ partizip_i = forms[0]
203
+ elif label == "Partizip II" and forms:
204
+ partizip_ii = forms[0]
205
+ elif len(items) == 2 and not imperative["singular"]:
206
+ # the Imperativ list carries no label — its two items are
207
+ # the singular (du) and plural (ihr) forms directly
208
+ imperative = {"singular": items[0], "plural": items[1]}
209
+ if not indikativ and not partizip_ii:
210
+ raise ValueError("no conjugation tables found on this page "
211
+ "(is it a verb?)")
212
+ return {
213
+ "indikativ": indikativ,
214
+ "imperative": imperative,
215
+ "infinitiv_mit_zu": infinitiv_mit_zu,
216
+ "partizip_i": partizip_i,
217
+ "partizip_ii": partizip_ii,
218
+ }
219
+
220
+
221
+ def parse_search(html: str) -> list[dict]:
222
+ """[{word, url}] — Duden headwords from the search results page."""
223
+ out: list[dict] = []
224
+ seen = set()
225
+ for slug in re.findall(r'href="/rechtschreibung/([^"]+)"', html):
226
+ if slug in seen:
227
+ continue
228
+ seen.add(slug)
229
+ display = slug.replace("_", " ")
230
+ out.append({"word": display,
231
+ "url": f"{BASE}/rechtschreibung/{slug}"})
232
+ return out