feat(sdk-docs): оптимизация MD-базы знаний + переход справки на неё

Оптимизация генератора docs/Kompas3D_SDK/ (build_kompas3d_sdk.py):
- слияние навигационных страниц в родителя (54 хаба, 475 интерфейсов);
  читаемые slug-имена + категория по доминирующему типу детей;
- шумоподавление: убраны артефакты «Интерфейс...», TOC-хвосты,
  breadcrumb-таблицы; синтаксис развёрнут из пайп-таблиц в плоский текст;
- слим frontmatter: sources сведён к корневой странице на группу;
- генератор самодостаточен — читает zoom_pageinfo.js напрямую,
  зависимость от kompas_sdk_index.tsv убрана.

Навык kompas-sdk-research переведён с kdoc.py/HTML на поиск по MD-базе
(Grep/Read). Удалены устаревшие скрипты: kdoc.py, convert_sdk_docs.py,
convert_sdk_to_md.py. Ссылки в CLAUDE.md/ARCHITECTURE.md/README.md/.gitignore
актуализированы.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:45:31 +03:00
parent 3452e21791
commit c761cba673
9 changed files with 310 additions and 1230 deletions
+49 -24
View File
@@ -1,8 +1,9 @@
--- ---
name: kompas-sdk-research name: kompas-sdk-research
description: > description: >
Делегирует поиск и чтение офлайн-справки КОМПАС SDK Делегирует поиск и чтение справки КОМПАС SDK субагенту на модели Sonnet.
(docs/KOMPAS_SDK_ru-RU через приложенный kdoc.py) субагенту на модели Sonnet. Источник — структурированная MD-база знаний docs/Kompas3D_SDK/ (статьи по
интерфейсам с YAML-тегами, ищется обычным Grep/Read).
Используй ВСЕГДА, когда нужно найти интерфейс/метод/константу COM API КОМПАС, Используй ВСЕГДА, когда нужно найти интерфейс/метод/константу COM API КОМПАС,
его сигнатуру (Automation/COM), значения перечисления, единицы измерения или его сигнатуру (Automation/COM), значения перечисления, единицы измерения или
паттерн использования — не рой справку сам, передавай Sonnet и получай паттерн использования — не рой справку сам, передавай Sonnet и получай
@@ -12,10 +13,11 @@ description: >
# kompas-sdk-research — делегирование исследования справки КОМПАС SDK Sonnet # kompas-sdk-research — делегирование исследования справки КОМПАС SDK Sonnet
Справка `docs/KOMPAS_SDK_ru-RU/` — это WebHelp-экспорт на ~26 000 страниц сырого Справка КОМПАС SDK сконвертирована в структурированную MD-базу знаний
HTML с шумом и криптическими именами файлов. Рыть её вручную дорого по контексту. `docs/Kompas3D_SDK/` — статьи сгруппированы по COM-интерфейсам, с YAML-тегами для
поиска, чистым текстом, развёрнутыми блоками синтаксиса и реальными изображениями.
Этот навык позволяет Opus не тратить контекст на поиск: опиши, что нужно узнать, — Этот навык позволяет Opus не тратить контекст на поиск: опиши, что нужно узнать, —
Sonnet прогонит приложенный `kdoc.py`, прочитает нужные топики и вернёт сжатый ответ Sonnet прогонит Grep/Read по базе, прочитает нужную статью и вернёт сжатый ответ
(интерфейс, сигнатура, параметры, значения констант, источник). (интерфейс, сигнатура, параметры, значения констант, источник).
## Когда применять ## Когда применять
@@ -27,6 +29,25 @@ Sonnet прогонит приложенный `kdoc.py`, прочитает н
Не применяй для правки документации проекта — это делает навык **docs-delegate**. Не применяй для правки документации проекта — это делает навык **docs-delegate**.
## Структура MD-базы `docs/Kompas3D_SDK/`
```
index.md — оглавление по 4 категориям со ссылками на статьи
interfaces/*.md — одна статья на интерфейс/тему; все методы и свойства как ## секции
enums/*.md — перечисления (пары «имя | значение | смысл»)
structures/*.md — структуры и параметрические интерфейсы
guides/*.md — концепции и руководства
resources/ — изображения (ссылки в статьях вида ../resources/имя.png)
```
Каждая статья начинается с YAML-фронтматтера: `type` (interface/enum/struct/guide),
`api` (api7/api5), `domain` (3d/2d/sketch/assembly/…), `tags`, `sources` (исходные `*.html`).
Имя файла статьи — имя интерфейса (`iapplication.md`, `ksbossextrusiondefinition.md`)
или читаемый слаг темы (`приложение.md`). Методы/свойства внутри — заголовки `## Имя - Описание`.
> База **в `.gitignore`** (воспроизводится). Если её нет — сгенерируй из локального
> зеркала справки: `python tools/build_kompas3d_sdk.py`.
## Шаги выполнения ## Шаги выполнения
### 1. Сформулируй точный вопрос ### 1. Сформулируй точный вопрос
@@ -52,10 +73,11 @@ prompt: <см. шаблон ниже>
--- ---
Ты — помощник по навигации в офлайн-справке **КОМПАС SDK** в репозитории Ты — помощник по навигации в справке **КОМПАС SDK** в репозитории
**kompas3d-mcp** (MCP-сервер для КОМПАС-3D через COM API, .NET 8, C#). **kompas3d-mcp** (MCP-сервер для КОМПАС-3D через COM API, .NET 8, C#).
Скрипт навигатора лежит в этом навыке: `.claude/skills/kompas-sdk-research/kdoc.py`; Справка — это MD-база знаний `docs/Kompas3D_SDK/`: статьи по интерфейсам с
индекс — `docs/kompas_sdk_index.tsv`. Запускай из корня репозитория. YAML-фронтматтером (`type`, `api`, `domain`, `tags`, `sources`); методы и
свойства — заголовки `## ` внутри статьи интерфейса.
**Вопрос для исследования:** **Вопрос для исследования:**
@@ -65,34 +87,37 @@ prompt: <см. шаблон ниже>
**Инструменты (используй именно их, не веб):** **Инструменты (используй именно их, не веб):**
``` - **Grep** по `docs/Kompas3D_SDK/` — ищи имя интерфейса/метода/свойства, русское
python .claude/skills/kompas-sdk-research/kdoc.py search "<ключевые слова>" # печатает "file.html<TAB>Заголовок" ключевое слово или тег. Имена методов — это заголовки `## SetSideParam - …`,
python .claude/skills/kompas-sdk-research/kdoc.py read <file.html> # чистый текст топика (без разметки) поэтому поиск по имени метода сразу ведёт в нужную статью.
``` - **Read** найденного `*.md` — статья интерфейса содержит все его методы/свойства,
блоки `Синтаксис Automation:`/`Синтаксис COM:` (в code-fence), параметры, примечания.
- `docs/Kompas3D_SDK/index.md` — оглавление по категориям, когда нужно осмотреть тему.
Полезные приёмы поиска:
- по перечислению: `Grep "ksMassUnitsEnum"` или загляни в `enums/`;
- фильтр по тегам в фронтматтере: `Grep "domain: \[.*3d"` , `Grep "type: enum"`;
- веер по многим интерфейсам сразу: `Grep "IPart7|IDocument3D|IPart"` по `interfaces/`.
`search` делает AND-поиск по заголовкам и выдержкам; `read` извлекает тело топика
(хлебные крошки, тип данных, синтаксис Automation/COM, параметры, возврат, примечания).
Индекс `docs/kompas_sdk_index.tsv` (`файл<TAB>заголовок<TAB>выдержка`) можно искать и
обычным grep — удобно, когда нужно веером пройтись по многим именам интерфейсов.
Точные числовые значения констант при необходимости сверяй в заголовках SDK: Точные числовые значения констант при необходимости сверяй в заголовках SDK:
`C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK\Include\*.h` (например `ksConstants*.h`, `ldefin2d.h`). `C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK\Include\*.h` (например `ksConstants*.h`, `ldefin2d.h`).
**Критично — два параллельных зеркала одних интерфейсов:** **Важно — что в базе и чего нет:**
- файлы **без** префикса (`iapplication_*`, `kspart_*`, `ksmassinertiaparam_*`) — это - База содержит **COM API7/API5** — то, что использует проект (файлы `iapplication*`,
**COM API7/API5**, которое использует проект. **Бери эти.** `kspart*`, `ksmassinertiaparam*` и т.п.). Это твой основной источник.
- файлы с префиксом **`ksapi_*`** — кроссплатформенный Qt/C++ **KsAPI** (не COM). **Игнорируй**, - Кроссплатформенный Qt/C++ **KsAPI** (`ksapi_*`) в базу **не включён** — если задача
если задача явно не про KsAPI. явно про KsAPI, скажи об этом (нужен отдельный источник).
**Правила:** **Правила:**
1. Не угадывай — находи через `kdoc.py` и читай первоисточник. 1. Не угадывай — находи через Grep/Read по `docs/Kompas3D_SDK/` и читай первоисточник.
2. Верни **сжатую выжимку**, а не дамп страниц: 2. Верни **сжатую выжимку**, а не дамп статьи:
- имя интерфейса и метода/свойства; - имя интерфейса и метода/свойства;
- синтаксис Automation (и COM, если важен порядок out-параметров); - синтаксис Automation (и COM, если важен порядок out-параметров);
- параметры (имя, тип, смысл) и возвращаемое значение; - параметры (имя, тип, смысл) и возвращаемое значение;
- для enum/констант — пары «имя = значение» и их смысл; - для enum/констант — пары «имя = значение» и их смысл;
- важные примечания (единицы измерения, побочные эффекты, версия КОМПАС); - важные примечания (единицы измерения, побочные эффекты, версия КОМПАС);
- имя(имена) исходных `*.html` как ссылку на источник. - имя статьи / `sources` из фронтматтера как ссылку на источник.
3. Если у метода несколько перегрузок или связанных интерфейсов — перечисли релевантные. 3. Если у метода несколько перегрузок или связанных интерфейсов — перечисли релевантные.
4. Если в COM-interop имя отличается от справки (частый случай в этом проекте), отметь это 4. Если в COM-interop имя отличается от справки (частый случай в этом проекте), отметь это
как вероятную тонкость и предложи свериться рефлексией по `libs/kompas-interop/*.dll`. как вероятную тонкость и предложи свериться рефлексией по `libs/kompas-interop/*.dll`.
-226
View File
@@ -1,226 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
kdoc — навигация по справке SDK КОМПАС-3D (Help & Manual WebHelp) для агента.
Часть навыка **kompas-sdk-research**. Справка — это ~26 000 HTML-страниц в
docs/KOMPAS_SDK_ru-RU/ со «зашумлённой» вёрсткой (шапка, подвал, JS). Листать их
напрямую неэффективно. Этот инструмент:
* build-index — один раз строит компактный индекс docs/kompas_sdk_index.tsv
(файл<TAB>заголовок<TAB>краткое описание) из zoom_pageinfo.js;
* search — ищет страницы по подстроке в заголовке/описании;
* read — печатает чистый текст одной страницы (тело топика без вёрстки).
Типичный сценарий агента (из корня репозитория):
python .claude/skills/kompas-sdk-research/kdoc.py search "выдавливание"
python .claude/skills/kompas-sdk-research/kdoc.py read ibossextrusiondefinition.html
Каталог справки ищется автоматически (вверх от текущего каталога и от скрипта,
папка docs/KOMPAS_SDK_ru-RU); можно переопределить переменной окружения KDOC_DIR.
Поиск по индексу можно вести и встроенным Grep по docs/kompas_sdk_index.tsv —
формат строки: <имя_файла>\t<заголовок>\t<описание>.
"""
import sys
import os
import re
import json
import html
import argparse
from html.parser import HTMLParser
# На Windows консоль часто cp1251 — принудительно выводим UTF-8,
# чтобы кириллица не искажалась при вызове из агента/любого шелла.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass
HERE = os.path.dirname(os.path.abspath(__file__))
_DOC_REL = os.path.join("docs", "KOMPAS_SDK_ru-RU")
def _find_doc_dir() -> str:
"""Найти каталог справки. Скрипт живёт в навыке, а справка — в репозитории,
поэтому ищем гибко, а не относительно скрипта."""
# 1) Явное переопределение.
env = os.environ.get("KDOC_DIR")
if env:
return os.path.abspath(env)
# 2) Поиск вверх от текущего каталога и от каталога скрипта.
for base in (os.getcwd(), HERE):
cur = os.path.abspath(base)
while True:
cand = os.path.join(cur, _DOC_REL)
if os.path.isdir(cand):
return cand
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
# 3) Фолбэк: корень репозитория на три уровня выше навыка
# (.claude/skills/<name>/ → <repo>/docs/KOMPAS_SDK_ru-RU).
return os.path.abspath(os.path.join(HERE, "..", "..", "..", _DOC_REL))
DOC_DIR = _find_doc_dir()
INDEX_PATH = os.path.abspath(os.path.join(DOC_DIR, "..", "kompas_sdk_index.tsv"))
PAGEINFO = os.path.join(DOC_DIR, "zoom_pageinfo.js")
# Текстовый «мусор» навигации/подвала, попадающий в тело топика.
NOISE = {
"please enable javascript to view this site.",
"scroll", "предыдущий", "вверх", "следующий",
"содержание", "содержане", "указатель", "поиск",
"подразделы:", "(отсутствуют)",
}
FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН")
def build_index() -> int:
"""Разобрать массив pagedata из zoom_pageinfo.js в TSV-индекс."""
if not os.path.isfile(PAGEINFO):
sys.exit(f"Не найден {PAGEINFO}. Проверьте KDOC_DIR / путь к справке.")
with open(PAGEINFO, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
marker = "pagedata = "
pos = text.find(marker)
if pos < 0:
sys.exit("В zoom_pageinfo.js не найден массив pagedata.")
payload = text[pos + len(marker):].strip()
payload = payload.rstrip(";").strip()
data = json.loads(payload) # [["./file.html","title","desc","img"], ...]
rows = []
for entry in data:
if not entry or not entry[0]:
continue
fname = entry[0].lstrip("./").strip()
title = html.unescape((entry[1] or "").strip())
desc = html.unescape((entry[2] or "").strip())
title = re.sub(r"\s+", " ", title)
desc = re.sub(r"\s+", " ", desc)[:300]
rows.append(f"{fname}\t{title}\t{desc}")
with open(INDEX_PATH, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(rows) + "\n")
print(f"Индекс записан: {INDEX_PATH} ({len(rows)} страниц)")
return len(rows)
def search(terms, limit=40):
"""Грубый поиск по индексу: AND по всем подстрокам, без регистра."""
if not os.path.isfile(INDEX_PATH):
print("Индекс отсутствует — строю...", file=sys.stderr)
build_index()
needles = [t.lower() for t in terms if t.strip()]
hits = 0
with open(INDEX_PATH, "r", encoding="utf-8") as f:
for line in f:
low = line.lower()
if all(n in low for n in needles):
parts = line.rstrip("\n").split("\t")
fname = parts[0]
title = parts[1] if len(parts) > 1 else ""
print(f"{fname}\t{title}")
hits += 1
if hits >= limit:
print(f"... (показаны первые {limit}; уточните запрос)")
break
if hits == 0:
print("Ничего не найдено.")
class _TopicExtractor(HTMLParser):
"""Собирает текст из <div id='topicbody'>, пропуская script/style."""
BLOCK = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4",
"table", "pre"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0 # глубина вложенности внутри topicbody
self.capturing = False
self.skip = 0 # внутри script/style
self.out = []
def handle_starttag(self, tag, attrs):
attrd = dict(attrs)
if not self.capturing and attrd.get("id") == "topicbody":
self.capturing = True
self.depth = 1
return
if not self.capturing:
return
if tag in ("script", "style"):
self.skip += 1
if tag in self.BLOCK:
self.out.append("\n")
if tag != "br":
self.depth += 1
def handle_endtag(self, tag):
if not self.capturing:
return
if tag in ("script", "style") and self.skip:
self.skip -= 1
if tag != "br":
self.depth -= 1
if self.depth <= 0:
self.capturing = False
def handle_data(self, data):
if self.capturing and not self.skip:
self.out.append(data)
def read(fname):
path = os.path.join(DOC_DIR, os.path.basename(fname))
if not path.endswith(".html"):
path += ".html"
if not os.path.isfile(path):
sys.exit(f"Нет файла: {path}")
with open(path, "r", encoding="utf-8", errors="replace") as f:
raw = f.read()
p = _TopicExtractor()
p.feed(raw)
text = "".join(p.out)
cleaned = []
for line in text.splitlines():
s = re.sub(r"[ \t ]+", " ", line).strip()
if not s:
continue
if s.lower() in NOISE:
continue
if FOOTER_RE.search(s):
break # подвал — дальше только копирайт/телефон
cleaned.append(s)
print(os.path.basename(path))
print("=" * len(os.path.basename(path)))
print("\n".join(cleaned).strip())
def main():
ap = argparse.ArgumentParser(
prog="kdoc", description="Навигация по справке SDK КОМПАС-3D для агента."
)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("build-index", help="построить docs/kompas_sdk_index.tsv")
sp = sub.add_parser("search", help="искать по заголовку/описанию")
sp.add_argument("terms", nargs="+")
sp.add_argument("--limit", type=int, default=40)
rp = sub.add_parser("read", help="печать чистого текста страницы")
rp.add_argument("file")
args = ap.parse_args()
if args.cmd == "build-index":
build_index()
elif args.cmd == "search":
search(args.terms, args.limit)
elif args.cmd == "read":
read(args.file)
if __name__ == "__main__":
main()
+4 -5
View File
@@ -60,13 +60,12 @@ usecases/
# ============================================================================ # ============================================================================
# Офлайн-зеркало справки SDK КОМПАС-3D (~1.4 ГБ, 26k+ файлов, © АСКОН). # Офлайн-зеркало справки SDK КОМПАС-3D (~1.4 ГБ, 26k+ файлов, © АСКОН).
# Лежит локально; для навигации см. .claude/skills/kompas-sdk-research/kdoc.py. Скачивается отдельно. # Источник для генерации MD-базы (см. ниже). Лежит локально, скачивается отдельно.
docs/KOMPAS_SDK_ru-RU/ docs/KOMPAS_SDK_ru-RU/
# Индекс справки, генерируется командой: python .claude/skills/kompas-sdk-research/kdoc.py build-index # MD-база знаний SDK, генерируется из зеркала выше командой:
docs/kompas_sdk_index.tsv # python tools/build_kompas3d_sdk.py
# Для поиска по ней — навык .claude/skills/kompas-sdk-research.
# MD-база знаний SDK, генерируется командой: python tools/build_kompas3d_sdk.py
docs/Kompas3D_SDK/ docs/Kompas3D_SDK/
# ============================================================================ # ============================================================================
+10 -14
View File
@@ -7,27 +7,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Build an **MCP (Model Context Protocol) server** that lets an LLM drive the CAD system **КОМПАС-3D** (ASCON). The server exposes КОМПАС operations (create/open documents, build 2D geometry, 3D parts and assemblies, read/write parameters, run macros) as MCP tools. Build an **MCP (Model Context Protocol) server** that lets an LLM drive the CAD system **КОМПАС-3D** (ASCON). The server exposes КОМПАС operations (create/open documents, build 2D geometry, 3D parts and assemblies, read/write parameters, run macros) as MCP tools.
Authoritative references: Authoritative references:
- **Offline SDK docs mirror in this repo: `docs/KOMPAS_SDK_ru-RU/`** — navigate it with `.claude/skills/kompas-sdk-research/kdoc.py` (see below). Prefer this over the web. For non-trivial lookups, delegate to the **`kompas-sdk-research`** skill (Sonnet subagent) to save context. - **SDK knowledge base in this repo: `docs/Kompas3D_SDK/`** — clean Markdown, one article per COM interface, with YAML tags; search it with Grep/Read (see below). Generated from the offline HTML mirror `docs/KOMPAS_SDK_ru-RU/` by `tools/build_kompas3d_sdk.py`. Prefer this over the web. For non-trivial lookups, delegate to the **`kompas-sdk-research`** skill (Sonnet subagent) to save context.
- Online SDK docs: https://help.ascon.ru/KOMPAS_SDK/22/ru-RU/index.html - Online SDK docs: https://help.ascon.ru/KOMPAS_SDK/22/ru-RU/index.html
- Local SDK install: `C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK` - Local SDK install: `C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK`
## Navigating the SDK docs (`.claude/skills/kompas-sdk-research/kdoc.py`) ## Navigating the SDK docs (`docs/Kompas3D_SDK/`)
`docs/KOMPAS_SDK_ru-RU/` is a 26 000-page Help&Manual WebHelp export — raw HTML buried under header/footer/JS noise, with cryptic filenames. **Do not browse it by hand.** Use the helper (bundled in the `kompas-sdk-research` skill; run from repo root, it auto-locates the docs): `docs/Kompas3D_SDK/` is a structured MD knowledge base distilled from the 26 000-page Help&Manual WebHelp export. One article per COM interface/topic, split into `interfaces/`, `enums/`, `structures/`, `guides/` (+ `resources/` images), each with YAML frontmatter (`type`, `api`, `domain`, `tags`, `sources`); methods/properties are `## ` headings inside the interface article. **Search it directly — do not browse the raw HTML:**
``` - **Grep** over `docs/Kompas3D_SDK/` for an interface/method/property name or Russian keyword (method names are `## SetSideParam - …` headings, so the name lands you in the right article).
python .claude/skills/kompas-sdk-research/kdoc.py search "<russian or interface keywords>" # find pages: prints "file.html\tTitle" - **Read** the matching `*.md` — the interface article holds all its members, `Синтаксис Automation:`/`Синтаксис COM:` blocks (in code fences), parameters, notes.
python .claude/skills/kompas-sdk-research/kdoc.py read <file.html> # print clean topic text (no markup) - `docs/Kompas3D_SDK/index.md` — table of contents by category. Filter by frontmatter tags, e.g. `Grep "type: enum"` or `Grep "domain: \[.*3d"`.
python .claude/skills/kompas-sdk-research/kdoc.py build-index # regenerate the index (only if docs change)
```
`search` does AND-matching over titles + excerpts; `read` extracts just the topic body (breadcrumb path, data type, Automation/COM syntax, parameters, return value, notes). Example: `search Visible IApplication``iapplication_visible.html`, then `read iapplication_visible.html`. The base is gitignored (reproducible). Regenerate from the local HTML mirror with `python tools/build_kompas3d_sdk.py` — it reads the WebHelp page registry `docs/KOMPAS_SDK_ru-RU/zoom_pageinfo.js` directly (no separate index step). KsAPI pages (`ksapi_*`) are intentionally excluded from the base.
The index lives at `docs/kompas_sdk_index.tsv` (`filename<TAB>title<TAB>excerpt`) and can also be searched with the Grep tool directly — handy for fanning out over many interface names at once. **The source HTML mirror contains two parallel mirrors of the same interfaces:**
- *«API интерфейсов. Версия 7»* — filenames **without** prefix (`iapplication_*`, `idocuments_*`). This is the **COM API7/API5** reference — the one this project uses; it is what lands in the MD base.
**The docs contain two parallel mirrors of the same interfaces:** - *«API интерфейсов. KsAPI»* — `ksapi_*`-prefixed filenames: the cross-platform Qt/C++ **KsAPI** flavour. Same concepts, different (non-COM) binding — excluded from the base; ignore unless explicitly working with KsAPI.
- *«API интерфейсов. Версия 7»* — filenames **without** prefix (`iapplication_*`, `idocuments_*`). This is the **COM API7/API5** reference — the one this project uses.
- *«API интерфейсов. KsAPI»* — `ksapi_*`-prefixed filenames: the cross-platform Qt/C++ **KsAPI** flavour. Same concepts, different (non-COM) binding — ignore unless explicitly working with KsAPI.
## Current state ## Current state
+2 -2
View File
@@ -72,9 +72,9 @@ src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключен
src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов
tests/Kompas.Mcp.Tests/ 60 тестов: unit + integration (integration требуют КОМПАС) tests/Kompas.Mcp.Tests/ 60 тестов: unit + integration (integration требуют КОМПАС)
libs/kompas-interop/ вендорские interop-сборки КОМПАС (из SDK Samples/Common) libs/kompas-interop/ вендорские interop-сборки КОМПАС (из SDK Samples/Common)
docs/ архитектура, план, презентация, навигатор по справке SDK docs/ архитектура, план, презентация, MD-база знаний SDK (Kompas3D_SDK/)
usecases/ полигон обкатки подходов (в .gitignore); приёмы поднимаются в навык kompas-3d usecases/ полигон обкатки подходов (в .gitignore); приёмы поднимаются в навык kompas-3d
.claude/skills/ навыки: docs-delegate, kompas-sdk-research (+ kdoc.py), kompas-3d (методика) .claude/skills/ навыки: docs-delegate, kompas-sdk-research, kompas-3d (методика)
``` ```
## Тесты ## Тесты
+1 -1
View File
@@ -2,7 +2,7 @@
> Статус: **реализовано и работает** (v1+v2+STEP/assembly+direct-edit+inspection). Актуализировано по коду. > Статус: **реализовано и работает** (v1+v2+STEP/assembly+direct-edit+inspection). Актуализировано по коду.
> Сопутствующий контекст по COM API КОМПАС — в [`../CLAUDE.md`](../CLAUDE.md). > Сопутствующий контекст по COM API КОМПАС — в [`../CLAUDE.md`](../CLAUDE.md).
> Имена интерфейсов и перечислений сверены со справкой SDK через `.claude/skills/kompas-sdk-research/kdoc.py` (навык `kompas-sdk-research`). > Имена интерфейсов и перечислений сверены со справкой SDK по MD-базе знаний `docs/Kompas3D_SDK/` (навык `kompas-sdk-research`).
--- ---
+244 -62
View File
@@ -20,16 +20,19 @@ build_kompas3d_sdk.py — Преобразование KOMPAS SDK HTML-спра
import os import os
import sys import sys
import re import re
import json
import shutil import shutil
import html as hmod import html as hmod
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from collections import defaultdict from collections import defaultdict, Counter
from typing import Optional from typing import Optional
HERE = Path(__file__).resolve().parent.parent HERE = Path(__file__).resolve().parent.parent
DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU" DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU"
INDEX_PATH = HERE / "docs" / "kompas_sdk_index.tsv" # Собственный реестр topic-страниц WebHelp (массив pagedata) — берём список
# страниц и заголовки прямо отсюда, без промежуточного индекса.
PAGEINFO_PATH = DOC_DIR / "zoom_pageinfo.js"
OUT_DIR = HERE / "docs" / "Kompas3D_SDK" OUT_DIR = HERE / "docs" / "Kompas3D_SDK"
RESOURCES_DIR = OUT_DIR / "resources" RESOURCES_DIR = OUT_DIR / "resources"
@@ -56,6 +59,25 @@ BREADCRUMB_RE = re.compile(
r"Automation|COM|Объект|Параметры 3D|Параметры 2D).*?>", r"Automation|COM|Объект|Параметры 3D|Параметры 2D).*?>",
re.IGNORECASE, re.IGNORECASE,
) )
# Обрезанная навигационная ссылка «назад к родителю» (<a class="topiclink">Интерфейс...</a>).
# Шум, повторяется в начале КАЖДОЙ секции метода/свойства.
NAV_STUB_RE = re.compile(
r"^(Интерфейс|Интерфейсы|Объект|Перечисление|Структура|Класс|Метод|Свойство)\.\.\.$"
)
# TOC-хвосты в конце вводной секции интерфейса: «IApplication - свойства», «… - методы».
# (Заголовки методов оканчиваются описанием, под этот шаблон не попадают.)
TOC_TAIL_RE = re.compile(
r"^[\wА-Яа-я.:]+ ?[-–] ?(свойства|методы|события|конструкторы|члены|перечисляемые)$",
re.IGNORECASE,
)
# Навигационная страница = список дочерних интерфейсов: «Интерфейс IPrintJob».
NAV_REF_RE = re.compile(
r"^(?:Интерфейс|Интерфейсы|Объект|Перечисление|Класс)\s+([A-Za-z_][A-Za-z0-9_]*)\s*$"
)
# Маркеры реального API-контента (если есть — страница НЕ навигационная).
API_MARKER_RE = re.compile(
r"Тип данных:|Синтаксис|Возвращает|Входные параметры|Выходные параметры"
)
# ─── TOC-страницы (пропускаются — дублируют содержимое) ───────────────────── # ─── TOC-страницы (пропускаются — дублируют содержимое) ─────────────────────
@@ -299,6 +321,10 @@ def clean_text(raw: str) -> list[str]:
break break
if ">" in s and BREADCRUMB_RE.match(s): if ">" in s and BREADCRUMB_RE.match(s):
continue continue
if NAV_STUB_RE.match(s):
continue
if TOC_TAIL_RE.match(s):
continue
cleaned.append(s) cleaned.append(s)
if in_fence: if in_fence:
cleaned.append("```") cleaned.append("```")
@@ -325,10 +351,28 @@ _CODE_ENDERS = frozenset({
}) })
def _detable_for_code(line: str) -> Optional[str]:
"""
Внутри code-fence MD-таблица рендерится буквально (пайпы, '---').
Секции «Синтаксис» — это таблицы (выражение | описание); разворачиваем их
в плоские строки. Возврат: None — строку-разделитель удалить; иначе строка.
Не-табличные строки возвращаются без изменений.
"""
s = line.strip()
if not (s.startswith("|") and s.endswith("|") and s.count("|") >= 2):
return line
cells = [c.strip() for c in s.strip("|").split("|")]
if cells and all(c and set(c) <= set("-:") for c in cells):
return None # строка-разделитель таблицы
kept = [c for c in cells if c]
return " ".join(kept)
def postprocess_code_blocks(lines: list[str]) -> list[str]: def postprocess_code_blocks(lines: list[str]) -> list[str]:
""" """
Оборачивает секции «Синтаксис» и «Пример» в MD code-fence блоки. Оборачивает секции «Синтаксис» и «Пример» в MD code-fence блоки.
Существующие ``` fence-блоки (от <pre>/<code>) пропускаются. Существующие ``` fence-блоки (от <pre>/<code>) пропускаются.
Табличные строки внутри fence разворачиваются в плоский текст.
""" """
result: list[str] = [] result: list[str] = []
in_code = False in_code = False
@@ -383,7 +427,14 @@ def postprocess_code_blocks(lines: list[str]) -> list[str]:
i += 1 i += 1
continue continue
result.append(line) if in_code:
transformed = _detable_for_code(line)
if transformed is None:
i += 1
continue # строка-разделитель таблицы — удаляем
result.append(transformed)
else:
result.append(line)
i += 1 i += 1
if in_code: if in_code:
@@ -421,17 +472,30 @@ def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[st
# ─── Индекс ────────────────────────────────────────────────────────────────── # ─── Индекс ──────────────────────────────────────────────────────────────────
def read_index() -> list[dict]: def read_pageinfo() -> list[dict]:
"""
Список topic-страниц из zoom_pageinfo.js (`pagedata = [["./file.html",
title, desc, img], ...]`). Это собственный реестр WebHelp — содержит ровно
страницы справки (без chrome-файлов), поэтому glob *.html не нужен.
"""
if not PAGEINFO_PATH.is_file():
sys.exit(f"Не найден {PAGEINFO_PATH}. Проверьте зеркало справки.")
text = PAGEINFO_PATH.read_text(encoding="utf-8", errors="replace")
marker = "pagedata = "
pos = text.find(marker)
if pos < 0:
sys.exit("В zoom_pageinfo.js не найден массив pagedata.")
payload = text[pos + len(marker):].strip().rstrip(";").strip()
data = json.loads(payload)
pages: list[dict] = [] pages: list[dict] = []
with open(INDEX_PATH, "r", encoding="utf-8") as f: for entry in data:
for line in f: if not entry or not entry[0]:
parts = line.rstrip("\n").split("\t") continue
if len(parts) >= 1 and parts[0]: fname = entry[0].lstrip("./").strip()
pages.append({ title = re.sub(r"\s+", " ", hmod.unescape((entry[1] or "").strip()))
"file": parts[0], desc = re.sub(r"\s+", " ", hmod.unescape((entry[2] or "").strip()))[:300]
"title": parts[1] if len(parts) > 1 else "", pages.append({"file": fname, "title": title, "desc": desc})
"desc": parts[2] if len(parts) > 2 else "",
})
return pages return pages
@@ -466,6 +530,25 @@ def build_groups(pages: list[dict]) -> dict[str, list[dict]]:
return dict(raw) return dict(raw)
def detect_nav(pages_data: list[dict]) -> tuple[bool, list[str]]:
"""
Навигационная страница: одна исходная страница, тело — список дочерних
интерфейсов (>=2 «Интерфейс X»), без маркеров API-контента.
Возвращает (is_nav, [имена дочерних интерфейсов]).
"""
if len(pages_data) != 1:
return False, []
refs: list[str] = []
for ln in pages_data[0]["body_lines"]:
s = ln.strip()
if API_MARKER_RE.search(s):
return False, []
m = NAV_REF_RE.match(s)
if m:
refs.append(m.group(1))
return (len(refs) >= 2), refs
# ─── Классификация ─────────────────────────────────────────────────────────── # ─── Классификация ───────────────────────────────────────────────────────────
def classify_type(group_key: str, representative_title: str) -> str: def classify_type(group_key: str, representative_title: str) -> str:
@@ -589,18 +672,95 @@ def _escape_yaml_str(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"') return s.replace("\\", "\\\\").replace('"', '\\"')
def render_article(group_key: str, pages_data: list[dict], # Приоритет типа при выборе категории слитого nav-родителя (tie-break).
article_type: str, source_files: list[str]) -> str: TYPE_PRIORITY = {"interface": 3, "struct": 2, "enum": 1, "guide": 0}
"""Генерирует содержимое MD-файла для группы страниц."""
rep = pages_data[0]
title = rep["title"] def _slugify(title: str) -> str:
breadcrumbs = rep["breadcrumbs"] """Читаемое имя файла из заголовка (кириллица сохраняется, пробелы → дефис)."""
s = title.strip().lower()
s = re.sub(r"[^\w]+", "-", s, flags=re.UNICODE)
s = re.sub(r"-+", "-", s).strip("-")
return s or "topic"
def _render_pages_body(pages_data: list[dict], multi: bool,
strip_nav_refs: bool = False) -> list[str]:
"""Рендерит тело страниц группы (## секции, изображения, code-fence)."""
parts: list[str] = []
for page_data in pages_data:
page_title = page_data["title"]
body_lines = list(page_data["body_lines"])
if multi:
parts.append(f"\n## {page_title}\n")
if body_lines and body_lines[0].strip() == page_title.strip():
body_lines = body_lines[1:]
if strip_nav_refs:
body_lines = [ln for ln in body_lines if not NAV_REF_RE.match(ln.strip())]
body_lines = postprocess_code_blocks(body_lines)
for img_path, alt in page_data["image_srcs"]:
img_name = copy_image(img_path)
alt_clean = alt.replace('"', "'")
parts.append(f"![{alt_clean}](../resources/{img_name})\n")
parts.append("\n".join(body_lines))
parts.append("")
return parts
def _collect_body(key: str, records: dict, parent_to_children: dict,
visited: set, sources_acc: list, *, as_child: bool) -> list[str]:
"""
Рекурсивно собирает тело статьи: свои страницы + инлайн дочерних групп
(для навигационных страниц, поглощающих детей).
"""
rec = records[key]
visited.add(key)
# Только корневая страница группы — без раздувания frontmatter под-страницами
# методов (iapplication.html, а не iapplication_visible.html × 30).
if rec["source_files"]:
sources_acc.append(rec["source_files"][0])
is_parent = key in parent_to_children
pages = rec["pages_data"]
if as_child:
# Поглощённый ребёнок: каждая его страница — ## секция.
multi = True
else:
# Своё тело верхнеуровневой статьи (для nav-родителя — лид без заголовка).
multi = (not is_parent) and len(pages) > 1
parts = _render_pages_body(pages, multi=multi, strip_nav_refs=is_parent)
for ck in parent_to_children.get(key, []):
if ck in visited:
continue
parts.extend(_collect_body(ck, records, parent_to_children,
visited, sources_acc, as_child=True))
return parts
def render_article(top_key: str, records: dict, parent_to_children: dict) -> str:
"""Генерирует содержимое MD-файла для группы (с инлайном детей, если nav)."""
rec = records[top_key]
title = rec["rep_title"]
breadcrumbs = rec["pages_data"][0]["breadcrumbs"]
article_type = rec["article_type"]
visited: set = set()
sources_acc: list[str] = []
body_parts = _collect_body(top_key, records, parent_to_children,
visited, sources_acc, as_child=False)
source_files = list(dict.fromkeys(sources_acc))
api_versions = extract_api_version(title, breadcrumbs) api_versions = extract_api_version(title, breadcrumbs)
domain_tags = extract_domain_tags(title, breadcrumbs) domain_tags = extract_domain_tags(title, breadcrumbs)
tags = build_tags(article_type, api_versions, domain_tags, group_key) tags = build_tags(article_type, api_versions, domain_tags, top_key)
# YAML frontmatter с безопасным экранированием строк
fm_lines = [ fm_lines = [
"---", "---",
f'title: "{_escape_yaml_str(title)}"', f'title: "{_escape_yaml_str(title)}"',
@@ -617,34 +777,10 @@ def render_article(group_key: str, pages_data: list[dict],
fm_lines.append("") fm_lines.append("")
md_parts = ["\n".join(fm_lines)] md_parts = ["\n".join(fm_lines)]
# Breadcrumbs — один раз в начале статьи
if breadcrumbs: if breadcrumbs:
bc_text = " > ".join(t for t, _ in breadcrumbs) bc_text = " > ".join(t for t, _ in breadcrumbs)
md_parts.append(f"*{bc_text}*\n") md_parts.append(f"*{bc_text}*\n")
md_parts.extend(body_parts)
for page_data in pages_data:
page_title = page_data["title"]
body_lines = list(page_data["body_lines"])
# Подзаголовок раздела для группы > 1 страницы
if len(pages_data) > 1:
md_parts.append(f"\n## {page_title}\n")
# Убираем дублирующуюся первую строку тела
if body_lines and body_lines[0].strip() == page_title.strip():
body_lines = body_lines[1:]
# Применяем code-fence постпроцессинг
body_lines = postprocess_code_blocks(body_lines)
# Изображения
for img_path, alt in page_data["image_srcs"]:
img_name = copy_image(img_path)
alt_clean = alt.replace('"', "'")
md_parts.append(f"![{alt_clean}](../resources/{img_name})\n")
md_parts.append("\n".join(body_lines))
md_parts.append("")
return "\n".join(md_parts) return "\n".join(md_parts)
@@ -697,23 +833,22 @@ def main():
(OUT_DIR / subdir).mkdir(parents=True, exist_ok=True) (OUT_DIR / subdir).mkdir(parents=True, exist_ok=True)
RESOURCES_DIR.mkdir(parents=True, exist_ok=True) RESOURCES_DIR.mkdir(parents=True, exist_ok=True)
pages = read_index() pages = read_pageinfo()
print(f"Страниц в индексе: {len(pages)}") print(f"Страниц в реестре: {len(pages)}")
groups = build_groups(pages) groups = build_groups(pages)
print(f"Групп после группировки: {len(groups)}") print(f"Групп после группировки: {len(groups)}")
stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0} stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0, "merged": 0}
index_entries: list[tuple[str, str, str]] = []
for group_key, group_pages in sorted(groups.items()): # ── Проход 1: парсинг всех групп в записи ────────────────────────────────
records: dict[str, dict] = {}
for group_key, group_pages in groups.items():
pages_data: list[dict] = [] pages_data: list[dict] = []
source_files: list[str] = [] source_files: list[str] = []
rep_title = "" rep_title = ""
for page_info in group_pages: for page_info in group_pages:
fname = page_info["file"] fpath = DOC_DIR / page_info["file"]
fpath = DOC_DIR / fname
if not fpath.exists(): if not fpath.exists():
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
@@ -722,24 +857,70 @@ def main():
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
pages_data.append(parsed) pages_data.append(parsed)
source_files.append(fname) source_files.append(page_info["file"])
if not rep_title: if not rep_title:
rep_title = parsed["title"] rep_title = parsed["title"]
stats["images"] += len(parsed["image_srcs"]) stats["images"] += len(parsed["image_srcs"])
if not pages_data: if not pages_data:
continue continue
article_type = classify_type(group_key, rep_title) article_type = classify_type(group_key, rep_title)
subdir = SUBDIR_FOR_TYPE.get(article_type, "guides") records[group_key] = {
out_name = group_key + ".md" "pages_data": pages_data,
out_path = OUT_DIR / subdir / out_name "source_files": source_files,
"rep_title": rep_title,
"article_type": article_type,
"subdir": SUBDIR_FOR_TYPE.get(article_type, "guides"),
}
# ── Детекция nav-страниц и резолв детей ──────────────────────────────────
parent_to_children: dict[str, list[str]] = {}
child_to_parent: dict[str, str] = {}
for key in sorted(records):
rec = records[key]
is_nav, refs = detect_nav(rec["pages_data"])
if not is_nav:
continue
children: list[str] = []
for ref in refs:
ck = get_group_key(ref.lower() + ".html")
if (ck in records and ck != key
and ck not in child_to_parent and ck not in parent_to_children):
children.append(ck)
child_to_parent[ck] = key
if children:
parent_to_children[key] = children
stats["merged"] += len(children)
# Категория слитой статьи = доминирующий тип (self + дети).
all_types = [rec["article_type"]] + [records[c]["article_type"] for c in children]
cnt = Counter(all_types)
best = max(all_types, key=lambda t: (cnt[t], TYPE_PRIORITY[t]))
rec["article_type"] = best
rec["subdir"] = SUBDIR_FOR_TYPE[best]
print(f"Nav-страниц: {len(parent_to_children)}, поглощено детей: {stats['merged']}")
# ── Проход 2: рендер всех групп, КРОМЕ поглощённых ───────────────────────
index_entries: list[tuple[str, str, str]] = []
used_names: dict[str, set] = defaultdict(set) # subdir → занятые имена
for group_key in sorted(records):
if group_key in child_to_parent:
continue # поглощён родителем
rec = records[group_key]
subdir = rec["subdir"]
# Слитые nav-родители: читаемое имя из заголовка вместо хеш-ключа.
if group_key in parent_to_children:
base = _slugify(rec["rep_title"])
out_name = base + ".md"
if out_name in used_names[subdir]:
out_name = f"{base}-{group_key}.md"
else:
out_name = group_key + ".md"
used_names[subdir].add(out_name)
out_path = OUT_DIR / subdir / out_name
try: try:
content = render_article(group_key, pages_data, article_type, source_files) content = render_article(group_key, records, parent_to_children)
out_path.write_text(content, encoding="utf-8") out_path.write_text(content, encoding="utf-8")
stats["processed"] += 1 stats["processed"] += 1
index_entries.append((subdir, out_name, rep_title)) index_entries.append((subdir, out_name, rec["rep_title"]))
except Exception as e: except Exception as e:
print(f" ⚠ Ошибка записи {out_name}: {e}", file=sys.stderr) print(f" ⚠ Ошибка записи {out_name}: {e}", file=sys.stderr)
stats["errors"] += 1 stats["errors"] += 1
@@ -748,6 +929,7 @@ def main():
print(f"\n✅ Done!") print(f"\n✅ Done!")
print(f" Статей записано: {stats['processed']}") print(f" Статей записано: {stats['processed']}")
print(f" Слито детей: {stats['merged']}")
print(f" Страниц пропущено: {stats['skipped']}") print(f" Страниц пропущено: {stats['skipped']}")
print(f" Ошибок: {stats['errors']}") print(f" Ошибок: {stats['errors']}")
print(f" Изображений: {stats['images']}") print(f" Изображений: {stats['images']}")
-426
View File
@@ -1,426 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
convert_sdk_docs.py — Экспорт справки KOMPAS SDK (Help & Manual WebHelp) в единый
KOMPAS_SDK_ru-RU.md.
Обрабатывает все HTML-страницы из docs/KOMPAS_SDK_ru-RU/, извлекая:
- Заголовок, хлебные крошки, тело топика
- Информативные изображения (сохраняются как base64 data-URI)
- Ссылки между страницами (преобразуются в якоря #page-N)
Результат: docs/KOMPAS_SDK_ru-RU.md — один большой Markdown-файл.
Изображения сохраняются ТОЛЬКО информативные (не UI/навигационные).
"""
import os
import sys
import re
import json
import base64
import html as hmod
from html.parser import HTMLParser
from pathlib import Path
# ─── Настройки ───────────────────────────────────────────────────────────────
HERE = Path(__file__).resolve().parent.parent
DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU"
INDEX_PATH = HERE / "docs" / "kompas_sdk_index.tsv"
OUTPUT_MD = HERE / "docs" / "KOMPAS_SDK_ru-RU.md"
# Изображения, которые НЕ являются информативными (UI-элементы навигации и т.п.)
UI_IMAGE_PREFIXES = [
# Навигация / UI
"leftright", "toc", "mob_", "TextPlus", "TextMinus", "TextNormal",
"ZoomIn", "ZoomClose", "spacer.gif", "bg.png", "page-bg.png",
"blackpaisley.jpeg", "banner_company", "kompas_logo_circle",
"kompas-logo", "internet.svg", "icon-new-window", "icon-open-file",
"warn", "note", "closehelp", "cicon_loadindex_ani", "kw-page",
"social_mail", "_a", "_praezession", "kbd.png", "page-icon",
"logo-image-desk", "logo-image-mobile", "close_data", "open_data",
]
# ─── Поиск информативных изображений ────────────────────────────────────────
def is_informative_image(src: str) -> bool:
"""Возвращает True, если изображение информативное (не UI)."""
basename = os.path.basename(src).lower()
for prefix in UI_IMAGE_PREFIXES:
if prefix.lower() in basename:
return False
# Также пропускаем tiny-изображения (< 500 байт) — обычно это spacer/иконки
img_path = DOC_DIR / src
if img_path.exists() and img_path.stat().st_size < 500:
return False
return True
# ─── Парсер для извлечения тела топика ──────────────────────────────────────
class TopicExtractor(HTMLParser):
"""Извлекает текст из <div id='topicbody'>, пропуская script/style."""
BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4",
"table", "pre", "td", "th"}
def __init__(self):
super().__init__(convert_char_refs=True)
self.depth = 0
self.capturing = False
self.skip = 0
self.out = []
def handle_starttag(self, tag, attrs):
attrd = dict(attrs)
if not self.capturing and attrd.get("id") == "topicbody":
self.capturing = True
self.depth = 1
return
if not self.capturing:
return
if tag in ("script", "style"):
self.skip += 1
if tag in self.BLOCK_TAGS:
self.out.append("\n")
if tag != "br":
self.depth += 1
def handle_endtag(self, tag):
if not self.capturing:
return
if tag in ("script", "style") and self.skip:
self.skip -= 1
if tag != "br":
self.depth -= 1
if self.depth <= 0:
self.capturing = False
def handle_data(self, data):
if self.capturing and not self.skip:
self.out.append(data)
# ─── Шум и подвал ────────────────────────────────────────────────────────────
NOISE_WORDS = {
"please enable javascript to view this site.",
"scroll", "предыдущий", "вверх", "следующий",
"содержание", "содержане", "указатель", "поиск",
"подразделы:", "(отсутствуют)", "поделиться",
}
FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН")
def clean_text(raw: str) -> list[str]:
"""Очищает текст от шума и подвала, возвращает список строк."""
cleaned = []
for line in raw.splitlines():
s = re.sub(r"[ \t]+", " ", line).strip()
if not s:
continue
if s.lower() in NOISE_WORDS:
continue
if FOOTER_RE.search(s):
break
cleaned.append(s)
return cleaned
# ─── Извлечение изображений как base64 data-URI ──────────────────────────────
def extract_images(html_content: str) -> list[tuple[str, str]]:
"""
Возвращает список (data_uri, alt_text) для информативных изображений.
data_uri — строка вида 'data:image/png;base64,...'
"""
images = []
for m in re.finditer(r'<img[^>]+>', html_content):
tag = m.group()
src_m = re.search(r'''src=["']([^"']+)["']''', tag)
alt_m = re.search(r'''alt=["']([^"']*)["']''', tag)
if not src_m:
continue
src = src_m.group(1)
alt = alt_m.group(1) if alt_m else ""
if not is_informative_image(src):
continue
img_path = DOC_DIR / src
if not img_path.exists():
continue
ext = os.path.splitext(src)[1].lower().lstrip(".")
mime_map = {
"jpg": "image/jpeg", "jpeg": "image/jpeg",
"png": "image/png", "gif": "image/gif",
"svg": "image/svg+xml", "webp": "image/webp",
"bmp": "image/bmp",
}
mime = mime_map.get(ext, f"image/{ext}")
data = base64.b64encode(img_path.read_bytes()).decode("ascii")
data_uri = f"data:{mime};base64,{data}"
images.append((data_uri, alt))
return images
# ─── Извлечение заголовка и хлебных крошек ───────────────────────────────────
def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]:
"""
Возвращает (title, breadcrumbs).
breadcrumbs — список (текст, filename) для каждой ступени.
"""
# Title из <title> или <p class="topictitle">
title = ""
for m in re.finditer(r'<title>(.*?)</title>', html_content):
title = hmod.unescape(m.group(1)).strip()
break
if not title:
for m in re.finditer(r'<p\s+class="topictitle"[^>]*>(.*?)</p>', html_content, re.DOTALL):
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
title = hmod.unescape(title)
break
# Breadcrumbs из <p id="ptopic_breadcrumbs">
breadcrumbs = []
bc_match = re.search(
r'<p\s+id="ptopic_breadcrumbs"[^>]*>(.*?)</p>',
html_content, re.DOTALL,
)
if bc_match:
bc_html = bc_match.group(1)
for a_m in re.finditer(r'<a[^>]+href="([^"]*)"[^>]*>(.*?)</a>', bc_html, re.DOTALL):
href = a_m.group(1)
text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip()
text = hmod.unescape(text)
if text:
breadcrumbs.append((text, os.path.basename(href)))
return title, breadcrumbs
# ─── Преобразование ссылок в якоря ──────────────────────────────────────────
def convert_links(md_lines: list[str], page_index: int) -> list[str]:
"""Преобразует [текст](file.html) → [текст](#page-{index}) в Markdown."""
result = []
for line in md_lines:
# Преобразуем ссылки на другие HTML-страницы в якоря
def replace_link(m):
href = m.group(1)
text = m.group(2)
fname = os.path.basename(href).replace(".html", "")
return f"[{text}](#page-{fname})"
line = re.sub(r'\[([^\]]+)\]\(([^)]+\.html[^)]*)\)', replace_link, line)
result.append(line)
return result
# ─── Преобразование HTML-сущностей в читаемый текст ──────────────────────────
def unescape_html(s: str) -> str:
"""Полностью раскрывает HTML-сущности."""
# Сначала &nbsp; и неразрывные пробелы
s = s.replace("&nbsp;", " ")
s = s.replace("&#160;", " ")
s = s.replace("&shy;", "")
s = s.replace("&mdash;", "")
s = s.replace("&ndash;", "")
s = s.replace("&laquo;", "«")
s = s.replace("&raquo;", "»")
s = s.replace("&quot;", '"')
s = s.replace("&#39;", "'")
s = s.replace("&apos;", "'")
s = s.replace("&lt;", "<")
s = s = s.replace("&gt;", ">")
return hmod.unescape(s)
# ─── Основной парсер одной страницы ──────────────────────────────────────────
def parse_page(html_content: str, page_index: int) -> dict | None:
"""
Парсит одну HTML-страницу и возвращает dict с полями:
title, breadcrumbs, body_lines (список строк Markdown),
images (list of data-uri), href_base (filename без .html).
Возвращает None, если страница не содержит полезного контента.
"""
# Извлекаем заголовок и хлебные крошки
title, breadcrumbs = extract_title_and_breadcrumbs(html_content)
# Извлекаем тело топика
extractor = TopicExtractor()
extractor.feed(html_content)
raw_text = "".join(extractor.out)
# Очищаем текст
body_lines = clean_text(raw_text)
# Если нет полезного контента — пропускаем
if not body_lines or len(body_lines) <= 2:
return None
# Извлекаем изображения
images = extract_images(html_content)
# Преобразуем ссылки
body_lines = convert_links(body_lines, page_index)
# Определяем basename для якорей
href_base_match = re.search(r'<link\s+rel="canonical"\s+href="[^"]*([^/]+\.html)"', html_content)
if not href_base_match:
# fallback: берём из breadcrumbs или URL
href_base_match = re.search(r'href="([^/]*\.html)"', html_content)
href_base = ""
if href_base_match:
href_base = os.path.basename(href_base_match.group(1)).replace(".html", "")
elif breadcrumbs:
href_base = os.path.basename(breadcrumbs[-1][1]).replace(".html", "")
return {
"title": title,
"breadcrumbs": breadcrumbs,
"body_lines": body_lines,
"images": images,
"href_base": href_base,
}
# ─── Построение единого Markdown-файла ───────────────────────────────────────
def build_index() -> list[dict]:
"""Строит индекс из zoom_pageinfo.js."""
if not INDEX_PATH.exists():
print("Индекс отсутствует — строю...", file=sys.stderr)
# Парсим zoom_pageinfo.js напрямую
pageinfo_path = DOC_DIR / "zoom_pageinfo.js"
with open(pageinfo_path, "r", encoding="utf-8") as f:
text = f.read()
marker = "pagedata = "
pos = text.find(marker)
if pos < 0:
sys.exit(f"Не найден pagedata в {pageinfo_path}")
payload = text[pos + len(marker):].strip().rstrip(";").strip()
data = json.loads(payload)
rows = []
for entry in data:
if not entry or not entry[0] or entry[0] == 0:
continue
fname = entry[0].lstrip("./").strip()
title = hmod.unescape((entry[1] or "").strip())
desc = hmod.unescape((entry[2] or "").strip())[:300]
rows.append({"file": fname, "title": title, "desc": desc})
with open(INDEX_PATH, "w", encoding="utf-8", newline="\n") as f:
for r in rows:
f.write(f"{r['file']}\t{r['title']}\t{r['desc']}\n")
# Читаем индекс
pages = []
with open(INDEX_PATH, "r", encoding="utf-8") as f:
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) >= 1:
pages.append({"file": parts[0], "title": parts[1] if len(parts) > 1 else "",
"desc": parts[2] if len(parts) > 2 else ""})
return pages
def main():
print(f"Каталог справки: {DOC_DIR}")
print(f"Выходной файл: {OUTPUT_MD}")
# Строим индекс если нужно
pages = build_index()
print(f"Страниц в индексе: {len(pages)}")
# Заголовок документа
md_parts = []
md_parts.append("# KOMPAS SDK — Справочная система КОМПАС-3D\n")
md_parts.append(f"*Автоматически экспортировано из Help & Manual WebHelp ({len(pages)} страниц).*\n")
# Оглавление (только страницы с заголовками)
toc_entries = []
processed_count = 0
skipped_count = 0
image_count = 0
for i, page_info in enumerate(pages):
fname = page_info["file"]
fpath = DOC_DIR / fname
if not fpath.exists():
skipped_count += 1
continue
try:
html_content = fpath.read_text(encoding="utf-8-sig")
except Exception as e:
print(f" ⚠ Не удалось прочитать {fname}: {e}", file=sys.stderr)
skipped_count += 1
continue
parsed = parse_page(html_content, i)
if parsed is None:
skipped_count += 1
continue
processed_count += 1
href_base = parsed["href_base"] or f"page-{i}"
# Собираем заголовок для TOC
title = parsed["title"] or fname.replace(".html", "").replace("_", " ")
toc_entries.append(f" - [{title}](#{href_base})")
# Разделитель страницы
md_parts.append(f"\n---\n\n")
md_parts.append(f"## {title}\n")
md_parts.append(f"<a id=\"{href_base}\" name=\"{href_base}\"></a>\n")
# Хлебные крошки
if parsed["breadcrumbs"]:
bc_text = " > ".join(t for t, _ in parsed["breadcrumbs"])
md_parts.append(f"*{bc_text}*\n")
# Изображения (вставляются перед телом или в нужных местах)
if parsed["images"]:
for data_uri, alt in parsed["images"]:
alt_clean = alt.replace('"', "'").replace("'", "\\'")
md_parts.append(f"![{alt_clean}]({data_uri})\n")
image_count += 1
# Тело страницы
for line in parsed["body_lines"]:
line_unescaped = unescape_html(line)
# Убираем лишние пробелы, но сохраняем структуру
md_parts.append(f"{line_unescaped}\n")
# Добавляем оглавление в начало (после заголовка)
final_md = md_parts[:2] # Заголовок + подзаголовок
final_md.append("## Оглавление\n")
final_md.extend(toc_entries)
final_md.extend(md_parts[2:])
# Записываем файл
output_text = "\n".join(final_md)
OUTPUT_MD.write_text(output_text, encoding="utf-8")
print(f"\n✅ Готово!")
print(f" Обработано страниц: {processed_count}")
print(f" Пропущено (пустые/ошибки): {skipped_count}")
print(f" Сохранено изображений: {image_count}")
file_size = OUTPUT_MD.stat().st_size
print(f" Размер файла: {file_size / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
main()
-470
View File
@@ -1,470 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
convert_sdk_to_md.py — Экспорт справки KOMPAS SDK (Help & Manual WebHelp) в
отдельные Markdown-файлы.
Каждая HTML-страница из docs/KOMPAS_SDK_ru-RU/ конвертируется в отдельный
.md файл в docs/KOMPAS_SDK_ru-RU/md/. Информативные изображения сохраняются
как base64 data-URI прямо в Markdown (inline).
Изображения, которые НЕ являются информативными (UI, навигация и т.п.), пропускаются.
"""
import os
import sys
import re
import json
import base64
import html as hmod
from html.parser import HTMLParser
from pathlib import Path
# ─── Настройки ───────────────────────────────────────────────────────────────
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8")
except Exception:
pass
HERE = Path(__file__).resolve().parent.parent
DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU"
INDEX_PATH = HERE / "docs" / "kompas_sdk_index.tsv"
OUTPUT_DIR = DOC_DIR / "md" # docs/KOMPAS_SDK_ru-RU/md/
# Изображения, которые НЕ являются информативными (UI-элементы навигации и т.п.)
UI_IMAGE_PREFIXES = [
"leftright", "toc", "mob_", "TextPlus", "TextMinus", "TextNormal",
"ZoomIn", "ZoomClose", "spacer.gif", "bg.png", "page-bg.png",
"blackpaisley.jpeg", "banner_company", "kompas_logo_circle",
"kompas-logo", "internet.svg", "icon-new-window", "icon-open-file",
"warn", "note", "closehelp", "cicon_loadindex_ani", "kw-page",
"social_mail", "_a", "_praezession", "kbd.png", "page-icon",
"logo-image-desk", "logo-image-mobile", "close_data", "open_data",
]
# ─── Проверка: информативное ли изображение ──────────────────────────────────
def is_informative_image(src: str) -> bool:
basename = os.path.basename(src).lower()
for prefix in UI_IMAGE_PREFIXES:
if prefix.lower() in basename:
return False
img_path = DOC_DIR / src
if img_path.exists() and img_path.stat().st_size < 500:
return False
return True
# ─── Парсер тела топика ─────────────────────────────────────────────────────
class TopicExtractor(HTMLParser):
BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4",
"table", "pre", "td", "th"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0
self.capturing = False
self.skip = 0
self.out = []
def handle_starttag(self, tag, attrs):
attrd = dict(attrs)
if not self.capturing and attrd.get("id") == "topicbody":
self.capturing = True
self.depth = 1
return
if not self.capturing:
return
if tag in ("script", "style"):
self.skip += 1
if tag in self.BLOCK_TAGS:
self.out.append("\n")
if tag != "br":
self.depth += 1
def handle_endtag(self, tag):
if not self.capturing:
return
if tag in ("script", "style") and self.skip:
self.skip -= 1
if tag != "br":
self.depth -= 1
if self.depth <= 0:
self.capturing = False
def handle_data(self, data):
if self.capturing and not self.skip:
self.out.append(data)
# ─── Шум и подвал ────────────────────────────────────────────────────────────
NOISE_WORDS = {
"please enable javascript to view this site.",
"scroll", "предыдущий", "вверх", "следующий",
"содержание", "содержане", "указатель", "поиск",
"подразделы:", "(отсутствуют)", "поделиться",
}
FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН")
def clean_text(raw: str) -> list[str]:
cleaned = []
for line in raw.splitlines():
s = re.sub(r"[ \t]+", " ", line).strip()
if not s:
continue
if s.lower() in NOISE_WORDS:
continue
if FOOTER_RE.search(s):
break
cleaned.append(s)
return cleaned
# ─── Извлечение изображений как base64 data-URI ──────────────────────────────
def extract_images(html_content: str) -> list[tuple[str, str]]:
images = []
for m in re.finditer(r'<img[^>]+>', html_content):
tag = m.group()
src_m = re.search(r'''src=["']([^"']+)["']''', tag)
alt_m = re.search(r'''alt=["']([^"']*)["']''', tag)
if not src_m:
continue
src = src_m.group(1)
alt = alt_m.group(1) if alt_m else ""
if not is_informative_image(src):
continue
img_path = DOC_DIR / src
if not img_path.exists():
continue
ext = os.path.splitext(src)[1].lower().lstrip(".")
mime_map = {
"jpg": "image/jpeg", "jpeg": "image/jpeg",
"png": "image/png", "gif": "image/gif",
"svg": "image/svg+xml", "webp": "image/webp",
"bmp": "image/bmp",
}
mime = mime_map.get(ext, f"image/{ext}")
data = base64.b64encode(img_path.read_bytes()).decode("ascii")
data_uri = f"data:{mime};base64,{data}"
images.append((data_uri, alt))
return images
# ─── Заголовок и хлебные крошки ──────────────────────────────────────────────
def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]:
title = ""
for m in re.finditer(r'<title>(.*?)</title>', html_content):
title = hmod.unescape(m.group(1)).strip()
break
if not title:
for m in re.finditer(r'<p\s+class="topictitle"[^>]*>(.*?)</p>', html_content, re.DOTALL):
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
title = hmod.unescape(title)
break
breadcrumbs = []
bc_match = re.search(
r'<p\s+id="ptopic_breadcrumbs"[^>]*>(.*?)</p>',
html_content, re.DOTALL,
)
if bc_match:
bc_html = bc_match.group(1)
for a_m in re.finditer(r'<a[^>]+href="([^"]*)"[^>]*>(.*?)</a>', bc_html, re.DOTALL):
href = a_m.group(1)
text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip()
text = hmod.unescape(text)
if text:
breadcrumbs.append((text, os.path.basename(href)))
return title, breadcrumbs
# ─── Раскрытие HTML-сущностей ────────────────────────────────────────────────
def unescape_html(s: str) -> str:
s = s.replace("&nbsp;", " ")
s = s.replace("&#160;", " ")
s = s.replace("&shy;", "")
s = s.replace("&mdash;", "")
s = s.replace("&ndash;", "")
s = s.replace("&laquo;", "«")
s = s.replace("&raquo;", "»")
s = s.replace("&quot;", '"')
s = s.replace("&#39;", "'")
s = s.replace("&apos;", "'")
s = s.replace("&lt;", "<")
s = s.replace("&gt;", ">")
return hmod.unescape(s)
# ─── Извлечение ссылок из HTML-тела топика ──────────────────────────────────
def extract_links_from_html(html_content: str) -> list[tuple[str, str]]:
"""
Находит все <a href="..."> в topicbody и возвращает список
[(anchor_text, href), ...] для последующей подстановки.
anchor_text — текст ссылки без HTML-тегов.
"""
# Ищем всё содержимое после id="topicbody" до <div class="topicfooter">
body_match = re.search(
r'id="topicbody">(.*?)(?:<div\s+class="topicfooter"|</body>)',
html_content, re.DOTALL)
if not body_match:
return []
body_html = body_match.group(1)
links = []
for a_m in re.finditer(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', body_html, re.DOTALL):
href = a_m.group(1)
if not href.endswith('.html'):
continue
text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip()
text = hmod.unescape(text)
if text:
links.append((text, href))
return links
# ─── Преобразование ссылок в тексте Markdown ────────────────────────────────
def convert_links_in_text(text: str, links: list[tuple[str, str]]) -> str:
"""
Заменяет [текст](file.html) на [текст](./file.md).
Также заменяет plain-text ссылки (просто текст, совпадающий с anchor).
"""
def replace_link(m):
link_text = m.group(1)
href = m.group(2)
fname = os.path.basename(href).replace(".html", ".md")
return f"[{link_text}](./{fname})"
text = re.sub(r'\[([^\]]+)\]\(([^)]+\.html[^)]*)\)', replace_link, text)
# Заменяем "текст (file.html)" → "[текст](./file.md)"
for link_text, href in links:
fname = os.path.basename(href).replace(".html", ".md")
old = f"{link_text} ({href})"
new = f"[{link_text}](./{fname})"
if old in text:
text = text.replace(old, new)
# Заменяем plain-text ссылки: текст, стоящий рядом со ссылкой,
# но без скобок — просто "Текст" на строке, где есть иная ссылка.
# Это покрывает случаи вида "...свойству IApplication::Visible значение TRUE."
# где IApplication::Visible — ссылка.
for link_text, href in links:
fname = os.path.basename(href).replace(".html", ".md")
try:
# Ищем точное совпадение текста ссылки (не внутри [[...]] или [..](..))
# Пропускаем, если текст уже в Markdown-формате [text](url)
pattern = r'(?<!\[)(?<!\]\()(?<!\])(?<!\()(' + re.escape(link_text) + r')(?!\w)(?!\]\(|\])'
replacement = f"[{link_text}](./{fname})"
text = re.sub(pattern, replacement, text)
except re.error:
# Если regex не сработал (нестандартные символы), пропускаем
pass
return text
# ─── Парсинг одной страницы ──────────────────────────────────────────────────
def parse_page(html_content: str) -> dict | None:
title, breadcrumbs = extract_title_and_breadcrumbs(html_content)
# Извлекаем ссылки из HTML ДО очистки от тегов
links = extract_links_from_html(html_content)
extractor = TopicExtractor()
extractor.feed(html_content)
raw_text = "".join(extractor.out)
body_lines = clean_text(raw_text)
if not body_lines or len(body_lines) <= 2:
return None
images = extract_images(html_content)
# Определяем basename для имени выходного файла
href_base_match = re.search(r'<link\s+rel="canonical"\s+href="[^"]*([^/]+\.html)"', html_content)
if not href_base_match:
href_base_match = re.search(r'href="([^/]*\.html)"', html_content)
href_base = ""
if href_base_match:
href_base = os.path.basename(href_base_match.group(1))
elif breadcrumbs:
href_base = breadcrumbs[-1][1]
if not href_base:
href_base = "index.html"
# Преобразуем ссылки в относительные .md
cleaned_body = []
for line in body_lines:
unescaped = unescape_html(line)
converted = convert_links_in_text(unescaped, links)
cleaned_body.append(converted)
return {
"title": title,
"breadcrumbs": breadcrumbs,
"body_lines": cleaned_body,
"images": images,
"links": links,
"href_base": href_base,
}
# ─── Построение индекса из zoom_pageinfo.js ──────────────────────────────────
def build_index() -> list[dict]:
if INDEX_PATH.exists():
pages = []
with open(INDEX_PATH, "r", encoding="utf-8") as f:
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) >= 1:
pages.append({"file": parts[0], "title": parts[1] if len(parts) > 1 else "",
"desc": parts[2] if len(parts) > 2 else ""})
return pages
pageinfo_path = DOC_DIR / "zoom_pageinfo.js"
with open(pageinfo_path, "r", encoding="utf-8") as f:
text = f.read()
marker = "pagedata = "
pos = text.find(marker)
if pos < 0:
sys.exit(f"Не найден pagedata в {pageinfo_path}")
payload = text[pos + len(marker):].strip().rstrip(";").strip()
data = json.loads(payload)
rows = []
for entry in data:
if not entry or not entry[0] or entry[0] == 0:
continue
fname = entry[0].lstrip("./").strip()
title = hmod.unescape((entry[1] or "").strip())
desc = hmod.unescape((entry[2] or "").strip())[:300]
rows.append({"file": fname, "title": title, "desc": desc})
with open(INDEX_PATH, "w", encoding="utf-8", newline="\n") as f:
for r in rows:
f.write(f"{r['file']}\t{r['title']}\t{r['desc']}\n")
return rows
# ─── Генерация YAML-фронтматтера ─────────────────────────────────────────────
def frontmatter(title: str, breadcrumbs: list[tuple[str, str]],
source_file: str) -> str:
lines = [f"---", f"title: \"{title}\"", f"source: {source_file}"]
if breadcrumbs:
lines.append(f"breadcrumbs:")
for text, fname in breadcrumbs:
lines.append(f' - text: "{text}"')
lines.append(f" file: {fname}")
lines.append("---")
return "\n".join(lines)
# ─── Основная функция ────────────────────────────────────────────────────────
def main():
print(f"Справка: {DOC_DIR}")
print(f"Выходной каталог: {OUTPUT_DIR}")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
pages = build_index()
print(f"Страниц в индексе: {len(pages)}")
processed = 0
skipped = 0
total_images = 0
errors = 0
pages_with_links = 0
for i, page_info in enumerate(pages):
fname = page_info["file"]
fpath = DOC_DIR / fname
out_path = OUTPUT_DIR / fname.replace(".html", ".md")
if not fpath.exists():
skipped += 1
continue
try:
html_content = fpath.read_text(encoding="utf-8-sig")
except Exception as e:
print(f" ⚠ Ошибка чтения {fname}: {e}", file=sys.stderr)
errors += 1
skipped += 1
continue
try:
parsed = parse_page(html_content)
except Exception as e:
print(f" ⚠ Parse error {fname}: {e}", file=sys.stderr)
errors += 1
skipped += 1
continue
if parsed is None:
skipped += 1
continue
if parsed["links"]:
pages_with_links += 1
# Генерируем MD-контент
md_parts = []
md_parts.append(frontmatter(
parsed["title"], parsed["breadcrumbs"], fname))
md_parts.append("")
# Хлебные крошки (как текст)
if parsed["breadcrumbs"]:
bc_text = " > ".join(t for t, _ in parsed["breadcrumbs"])
md_parts.append(f"*{bc_text}*")
md_parts.append("")
# Изображения
if parsed["images"]:
for data_uri, alt in parsed["images"]:
alt_clean = alt.replace('"', "'").replace("'", "\\'")
md_parts.append(f"![{alt_clean}]({data_uri})")
md_parts.append("")
total_images += 1
# Тело страницы
md_parts.extend(parsed["body_lines"])
md_parts.append("") # trailing newline
output_text = "\n".join(md_parts)
out_path.write_text(output_text, encoding="utf-8")
processed += 1
print(f"\nDone!")
print(f" Processed pages: {processed}")
print(f" Skipped (empty/errors): {skipped}")
print(f" Read errors: {errors}")
print(f" Images saved: {total_images}")
print(f" Pages with links: {pages_with_links}")
if __name__ == "__main__":
main()