3a98afc67f
Зафиксирован фундамент проекта MCP-сервера КОМПАС-3D: - docs/: ARCHITECTURE.md, IMPLEMENTATION_PLAN.md, presentation.html - tools/kdoc.py: навигатор по справке SDK - libs/kompas-interop/: вендорские interop-сборки КОМПАС из SDK - src/Kompas.Mcp.ConnectSpike: net8.0-windows консоль, доказывает работу COM-интеропа и attach/launch подключения к КОМПАС (IApplication, версия 24.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
201 lines
7.7 KiB
Python
201 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
kdoc — навигация по справке SDK КОМПАС-3D (Help & Manual WebHelp) для агента.
|
||
|
||
Справка — это ~26 000 HTML-страниц в docs/KOMPAS_SDK_ru-RU/ со «зашумлённой»
|
||
вёрсткой (шапка, подвал, JS). Листать их напрямую неэффективно. Этот инструмент:
|
||
|
||
* build-index — один раз строит компактный индекс docs/kompas_sdk_index.tsv
|
||
(файл<TAB>заголовок<TAB>краткое описание) из zoom_pageinfo.js;
|
||
* search — ищет страницы по подстроке в заголовке/описании;
|
||
* read — печатает чистый текст одной страницы (тело топика без вёрстки).
|
||
|
||
Типичный сценарий агента:
|
||
python tools/kdoc.py search "выдавливание" # найти нужный интерфейс
|
||
python tools/kdoc.py read ibossextrusiondefinition.html
|
||
|
||
Поиск по индексу можно вести и встроенным Grep по docs/kompas_sdk_index.tsv —
|
||
формат строки: <имя_файла>\t<заголовок>\t<описание>.
|
||
"""
|
||
import sys
|
||
import os
|
||
import re
|
||
import json
|
||
import html
|
||
import argparse
|
||
from html.parser import HTMLParser
|
||
|
||
# На Windows консоль часто cp1251 — принудительно выводим UTF-8,
|
||
# чтобы кириллица не искажалась при вызове из агента/любого шелла.
|
||
for _stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
_stream.reconfigure(encoding="utf-8")
|
||
except (AttributeError, ValueError):
|
||
pass
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
DOC_DIR = os.environ.get(
|
||
"KDOC_DIR", os.path.join(HERE, "..", "docs", "KOMPAS_SDK_ru-RU")
|
||
)
|
||
DOC_DIR = os.path.abspath(DOC_DIR)
|
||
INDEX_PATH = os.path.abspath(os.path.join(DOC_DIR, "..", "kompas_sdk_index.tsv"))
|
||
PAGEINFO = os.path.join(DOC_DIR, "zoom_pageinfo.js")
|
||
|
||
# Текстовый «мусор» навигации/подвала, попадающий в тело топика.
|
||
NOISE = {
|
||
"please enable javascript to view this site.",
|
||
"scroll", "предыдущий", "вверх", "следующий",
|
||
"содержание", "содержане", "указатель", "поиск",
|
||
"подразделы:", "(отсутствуют)",
|
||
}
|
||
FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН")
|
||
|
||
|
||
def build_index() -> int:
|
||
"""Разобрать массив pagedata из zoom_pageinfo.js в TSV-индекс."""
|
||
if not os.path.isfile(PAGEINFO):
|
||
sys.exit(f"Не найден {PAGEINFO}. Проверьте KDOC_DIR / путь к справке.")
|
||
with open(PAGEINFO, "r", encoding="utf-8", errors="replace") as f:
|
||
text = f.read()
|
||
marker = "pagedata = "
|
||
pos = text.find(marker)
|
||
if pos < 0:
|
||
sys.exit("В zoom_pageinfo.js не найден массив pagedata.")
|
||
payload = text[pos + len(marker):].strip()
|
||
payload = payload.rstrip(";").strip()
|
||
data = json.loads(payload) # [["./file.html","title","desc","img"], ...]
|
||
|
||
rows = []
|
||
for entry in data:
|
||
if not entry or not entry[0]:
|
||
continue
|
||
fname = entry[0].lstrip("./").strip()
|
||
title = html.unescape((entry[1] or "").strip())
|
||
desc = html.unescape((entry[2] or "").strip())
|
||
title = re.sub(r"\s+", " ", title)
|
||
desc = re.sub(r"\s+", " ", desc)[:300]
|
||
rows.append(f"{fname}\t{title}\t{desc}")
|
||
|
||
with open(INDEX_PATH, "w", encoding="utf-8", newline="\n") as f:
|
||
f.write("\n".join(rows) + "\n")
|
||
print(f"Индекс записан: {INDEX_PATH} ({len(rows)} страниц)")
|
||
return len(rows)
|
||
|
||
|
||
def search(terms, limit=40):
|
||
"""Грубый поиск по индексу: AND по всем подстрокам, без регистра."""
|
||
if not os.path.isfile(INDEX_PATH):
|
||
print("Индекс отсутствует — строю...", file=sys.stderr)
|
||
build_index()
|
||
needles = [t.lower() for t in terms if t.strip()]
|
||
hits = 0
|
||
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
low = line.lower()
|
||
if all(n in low for n in needles):
|
||
parts = line.rstrip("\n").split("\t")
|
||
fname = parts[0]
|
||
title = parts[1] if len(parts) > 1 else ""
|
||
print(f"{fname}\t{title}")
|
||
hits += 1
|
||
if hits >= limit:
|
||
print(f"... (показаны первые {limit}; уточните запрос)")
|
||
break
|
||
if hits == 0:
|
||
print("Ничего не найдено.")
|
||
|
||
|
||
class _TopicExtractor(HTMLParser):
|
||
"""Собирает текст из <div id='topicbody'>, пропуская script/style."""
|
||
BLOCK = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4",
|
||
"table", "pre"}
|
||
|
||
def __init__(self):
|
||
super().__init__(convert_charrefs=True)
|
||
self.depth = 0 # глубина вложенности внутри topicbody
|
||
self.capturing = False
|
||
self.skip = 0 # внутри script/style
|
||
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:
|
||
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)
|
||
|
||
|
||
def read(fname):
|
||
path = os.path.join(DOC_DIR, os.path.basename(fname))
|
||
if not path.endswith(".html"):
|
||
path += ".html"
|
||
if not os.path.isfile(path):
|
||
sys.exit(f"Нет файла: {path}")
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
raw = f.read()
|
||
p = _TopicExtractor()
|
||
p.feed(raw)
|
||
text = "".join(p.out)
|
||
|
||
cleaned = []
|
||
for line in text.splitlines():
|
||
s = re.sub(r"[ \t ]+", " ", line).strip()
|
||
if not s:
|
||
continue
|
||
if s.lower() in NOISE:
|
||
continue
|
||
if FOOTER_RE.search(s):
|
||
break # подвал — дальше только копирайт/телефон
|
||
cleaned.append(s)
|
||
print(os.path.basename(path))
|
||
print("=" * len(os.path.basename(path)))
|
||
print("\n".join(cleaned).strip())
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
prog="kdoc", description="Навигация по справке SDK КОМПАС-3D для агента."
|
||
)
|
||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||
sub.add_parser("build-index", help="построить docs/kompas_sdk_index.tsv")
|
||
sp = sub.add_parser("search", help="искать по заголовку/описанию")
|
||
sp.add_argument("terms", nargs="+")
|
||
sp.add_argument("--limit", type=int, default=40)
|
||
rp = sub.add_parser("read", help="печать чистого текста страницы")
|
||
rp.add_argument("file")
|
||
args = ap.parse_args()
|
||
|
||
if args.cmd == "build-index":
|
||
build_index()
|
||
elif args.cmd == "search":
|
||
search(args.terms, args.limit)
|
||
elif args.cmd == "read":
|
||
read(args.file)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|