гора.
Aperwind — каталог недвижимости Таиланда

Технический стек

Репозиторий, слой данных, ежедневный синк, рендеринг, SEO-слой, медиа, деплой и техдолг.

~5 мин чтения · 970 слов

TL;DR

Next.js 15 на App Router, React 19, TypeScript, PostgreSQL 16 в докере, Node 22 одним процессом под systemd, nginx впереди. Семь зависимостей в проде: next, react, react-dom, pg, rrweb, rrweb-player, isbot. Ни CSS-фреймворка, ни ORM, ни библиотеки локализации — 1 715 строк собственного CSS, свои SQL-запросы, свой словарь.

Рядом с сайтом — питоновый конвейер синхронизации каталога и 397 тестов на node:test. 170 коммитов с 31 августа 2026 года, 31 260 строк TypeScript.

Стек и архитектура

Репозиторий

src/app — маршруты, src/lib — 96 модулей предметной логики с тестами рядом, src/components — 48 файлов сайта и админки, db — схема, миграции и питоновый синк, scripts — 32 инструмента, content — данные под git, docs — 18 разборов подсистем.

Слой данных

22 таблицы в схеме public и 7 в analytics. Аналитика вынесена в отдельную схему намеренно: переимпорт контента делает drop table cascade, и статистика уехала бы вместе с каталогом.

Ключевая таблица — unit_ids: наши номера лотов с датой первой встречи и датой последней правки. Её единственную синк не пересоздаёт. Номер выдаётся один раз и не восстанавливается ниоткуда: потеряем таблицу — потеряем и lastmod в карте сайта, и связь с проиндексированными страницами.

ORM нет, запросы каталога написаны руками, и у них есть инвариант: order by u.sheet, u.sheet_row, u.id обязателен — по этому порядку раздаются суффиксы повторяющихся слагов, и без сортировки один и тот же объект после переимпорта получал бы то -2, то адрес без суффикса вместе со всей накопленной по адресу статистикой.

Ежедневный синк

Утренний перенос — служба на Python, таймер в 10:00 по Бангкоку, четыре шага: проверка структуры всех листов источника, подготовка плана со снимком и карантином, применение плана одной транзакцией под блокировкой таблиц, сброс кэша сайта через закрытый секретом HTTP-эндпоинт.

Три вещи, ради которых это переписано с первой версии:

Одна транзакция вместо truncate. Первая версия чистила таблицу лотов и заливала заново; сбой на середине оставлял пустой каталог.

Устаревший план не применяется. Состояние базы сверяется повторно под блокировкой, а вчерашнее разрешение не переносится на сегодняшнюю книгу.

Откат — штатная операция. Откатывается последний применённый план и только при совпадении контрольного состояния базы.

Рендеринг и кэш

Листинги динамические — они читают параметры запроса. Главная, подборки, гиды и страницы комплексов живут на ISR с пересчётом раз в час. Слой настроек витрины кэшируется на 30 секунд и сбрасывается записью.

Тонкое место нашлось здесь: чтение, стартовавшее до правки, дописывало снимок со свежим временем и отменяло инвалидацию — до 30 секунд витрина показывала доправочное состояние, и ISR успевал его зафиксировать. Теперь инвалидация двигает счётчик поколений, а читатель с разошедшимся поколением перечитывает до возврата результата.

Маршруты и SEO

Адрес лота — шесть сегментов: язык, тип сделки, город, район, тип жилья, слаг. Гигиена адресов вынесена в middleware: lowercase по декодированному пути, 410 на мёртвый раздел донора, 301 на пустые поля GET-формы и ?page=1, 308 на раздел без языкового префикса.

Карта сайта разбита на четыре: страницы, листинги, объекты, комплексы — 1 958 адресов, то есть 979 страниц в двух локалях. Тонкие страницы без фотографий и описания в неё не попадают: адрес в карте сайта при noindex в шапке — прямое противоречие. lastmod ставится только у лотов, чью правку мы застали.

Разметка — 23 типа schema.org. Пороги индексации листингов вынесены в отдельный модуль и проверяются тестами: город от пяти лотов, остальное от трёх, листинг без района — всегда noindex.

Медиа

Фотографии не отдаются из Next. Исходники обрабатываются в sharp в четыре ширины (400/800/1200/1600) и раздаются nginx с годовым иммутабельным кэшем: 70 076 файлов webp на 7,6 ГБ, 22 508 строк с путями в базе.

Выбор обложки и разметка кадра (съёмка / рендер / планировка) лежат в git, а не в базе: правка приходит с доказательством и откатывается одним git revert. Разъезд стережёт проверка данных.

Аналитика

События append-only, счётчиков-агрегатов в таблицах нет — всё выводится SQL-ом, поэтому повтор доставки не может испортить цифру. Идемпотентность на трёх уровнях: uuid события, ключ навигации на клиенте (гасит двойной прогон эффекта в Strict Mode) и uuid заявки.

Вовлечённое время — сумма интервалов между отметками активности, каждый обрезан 20 секундами: забытая на два часа вкладка не превращается в два часа чтения. Приём событий всегда отвечает 204 — телеметрия не имеет права ломать сайт, — но порядок проверок внутри значим: кука отказа, соль для хеша адреса, рейт-лимит, детектор ботов, Origin, потолки размера тела.

Хостинг и деплой

Один VPS: 4 ядра, 16 ГБ, Docker с Postgres, Node под systemd, nginx с HTTP/2 и HSTS, Let's Encrypt с автопродлением. Бэкап раз в трое суток на два внешних хранилища.

Деплой — пуш в main. Два независимых триггера: вебхук GitHub с проверкой подписи и страховочный крон раз в минуту, сравнивающий HEAD с origin/main. Оба зовут один скрипт под flock: fetch, reset, установка зависимостей, сборка, рестарт, smoke-запрос главной. Провал smoke останавливает выкатку.

Что бы я переписал

Один процесс Next — это потолок. Страница комплекса стоит около 1,4 секунды процессора. Обход всех адресов карты сайта в темпе 400 запросов в минуту положил очередь: nginx отдал 148 ответов 504. Кэша страниц и ограничения очереди в nginx нет до сих пор.

Рейт-лимит живёт в памяти процесса. Пока инстанс один, это работает. Второй воркер — и лимит нужно выносить наружу вместе с ISR-кэшем и сбросом поколений.

Часть контента не воспроизводится из репозитория. Тексты, обложки в базе и видео восстанавливаются только из дампа раз в трое суток. Обложки и разметка кадров уже уехали в git, тексты — ещё нет.

Идентичность лота держится на ключе «слаг проекта + код». У 15 лотов кода нет вовсе, у 5 ключей он неоднозначен. Настоящее решение — стабильный номер в самой таблице поставщика; остальное здесь компенсация чужой схемы.

Проверка данных не запускается сама. Одиннадцать проверок, от пропавшей цены до дубля обложки, гоняются руками. Им место в таймере рядом с синком.

Английский корпус гидов разошёлся с каталогом. 86 чисел в английских текстах отстали от базы. Конвейер сверки есть, прогнать его по английским гидам ещё предстоит.