Merge pull request #183 from ArtemFilin1990/claude/bitrix24-kb-archive-22rli5
Add Bitrix24 Knowledge Base ZIP archive builder
This commit is contained in:
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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user