Все вики-движки врут (или стоят дорого)
Obsidian с Copilot, Notion AI, Foam — крутые инструменты, пока вы не видите счёт за API. Я однажды запустил авто-тегирование 500 заметок через GPT-4. Спойлер: пришлось продать почку. Шутка, но боль реальна. LLM-вики решают задачу, которую можно решить простым парсингом и графом зависимостей. Зачем платить за токены, если можно написать 200 строк на Python?
Эта статья — мой личный манифест отказа от переусложнённых решений. Мы соберём локальный компилятор заметок, который парсит Markdown, строит граф связей, генерирует статический HTML и проверяет качество линтером. Без единого вызова нейросети. Всё работает на вашем ноутбуке, бесплатно и молниеносно.
Эта статья — идеальный кандидат для подхода «Delegation Filter»: мы явно решаем, что не нужно LLM. Подробнее — в моём материале Delegation Filter: когда НЕ использовать LLM в продакшн-пайплайнах.
Что мы построим: архитектура за 5 минут
Представьте, что у вас есть папка с .md файлами, внутри которых ссылки вида [[Заметка 1]]. Наша программа:
- Парсер — вытаскивает заголовки, теги, ссылки и метаданные из каждого файла.
- Построитель графа — собирает словарь: файл → список связанных файлов.
- Генератор HTML — превращает всё в статический сайт с навигацией.
- Линтер — ищет битые ссылки, дубликаты заголовков, опечатки.
Звучит скучно? Зато работает. И никакой LLM не нужна. Если вам всё же интересны гибридные подходы — почитайте про контекстную инженерию для локальных LLM.
Шаг 1. Парсер: выкусываем мясо из Markdown
Первое, что я написал — неправильный парсер. Он разбивал файл по строкам и искал [[...]] регуляркой. Работало, пока в заметке не появился блочный код с квадратными скобками. Баг №1: [[code]] внутри ``` ломал всё.
Как НЕ надо: простая регулярка без учёта контекста. re.findall(r'\[\[(.*?)\]\]', text)
Правильный парсер сначала вырезает блоки кода (и инлайн-код), а потом ищет вики-ссылки. Добавляем парсинг заголовков # и метаданных (YAML front matter).
import re, yaml, os, json from pathlib import Path def parse_markdown(filepath: Path) -> dict: with open(filepath, 'r', encoding='utf-8') as f: raw = f.read() # Убираем блоки кода, чтобы не ловить ссылки внутри cleaned = re.sub(r'```.*?```', '', raw, flags=re.DOTALL) cleaned = re.sub(r'`[^`]+`', '', cleaned) # Извлекаем front matter (если есть) meta = {} if cleaned.startswith('---'): _, fm, rest = cleaned.split('---', 2) meta = yaml.safe_load(fm) or {} cleaned = rest # Заголовки titles = re.findall(r'^# (.+)$', cleaned, re.MULTILINE) # Вики-ссылки links = re.findall(r'\[\[(.*?)\]\]', cleaned) return { 'path': filepath, 'meta': meta, 'titles': titles, 'links': links, 'content': cleaned }Обратите внимание: после вырезания кода мы теряем позиции, но нам нужны только имена ссылок. Если нужны offset — сохраняйте маппинг. Для нашей задачи хватит и так.
Шаг 2. Граф: без NetworkX, своими руками
Многие тянут networkx для построения графа. Но нам нужен просто словарь: {'file': ['linked_file1', 'linked_file2']}. Тяжёлая библиотека не нужна.
class WikiGraph: def __init__(self, notes_dir: Path): self.dir = notes_dir self.nodes: dict[str, dict] = {} def build(self): for md_file in self.dir.glob('*.md'): parsed = parse_markdown(md_file) node_name = md_file.stem self.nodes[node_name] = { 'title': parsed['titles'][0] if parsed['titles'] else node_name, 'links': [self._normalize_link(link) for link in parsed['links']], 'meta': parsed['meta'] } # Добавляем обратные ссылки for name, node in self.nodes.items(): backlinks = [] for other_name, other_node in self.nodes.items(): if name in other_node['links']: backlinks.append(other_name) node['backlinks'] = backlinks @staticmethod def _normalize_link(link: str) -> str: # Приводим к слагу для поиска return link.strip().lower().replace(' ', '-')Баг №2: я забыл нормализовать ссылки — [[Заметка 1]] и [[заметка-1]] считались разными. Фикс — _normalize_link. Не спорю, неидеально, но для 99% случаев хватает.
Шаг 3. Генератор HTML: из Markdown в веб
Хотим получить сайт, где каждая страница — это заметка с панелью «Связанные заметки» и «Обратные ссылки». Используем стандартную библиотеку markdown (или mistune для скорости).
import markdown from jinja2 import Template # лёгкий шаблонизатор HTML_TEMPLATE = ''' {{ title }} {{ title }}
{{ content_html }} Связанные заметки
{% for link in links %} - {{ link }}
{% endfor %}
Обратные ссылки
{% for back in backlinks %} - {{ back }}
{% endfor %}
''' def generate_html(graph: WikiGraph, output_dir: Path): template = Template(HTML_TEMPLATE) for name, node in graph.nodes.items(): # Конвертируем Markdown в HTML md_content = (graph.dir / f"{name}.md").read_text() html_content = markdown.markdown(md_content) html = template.render( title=node['title'], content_html=html_content, links=node['links'], backlinks=node['backlinks'] ) (output_dir / f"{name}.html").write_text(html)Если хотите красивую подсветку кода и удобную навигацию — подключите Prism.js. Я добавил в шаблон ссылку на CDN.
Шаг 4. Линтер: ищем мусор в заметках
Без линтера ваша вики быстро превратится в свалку. Наш линтер проверяет:
- Битые ссылки — ссылается ли [[файл]] на существующий .md файл.
- Дубликаты заголовков — несколько страниц с одинаковым H1.
- Орфографию — через pyspellchecker (если установлен).
- Пустые страницы — файлы без текста.
def lint(graph: WikiGraph): errors = [] for name, node in graph.nodes.items(): # Битые ссылки for link in node['links']: if link not in graph.nodes: errors.append(f"{name}: битая ссылка на '{link}'") # Пустые страницы content = (graph.dir / f"{name}.md").read_text().strip() if not content: errors.append(f"{name}: пустой файл") # Дубликаты заголовков titles = [node['title'] for node in graph.nodes.values()] duplicates = [t for t in titles if titles.count(t) > 1] for dup in set(duplicates): errors.append(f"Дубликат заголовка: '{dup}'") return errorsБаг №3: линтер ругался на [[index]], хотя я намеренно делал индексную страницу. Пришлось добавить белый список.
Собираем всё вместе и запускаем
# main.py from pathlib import Path import sys NOTES_DIR = Path('./notes') OUTPUT_DIR = Path('./site') def main(): graph = WikiGraph(NOTES_DIR) graph.build() errors = lint(graph) if errors: for err in errors: print(f'[LINT] {err}') # Можно остановиться, но я предпочитаю генерировать даже с ошибками # raise SystemExit(1) OUTPUT_DIR.mkdir(exist_ok=True) generate_html(graph, OUTPUT_DIR) print('Сайт сгенерирован в ./site') if __name__ == '__main__': main()Теперь python main.py — и вы получаете статический сайт. Разместите его на любом хостинге, например Vercel (бесплатный тариф отлично подходит).
Производительность: почему это быстрее LLM
Сравните: парсинг 1000 файлов занимает ~0.3 секунды. LLM-вики на каждый запрос тратит 2-5 секунд + деньги. Наш компилятор — это однократный прогон. Не верите — замерьте сами. Кстати, о производительности локальных моделей: в статье Новый сэмплер и верификатор для llama.cpp показано, как даже крошечная модель может быть быстрой, но для нашей задачи и она избыточна.
Тесты: как убедиться, что всё не развалится
# test_wiki.py def test_parse_ignores_code_blocks(): text = '```\n[[code]]\n```\nOutside [[valid]]' result = parse_markdown(text) assert result['links'] == ['valid'] def test_lint_detects_broken_links(): # создаём временные файлы ... graph = WikiGraph(...) graph.build() errors = lint(graph) assert any('broken' in e for e in errors)Юнит-тесты на парсер, интеграционные на граф и линтер — всё в одном файле. Я запускаю pytest перед каждым коммитом.
Когда LLM всё-таки нужна? (и когда нет)
Если вы пишете заметки на естественном языке и хотите семантический поиск (например, «найди все записи про деплой Kubernetes») — тут без LLM сложно. Но базовая навигация по ссылкам и графам — это чистая алгоритмика. Я часто комбинирую: для быстрой навигации использую описанный компилятор, а для сложных запросов поднимаю локальную модель через Ollama. Как настроить — читайте в гайде Приватный перевод кода на локальной LLM.
Но помните: сначала спросите себя «Можно ли это сделать без нейросети?». Мой опыт показывает, что в 80% случаев ответ «да».