Files
kompas3d-mcp/tools/convert_sdk_docs.py
T
mikhail 28d2ae3868 feat(inspection): структурное «зрение» модели — осмотр без снимка
Новый слой «модель как данные» (приоритетный над model_snapshot):
- describe_model — структурный «паспорт» детали одним вызовом (габарит,
  МЦХ, тела, сводка топологии, дерево построения с параметрами, переменные)
- list_features / list_bodies / list_variables
- describe_face / describe_edge (drill-down по индексу)
- measure (расстояние/угол между гранями/рёбрами/вершинами)
- model_snapshot понижен до fallback для визуально-пространственных вопросов

Реализация: ModelInspectionService (COM) + чистый InspectionText (рендер,
группировка, детект «импорт без истории») + record-модели; InspectionTools.
Дерево читается через ksPart.GetFeature()->SubFeatureCollection; точный тип
и параметры операций берутся из GetObject()->GetDefinition() (ksFeature.type
отдаёт лишь coarse o3d_entity).

Снимок дерева также включает слои conversion (STEP), editing (move_face/solid)
и validation, развивавшиеся параллельно. Полный прогон: 61 тест зелёный,
43 MCP-инструмента. Документация (README, ARCHITECTURE, OPEN_QUESTIONS,
CLAUDE.md, presentation.html) синхронизирована.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:44:38 +03:00

427 lines
17 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 -*-
"""
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-сущности."""
# Сначала &nbsp; и неразрывные пробелы
s = s.replace("&nbsp;", " ")
s = s.replace("&#160;", " ")
s = s.replace("&shy;", "")
s = s.replace("&mdash;", "")
s = s.replace("&ndash;", "")
s = s.replace("&laquo;", "«")
s = s.replace("&raquo;", "»")
s = s.replace("&quot;", '"')
s = s.replace("&#39;", "'")
s = s.replace("&apos;", "'")
s = s.replace("&lt;", "<")
s = s = s.replace("&gt;", ">")
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"![{alt_clean}]({data_uri})\n")
image_count += 1
# Тело страницы
for line in parsed["body_lines"]:
line_unescaped = unescape_html(line)
# Убираем лишние пробелы, но сохраняем структуру
md_parts.append(f"{line_unescaped}\n")
# Добавляем оглавление в начало (после заголовка)
final_md = md_parts[:2] # Заголовок + подзаголовок
final_md.append("## Оглавление\n")
final_md.extend(toc_entries)
final_md.extend(md_parts[2:])
# Записываем файл
output_text = "\n".join(final_md)
OUTPUT_MD.write_text(output_text, encoding="utf-8")
print(f"\n✅ Готово!")
print(f" Обработано страниц: {processed_count}")
print(f" Пропущено (пустые/ошибки): {skipped_count}")
print(f" Сохранено изображений: {image_count}")
file_size = OUTPUT_MD.stat().st_size
print(f" Размер файла: {file_size / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
main()