Files
kompas3d-mcp/tools/convert_sdk_to_md.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

471 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_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("&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.replace("&gt;", ">")
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"![{alt_clean}]({data_uri})")
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()