2026-10-28 23:43:05 +03:00 | | | '''
|
| | | File: build_article_html.py
|
2026-10-29 14:39:39 +03:00 | | | Description: Собирает из docs/articles/part-04/article.md HTML-фрагмент для визуального
|
2026-10-28 23:43:05 +03:00 | | | редактора статей на MQL5.community и отдельные файлы с блоками кода.
|
| | |
|
| | | Запуск из корня проекта:
|
2026-10-29 14:39:39 +03:00 | | | python docs/articles/part-04/publish/build_article_html.py
|
2026-10-28 23:43:05 +03:00 | | | '''
|
| | |
|
| | | import html
|
| | | import re
|
| | | from pathlib import Path
|
| | |
|
| | | import markdown
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | ARTICLE_DIR = Path(__file__).resolve().parents[1]
|
| | | SRC = ARTICLE_DIR / 'article.md'
|
| | | OUT_DIR = ARTICLE_DIR / 'publish'
|
2026-10-28 23:43:05 +03:00 | | | CODE_DIR = OUT_DIR / 'code'
|
| | |
|
| | | ARTICLE_URL_RE = re.compile(r'href="https://www\.mql5\.com(/ru/[^"]*)"')
|
| | |
|
| | | # Якорные метки: латиница, цифры и «_», строчные, не начинаются с цифры
|
| | | ANCHORS = {
|
| | | 'Введение': 'intro',
|
| | | 'Чем не устраивает текущий подход': 'current_approach',
|
| | | 'Намечаем путь': 'plan',
|
| | | 'Сервис как тип MQL5-программы': 'service_type',
|
| | | 'Схема базы данных': 'db_schema',
|
| | | 'Реализация сервиса ServiceStateWriter.mq5': 'implementation',
|
| | | 'Что показал первый запуск': 'first_run',
|
| | | 'Обновление Python-стороны': 'python_side',
|
| | | 'Совместимость с уже написанным интерфейсом': 'compatibility',
|
| | | 'Развёртывание через config/services.ini': 'deploy',
|
| | | 'Тестирование': 'testing',
|
| | | 'Заключение': 'conclusion',
|
| | | }
|
| | |
|
| | | FIGURES = {
|
| | | 'all-terminals-stopped.png': 1,
|
| | | 'service-not-running.png': 2,
|
| | | 'all-terminals-running.png': 3,
|
| | | }
|
| | |
|
| | |
|
| | | def split_code(text: str):
|
| | | '''Выносит блоки кода в отдельный список, оставляя в тексте метки.'''
|
| | | blocks = []
|
| | |
|
| | | def repl(m):
|
| | | blocks.append((m.group(1).strip(), m.group(2).rstrip('\n')))
|
| | | return f'\n\n@@CODE{len(blocks) - 1}@@\n\n'
|
| | |
|
| | | text = re.sub(r'```(\w*)\n(.*?)\n```', repl, text, flags=re.S)
|
| | | return text, blocks
|
| | |
|
| | |
|
| | | def figures(text: str) -> str:
|
| | | '''Рисунок + подпись -> заглушка для картинки и подпись стилем small.'''
|
| | | pattern = re.compile(
|
| | | r'!\[[^\]]*\]\(images/([\w.-]+)\)\n\*(Рис\. \d+\..*?)\*')
|
| | |
|
| | | def repl(m):
|
| | | name, caption = m.group(1), m.group(2)
|
| | | return (
|
| | | '\n\n<p style="text-align:center;">[[РИС. %d: файл %s]]</p>\n\n'
|
| | | '<p style="text-align:center;"><span class="small">%s</span></p>\n\n'
|
| | | % (FIGURES[name], name, caption))
|
| | |
|
| | | return pattern.sub(repl, text)
|
| | |
|
| | |
|
| | | def build() -> None:
|
| | | text = SRC.read_text(encoding='utf-8')
|
| | |
|
| | | # Название статьи на сайте задаётся отдельно, оглавление соберём ниже
|
| | | text = text[text.index('## Введение'):]
|
| | |
|
| | | # Блок «Содержание архива» оформляется как жирный абзац, а не заголовок
|
| | | text = text.replace('## Содержание архива', '**Содержание архива**')
|
| | |
|
| | | text, blocks = split_code(text)
|
| | | text = figures(text)
|
| | |
|
| | | body = markdown.markdown(text, extensions=['tables', 'sane_lists'])
|
| | |
|
| | | # Заголовки: название статьи в h2, разделы в h3, подразделы в h4
|
| | | body = body.replace('<h3>', '<h4>').replace('</h3>', '</h4>')
|
| | | body = body.replace('<h2>', '<h3>').replace('</h2>', '</h3>')
|
| | |
|
| | | # Инлайн-код -> курсив: редактор сайта не умеет моноширинный текст в строке
|
| | | body = re.sub(r'<code>(.*?)</code>', r'<i>\1</i>', body, flags=re.S)
|
| | |
|
| | | # В заголовках форматирования не нужно: они попадают в план статьи
|
| | | body = re.sub(
|
| | | r'<(h[34])>(.*?)</(h[34])>',
|
| | | lambda m: '<%s>%s</%s>' % (
|
| | | m.group(1), re.sub(r'</?i>', '', m.group(2)), m.group(3)),
|
| | | body)
|
| | | body = body.replace('<strong>', '<b>').replace('</strong>', '</b>')
|
| | | body = body.replace('<em>', '<i>').replace('</em>', '</i>')
|
| | |
|
| | | # Якоря у заголовков разделов и оглавление со ссылками на них
|
| | | toc = []
|
| | |
|
| | | def add_anchor(m):
|
| | | title = m.group(1)
|
| | | anchor = ANCHORS[title]
|
| | | toc.append((anchor, title))
|
| | | return '<h3 id="%s">%s</h3>' % (anchor, title)
|
| | |
|
| | | body = re.sub(r'<h3>(.*?)</h3>', add_anchor, body)
|
| | |
|
| | | # Якорные ссылки с путём статьи: короткие вида #id сайт вырезает при сохранении
|
| | | items = '\n'.join(
|
| | | '<li><a href="/ru/articles/20150/#%s">%s</a></li>' % t for t in toc)
|
| | | body = '<p><b>Содержание</b></p>\n<ul>\n%s\n</ul>\n' % items + body
|
| | |
|
| | | # Ссылки: на сайте MQL5 относительные, все открываются в новой вкладке
|
| | | body = ARTICLE_URL_RE.sub(r'href="\1"', body)
|
| | | body = re.sub(r'<a href="((?!/ru/articles/20150/#)[^"]*)">',
|
| | | r'<a href="\1" target="_blank">', body)
|
| | |
|
| | | # Предупреждение
|
| | | body = re.sub(r'<blockquote>\s*<p><b>Предупреждение\.</b>',
|
| | | '<div class="atten">\n<p><b>Предупреждение</b>.', body)
|
| | | body = body.replace('</blockquote>', '</div>')
|
| | |
|
| | | # Таблицы в стандартном стиле сайта
|
| | | body = body.replace(
|
| | | '<table>',
|
| | | '<table class="standart" width="100%" cellspacing="0" cellpadding="3">')
|
| | |
|
| | | # Блоки кода
|
| | | CODE_DIR.mkdir(parents=True, exist_ok=True)
|
| | | for old in CODE_DIR.glob('*.txt'):
|
| | | old.unlink()
|
| | | for i, (lang, code) in enumerate(blocks):
|
| | | block = ('<div class="code-box"><pre class="code">%s</pre></div>'
|
| | | % html.escape(code, quote=False))
|
| | | body = body.replace(f'<p>@@CODE{i}@@</p>', block)
|
| | | (CODE_DIR / f'{i + 1:02d}_{lang or "text"}.txt').write_text(
|
| | | code + '\n', encoding='utf-8')
|
| | |
|
| | | (OUT_DIR / 'article4.html').write_text(body + '\n', encoding='utf-8')
|
| | | print(f'article4.html: {len(body)} символов, блоков кода: {len(blocks)}')
|
| | |
|
| | |
|
| | | if __name__ == '__main__':
|
| | | build()
|