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

752 lines
29 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 -*-
"""
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 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*«?АСКОН")
# 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,
)
# ─── 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 ""
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
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 postprocess_code_blocks(lines: list[str]) -> list[str]:
"""
Оборачивает секции «Синтаксис» и «Пример» в MD code-fence блоки.
Существующие ``` fence-блоки (от <pre>/<code>) пропускаются.
"""
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
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_index() -> list[dict]:
pages: list[dict] = []
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'
'_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 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", "трёхмерн", "", "solid", "тело"],
"2d": ["2d", "двухмерн", "", "плоск"],
"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('"', '\\"')
def render_article(group_key: str, pages_data: list[dict],
article_type: str, source_files: list[str]) -> str:
"""Генерирует содержимое MD-файла для группы страниц."""
rep = pages_data[0]
title = rep["title"]
breadcrumbs = rep["breadcrumbs"]
api_versions = extract_api_version(title, breadcrumbs)
domain_tags = extract_domain_tags(title, breadcrumbs)
tags = build_tags(article_type, api_versions, domain_tags, group_key)
# YAML frontmatter с безопасным экранированием строк
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)]
# Breadcrumbs — один раз в начале статьи
if breadcrumbs:
bc_text = " > ".join(t for t, _ in breadcrumbs)
md_parts.append(f"*{bc_text}*\n")
for page_data in pages_data:
page_title = page_data["title"]
body_lines = list(page_data["body_lines"])
# Подзаголовок раздела для группы > 1 страницы
if len(pages_data) > 1:
md_parts.append(f"\n## {page_title}\n")
# Убираем дублирующуюся первую строку тела
if body_lines and body_lines[0].strip() == page_title.strip():
body_lines = body_lines[1:]
# Применяем code-fence постпроцессинг
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('"', "'")
md_parts.append(f"![{alt_clean}](../resources/{img_name})\n")
md_parts.append("\n".join(body_lines))
md_parts.append("")
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_index()
print(f"Страниц в индексе: {len(pages)}")
groups = build_groups(pages)
print(f"Групп после группировки: {len(groups)}")
stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0}
index_entries: list[tuple[str, str, str]] = []
for group_key, group_pages in sorted(groups.items()):
pages_data: list[dict] = []
source_files: list[str] = []
rep_title = ""
for page_info in group_pages:
fname = page_info["file"]
fpath = DOC_DIR / fname
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(fname)
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)
subdir = SUBDIR_FOR_TYPE.get(article_type, "guides")
out_name = group_key + ".md"
out_path = OUT_DIR / subdir / out_name
try:
content = render_article(group_key, pages_data, article_type, source_files)
out_path.write_text(content, encoding="utf-8")
stats["processed"] += 1
index_entries.append((subdir, out_name, 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['skipped']}")
print(f" Ошибок: {stats['errors']}")
print(f" Изображений: {stats['images']}")
if __name__ == "__main__":
main()