diff --git a/.claude/skills/kompas-sdk-research/SKILL.md b/.claude/skills/kompas-sdk-research/SKILL.md index ad6c13e..b7965c6 100644 --- a/.claude/skills/kompas-sdk-research/SKILL.md +++ b/.claude/skills/kompas-sdk-research/SKILL.md @@ -1,8 +1,9 @@ --- name: kompas-sdk-research description: > - Делегирует поиск и чтение офлайн-справки КОМПАС SDK - (docs/KOMPAS_SDK_ru-RU через приложенный kdoc.py) субагенту на модели Sonnet. + Делегирует поиск и чтение справки КОМПАС SDK субагенту на модели Sonnet. + Источник — структурированная MD-база знаний docs/Kompas3D_SDK/ (статьи по + интерфейсам с YAML-тегами, ищется обычным Grep/Read). Используй ВСЕГДА, когда нужно найти интерфейс/метод/константу COM API КОМПАС, его сигнатуру (Automation/COM), значения перечисления, единицы измерения или паттерн использования — не рой справку сам, передавай Sonnet и получай @@ -12,10 +13,11 @@ description: > # kompas-sdk-research — делегирование исследования справки КОМПАС SDK Sonnet -Справка `docs/KOMPAS_SDK_ru-RU/` — это WebHelp-экспорт на ~26 000 страниц сырого -HTML с шумом и криптическими именами файлов. Рыть её вручную дорого по контексту. +Справка КОМПАС SDK сконвертирована в структурированную MD-базу знаний +`docs/Kompas3D_SDK/` — статьи сгруппированы по COM-интерфейсам, с YAML-тегами для +поиска, чистым текстом, развёрнутыми блоками синтаксиса и реальными изображениями. Этот навык позволяет Opus не тратить контекст на поиск: опиши, что нужно узнать, — -Sonnet прогонит приложенный `kdoc.py`, прочитает нужные топики и вернёт сжатый ответ +Sonnet прогонит Grep/Read по базе, прочитает нужную статью и вернёт сжатый ответ (интерфейс, сигнатура, параметры, значения констант, источник). ## Когда применять @@ -27,6 +29,25 @@ Sonnet прогонит приложенный `kdoc.py`, прочитает н Не применяй для правки документации проекта — это делает навык **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. Сформулируй точный вопрос @@ -52,10 +73,11 @@ prompt: <см. шаблон ниже> --- -Ты — помощник по навигации в офлайн-справке **КОМПАС SDK** в репозитории +Ты — помощник по навигации в справке **КОМПАС SDK** в репозитории **kompas3d-mcp** (MCP-сервер для КОМПАС-3D через COM API, .NET 8, C#). -Скрипт навигатора лежит в этом навыке: `.claude/skills/kompas-sdk-research/kdoc.py`; -индекс — `docs/kompas_sdk_index.tsv`. Запускай из корня репозитория. +Справка — это MD-база знаний `docs/Kompas3D_SDK/`: статьи по интерфейсам с +YAML-фронтматтером (`type`, `api`, `domain`, `tags`, `sources`); методы и +свойства — заголовки `## ` внутри статьи интерфейса. **Вопрос для исследования:** @@ -65,34 +87,37 @@ prompt: <см. шаблон ниже> **Инструменты (используй именно их, не веб):** -``` -python .claude/skills/kompas-sdk-research/kdoc.py search "<ключевые слова>" # печатает "file.htmlЗаголовок" -python .claude/skills/kompas-sdk-research/kdoc.py read # чистый текст топика (без разметки) -``` +- **Grep** по `docs/Kompas3D_SDK/` — ищи имя интерфейса/метода/свойства, русское + ключевое слово или тег. Имена методов — это заголовки `## SetSideParam - …`, + поэтому поиск по имени метода сразу ведёт в нужную статью. +- **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` (`файлзаголовоквыдержка`) можно искать и -обычным grep — удобно, когда нужно веером пройтись по многим именам интерфейсов. Точные числовые значения констант при необходимости сверяй в заголовках SDK: `C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK\Include\*.h` (например `ksConstants*.h`, `ldefin2d.h`). -**Критично — два параллельных зеркала одних интерфейсов:** -- файлы **без** префикса (`iapplication_*`, `kspart_*`, `ksmassinertiaparam_*`) — это - **COM API7/API5**, которое использует проект. **Бери эти.** -- файлы с префиксом **`ksapi_*`** — кроссплатформенный Qt/C++ **KsAPI** (не COM). **Игнорируй**, - если задача явно не про KsAPI. +**Важно — что в базе и чего нет:** +- База содержит **COM API7/API5** — то, что использует проект (файлы `iapplication*`, + `kspart*`, `ksmassinertiaparam*` и т.п.). Это твой основной источник. +- Кроссплатформенный Qt/C++ **KsAPI** (`ksapi_*`) в базу **не включён** — если задача + явно про KsAPI, скажи об этом (нужен отдельный источник). **Правила:** -1. Не угадывай — находи через `kdoc.py` и читай первоисточник. -2. Верни **сжатую выжимку**, а не дамп страниц: +1. Не угадывай — находи через Grep/Read по `docs/Kompas3D_SDK/` и читай первоисточник. +2. Верни **сжатую выжимку**, а не дамп статьи: - имя интерфейса и метода/свойства; - синтаксис Automation (и COM, если важен порядок out-параметров); - параметры (имя, тип, смысл) и возвращаемое значение; - для enum/констант — пары «имя = значение» и их смысл; - важные примечания (единицы измерения, побочные эффекты, версия КОМПАС); - - имя(имена) исходных `*.html` как ссылку на источник. + - имя статьи / `sources` из фронтматтера как ссылку на источник. 3. Если у метода несколько перегрузок или связанных интерфейсов — перечисли релевантные. 4. Если в COM-interop имя отличается от справки (частый случай в этом проекте), отметь это как вероятную тонкость и предложи свериться рефлексией по `libs/kompas-interop/*.dll`. diff --git a/.claude/skills/kompas-sdk-research/kdoc.py b/.claude/skills/kompas-sdk-research/kdoc.py deleted file mode 100644 index b4b9718..0000000 --- a/.claude/skills/kompas-sdk-research/kdoc.py +++ /dev/null @@ -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 - (файлзаголовоккраткое описание) из 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// → /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): - """Собирает текст из
, пропуская 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() diff --git a/.gitignore b/.gitignore index 9172390..7a1f0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,13 +60,12 @@ usecases/ # ============================================================================ # Офлайн-зеркало справки SDK КОМПАС-3D (~1.4 ГБ, 26k+ файлов, © АСКОН). -# Лежит локально; для навигации см. .claude/skills/kompas-sdk-research/kdoc.py. Скачивается отдельно. +# Источник для генерации MD-базы (см. ниже). Лежит локально, скачивается отдельно. docs/KOMPAS_SDK_ru-RU/ -# Индекс справки, генерируется командой: python .claude/skills/kompas-sdk-research/kdoc.py build-index -docs/kompas_sdk_index.tsv - -# MD-база знаний SDK, генерируется командой: python tools/build_kompas3d_sdk.py +# MD-база знаний SDK, генерируется из зеркала выше командой: +# python tools/build_kompas3d_sdk.py +# Для поиска по ней — навык .claude/skills/kompas-sdk-research. docs/Kompas3D_SDK/ # ============================================================================ diff --git a/CLAUDE.md b/CLAUDE.md index 5409199..e5b8220 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. 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 - 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:** -``` -python .claude/skills/kompas-sdk-research/kdoc.py search "" # find pages: prints "file.html\tTitle" -python .claude/skills/kompas-sdk-research/kdoc.py read # print clean topic text (no markup) -python .claude/skills/kompas-sdk-research/kdoc.py build-index # regenerate the index (only if docs change) -``` +- **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). +- **Read** the matching `*.md` — the interface article holds all its members, `Синтаксис Automation:`/`Синтаксис COM:` blocks (in code fences), parameters, notes. +- `docs/Kompas3D_SDK/index.md` — table of contents by category. Filter by frontmatter tags, e.g. `Grep "type: enum"` or `Grep "domain: \[.*3d"`. -`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` (`filenametitleexcerpt`) and can also be searched with the Grep tool directly — handy for fanning out over many interface names at once. - -**The docs contain 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. -- *«API интерфейсов. KsAPI»* — `ksapi_*`-prefixed filenames: the cross-platform Qt/C++ **KsAPI** flavour. Same concepts, different (non-COM) binding — ignore unless explicitly working with KsAPI. +**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. +- *«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. ## Current state diff --git a/README.md b/README.md index 798e884..acd5a98 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключен src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов tests/Kompas.Mcp.Tests/ 60 тестов: unit + integration (integration требуют КОМПАС) libs/kompas-interop/ вендорские interop-сборки КОМПАС (из SDK Samples/Common) -docs/ архитектура, план, презентация, навигатор по справке SDK +docs/ архитектура, план, презентация, MD-база знаний SDK (Kompas3D_SDK/) usecases/ полигон обкатки подходов (в .gitignore); приёмы поднимаются в навык kompas-3d -.claude/skills/ навыки: docs-delegate, kompas-sdk-research (+ kdoc.py), kompas-3d (методика) +.claude/skills/ навыки: docs-delegate, kompas-sdk-research, kompas-3d (методика) ``` ## Тесты diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8d2a85..b1ca604 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ > Статус: **реализовано и работает** (v1+v2+STEP/assembly+direct-edit+inspection). Актуализировано по коду. > Сопутствующий контекст по 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`). --- diff --git a/tools/build_kompas3d_sdk.py b/tools/build_kompas3d_sdk.py index 35acf64..24df607 100644 --- a/tools/build_kompas3d_sdk.py +++ b/tools/build_kompas3d_sdk.py @@ -20,16 +20,19 @@ build_kompas3d_sdk.py — Преобразование KOMPAS SDK HTML-спра import os import sys import re +import json import shutil import html as hmod from html.parser import HTMLParser from pathlib import Path -from collections import defaultdict +from collections import defaultdict, Counter from typing import Optional HERE = Path(__file__).resolve().parent.parent 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" RESOURCES_DIR = OUT_DIR / "resources" @@ -56,6 +59,25 @@ BREADCRUMB_RE = re.compile( r"Automation|COM|Объект|Параметры 3D|Параметры 2D).*?>", re.IGNORECASE, ) +# Обрезанная навигационная ссылка «назад к родителю» (Интерфейс...). +# Шум, повторяется в начале КАЖДОЙ секции метода/свойства. +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-страницы (пропускаются — дублируют содержимое) ───────────────────── @@ -299,6 +321,10 @@ def clean_text(raw: str) -> list[str]: break if ">" in s and BREADCRUMB_RE.match(s): continue + if NAV_STUB_RE.match(s): + continue + if TOC_TAIL_RE.match(s): + continue cleaned.append(s) if in_fence: 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]: """ Оборачивает секции «Синтаксис» и «Пример» в MD code-fence блоки. Существующие ``` fence-блоки (от
/) пропускаются.
+    Табличные строки внутри fence разворачиваются в плоский текст.
     """
     result: list[str] = []
     in_code = False
@@ -383,7 +427,14 @@ def postprocess_code_blocks(lines: list[str]) -> list[str]:
             i += 1
             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
 
     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] = []
-    with open(INDEX_PATH, "r", encoding="utf-8") as f:
-        for line in f:
-            parts = line.rstrip("\n").split("\t")
-            if len(parts) >= 1 and parts[0]:
-                pages.append({
-                    "file":  parts[0],
-                    "title": parts[1] if len(parts) > 1 else "",
-                    "desc":  parts[2] if len(parts) > 2 else "",
-                })
+    for entry in data:
+        if not entry or not entry[0]:
+            continue
+        fname = entry[0].lstrip("./").strip()
+        title = re.sub(r"\s+", " ", hmod.unescape((entry[1] or "").strip()))
+        desc = re.sub(r"\s+", " ", hmod.unescape((entry[2] or "").strip()))[:300]
+        pages.append({"file": fname, "title": title, "desc": desc})
     return pages
 
 
@@ -466,6 +530,25 @@ def build_groups(pages: list[dict]) -> dict[str, list[dict]]:
     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:
@@ -589,18 +672,95 @@ def _escape_yaml_str(s: str) -> str:
     return s.replace("\\", "\\\\").replace('"', '\\"')
 
 
-def render_article(group_key: str, pages_data: list[dict],
-                   article_type: str, source_files: list[str]) -> str:
-    """Генерирует содержимое MD-файла для группы страниц."""
-    rep = pages_data[0]
-    title = rep["title"]
-    breadcrumbs = rep["breadcrumbs"]
+# Приоритет типа при выборе категории слитого nav-родителя (tie-break).
+TYPE_PRIORITY = {"interface": 3, "struct": 2, "enum": 1, "guide": 0}
+
+
+def _slugify(title: str) -> str:
+    """Читаемое имя файла из заголовка (кириллица сохраняется, пробелы → дефис)."""
+    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)
     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 = [
         "---",
         f'title: "{_escape_yaml_str(title)}"',
@@ -617,34 +777,10 @@ def render_article(group_key: str, pages_data: list[dict],
     fm_lines.append("")
 
     md_parts = ["\n".join(fm_lines)]
-
-    # Breadcrumbs — один раз в начале статьи
     if breadcrumbs:
         bc_text = " > ".join(t for t, _ in breadcrumbs)
         md_parts.append(f"*{bc_text}*\n")
-
-    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("")
+    md_parts.extend(body_parts)
 
     return "\n".join(md_parts)
 
@@ -697,23 +833,22 @@ def main():
         (OUT_DIR / subdir).mkdir(parents=True, exist_ok=True)
     RESOURCES_DIR.mkdir(parents=True, exist_ok=True)
 
-    pages = read_index()
-    print(f"Страниц в индексе: {len(pages)}")
+    pages = read_pageinfo()
+    print(f"Страниц в реестре: {len(pages)}")
 
     groups = build_groups(pages)
     print(f"Групп после группировки: {len(groups)}")
 
-    stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0}
-    index_entries: list[tuple[str, str, str]] = []
+    stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0, "merged": 0}
 
-    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] = []
         source_files: list[str] = []
         rep_title = ""
-
         for page_info in group_pages:
-            fname = page_info["file"]
-            fpath = DOC_DIR / fname
+            fpath = DOC_DIR / page_info["file"]
             if not fpath.exists():
                 stats["skipped"] += 1
                 continue
@@ -722,24 +857,70 @@ def main():
                 stats["skipped"] += 1
                 continue
             pages_data.append(parsed)
-            source_files.append(fname)
+            source_files.append(page_info["file"])
             if not rep_title:
                 rep_title = parsed["title"]
             stats["images"] += len(parsed["image_srcs"])
-
         if not pages_data:
             continue
-
         article_type = classify_type(group_key, rep_title)
-        subdir = SUBDIR_FOR_TYPE.get(article_type, "guides")
-        out_name = group_key + ".md"
-        out_path = OUT_DIR / subdir / out_name
+        records[group_key] = {
+            "pages_data": pages_data,
+            "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:
-            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")
             stats["processed"] += 1
-            index_entries.append((subdir, out_name, rep_title))
+            index_entries.append((subdir, out_name, rec["rep_title"]))
         except Exception as e:
             print(f"  ⚠ Ошибка записи {out_name}: {e}", file=sys.stderr)
             stats["errors"] += 1
@@ -748,6 +929,7 @@ def main():
 
     print(f"\n✅ Done!")
     print(f"   Статей записано:  {stats['processed']}")
+    print(f"   Слито детей:      {stats['merged']}")
     print(f"   Страниц пропущено: {stats['skipped']}")
     print(f"   Ошибок:           {stats['errors']}")
     print(f"   Изображений:      {stats['images']}")
diff --git a/tools/convert_sdk_docs.py b/tools/convert_sdk_docs.py
deleted file mode 100644
index 08519a7..0000000
--- a/tools/convert_sdk_docs.py
+++ /dev/null
@@ -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):
-    """Извлекает текст из 
, пропуская 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']+>', 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 из или <p class="topictitle"> - title = "" - for m in re.finditer(r'<title>(.*?)', html_content): - title = hmod.unescape(m.group(1)).strip() - break - if not title: - for m in re.finditer(r']*>(.*?)

', html_content, re.DOTALL): - title = re.sub(r'<[^>]+>', '', m.group(1)).strip() - title = hmod.unescape(title) - break - - # Breadcrumbs из

- breadcrumbs = [] - bc_match = re.search( - r']*>(.*?)

', - html_content, re.DOTALL, - ) - if bc_match: - bc_html = bc_match.group(1) - for a_m in re.finditer(r']+href="([^"]*)"[^>]*>(.*?)', 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-сущности.""" - # Сначала   и неразрывные пробелы - s = s.replace(" ", " ") - s = s.replace(" ", " ") - s = s.replace("­", "") - s = s.replace("—", "—") - s = s.replace("–", "–") - s = s.replace("«", "«") - s = s.replace("»", "»") - s = s.replace(""", '"') - s = s.replace("'", "'") - s = s.replace("'", "'") - s = s.replace("<", "<") - s = s = s.replace(">", ">") - 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' 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"\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() diff --git a/tools/convert_sdk_to_md.py b/tools/convert_sdk_to_md.py deleted file mode 100644 index e5dc559..0000000 --- a/tools/convert_sdk_to_md.py +++ /dev/null @@ -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']+>', 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'(.*?)', html_content): - title = hmod.unescape(m.group(1)).strip() - break - if not title: - for m in re.finditer(r']*>(.*?)

