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:
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 (методика)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|||||||
@@ -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
@@ -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"\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"\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']}")
|
||||||
|
|||||||
@@ -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-сущности."""
|
|
||||||
# Сначала и неразрывные пробелы
|
|
||||||
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'<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"\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()
|
|
||||||
@@ -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(" ", " ")
|
|
||||||
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]]:
|
|
||||||
"""
|
|
||||||
Находит все <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"")
|
|
||||||
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()
|
|
||||||
Reference in New Issue
Block a user