Экспорт для Markdown-публикации
Оглавление серии «Агрегатор лент» · Урок 9 из 10.
Подборка уже доступна в браузере, но редакция ProfessorWeb работает с Markdown. Добавим экспорт подтверждённых ссылок в отдельную папку. Результат урока — самостоятельные заготовки с происхождением записи и файл manifest.json, который перечисляет актуальный состав экспорта. Никакой файл этого шага автоматически не подключается к сайту и не получает публичный URL.
Заготовка содержит заголовок, ссылку и место для собственного объяснения редактора. Мы не выдаём сообщение RSS за готовый учебный материал. Прочитав первоисточник, автор может написать кейс, заметку о выпуске или полноценный урок с примером; эти форматы требуют разных объёмов работы. Экспорт помогает сохранить точную связь с источником, а не заменяет исследование темы.
Отбор читается одним запросом
Экспортер открывает SQLite только для чтения и сразу получает весь выбранный набор через fetchall. Условие такое же, как у API: reviewed_hash=fingerprint. Затем соединение больше не используется для новых выборок. Папка получается из одного прочитанного состава, а не из последовательности страниц, между которыми могли появиться новые подтверждения.
Это снимок решения на момент выборки. Если сборщик изменил источник после чтения, существующий экспорт не меняется сам. Следующий запуск пересчитает состав и создаст новый manifest.json. Поэтому файл отбора нужно рассматривать как отдельный редакционный артефакт с собственным жизненным циклом, а не как постоянно синхронное зеркало базы.
Полный exporter.py этого шага:
import hashlib, json, re, sqlite3
from pathlib import Path
from urllib.parse import quote
from config import BASE, DB, SOURCES
from normalize import safe_link
def markdown_text(value):
value = value.replace("&", "&").replace("<", "<").replace(">", ">")
return re.sub(r"([\\`*_{}\[\]()#+.!|~-])", r"\\\1", value)
def export():
output = BASE / "exports"
output.mkdir(exist_ok=True)
con = sqlite3.connect(DB.as_uri() + "?mode=ro", uri=True, isolation_level=None)
con.row_factory = sqlite3.Row
try:
rows = con.execute("SELECT * FROM entries WHERE reviewed_hash=fingerprint ORDER BY id").fetchall()
selected = []
for row in rows:
url = safe_link(row["url"])
if not url: continue
url = quote(url, safe="/:?&=;%@+$,!~*'-._#")
origin = SOURCES[row["source"]]["name"]
front = {"title": row["title"], "draft": True, "source_id": row["source"],
"source_url": url, "source_version": row["fingerprint"]}
lines = ["---"] + [key + ": " + json.dumps(value, ensure_ascii=False) for key, value in front.items()] + ["---", ""]
lines += [markdown_text(row["title"]), "", "Источник: " + markdown_text(origin) + ".", "", "[Открыть первоисточник](<" + url + ">)", "", "Редактор: добавьте собственное описание после изучения материала.", ""]
content = "\n".join(lines).encode()
target = output / (row["id"] + "-" + row["fingerprint"] + ".md")
if target.exists():
if target.read_bytes() != content: raise ValueError("Существующий экспорт изменён вручную")
else:
temporary = target.with_suffix(".tmp")
temporary.write_bytes(content)
temporary.replace(target)
selected.append({"file":target.name,"id":row["id"],"source_version":row["fingerprint"],"sha256":hashlib.sha256(content).hexdigest()})
manifest = json.dumps({"schema":1,"items":selected}, ensure_ascii=False, indent=2).encode()
target = output / "manifest.json"
if not target.exists() or target.read_bytes() != manifest:
temporary = output / "manifest.json.tmp"
temporary.write_bytes(manifest)
temporary.replace(target)
finally:
con.close()
if __name__ == "__main__": export()
Обратите внимание на поля происхождения: source_id, source_url и source_version. Первый связывает заготовку с конфигурацией источника, второй ведёт к материалу издателя, третий обозначает отпечаток нормализованных метаданных, которые увидел редактор. Значение не является версией полного текста чужой статьи: приложение её не скачивало и не сравнивало.
Имя файла включает версию
Имя строится из внутреннего ID записи и отпечатка содержания. Оба значения получены из наших хешей и не содержат символов пути. Издатель не сможет подставить заголовок ../../index и заставить экспортер записать файл вне выбранной папки. Человекочитаемое название хранится внутри заготовки, а безопасное техническое имя обеспечивает однозначность.
Если источник изменился и редактор подтвердил новую версию, она получает другое имя. Старый файл остаётся на диске, но больше не обязательно входит в актуальную подборку. Такой подход удобен для сверки: две версии можно сравнить и увидеть, почему появился новый кандидат. Одновременно он запрещает считать все файлы exports/*.md текущим отбором.
Актуальный состав задаёт только manifest.json. Если подтверждение отозвано из-за изменения источника, следующий экспорт исключает старую заготовку из этого списка. При отсутствии отбора массив items становится пустым. Исторические файлы не удаляются автоматически: редактор мог добавить в них собственные заметки, и сборщик не должен стирать эту работу при очередном обновлении.
Повторный экспорт имеет явный договор
Перед созданием файла сравниваем существующие байты с ожидаемой заготовкой. Совпадение означает, что действие уже выполнено; содержимое и время изменения файла сохраняются. Различие вызывает ошибку, поскольку файл мог быть отредактирован вручную. Экспортер не перезаписывает авторский текст только ради удобства повторного запуска.
Для дальнейшей работы лучше скопировать выбранную заготовку в отдельную редакционную папку и редактировать копию. Каталог exports тогда остаётся местом управляемых снимков, а собственная статья получает своё имя, структуру и историю Git. Такая граница проще, чем пытаться угадать по строкам файла, какая часть принадлежит генератору, а какая человеку.
Новые байты сначала записываются во временный файл рядом с итоговым, затем имя заменяется. Для манифеста применяется тот же порядок. Читатель не должен увидеть половину JSON после штатной замены файла. Это не транзакция между всеми Markdown-файлами и манифестом: при аварии могут остаться новые заготовки без ссылки в составе. Старый завершённый манифест продолжит перечислять предыдущий набор.
В манифест включаем SHA-256 точных байтов каждого файла. Это позволяет будущему инструменту переноса сравнить ожидаемую заготовку с фактической. Отпечаток исходных метаданных и отпечаток экспортированного файла решают разные задачи: первый относится к решению редактора, второй — к полученному артефакту. Смешивать их в одном поле было бы неудобно при добавлении собственного текста.
Markdown тоже имеет синтаксис
Название источника и заголовок не вставляем как произвольную разметку. Помощник markdown_text экранирует значимые символы Markdown и HTML. Например, квадратные скобки в названии должны оставаться частью названия, а не начинать новую ссылку. YAML-значения сериализуются через JSON-строки, совместимые с нужной частью YAML; строка с двоеточием не превращается в дополнительное поле.
Адрес проходит прежнюю URL-политику и кодирование опасных для конструкции символов. Ссылка записывается в форме с угловыми скобками вокруг URL внутри Markdown. Это позволяет избежать случайного завершения адреса скобкой из пути. Данные не получают право сформировать новый блок метаданных или собственный HTML-тег, даже если внешнее название выглядит необычно.
В начале заготовки стоит draft: true. Но одной этой отметки недостаточно для её автоматического размещения в нашем генераторе сайта: у материала нет договора учебного раздела, подходящего URL и авторского содержания. Перенос в content/articles должен быть осознанным действием после редакционной работы, иначе каталог наполнится краткими заголовками вместо самостоятельных руководств.
После самостоятельного запуска ожидается один неизменяемый файл на каждую выбранную версию и манифест со ссылками на эти файлы. Повторный запуск без новых решений не должен менять их байты. Изменение уже экспортированной заготовки вручную должно остановить повторную генерацию, а отозванная версия должна исчезнуть из нового состава. В последнем уроке рассмотрим пределы истории, состояние источников и условия расширения такого приложения.
Перед переносом материала в сайт редактор выбирает его собственный постоянный URL и место в каталоге. Внешний ID ленты не должен определять адрес нового урока: издатель может изменить формат своей системы, а ссылку ProfessorWeb нужно сохранять. Поля источника остаются справочной связью, тогда как заголовок, описание и раздел нашей статьи проходят отдельное решение автора.
Если на основе нескольких источников пишется одно руководство, не следует автоматически склеивать заготовки в длинный список цитат. Сначала выбирается цель урока и развивающийся пример, затем объяснения строятся вокруг результата читателя. Ссылки на документацию помогают проверить факты, но не заменяют причинный разбор. Такой рабочий порядок оставляет агрегатор полезным инструментом редакции и сохраняет самостоятельность будущего текста.
Полное состояние шага — папка lesson-09 архива «Сигнал». Код, сеть, база данных и учебный сервер при подготовке не запускались; описанное поведение ожидается из разбора исходников.