', html_content, re.DOTALL): - title = re.sub(r'<[^>]+>', '', m.group(1)).strip() - title = hmod.unescape(title) - break - - breadcrumbs = [] - bc_match = re.search( - r']*>(.*?)

', - html_content, re.DOTALL, - ) - if bc_match: - bc_html = bc_match.group(1) - for a_m in re.finditer(r']+href="([^"]*)"[^>]*>(.*?)', 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(" ", " ") - s = s.replace(" ", " ") - s = s.replace("­", "") - s = s.replace("—", "—") - s = s.replace("–", "–") - s = s.replace("«", "«") - s = s.replace("»", "»") - s = s.replace(""", '"') - s = s.replace("'", "'") - s = s.replace("'", "'") - s = s.replace("<", "<") - s = s.replace(">", ">") - return hmod.unescape(s) - - -# ─── Извлечение ссылок из HTML-тела топика ────────────────────────────────── - -def extract_links_from_html(html_content: str) -> list[tuple[str, str]]: - """ - Находит все в topicbody и возвращает список - [(anchor_text, href), ...] для последующей подстановки. - anchor_text — текст ссылки без HTML-тегов. - """ - # Ищем всё содержимое после id="topicbody" до
- body_match = re.search( - r'id="topicbody">(.*?)(?:)', - html_content, re.DOTALL) - if not body_match: - return [] - body_html = body_match.group(1) - - links = [] - for a_m in re.finditer(r']+href=["\']([^"\']+)["\'][^>]*>(.*?)', 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'(? 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' 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()