Skip to content

Instantly share code, notes, and snippets.

@baslie
Last active May 17, 2025 12:18
Show Gist options
  • Select an option

  • Save baslie/e2035422675bfcfcc85ca89ec95f12bf to your computer and use it in GitHub Desktop.

Select an option

Save baslie/e2035422675bfcfcc85ca89ec95f12bf to your computer and use it in GitHub Desktop.
Экспорт документации Midjourney в ZIP-архив из markdown-файлов
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
**********************************************************************
Экспорт документации Midjourney через Zendesk Help Center API
**********************************************************************
* Назначение
Экспорт статей help-центра docs.midjourney.com в офлайновый архив:
- собирает навигацию (категории → разделы → статьи);
- конвертирует каждую статью из HTML в Markdown;
- складывает Markdown-файлы в ту же иерархию директорий;
- формирует navigation.json (дерево сайтовой навигации);
- упаковывает всё в ZIP midjourney_docs_offline.zip.
* Особенности
– Скрипт сохраняет ВСЁ в ту же папку, где лежит сам .py-файл
(не важно, из каких «текущих» директорий его запустили).
– Можно запускать двойным кликом в Windows 10 —
консоль не закроется сразу, а попросит нажать Enter.
– Требует пакеты `cloudscraper` и `markdownify`.
* Установка зависимостей
python -m pip install cloudscraper markdownify
* Проверено — Python 3.9+ на Windows 10/11.
**********************************************************************
"""
import re
import json
import zipfile
from pathlib import Path
from urllib.parse import urljoin
import cloudscraper
from markdownify import markdownify as md
# ──────────────────────────────────────────────
# Константы и базовые настройки
# ──────────────────────────────────────────────
#: Абсолютный путь к каталогу, где лежит сам скрипт.
#: Используем Path.resolve(), чтобы избежать «.» и символических ссылок.
BASE_DIR: Path = Path(__file__).resolve().parent
#: Корневой URL help-центра Midjourney.
BASE_URL: str = "https://docs.midjourney.com/"
#: Корень REST-API Zendesk для англоязычного контента.
API_ROOT: str = urljoin(BASE_URL, "api/v2/help_center/en-us/")
#: Папка, куда будут складываться Markdown-файлы
#: (создаётся автоматически).
OUTPUT_DIR: Path = BASE_DIR / "midjourney_docs_export"
#: Имя файла дерева навигации.
NAV_FILE: str = "navigation.json"
#: Полный путь к итоговому ZIP-архиву.
ZIP_NAME: Path = BASE_DIR / "midjourney_docs_offline.zip"
#: Заголовок «как будто мы браузер» (помогает обойти Cloudflare).
HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/113.0.0.0 Safari/537.36"
)
}
#: HTTP-клиент Cloudscraper (обходит Cloudflare JS-чек).
scraper = cloudscraper.create_scraper()
# ──────────────────────────────────────────────
# Вспомогательные функции
# ──────────────────────────────────────────────
def slugify(text: str) -> str:
"""
Превращает произвольную строку в «безопасный» slug
(только латиница, цифры и дефисы) — подходит
для имён директорий и файлов под Windows/macOS/Linux.
>>> slugify("Section: Примеры 2.0!") -> "section-primery-2-0"
"""
text = text.lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = text.strip("-")
return text or "item"
def fetch_all(endpoint: str, key: str) -> list[dict]:
"""
Проходит по постраничному API и возвращает объединённый список объектов.
:param endpoint: относительный путь (например 'categories.json')
:param key: ключ, в котором Zendesk присылает массив ('categories', 'articles' …)
"""
items: list[dict] = []
url: str | None = urljoin(API_ROOT, endpoint)
while url:
print(f"[INFO] GET {url}")
resp = scraper.get(url, headers=HEADERS, timeout=60)
resp.raise_for_status()
data: dict = resp.json()
items.extend(data.get(key, []))
url = data.get("next_page") # Zendesk даёт абсолютную ссылку или None
return items
# ──────────────────────────────────────────────
# Основная логика
# ──────────────────────────────────────────────
def build_navigation() -> list[dict]:
"""
Строит древо навигации (категории → разделы → статьи)
и сохраняет его в navigation.json.
"""
nav: list[dict] = []
# 1. Категории
categories = fetch_all("categories.json", "categories")
for category in categories:
cat_node: dict = {
"id": category["id"],
"title": category["name"],
"children": [],
}
# 2. Разделы внутри категории
sections = fetch_all(f"categories/{category['id']}/sections.json", "sections")
for section in sections:
sec_node: dict = {
"id": section["id"],
"title": section["name"],
"children": [],
}
# 3. Статьи внутри раздела
articles = fetch_all(f"sections/{section['id']}/articles.json", "articles")
for article in articles:
sec_node["children"].append(
{
"id": article["id"],
"title": article["title"],
"url": article["html_url"],
}
)
cat_node["children"].append(sec_node)
nav.append(cat_node)
# 4. Сохраняем дерево в navigation.json
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
nav_path: Path = OUTPUT_DIR / NAV_FILE
with nav_path.open("w", encoding="utf-8") as f:
json.dump(nav, f, ensure_ascii=False, indent=2)
print(f"[INFO] Navigation saved: {nav_path}")
return nav
def save_articles(nav: list[dict]) -> list[Path]:
"""
Проходит по навигации и скачивает каждую статью,
конвертируя в Markdown. Возвращает список
абсолютных путей к созданным .md-файлам.
"""
saved_files: list[Path] = []
for cat in nav:
cat_dir = slugify(cat["title"])
for sec in cat["children"]:
sec_dir = slugify(sec["title"])
for art in sec["children"]:
# 1. Получаем JSON статьи
art_json_url = urljoin(API_ROOT, f"articles/{art['id']}.json")
print(f"[INFO] GET {art_json_url}")
resp = scraper.get(art_json_url, headers=HEADERS, timeout=60)
resp.raise_for_status()
art_data = resp.json().get("article", {})
body_html: str = art_data.get("body", "")
markdown: str = md(body_html, heading_style="ATX")
# 2. Сохраняем Markdown
dir_path: Path = OUTPUT_DIR / cat_dir / sec_dir
dir_path.mkdir(parents=True, exist_ok=True)
file_name = f"{art['id']}-{slugify(art['title'])}.md"
file_path: Path = dir_path / file_name
with file_path.open("w", encoding="utf-8") as f:
f.write(f"# {art['title']}\n\n")
f.write(markdown)
print(f"[INFO] Saved: {file_path}")
saved_files.append(file_path)
return saved_files
def create_archive(files: list[Path]) -> None:
"""
Упаковывает navigation.json и все Markdown-файлы в ZIP,
сохраняя относительные пути (docs/…).
"""
zip_path: Path = ZIP_NAME
print(f"[INFO] Creating ZIP: {zip_path}")
with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zipf:
# navigation.json кладём в корень архива
zipf.write(OUTPUT_DIR / NAV_FILE, arcname=NAV_FILE)
# все статьи — в подпапку docs/
for f in files:
arcname = Path("docs") / f.relative_to(OUTPUT_DIR)
zipf.write(f, arcname=arcname)
print("[INFO] Archive created.")
# ──────────────────────────────────────────────
# Точка входа
# ──────────────────────────────────────────────
def main() -> None:
"""
Последовательный запуск всех шагов:
навигация → статьи → архив.
"""
navigation = build_navigation()
md_files = save_articles(navigation)
create_archive(md_files)
# ──────────────────────────────────────────────
# Самозапуск + «pause» для двойного клика
# ──────────────────────────────────────────────
if __name__ == "__main__":
try:
main()
print("\nГотово! Архив создан.")
except KeyboardInterrupt:
print("\n[WARN] Операция прервана пользователем.")
except Exception as exc: # noqa: BLE001
print(f"\n[ERROR] {exc.__class__.__name__}: {exc}")
finally:
# «Пауза», чтобы окно консоли не закрылось мгновенно
input("\nНажмите <Enter>, чтобы закрыть…")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment