c761cba673
Оптимизация генератора 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>
940 lines
39 KiB
Python
940 lines
39 KiB
Python
#!/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", "трёхмерн", "3д", "solid", "тело"],
|
||
"2d": ["2d", "двухмерн", "2д", "плоск"],
|
||
"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"\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()
|