diff --git a/.gitignore b/.gitignore index d832522..9172390 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,11 @@ Desktop.ini # ---- Scratch: тестовые документы КОМПАС, снимки рендера -------------------- .scratch/ +# ---- Use case'ы проработки MCP (локальная песочница, не разделяется) -------- +# Папки кейсов с описаниями (case.md) и артефактами построений (.m3d, .png …). +# Каркас и правила — в usecases/README.md (см. там же шаблон _TEMPLATE/). +usecases/ + # ============================================================================ # Большие вендорские/генерируемые артефакты — НЕ коммитим # ============================================================================ @@ -61,6 +66,9 @@ docs/KOMPAS_SDK_ru-RU/ # Индекс справки, генерируется командой: python .claude/skills/kompas-sdk-research/kdoc.py build-index docs/kompas_sdk_index.tsv +# MD-база знаний SDK, генерируется командой: python tools/build_kompas3d_sdk.py +docs/Kompas3D_SDK/ + # ============================================================================ # Отслеживается НАМЕРЕННО (нужно для сборки), не игнорировать: # libs/kompas-interop/*.dll — interop-сборки КОМПАС из SDK (Samples/Common) diff --git a/tools/build_kompas3d_sdk.py b/tools/build_kompas3d_sdk.py new file mode 100644 index 0000000..9ce3d7a --- /dev/null +++ b/tools/build_kompas3d_sdk.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +build_kompas3d_sdk.py — Преобразование KOMPAS SDK HTML-справки в +структурированную базу знаний docs/Kompas3D_SDK/. + +Стратегия: + - Группировка: одна MD-статья на COM-интерфейс/тему (по префиксу имени файла). + - Изображения: копируются файлами в resources/ (не base64). + - KsAPI (ksapi_*.html): пропускаются. + - Фронтматтер: type, api, domain, tags, sources. + +Запуск (из корня репо): + python tools/build_kompas3d_sdk.py +""" +import os +import sys +import re +import shutil +import html as hmod +from html.parser import HTMLParser +from pathlib import Path +from collections import defaultdict +from typing import Optional + +HERE = Path(__file__).resolve().parent.parent +DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU" +INDEX_PATH = HERE / "docs" / "kompas_sdk_index.tsv" +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*«?АСКОН") + + +# ─── Утилиты ──────────────────────────────────────────────────────────────── + +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"} + VOID_TAGS = {"img", "input", "link", "meta", "hr", "area", "base", + "col", "embed", "source", "track", "wbr"} + + 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 not in ("br",) and tag not in self.VOID_TAGS: + 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 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 + + + +def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]: + title = "" + for m in re.finditer(r'
]*>(.*?)
', html_content, re.DOTALL): + title = re.sub(r'<[^>]+>', '', m.group(1)).strip() + title = hmod.unescape(title) + break + breadcrumbs = [] + bc_match = re.search( + r']*>(.*?)
', + html_content, re.DOTALL) + if bc_match: + bc_html = bc_match.group(1) + for a_m in re.finditer(r']+href="([^"]*)"[^>]*>(.*?)', bc_html, re.DOTALL): + href = a_m.group(1) + text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip() + text = hmod.unescape(text) + if text: + breadcrumbs.append((text, os.path.basename(href))) + return title, breadcrumbs + + +def read_index() -> list[dict]: + 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 and parts[0]: + pages.append({ + "file": parts[0], + "title": parts[1] if len(parts) > 1 else "", + "desc": parts[2] if len(parts) > 2 else "", + }) + return pages + + +# ─── Группировка ───────────────────────────────────────────────────────────── + +def get_group_key(filename: str) -> str: + """ + Извлекает ключ группы из имени файла. + Стратегия: часть до первого '_' (после удаления ведущего '_'). + Примеры: + 'iapplication_visible.html' → 'iapplication' + 'iapplication_visible_ex.html' → 'iapplication' + '_dkgax_caption.html' → 'dkgax' + '3d_curves.html' → '3d' + 'zoomtypeenum.html' → 'zoomtypeenum' (standalone) + """ + 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_* (KsAPI — Qt/C++ cross-platform, не нужен). + """ + raw: dict[str, list[dict]] = defaultdict(list) + for page in pages: + fname = page["file"] + if fname.startswith("ksapi_"): + continue + key = get_group_key(fname) + raw[key].append(page) + return dict(raw) + + +# ─── Классификация ─────────────────────────────────────────────────────────── + +def classify_type(group_key: str, representative_title: str) -> str: + """Определяет тип: 'interface', 'enum', 'struct', 'guide'.""" + key = group_key.lower() + title = representative_title.lower() + + if key.endswith("enum") or "enum" in title or "перечислени" in title: + return "enum" + if re.match(r"^i[a-z]", key): + return "interface" + 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 = [] + 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]: + all_text = (title + " " + " ".join(t for t, _ in breadcrumbs)).lower() + domain_map = { + "3d": ["3d", "трёхмерн", "3д", "solid", "тело"], + "2d": ["2d", "двухмерн", "2д", "плоск"], + "sketch": ["эскиз"], + "assembly": ["сборк", "компонент", "assembly"], + "sheetmetal": ["листовой", "сгиб", "гибк"], + "drawing": ["чертёж", "чертеж", "drawing", "выносн"], + "feature": ["операци", "feature", "выдавлив", "вращени", "скругл", "фаск"], + } + found = [] + 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 = 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 + + image_srcs: list[tuple[Path, str]] = [] + for m in re.finditer(r'