Files
kompas3d-mcp/tools/build_kompas3d_sdk.py
T
mikhail c761cba673 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>
2026-05-26 19:45:31 +03:00

940 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
build_kompas3d_sdk.py — Преобразование KOMPAS SDK HTML-справки в
структурированную базу знаний docs/Kompas3D_SDK/.
Стратегия:
- Группировка: одна MD-статья на COM-интерфейс/тему (по префиксу имени файла).
- Изображения: копируются файлами в resources/ (не base64).
- KsAPI (ksapi_*.html): пропускаются.
- TOC-страницы (- методы, - свойства): пропускаются.
- Таблицы: рендерятся как MD-таблицы.
- Синтаксис/примеры: оборачиваются в code-fence блоки.
- Навигационный chrome: пропускается (breadcrumbs, nav-box, footer).
- Фронтматтер: type, api, domain, tags, sources (YAML-safe escaping).
Запуск (из корня репо):
python tools/build_kompas3d_sdk.py
"""
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, Counter
from typing import Optional
HERE = Path(__file__).resolve().parent.parent
DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU"
# Собственный реестр topic-страниц WebHelp (массив pagedata) — берём список
# страниц и заголовки прямо отсюда, без промежуточного индекса.
PAGEINFO_PATH = DOC_DIR / "zoom_pageinfo.js"
OUT_DIR = HERE / "docs" / "Kompas3D_SDK"
RESOURCES_DIR = OUT_DIR / "resources"
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",
]
NOISE_WORDS = {
"please enable javascript to view this site.",
"scroll", "предыдущий", "вверх", "следующий",
"содержание", "содержане", "указатель", "поиск",
"подразделы:", "(отсутствуют)", "поделиться",
}
FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН")
# Breadcrumb lines to drop (they repeat in every section heading)
BREADCRUMB_RE = re.compile(
r"^(KOMPAS SDK|API интерфейсов|KsAPI|Справочник|Версия \d|"
r"Automation|COM|Объект|Параметры 3D|Параметры 2D).*?>",
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_SUFFIXES = (
" - методы", " - свойства", " - события", " - конструкторы",
" - члены", " - перечисляемые",
" methods", " properties", " events", " members",
)
_TOC_EXACT = {"методы", "свойства", "события", "конструкторы"}
def is_toc_page_title(title: str) -> bool:
t = title.strip().lower()
if t in _TOC_EXACT:
return True
return any(t.endswith(s.lower()) for s in _TOC_SUFFIXES)
# ─── Утилиты изображений ─────────────────────────────────────────────────────
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
def copy_image(img_path: Path) -> str:
"""Копирует изображение в resources/ с collision-safe именованием."""
RESOURCES_DIR.mkdir(parents=True, exist_ok=True)
try:
rel = img_path.relative_to(DOC_DIR)
dest_name = str(rel).replace(os.sep, "_").replace("/", "_")
except ValueError:
dest_name = img_path.name
dest = RESOURCES_DIR / dest_name
if not dest.exists():
shutil.copy2(img_path, dest)
return dest_name
# ─── HTML → MD парсер (таблицы, skip-зоны, <pre>/<code>) ───────────────────
class HtmlToMdExtractor(HTMLParser):
"""
Парсер контентной зоны Help & Manual WebHelp:
- Захватывает только div#hmpagebody_scroller (содержимое без навигации).
- Рендерит <table> в Markdown-таблицы.
- Сохраняет <pre> как code-fence, <code> как inline-code.
Стратегия входа: ищем id="hmpagebody_scroller" (содержательная зона без
chrome). Навигационный header (hmpageheader), breadcrumbs и footer
оказываются СНАРУЖИ этого div и автоматически пропускаются.
Это надёжнее, чем skip-зоны, т.к. HTML может содержать незакрытые теги
в навигационной части (Help & Manual особенность).
"""
BLOCK_TAGS = {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "pre"}
VOID_TAGS = {"img", "input", "link", "meta", "hr", "area", "base",
"col", "embed", "source", "track", "wbr"}
# IDs которые служат точкой входа (по убыванию специфичности)
ENTRY_IDS = ("hmpagebody_scroller", "hmpagebody", "topicbody")
def __init__(self):
super().__init__(convert_charrefs=True)
# Capture state
self.depth: int = 0
self.capturing: bool = False
self.skip_script: int = 0
# Table
self.table_depth: int = 0
self.table_rows: list = []
self.cur_cells: list = []
self.cur_cell: list = []
self.cell_is_header: bool = False
# Pre / code
self.in_pre: bool = False
self.code_depth: int = 0
# Output
self.out: list[str] = []
def handle_starttag(self, tag: str, attrs):
attrd = dict(attrs)
# Try to activate capturing on the content div
if not self.capturing:
elem_id = attrd.get("id", "")
if elem_id in self.ENTRY_IDS:
self.capturing = True
self.depth = 1
return
# Table
if tag == "table":
self.table_depth += 1
if self.table_depth == 1:
self.table_rows = []
self.cur_cells = []
self.cur_cell = []
return
if self.table_depth > 0:
if tag == "tr":
self.cur_cells = []
self.cell_is_header = False
elif tag == "th":
self.cell_is_header = True
self.cur_cell = []
elif tag == "td":
self.cur_cell = []
return
# Script / style
if tag in ("script", "style"):
self.skip_script += 1
return
# Pre
if tag == "pre":
self.in_pre = True
self.out.append("\n```\n")
return
# Inline code
if tag == "code" and not self.in_pre:
self.code_depth += 1
self.out.append("`")
return
# Block separators
if tag in self.BLOCK_TAGS:
self.out.append("\n")
# Depth tracking (for exit detection only)
if tag not in self.VOID_TAGS and tag != "br":
self.depth += 1
def handle_endtag(self, tag: str):
if not self.capturing:
return
# Table exit
if self.table_depth > 0:
if tag in ("td", "th"):
cell_text = " ".join("".join(self.cur_cell).split())
self.cur_cells.append(cell_text)
self.cur_cell = []
elif tag == "tr":
if self.cur_cells:
self.table_rows.append((self.cell_is_header, list(self.cur_cells)))
self.cur_cells = []
elif tag == "table":
self.table_depth -= 1
if self.table_depth == 0:
md_tbl = self._render_table()
if md_tbl:
self.out.append("\n" + md_tbl + "\n")
self.table_rows = []
return
# Script / style
if tag in ("script", "style") and self.skip_script:
self.skip_script -= 1
return
# Pre
if tag == "pre":
if self.out and not self.out[-1].endswith("\n"):
self.out.append("\n")
self.out.append("```\n")
self.in_pre = False
return
# Inline code
if tag == "code" and self.code_depth > 0:
self.code_depth -= 1
self.out.append("`")
return
if tag not in self.VOID_TAGS and tag != "br":
self.depth -= 1
if self.depth <= 0:
self.capturing = False
def handle_data(self, data: str):
if not self.capturing or self.skip_script:
return
if self.table_depth > 0:
self.cur_cell.append(data)
return
self.out.append(data)
def _render_table(self) -> str:
if not self.table_rows:
return ""
max_cols = max((len(r[1]) for r in self.table_rows), default=0)
if max_cols == 0:
return ""
# Пропускаем навигационные breadcrumb-таблицы:
# Help & Manual рендерит хлебные крошки как 2-колоночную таблицу,
# где вторая колонка = "Scroll" (кнопка прокрутки).
if any(len(r) >= 2 and r[-1].strip().lower() == "scroll"
for _is_hdr, r in self.table_rows):
return ""
lines: list[str] = []
header_done = False
for _is_hdr, cells in self.table_rows:
while len(cells) < max_cols:
cells.append("")
cells = cells[:max_cols]
lines.append("| " + " | ".join(cells) + " |")
if not header_done:
lines.append("| " + " | ".join(["---"] * max_cols) + " |")
header_done = True
return "\n".join(lines)
# ─── Очистка текста ──────────────────────────────────────────────────────────
def clean_text(raw: str) -> list[str]:
"""Очищает текст; сохраняет code-fence блоки без изменений."""
cleaned: list[str] = []
in_fence = False
for line in raw.splitlines():
stripped = line.strip()
if stripped.startswith("```"):
in_fence = not in_fence
cleaned.append(line)
continue
if in_fence:
cleaned.append(line)
continue
s = re.sub(r"[ \t]+", " ", line).strip()
if not s:
continue
if s.lower() in NOISE_WORDS:
continue
if FOOTER_RE.search(s):
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("```")
return cleaned
# ─── Code-block постпроцессинг ───────────────────────────────────────────────
_CODE_STARTERS: dict[str, str] = {}
for _k in ("синтаксис automation:", "синтаксис automation",
"синтаксис com:", "синтаксис com",
"синтаксис:", "синтаксис"):
_CODE_STARTERS[_k] = ""
for _k in ("пример:", "пример", "примеры:", "примеры",
"examples:", "example:", "example"):
_CODE_STARTERS[_k] = "vb"
_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-блоки (от <pre>/<code>) пропускаются.
Табличные строки внутри fence разворачиваются в плоский текст.
"""
result: list[str] = []
in_code = False
existing_fence = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
lower = stripped.lower()
# Пропускаем существующие fence-блоки (из <pre>)
if stripped.startswith("```"):
existing_fence = not existing_fence
result.append(line)
i += 1
continue
if existing_fence:
result.append(line)
i += 1
continue
# Заголовок, открывающий новый code-block
if lower in _CODE_STARTERS:
if in_code:
result.append("```")
result.append(stripped)
lang = _CODE_STARTERS[lower]
result.append(f"```{lang}")
in_code = True
i += 1
continue
# Заголовок, закрывающий текущий code-block (не стартер)
if in_code and lower in _CODE_ENDERS:
result.append("```")
in_code = False
continue # повторно обрабатываем эту строку (теперь in_code=False)
# Пустая строка внутри code-block
if in_code and not stripped:
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
nxt = lines[j].strip().lower() if j < len(lines) else ""
if nxt in _CODE_ENDERS or nxt in _CODE_STARTERS or not lines[i + 1:]:
result.append("```")
in_code = False
result.append("")
else:
result.append(line)
i += 1
continue
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:
result.append("```")
return result
# ─── Заголовок и хлебные крошки ──────────────────────────────────────────────
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: list[tuple[str, str]] = []
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 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] = []
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
# ─── Группировка ─────────────────────────────────────────────────────────────
def get_group_key(filename: str) -> str:
"""
Ключ = часть до первого '_' (удаляя ведущий '_').
'iapplication_visible.html''iapplication'
'_dkgax_caption.html''dkgax'
'3d_curves.html''3d'
'zoomtypeenum.html''zoomtypeenum'
"""
stem = filename.replace(".html", "")
if stem.startswith("_"):
stem = stem[1:]
parts = stem.split("_", 1)
return parts[0] if len(parts) > 1 else stem
def build_groups(pages: list[dict]) -> dict[str, list[dict]]:
"""Группирует страницы, пропуская KsAPI и TOC-страницы."""
raw: dict[str, list[dict]] = defaultdict(list)
for page in pages:
fname = page["file"]
if fname.startswith("ksapi_"):
continue
if is_toc_page_title(page.get("title", "")):
continue
key = get_group_key(fname)
raw[key].append(page)
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:
"""Определяет тип статьи: 'interface', 'enum', 'struct', 'guide'."""
key = group_key.lower()
title = representative_title.lower()
# Enum — специфичный признак, проверяем первым
if key.endswith("enum") or "enum" in title or "перечислени" in title:
return "enum"
# Interface — сначала по тексту заголовка (ПЕРЕД проверкой суффикса ключа!)
# Исправляет ложную классификацию ksXxxDefinition как struct
if any(w in title for w in ("интерфейс", "интерфейсы", "automation", "idispatch")):
return "interface"
# Interface — COM-конвенция (начинается с 'i' + строчная)
if re.match(r"^i[a-z]", key):
return "interface"
# Struct / параметры
if any(s in key for s in ("param", "definition", "struct")):
return "struct"
if any(s in title for s in ("параметры", "структура", "param ")):
return "struct"
return "guide"
def extract_api_version(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]:
all_text = (title + " " + " ".join(t for t, _ in breadcrumbs)).lower()
versions: list[str] = []
if "версия 7" in all_text or "api7" in all_text or "api 7" in all_text:
versions.append("api7")
if "api5" in all_text or "api 5" in all_text:
versions.append("api5")
return versions or ["api7"]
def extract_domain_tags(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]:
# Вес: заголовок + последние 2 breadcrumb (более точно, чем все предки)
last_bc = " ".join(t for t, _ in breadcrumbs[-2:]) if breadcrumbs else ""
all_text = (title + " " + last_bc).lower()
domain_map: dict[str, list[str]] = {
"3d": ["3d", "трёхмерн", "", "solid", "тело"],
"2d": ["2d", "двухмерн", "", "плоск"],
"sketch": ["эскиз"],
"assembly": ["сборк", "компонент", "assembly"],
"sheetmetal": ["листовой", "сгиб", "гибк"],
"drawing": ["чертёж", "чертеж", "drawing", "выносн"],
"feature": ["операци", "feature", "выдавлив", "вращени", "скругл", "фаск"],
}
found: list[str] = []
for domain, keywords in domain_map.items():
if any(k in all_text for k in keywords):
found.append(domain)
return found
def build_tags(article_type: str, api_versions: list[str],
domain_tags: list[str], group_key: str) -> list[str]:
tags = [article_type] + api_versions + domain_tags + [group_key]
return list(dict.fromkeys(tags))
# ─── Парсинг HTML-страницы ───────────────────────────────────────────────────
def parse_page(fpath: Path) -> Optional[dict]:
"""Парсит HTML-страницу. Возвращает None если контент пустой."""
try:
html_content = fpath.read_text(encoding="utf-8-sig")
except Exception as e:
print(f" ⚠ Ошибка чтения {fpath.name}: {e}", file=sys.stderr)
return None
title, breadcrumbs = extract_title_and_breadcrumbs(html_content)
extractor = HtmlToMdExtractor()
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
# Информативные изображения
image_srcs: list[tuple[Path, str]] = []
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 is_informative_image(src):
img_path = DOC_DIR / src
if img_path.exists():
image_srcs.append((img_path, alt))
return {
"title": title,
"breadcrumbs": breadcrumbs,
"body_lines": body_lines,
"image_srcs": image_srcs,
}
# ─── Генерация MD-статьи ──────────────────────────────────────────────────────
SUBDIR_FOR_TYPE = {
"interface": "interfaces",
"enum": "enums",
"struct": "structures",
"guide": "guides",
}
def _escape_yaml_str(s: str) -> str:
"""Экранирует строку для безопасного использования в YAML double-quoted значении."""
return s.replace("\\", "\\\\").replace('"', '\\"')
# Приоритет типа при выборе категории слитого 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, top_key)
fm_lines = [
"---",
f'title: "{_escape_yaml_str(title)}"',
f"type: {article_type}",
f"api: [{', '.join(api_versions)}]",
]
if domain_tags:
fm_lines.append(f"domain: [{', '.join(domain_tags)}]")
fm_lines.append(f"tags: [{', '.join(tags)}]")
fm_lines.append("sources:")
for sf in source_files:
fm_lines.append(f" - {sf}")
fm_lines.append("---")
fm_lines.append("")
md_parts = ["\n".join(fm_lines)]
if breadcrumbs:
bc_text = " > ".join(t for t, _ in breadcrumbs)
md_parts.append(f"*{bc_text}*\n")
md_parts.extend(body_parts)
return "\n".join(md_parts)
# ─── Генерация index.md ───────────────────────────────────────────────────────
def _write_index(entries: list[tuple[str, str, str]]):
"""Генерирует docs/Kompas3D_SDK/index.md с TOC по 4 категориям."""
lines = [
"# КОМПАС-3D SDK — База знаний\n",
"> Автоматически сгенерировано из `docs/KOMPAS_SDK_ru-RU/`"
" скриптом `build_kompas3d_sdk.py`.\n",
"",
]
categories = {
"interfaces": "## Интерфейсы (COM API)",
"enums": "## Перечисления",
"structures": "## Структуры и параметры",
"guides": "## Руководства и концепции",
}
by_subdir: dict[str, list[tuple[str, str]]] = defaultdict(list)
for subdir, fname, title in sorted(entries, key=lambda x: x[2].lower()):
by_subdir[subdir].append((fname, title))
for subdir, header in categories.items():
if subdir not in by_subdir:
continue
lines.append(header)
for fname, title in by_subdir[subdir]:
lines.append(f"- [{_escape_yaml_str(title)}](./{subdir}/{fname})")
lines.append("")
(OUT_DIR / "index.md").write_text("\n".join(lines), encoding="utf-8")
print(f" index.md: {len(entries)} ссылок")
# ─── Главная функция ──────────────────────────────────────────────────────────
def main():
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8")
except Exception:
pass
print(f"Входная справка: {DOC_DIR}")
print(f"Выходная база: {OUT_DIR}")
for subdir in SUBDIR_FOR_TYPE.values():
(OUT_DIR / subdir).mkdir(parents=True, exist_ok=True)
RESOURCES_DIR.mkdir(parents=True, exist_ok=True)
pages = read_pageinfo()
print(f"Страниц в реестре: {len(pages)}")
groups = build_groups(pages)
print(f"Групп после группировки: {len(groups)}")
stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0, "merged": 0}
# ── Проход 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:
fpath = DOC_DIR / page_info["file"]
if not fpath.exists():
stats["skipped"] += 1
continue
parsed = parse_page(fpath)
if parsed is None:
stats["skipped"] += 1
continue
pages_data.append(parsed)
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)
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, records, parent_to_children)
out_path.write_text(content, encoding="utf-8")
stats["processed"] += 1
index_entries.append((subdir, out_name, rec["rep_title"]))
except Exception as e:
print(f" ⚠ Ошибка записи {out_name}: {e}", file=sys.stderr)
stats["errors"] += 1
_write_index(index_entries)
print(f"\n✅ Done!")
print(f" Статей записано: {stats['processed']}")
print(f" Слито детей: {stats['merged']}")
print(f" Страниц пропущено: {stats['skipped']}")
print(f" Ошибок: {stats['errors']}")
print(f" Изображений: {stats['images']}")
if __name__ == "__main__":
main()