Last active
May 17, 2025 12:18
-
-
Save baslie/e2035422675bfcfcc85ca89ec95f12bf to your computer and use it in GitHub Desktop.
Экспорт документации Midjourney в ZIP-архив из markdown-файлов
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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