28d2ae3868
Новый слой «модель как данные» (приоритетный над 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>
471 lines
17 KiB
Python
471 lines
17 KiB
Python
#!/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(" ", " ")
|
||
s = s.replace(" ", " ")
|
||
s = s.replace("­", "")
|
||
s = s.replace("—", "—")
|
||
s = s.replace("–", "–")
|
||
s = s.replace("«", "«")
|
||
s = s.replace("»", "»")
|
||
s = s.replace(""", '"')
|
||
s = s.replace("'", "'")
|
||
s = s.replace("'", "'")
|
||
s = s.replace("<", "<")
|
||
s = s.replace(">", ">")
|
||
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"")
|
||
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()
|