Когда нужно собирать публичные данные с двадцати с лишним платформ, рука сама тянется нарисовать общие сущности: один Post, один User, а затем приводить к ним ответы всех сервисов. Видео на bilibili и tiktok, ответ на zhihu, публикация в linkedin — на первый взгляд это всё «контент плюс автор». На трёх первых интеграциях такая схема кажется удачной. К двадцатой она уже не упрощает работу, а заваливает ею.
В итоге я не стал строить единую модель. Для двадцати с лишним платформ и двухсот с лишним команд жизнеспособным оказался более прямолинейный подход: каждая платформа отвечает только за себя.
Почему единая модель перестаёт помогать
Проблема проявляется не сразу. К восьмой платформе у Post уже висит десяток необязательных полей: где-то есть счётчик danmaku, где-то его нет; «время публикации» в одном API приходит timestamp с точностью до секунды, а в другом — строкой вроде «3 days ago». На двадцати с лишним платформах модель окончательно разваливается — не с ошибкой компиляции, а гораздо хуже: она перестаёт что-либо экономить. Любой потребляющий код сначала вынужден выяснять, заполнила ли конкретная платформа нужное поле. Логика этих проверок становится длиннее, чем разбор исходного ответа, а единый слой превращается в препятствие, которое приходится обходить.
На стороне записи та же цена. Каждая новая интеграция заставляет возвращаться к общей модели и втискивать очередные поля в форму, рассчитанную на старые платформы.
Одна платформа — один bounded context
Рабочая схема устроена наоборот: не строить общую модель, а дать каждой платформе вести собственные дела. В каталоге каждая платформа получает контекст <platform>_reverse/, которому принадлежат четыре зоны ответственности — и ни одна из них не отдаётся наружу:
Проверка входных данных. Только сама платформа знает формат своих id и допустимые сочетания параметров.
Протокол. Прямой HTTP или фрагмент локального JS для подписи, нужный домен и заголовки — всё это внутренние детали платформы.
Подпись запросов. Механизмы подписи у разных платформ слишком различаются. Попытка свести их к общему signer быстро порождает монстра из if-else.
Нормализация ответов. Сырой ответ приводится к структуре, которой владеет сама платформа, а не глобальная универсальная модель.
Последний пункт проще всего понять неправильно. «Нет единой модели» не означает «нет нормализации». Нормализация нужна каждой платформе, но целевая структура определяется ею самой, а не навязывается общим контрактом. Объединять стоит лишь то, что действительно является одним и тем же объектом: например, если два endpoint внутри одной платформы используют одинаковую структуру поста. Ошибка начинается, когда такое внутреннее объединение пытаются растянуть на разные платформы.
В общий слой — только то, что действительно одинаково
Что тогда остаётся в shared layer? Только возможности, которые на самом деле одинаково работают для всех платформ, а не просто выглядят похожими. В моём общем слое всего три вещи:
Read-model интерфейса. Из деклараций argparse каждой платформы строится единый каталог возможностей. Здесь унифицируется способ находить и описывать команды, но не их ответы. Первое действительно общее для всех платформ, второе остаётся внутренним делом конкретной платформы. Идея «декларация и есть интерфейс» подробнее разобрана в материале об interface-as-code.
Локальный loopback-транспорт. Аутентифицированные запросы проходят через локальный сервис WebSocket-сессий. Он одинаково обращается со всеми платформами и не затрагивает ни одного их бизнес-поля.
Точка диспетчеризации. Она находит платформу, передаёт команду в её контекст — и на этом её работа заканчивается.
Критерий простой: чтобы попасть в общий слой, возможность должна вести себя одинаково на каждой платформе. Транспорт, диспетчеризация и генерация описаний интерфейса этому условию соответствуют. А «единица контента» у bilibili и linkedin ведёт себя совершенно по-разному — значит, ей не место в shared layer. Внешнее сходство — главная ловушка абстракций. Два видео кажутся одинаковыми, и их хочется объединить. Но сходство на поверхности не означает одинакового поведения; если принять его за общий доменный объект, единая модель начинает разваливаться.
Распределение команд показывает цену абстракции
Если всё ещё хочется создать единую модель, достаточно посмотреть на реальное распределение команд. Есть 22 платформы и 241 команда, причём они распределены крайне неравномерно:
Платформа | Команды |
|---|---|
tiktok | 34 |
bilibili | 26 |
18 | |
zhihu | 18 |
douyin | 17 |
xiaohongshu | 16 |
Остальные 16 платформ | от 1 до 13 у каждой |
На шесть крупнейших платформ приходится 129 команд — больше половины общего числа. Вторая половина распределена между 16 платформами длинного хвоста: у многих есть лишь две или три команды, а у некоторых — всего одна.
Именно такое распределение определяет экономику абстракции. Цена единой модели фиксирована: каждому интегратору приходится заполнять поля, проверять null и обходить ограничения схемы. Выгода же распределяется по платформам. Для платформы из длинного хвоста с двумя или тремя командами она становится отрицательной: код адаптера, который нужен для подгонки под общую модель, оказывается длиннее всей её прикладной логики.
Не создавайте абстракцию в ожидании единственной реализации
Из этого распределения следует ещё одно правило: не резервируйте абстракцию под единственную реализацию. Если у платформы сейчас одна реализация, не добавляйте repository, factory или слой интерфейсов на случай, что когда-нибудь появится вторая. Новая платформа должна добавляться как один контекст <platform>_reverse/, без предварительной доработки общего базового класса.
Интерфейсный слой ценен, когда делает взаимозаменяемыми несколько реализаций. При одной реализации его польза равна нулю, а стоимость сопровождения остаётся положительной. Резервировать место для несуществующей второй реализации и для несуществующей межплатформенной общности — одна и та же ошибка. Это вновь подтвердилось при миграции между языками: в старом реестре из нескольких сотен команд часть намеренно не мигрировали пакетно и не оставили ни заглушек, ни прокси совместимости. Потому что пустая оболочка обходится дороже, чем пробел: она создаёт у следующего человека ложное впечатление, будто за ней что-то есть. С зарезервированной абстракцией происходит то же самое.
Нормализуйте через модель с контекстом конкретной платформы
Принцип «разделять по платформам, не строить единую модель» сохраняется и при нормализации с помощью модели. Когда нужно привести сырые ответы с двадцати с лишним платформ к удобной для анализа структуре, естественно поручить это модели. Но здесь легко повторить ту же ошибку из кодового слоя: задать общую схему, передать JSON каждой платформы и попросить привести его к ней. Это не работает. Модель не знает, одинаковый ли смысл у поля просмотров в bilibili и поля просмотров в tiktok. А попытка загнать всё в схему наименьшего общего знаменателя приводит либо к потере значимого для платформы поля, либо к его неточному заполнению.
Правильнее давать контекст отдельно для каждой платформы: «это bilibili, вот что означают эти поля, вот такую структуру я хочу получить именно для этой платформы». Нормализуйте платформы по одной, а объединение между ними оставьте аналитическому слою. Процесс делится на несколько этапов, и каждый требует от модели разных качеств:
Этап | Нужная способность | Выбор | model id |
|---|---|---|---|
Разобрать структуру полного сырого ответа одной платформы | Длинный контекст: целиком принимает ответ и примечания к полям | Kimi K3 |
|
Определить границу нормализации: какие поля действительно общие, а какие специфичны для платформы | Сильное рассуждение и устойчивость к избыточному объединению | Claude Opus 5 |
|
Массово извлекать поля для каждой платформы, сопоставляя записи по одной | Низкая стоимость при сотнях и тысячах параллельных вызовов | Claude Sonnet 5 |
|
Объяснить, почему одноимённые поля двух платформ не совпадают по смыслу | Средний уровень рассуждений, объясняющий различия через поля | GPT-5.6 Sol |
|
Только на втором этапе смена модели заметно меняет результат. Здесь проверяется, способна ли модель признать, что два поля на деле означают разное. Это та же задача, что и в разделе о контрдоказательствах при определении семейства алгоритмов: слабая модель следует подсказке «объединить», а сильная указывает на границу.
Главная проблема — цена переключения
Эти четыре уровня поставляют три вендора, с тремя SDK, тремя схемами авторизации и тремя форматами ошибок. Переписывать клиент трижды, чтобы менять модели между этапами, невыгодно. Поэтому многие используют один уровень на всём процессе — и нередко тот, который как раз не справляется с определением границы. В результате снова появляется схема, которая рушится на двадцати с лишним платформах.
AIReiter убирает эту сложность: один ключ, один OpenAI-совместимый интерфейс, все четыре уровня за ним. Для переключения достаточно изменить поле model в теле запроса.
# Set the normalization boundary: the reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<one platform response sample + have it mark which fields are platform-specific>"}]
}'
# Extract fields per platform in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Field-difference attribution:
# "model": "gpt-5.6-sol"
Если вы уже используете OpenAI SDK, направьте base_url на https://aireiter.com/api/v1 и больше ничего не меняйте. В Anthropic SDK используйте POST /api/v1/messages с тем же ключом.
По цене модели Claude доступны со скидкой 30% от прайса, GPT — за половину цены, а Kimi K3 вызывается тем же ключом. Скидка приходится на основную статью расходов: массовое извлечение полей по платформам — самый насыщенный вызовами этап. Двадцать с лишним платформ, у каждой сотни или тысячи записей, один вызов на запись — всё это работает на самом доступном Sonnet, поверх которого действует скидка 30%. Чтение длинного полного ответа через Kimi K3 требует нескольких сотен тысяч токенов на вход, и это ещё одна заметная часть затрат. А рассуждающий уровень для определения границ вызывается редко, поэтому почти ничего не стоит.
Попробовать без регистрации: сначала вручную прогоните ответ одной платформы и посмотрите, честно ли модель отмечает различия или торопится всё сгладить. После этого решайте, стоит ли подключать её к процессу.
Вывод
При сборе данных с разных платформ первая реакция — абстрагировать всё в общие Post/User. На малом масштабе это приятно, но на двадцати с лишним платформах такая схема неизбежно рушится: её цена фиксирована, выгода зависит от конкретной платформы, а распределение команд имеет длинный хвост. Устойчивый вариант — отдельный bounded context для каждой платформы, который сам владеет проверкой входных данных, протоколом, подписью и нормализацией ответов. В общий слой должны попадать только вещи, ведущие себя одинаково везде: транспорт, диспетчеризация, генерация описаний интерфейса. Не доменная модель, которая лишь выглядит похожей, и не абстракции, зарезервированные под единственную реализацию или несуществующую общность.
Для модели вывод тот же: нормализуйте с контекстом конкретной платформы, не скармливайте ей единую схему, а межплатформенное объединение выполняйте только на аналитическом слое. Полный четырёхэтапный workflow подробнее описывает разделение по четырём уровням. Когда все они доступны через единый интерфейс, цена переключения перестаёт быть поводом ими не пользоваться.