Merge pull request #183 from ArtemFilin1990/claude/bitrix24-kb-archive-22rli5

Add Bitrix24 Knowledge Base ZIP archive builder
This commit is contained in:
Artem Filin authored and GitHub committed 2026-10-21 06:20:33 -07:00
commit 9011de80a3
6 files changed
+630 -40

No files matched your search

@@ -0,0 +1,80 @@
# Сборка ZIP-архива Базы знаний Bitrix24 — Технический справочник «Эверест»
`scripts/kb-archive-builder.py` собирает `manifest.json` (упакованный в ZIP) из
полного экспорта Landing-сайта Bitrix24 (`landing.site.fullExport`) для импорта
через **Сотрудники → База знаний → ⚙️ → Импорт Базы знаний → Загрузить ZIP**.
## Режим безопасности
Скрипт всегда требует входные данные явно:
- `--input <файл>` — офлайн-режим. Загружает локальный JSON-экспорт
(результат `landing.site.fullExport`), без обращения к Bitrix24. Используется
по умолчанию для разработки, тестов и проверки преобразований.
- `--execute --site-id <ID>` — реальный вызов `landing.site.fullExport`.
Возможен только при одновременном выполнении условий:
1. Установлен `BITRIX24_WEBHOOK_URL` (HTTPS).
2. Установлен `BITRIX24_EXPORT_CONFIRM=true`.
Без выполнения этих условий скрипт завершается с ошибкой и ничего не пишет.
## Офлайн-сборка (без credentials)
```bash
python knowledge-base/everest/scripts/kb-archive-builder.py \
--input site-export.json \
--output kb_everest_podshipniki.zip
```
## Реальная сборка из живого сайта
```bash
export BITRIX24_WEBHOOK_URL
BITRIX24_EXPORT_CONFIRM=true \
python knowledge-base/everest/scripts/kb-archive-builder.py \
--site-id 109 \
--execute \
--output kb_everest_podshipniki.zip
```
Дополнительные параметры:
- `--scope` — внутренний scope лендинга для экспорта, по умолчанию `knowledge`.
- `--sections` — путь к JSON со структурой навигации (по умолчанию
`source/kb_archive_sections.json`).
- `--name`, `--description` — название и описание базы знаний в манифесте.
- `--base-url` — старый абсолютный префикс ссылок, который заменяется на `/`
(по умолчанию `https://ewerest.bitrix24.ru/everest-bearing-kb/`). Пустая
строка отключает замену ссылок.
## Что делает скрипт
1. **Метаданные**: устанавливает `type = "knowledge"`, название, описание и
тему оформления (`ADDITIONAL_FIELDS`: цвета, шрифты).
2. **Ссылки**: заменяет абсолютные ссылки на старый домен относительными
(`/{code}/`).
3. **Пустые страницы**: страницы без блоков (`items = {}`) заполняются
шаблонной заметкой со статусом «нет данных».
4. **Главная страница**: на странице `LANDING_ID_INDEX` добавляется блок
«Содержание справочника» со ссылками на все разделы и страницы из
`source/kb_archive_sections.json`.
5. **Навигация**: в конец последнего текстового блока каждой страницы
добавляется блок навигации со ссылкой на главную и названием раздела.
6. **ZIP**: результат упаковывается в один файл `manifest.json` внутри ZIP.
## Структура навигации
`source/kb_archive_sections.json` описывает 12 разделов справочника и раздел
«Сопутствующая продукция» — каждая запись содержит код страницы (`code`) и
заголовок (`title`), используемые для оглавления главной страницы и
хлебных крошек навигации.
## Запреты
- Не хранить `BITRIX24_WEBHOOK_URL` в репозитории.
- Не коммитить токены, пароли, webhook URL, ID пользователей и production
credentials.
- Не запускать `--execute` без действующей ротации вебхука и без проверки
итогового ZIP в офлайн-режиме.
- Импорт в Bitrix24 пересоздаёт страницы с новыми ID — перед загрузкой ZIP
проверить содержимое `manifest.json` локально.
@@ -11,13 +11,11 @@ import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from kb_common import GENERATED_DIR, REPORTS_DIR
from kb_common import GENERATED_DIR, REPORTS_DIR, call_bitrix, mask_url, require_https_webhook
DEFAULT_EXPORT = GENERATED_DIR / "json" / "bitrix24-import.json"
DEFAULT_DRY_RUN_REPORT = REPORTS_DIR / "bitrix24-import-dry-run.md"
@@ -45,13 +43,6 @@ class ImportAction:
label: str
def mask_url(url: str) -> str:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return "<invalid-url>"
return f"{parsed.scheme}://{parsed.netloc}/***"
def load_payload(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
@@ -198,16 +189,6 @@ def markdown_to_html(markdown: str) -> str:
return "\n".join(line for line in lines if line)
def require_https_webhook() -> str:
webhook_url = os.environ.get("BITRIX24_WEBHOOK_URL", "").strip()
if not webhook_url:
raise RuntimeError("Real import is blocked. Required environment: BITRIX24_WEBHOOK_URL")
parsed = urlparse(webhook_url)
if parsed.scheme != "https" or not parsed.netloc:
raise RuntimeError("BITRIX24_WEBHOOK_URL must be a valid HTTPS webhook base URL")
return webhook_url.rstrip("/")
def require_confirm() -> None:
confirm = os.environ.get("BITRIX24_IMPORT_CONFIRM", "").strip().lower()
if confirm != "true":
@@ -233,26 +214,6 @@ def require_landing_env() -> tuple[str, str, str]:
return webhook_url, site_id, scope
def post_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json; charset=utf-8"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - explicit operator-provided webhook URL.
return json.loads(response.read().decode("utf-8"))
def call_bitrix(webhook_url: str, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
endpoint = f"{webhook_url}/{method}.json"
response = post_json(endpoint, params, timeout)
if "error" in response:
error_description = response.get("error_description", response["error"])
raise RuntimeError(f"{method}: {error_description}")
return response
def extract_result_id(response: dict[str, Any]) -> Any:
result = response.get("result")
if isinstance(result, dict):
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Build a Bitrix24 Knowledge Base import ZIP (manifest.json) from a Landing site export.
Offline/dry-run usage (no Bitrix24 credentials required):
python kb-archive-builder.py --input site-export.json --output kb_everest.zip
Live export usage (requires an explicit confirmation gate):
BITRIX24_WEBHOOK_URL=https://example.bitrix24.ru/rest/1/<token> \\
BITRIX24_EXPORT_CONFIRM=true \\
python kb-archive-builder.py --site-id 109 --execute --output kb_everest.zip
The resulting ZIP contains a single ``manifest.json`` file ready for
"Сотрудники → База знаний → ⚙️ → Импорт Базы знаний → Загрузить ZIP".
"""
from __future__ import annotations
import argparse
import html
import json
import os
import sys
import zipfile
from pathlib import Path
from typing import Any
from kb_common import GENERATED_DIR, SOURCE_DIR, call_bitrix, read_json, require_https_webhook
DEFAULT_SECTIONS = SOURCE_DIR / "kb_archive_sections.json"
DEFAULT_OUTPUT = GENERATED_DIR / "bitrix24" / "kb_everest_podshipniki.zip"
DEFAULT_BASE_URL = "https://ewerest.bitrix24.ru/everest-bearing-kb/"
DEFAULT_SCOPE = "knowledge"
DEFAULT_NAME = "Технический справочник: подшипники и промышленная продукция"
DEFAULT_DESCRIPTION = "Внутренний справочник ООО «Эверест». Для менеджеров, снабжения и технических специалистов."
DEFAULT_THEME: dict[str, str] = {
"BACKGROUND_USE": "Y",
"BACKGROUND_COLOR": "#ffffff",
"THEME_COLOR": "#EE690B",
"THEMEFONTS_CODE_H": "Roboto",
"THEMEFONTS_CODE": "Open Sans",
"THEMEFONTS_COLOR": "#333333",
"THEMEFONTS_COLOR_H": "#1a1a1a",
}
CONTENT_BLOCK_CODE = "08.3.one_col_fix_title_and_text"
WRAPPER_STYLE = {"#wrapper": ["landing-block g-pt-30 g-pb-30"]}
NAV_TEMPLATE = (
'<div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;">'
'<div style="font-size:11px;font-family:monospace;color:#999;margin-bottom:8px;">НАВИГАЦИЯ</div>'
'<a href="/" style="color:#EE690B;text-decoration:none;font-size:12px;">← Главная</a>'
"{breadcrumb}"
"</div>"
)
def load_export(path: Path) -> dict[str, Any]:
return read_json(path)
def fetch_export(site_id: int, scope: str, timeout: float) -> dict[str, Any]:
if os.environ.get("BITRIX24_EXPORT_CONFIRM", "").strip().lower() != "true":
raise RuntimeError("Live export is blocked. Required environment: BITRIX24_EXPORT_CONFIRM=true")
webhook_url = require_https_webhook()
response = call_bitrix(
webhook_url,
"landing.site.fullExport",
{"id": site_id, "params": {"scope": scope}},
timeout,
)
result = response.get("result")
if not isinstance(result, dict) or not result:
raise RuntimeError(f"landing.site.fullExport returned no data for site {site_id}")
return result
def apply_metadata(export: dict[str, Any], name: str, description: str, theme: dict[str, str]) -> None:
export["type"] = "knowledge"
export["name"] = name
export["description"] = description
fields = export.get("fields")
if not isinstance(fields, dict):
fields = {}
export["fields"] = fields
additional_fields = fields.get("ADDITIONAL_FIELDS")
if not isinstance(additional_fields, dict):
additional_fields = {}
fields["ADDITIONAL_FIELDS"] = additional_fields
additional_fields.update(theme)
def fix_internal_links(export: dict[str, Any], base_url: str) -> dict[str, Any]:
if not base_url:
return export
serialized = json.dumps(export, ensure_ascii=False)
base_url_stripped = base_url.rstrip("/")
serialized = serialized.replace(base_url_stripped + "/", "/")
serialized = serialized.replace(base_url_stripped, "/")
return json.loads(serialized)
def iter_navigation_pages(sections: dict[str, Any]):
for group in sections["groups"]:
for page in group["pages"]:
yield group["title"], page["code"], page["title"]
def page_section_titles(sections: dict[str, Any]) -> dict[str, str]:
return {code: group_title for group_title, code, _ in iter_navigation_pages(sections)}
def page_titles(sections: dict[str, Any]) -> dict[str, str]:
return {code: title for _, code, title in iter_navigation_pages(sections)}
def fill_empty_pages(export: dict[str, Any], sections: dict[str, Any]) -> None:
titles = page_titles(sections)
for page_code, page in export.get("items", {}).items():
if not isinstance(page, dict):
continue
if page.get("items"):
continue
title = titles.get(page_code, page.get("name", page_code))
description = page.get("description") or "нет данных"
block_id = f"#block_{page_code.replace('-', '_')}"
page["items"] = {
block_id: {
"code": CONTENT_BLOCK_CODE,
"access": "X",
"nodes": {
".landing-block-node-title": [title],
".landing-block-node-text": [
f"<p><strong>Назначение:</strong> {html.escape(description)}</p>"
"<p><em>Статус: нет данных. Заполнить техническим специалистом.</em></p>"
],
},
"style": dict(WRAPPER_STYLE),
}
}
def build_index_page(export: dict[str, Any], sections: dict[str, Any]) -> None:
fields = export.get("fields")
index_code = fields.get("LANDING_ID_INDEX") if isinstance(fields, dict) else None
page = export.get("items", {}).get(index_code) if index_code else None
if not isinstance(page, dict):
return
toc_parts = []
for group in sections["groups"]:
items_html = "".join(
f'<li><a href="/{page_item["code"]}/" style="color:#EE690B;text-decoration:none;">'
f'{html.escape(page_item["title"])}</a></li>'
for page_item in group["pages"]
)
toc_parts.append(f"<h3>{html.escape(group['title'])}</h3><ul>{items_html}</ul>")
toc_block = {
"code": CONTENT_BLOCK_CODE,
"access": "X",
"nodes": {
".landing-block-node-title": ["Содержание справочника"],
".landing-block-node-text": ["".join(toc_parts)],
},
"style": dict(WRAPPER_STYLE),
}
page["items"] = {"#block_toc_index": toc_block, **page.get("items", {})}
def add_navigation(export: dict[str, Any], sections: dict[str, Any]) -> None:
section_titles = page_section_titles(sections)
for page_code, page in export.get("items", {}).items():
if not isinstance(page, dict):
continue
blocks = page.get("items")
if not isinstance(blocks, dict):
continue
text_blocks = [
block
for block in blocks.values()
if isinstance(block, dict)
and isinstance(block.get("nodes"), dict)
and isinstance(block["nodes"].get(".landing-block-node-text"), list)
]
if not text_blocks:
continue
section_title = section_titles.get(page_code)
breadcrumb = (
f'<span style="color:#999;font-size:12px;"> / {html.escape(section_title)}</span>' if section_title else ""
)
text_blocks[-1]["nodes"][".landing-block-node-text"].append(NAV_TEMPLATE.format(breadcrumb=breadcrumb))
def build_zip(export: dict[str, Any], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", json.dumps(export, ensure_ascii=False, indent=2))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--input", type=Path, help="Local landing.site.fullExport JSON for offline/dry-run use.")
parser.add_argument("--site-id", type=int, help="Landing site ID for a live landing.site.fullExport call.")
parser.add_argument("--execute", action="store_true", help="Allow a live landing.site.fullExport call.")
parser.add_argument("--scope", default=DEFAULT_SCOPE, help="Landing scope for the live export request.")
parser.add_argument("--sections", type=Path, default=DEFAULT_SECTIONS, help="Navigation sections JSON.")
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Output ZIP path.")
parser.add_argument("--name", default=DEFAULT_NAME, help="Knowledge base title.")
parser.add_argument("--description", default=DEFAULT_DESCRIPTION, help="Knowledge base description.")
parser.add_argument(
"--base-url",
default=DEFAULT_BASE_URL,
help="Old absolute base URL to strip from internal links (empty string disables link fixing).",
)
parser.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout for the live export request.")
args = parser.parse_args()
if args.input:
export = load_export(args.input)
elif args.execute:
if args.site_id is None:
parser.error("--execute requires --site-id")
try:
export = fetch_export(args.site_id, args.scope, args.timeout)
except Exception as exc:
print(f"Error fetching export from Bitrix24: {exc}", file=sys.stderr)
return 1
else:
parser.error(
"Specify --input <file> for offline mode, or --execute --site-id <id> with "
"BITRIX24_WEBHOOK_URL and BITRIX24_EXPORT_CONFIRM=true for a live export."
)
return 2
sections = read_json(args.sections)
apply_metadata(export, args.name, args.description, DEFAULT_THEME)
export = fix_internal_links(export, args.base_url)
fill_empty_pages(export, sections)
build_index_page(export, sections)
add_navigation(export, sections)
build_zip(export, args.output)
print(f"ZIP: {args.output} ({args.output.stat().st_size // 1024} KB)")
print(f"Pages: {len(export.get('items', {}))}")
print("Импорт: Сотрудники → База знаний → ⚙️ → Импорт Базы знаний → Загрузить ZIP")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -3,9 +3,12 @@
from __future__ import annotations
import json
import os
import re
import urllib.request
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
KB_TITLE = "Технический справочник «Эверест»"
ROOT = Path(__file__).resolve().parents[1]
@@ -28,6 +31,43 @@ FORBIDDEN_BRAND_PHRASES = {
}
def mask_url(url: str) -> str:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return "<invalid-url>"
return f"{parsed.scheme}://{parsed.netloc}/***"
def require_https_webhook(env_var: str = "BITRIX24_WEBHOOK_URL") -> str:
webhook_url = os.environ.get(env_var, "").strip()
if not webhook_url:
raise RuntimeError(f"Real request is blocked. Required environment: {env_var}")
parsed = urlparse(webhook_url)
if parsed.scheme != "https" or not parsed.netloc:
raise RuntimeError(f"{env_var} must be a valid HTTPS webhook base URL")
return webhook_url.rstrip("/")
def post_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json; charset=utf-8"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - explicit operator-provided webhook URL.
return json.loads(response.read().decode("utf-8"))
def call_bitrix(webhook_url: str, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
endpoint = f"{webhook_url}/{method}.json"
response = post_json(endpoint, params, timeout)
if "error" in response:
error_description = response.get("error_description", response["error"])
raise RuntimeError(f"{method}: {error_description}")
return response
def read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
@@ -0,0 +1,124 @@
{
"title": "Технический справочник: подшипники и промышленная продукция",
"groups": [
{
"title": "1. Маркировка подшипников",
"pages": [
{"code": "p11-oboznacheniya-gost-sistema", "title": "Система обозначений по ГОСТ"},
{"code": "p11-klass-tochnosti-gost-iso-abec", "title": "Классы точности ГОСТ / ISO / ABEC"},
{"code": "p11-radialnyy-osevoy-zazor", "title": "Радиальные и осевые зазоры"},
{"code": "p11-uplotneniya-shchitki-kanavki", "title": "Уплотнения ZZ / 2RS"},
{"code": "p11-primery-oboznacheniya", "title": "Примеры расшифровки"}
]
},
{
"title": "2. ГОСТ / ISO / ЕТУ / Аналоги",
"pages": [
{"code": "p12-analogi-gost-iso", "title": "Аналоги ГОСТ → ISO"},
{"code": "p12-analogi-iso-gost", "title": "Аналоги ISO → ГОСТ"},
{"code": "p12-gost-iso-razlichiya", "title": "Основные различия ГОСТ / ISO"},
{"code": "p12-tn-ved-kody", "title": "Коды ТН ВЭД"}
]
},
{
"title": "3. Конструкция и теория",
"pages": [
{"code": "p13-chto-takoe-podshipnik", "title": "Что такое подшипник"},
{"code": "p13-klassifikatsiya-podshipnikov", "title": "Классификация"},
{"code": "p13-tipy-nagruzok", "title": "Типы нагрузок"},
{"code": "p13-posadki-podshipnikov", "title": "Посадки подшипников"}
]
},
{
"title": "4. Типы подшипников",
"pages": [
{"code": "p14-sharikovye-radialny", "title": "Шариковые радиальные"},
{"code": "p14-rolikovye-tsilindricheskie", "title": "Цилиндрические NU/NJ/N"},
{"code": "p14-rolikovye-konicheskie", "title": "Конические роликовые"},
{"code": "p14-rolikovye-sfericheskie", "title": "Сферические роликовые"},
{"code": "p14-igolchatye-podshipniki", "title": "Игольчатые"}
]
},
{
"title": "5. Специальные серии",
"pages": [
{"code": "p15-sfericheskie-rolikovye", "title": "Сферические 22xx / 23xx"},
{"code": "p15-uzly-ucf-ucfl-ucp-uct", "title": "Корпусные UCF / UCP / UCFL"},
{"code": "p15-sharnirnye-sh-shl-shs", "title": "Шарнирные ШС / GE"},
{"code": "p15-skf-y-type-yel-yet-yar", "title": "SKF Y-тип"}
]
},
{
"title": "6. Подбор и взаимозаменяемость",
"pages": [
{"code": "p16-kak-vybrat-podshipnik", "title": "Как выбрать подшипник"},
{"code": "p16-kak-podobrat-analog", "title": "Как подобрать аналог"},
{"code": "p16-posadki-podshipnikov", "title": "Посадки"},
{"code": "p16-podbor-po-nagruzke", "title": "Подбор по нагрузке C/C₀"}
]
},
{
"title": "7. Комплектующие",
"pages": [
{"code": "p17-separatory-tipy", "title": "Типы сепараторов"},
{"code": "p17-zakreplitelnye-vtulki", "title": "Закрепительные втулки"},
{"code": "p17-stopornye-koltsa", "title": "Стопорные кольца"}
]
},
{
"title": "8. Монтаж и смазка",
"pages": [
{"code": "p18-montazh-podshipnikov", "title": "Монтаж подшипников"},
{"code": "p18-smazka-podshipnikov", "title": "Смазка"},
{"code": "p18-khranenie-podshipnikov", "title": "Хранение на складе"},
{"code": "p18-reviziya-podshipnikov", "title": "Ревизия"}
]
},
{
"title": "9. Повреждения",
"pages": [
{"code": "p19-prichiny-povrezhdeniy", "title": "Причины повреждений"},
{"code": "p19-iznos-dorozhek", "title": "Питтинг и бринеллинг"},
{"code": "p19-korroziya-podshipnikov", "title": "Коррозия"},
{"code": "p19-otkazy-elektrodvigateli", "title": "Отказы в эл. двигателях"}
]
},
{
"title": "10. Производители",
"pages": [
{"code": "p110-oboznacheniya-skf", "title": "SKF"},
{"code": "p110-oboznacheniya-fag", "title": "FAG"},
{"code": "p110-oboznacheniya-nsk", "title": "NSK"},
{"code": "p110-zavody-sng", "title": "Заводы СНГ / ЕПК"},
{"code": "p110-priznaki-kontrafakta", "title": "Признаки контрафакта"}
]
},
{
"title": "11. Автомобильные",
"pages": [
{"code": "p111-stupichnye-podshipniki", "title": "Ступичные подшипники"},
{"code": "p111-kak-vybrat-stupichnyy", "title": "Как выбрать ступичный"},
{"code": "p111-simptomy-neispravnosti-stupitsy", "title": "Симптомы неисправности"}
]
},
{
"title": "12. Линейные системы",
"pages": [
{"code": "p112-lineynye-napravlyayushchie-kach", "title": "Линейные направляющие"},
{"code": "p112-shvp-sharikovaya", "title": "ШВП"},
{"code": "p112-pretsizionnye-valy", "title": "Прецизионные валы"}
]
},
{
"title": "Сопутствующая продукция",
"pages": [
{"code": "soput-s-rti-salniki", "title": "Сальники и манжеты"},
{"code": "soput-s-remni-obshchee", "title": "Приводные ремни"},
{"code": "soput-s-tsepi-obshchee", "title": "Цепи и звёздочки"},
{"code": "soput-s-rvd", "title": "Рукава высокого давления"},
{"code": "soput-s-nasosy-nsh", "title": "Насосы НШ"},
{"code": "soput-s-vtulki-taper-bush", "title": "Втулки Taper Bush"}
]
}
]
}
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import subprocess
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "knowledge-base/everest/scripts/kb-archive-builder.py"
BASE_URL = "https://ewerest.bitrix24.ru/everest-bearing-kb/"
def make_export() -> dict:
return {
"charset": "UTF-8",
"type": "page",
"name": "Старый сайт",
"description": "Старое описание",
"fields": {
"TITLE": "Старый сайт",
"LANDING_ID_INDEX": "index",
"ADDITIONAL_FIELDS": {"THEME_USE": "N"},
},
"items": {
"index": {
"old_id": "1",
"code": "index",
"name": "Главная",
"description": "Главная страница",
"items": {},
},
"p11-oboznacheniya-gost-sistema": {
"old_id": "2",
"code": "p11-oboznacheniya-gost-sistema",
"name": "Система обозначений по ГОСТ",
"description": "Описание",
"items": {
"#block1": {
"code": "08.3.one_col_fix_title_and_text",
"nodes": {
".landing-block-node-title": ["Система обозначений по ГОСТ"],
".landing-block-node-text": [
f'<p>См. также <a href="{BASE_URL}p12-analogi-gost-iso/">аналоги</a>.</p>'
],
},
"style": {"#wrapper": ["landing-block g-pt-30 g-pb-30"]},
}
},
},
"soput-s-rti-salniki": {
"old_id": "3",
"code": "soput-s-rti-salniki",
"name": "Сальники и манжеты",
"description": "",
"items": {},
},
},
}
def run_builder(input_path: Path, output_path: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), "--input", str(input_path), "--output", str(output_path)],
cwd=ROOT,
check=False,
text=True,
capture_output=True,
)
def test_kb_archive_builder_produces_manifest(tmp_path: Path) -> None:
input_path = tmp_path / "export.json"
output_path = tmp_path / "kb_everest.zip"
input_path.write_text(json.dumps(make_export(), ensure_ascii=False), encoding="utf-8")
result = run_builder(input_path, output_path)
assert result.returncode == 0, result.stderr + result.stdout
assert "Импорт:" in result.stdout
with zipfile.ZipFile(output_path) as archive:
assert archive.namelist() == ["manifest.json"]
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
# Metadata
assert manifest["type"] == "knowledge"
assert manifest["name"] == "Технический справочник: подшипники и промышленная продукция"
assert manifest["fields"]["ADDITIONAL_FIELDS"]["THEME_COLOR"] == "#EE690B"
assert manifest["fields"]["ADDITIONAL_FIELDS"]["THEME_USE"] == "N"
items = manifest["items"]
# Internal links rewritten to relative paths
article_text = items["p11-oboznacheniya-gost-sistema"]["items"]["#block1"]["nodes"][".landing-block-node-text"]
assert any("/p12-analogi-gost-iso/" in node for node in article_text)
assert not any(BASE_URL in node for node in article_text)
# Navigation appended to the last text block, with breadcrumb to its section
last_text = article_text[-1]
assert "НАВИГАЦИЯ" in last_text
assert "← Главная" in last_text
assert "1. Маркировка подшипников" in last_text
# Empty page filled with the template
filled = items["soput-s-rti-salniki"]["items"]
assert filled
filled_block = next(iter(filled.values()))
assert filled_block["nodes"][".landing-block-node-title"] == ["Сальники и манжеты"]
assert "нет данных" in filled_block["nodes"][".landing-block-node-text"][0]
# Index page gets a table of contents covering all navigation sections
index_items = items["index"]["items"]
toc_block = index_items["#block_toc_index"]
toc_html = toc_block["nodes"][".landing-block-node-text"][0]
assert "Содержание справочника" in toc_block["nodes"][".landing-block-node-title"]
assert "/p11-oboznacheniya-gost-sistema/" in toc_html
assert "/soput-s-rti-salniki/" in toc_html
assert "Сопутствующая продукция" in toc_html
def test_kb_archive_builder_requires_input_or_execute(tmp_path: Path) -> None:
output_path = tmp_path / "kb_everest.zip"
result = subprocess.run(
[sys.executable, str(SCRIPT), "--output", str(output_path)],
cwd=ROOT,
check=False,
text=True,
capture_output=True,
)
assert result.returncode != 0
assert "BITRIX24_EXPORT_CONFIRM" in result.stderr
assert not output_path.exists()