Как я перенёс проверку цен с VPS на компьютеры пользователей — и зачем всё-таки оставил сервер

от автора

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

На схеме всё выглядело просто. На практике сервер быстро превратился в самое ненадёжное место системы.

Страницы, которые нормально открывались в обычном браузере, с VPS возвращали 403. Попытки менять HTTP/2 на HTTP/1.1, способ передачи cookies и конечный адрес API не помогали. Генератор мог построить формально корректное правило извлечения, но проверял его не на карточке товара, а на странице «Похоже, нет соединения».

Вместо очередной попытки сделать VPS похожим на браузер пользователя я поменял границу системы: сервер стал центром управления, а получение страницы и извлечение данных переехали в настольное приложение.

В этой статье расскажу, как сейчас устроен PriceTrack, зачем в нём одновременно существуют серверный базовый профиль и локальный рецепт, как выполняются фоновые проверки и какие ошибки обнаружились уже после появления трея, нескольких аккаунтов и обязательных обновлений.

Это не инструкция по обходу защиты маркетплейса. Речь об архитектуре клиентского приложения, в котором страница открывается на компьютере самого пользователя, а сервер не получает его браузерную сессию.

Как сервер оказался не в том месте

Изначальная задача была простой: сохранить ссылки на товары, периодически получать их актуальную цену и наличие, строить историю и уведомлять пользователя об изменениях. Для этого сервис сначала определял магазин по адресу, затем строил правило извлечения данных, проверял его на странице и сохранял рабочую версию.

Само правило представляло собой небольшой декларативный язык. Никакого произвольного исполняемого кода: выбрать элемент, получить текст или атрибут, применить регулярное выражение, нормализовать значение и преобразовать его к нужному типу. Упрощённое извлечение цены выглядело примерно так:

{  "price": {    "pipeline": [      {        "action": "select",        "selector": "[data-widget='webPrice'] .tsHeadline600Large",        "wait": 6000,        "state": "attached"      },      { "action": "text" },      { "action": "regex", "pattern": "([\\d\\s,.]+)" },      { "action": "replace", "from": "\\s", "to": "", "regex": true },      { "action": "parse", "type": "float" }    ]  }}

Я довольно быстро выяснил, что проблема была не в этом языке. Ошибочным оказалось другое предположение: сервер не обязательно видит ту же страницу, которую видит пользователь.

На одной из тестовых карточек генератор сообщил, что структура правила корректна, но цена и название отсутствуют. Диагностика показала следующее:

{  "final_host": "www.ozon.ru",  "page_title": "Похоже, нет соединения",  "navigation_error": null,  "price_text": null,  "extraction_backend": "page_dom"}

Я дополнительно проверил обращения к www.ozon.ru и api.ozon.ru, HTTP/2 и HTTP/1.1, передачу cookies через хранилище и через заголовок. Результат не изменился: сервер получал 403 с JSON-ответом.

Это оказалось важнее очередного подбора CSS-селектора. Селектор вполне может быть правильным и при этом совершенно бесполезным, если перед нами вообще не карточка товара. Добавлять в такой ситуации ещё пять запасных селекторов — значит лечить не ту проблему.

Отсюда для меня сформировалось главное архитектурное требование: правило нужно проверять в окружении, максимально близком к тому, где оно будет выполняться. Централизованный серверный браузер этому требованию не соответствовал и заодно создавал единую точку отказа. Если магазин ограничивал его сетевое окружение, переставали работать проверки сразу у всех пользователей.

Сервер перестал быть исполнителем

После этого ответственность разделилась иначе.

Сервер

Настольное приложение

Хранит аккаунты, устройства и лимиты

Открывает страницы магазинов

Хранит версии базовых профилей

Хранит локальную браузерную сессию

Проверяет структуру разрешённых действий

Проверяет подпись и совместимость профиля

Шифрует профили в базе и подписывает перед выдачей

Выполняет профиль в браузере

Назначает версии профилей устройствам

Создаёт быстрый локальный рецепт

Принимает только разрешённые результаты

Планирует фоновые проверки

Управляет выпусками приложения

Показывает историю, графики и уведомления

Сервер больше не открывает страницы магазинов, не получает cookies пользователя и не запускает браузер для проверки товара. При этом он не исчез: из исполнителя он превратился в управляющий контур.

Проверка начинается с того, что приложение получает подписанный базовый профиль для нужного магазина и проверяет его подпись, назначение и совместимость со своей версией. Затем оно пытается применить локальный быстрый рецепт, если такой уже существует. Успешный обязательный результат — например, цена или достоверный признак отсутствия товара — завершает проверку.

Если рецепта ещё нет или он перестал работать после изменения страницы, приложение открывает локальный браузер и выполняет базовый профиль. Только успешное браузерное извлечение может стать основанием для нового локального рецепта. На сервер после этого уходит не страница и не состояние браузера, а ограниченный результат проверки: цена, наличие и те дополнительные поля, которые профиль явно разрешает синхронизировать.

Мне нравится в этой схеме одно свойство: быстрый путь не пытается скрыть собственную поломку. Если локальная оптимизация перестала работать, система возвращается к более медленному исходному способу и может восстановить её заново.

Базовый профиль и локальный рецепт

Поначалу я называл и то и другое «правилом», и это довольно быстро стало мешать. Сейчас у сущностей разные роли.

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

Сокращённо контракт выглядит примерно так:

{  "schema_version": 2,  "target": "ozon",  "compatibility": {    "min_desktop_version": "0.1.3"  },  "execution": {    "order": ["recipe", "playwright"],    "success": {      "required_any": ["price", "out_of_stock"],      "blocked_field": "access_denied"    }  },  "fields": {    "title": { "value_type": "text", "send_to_server": true },    "price": { "value_type": "number", "send_to_server": true },    "in_stock": { "value_type": "boolean", "send_to_server": true }  }}

Локальный рецепт — другая сущность. Это оптимизация, построенная на конкретном компьютере по фактически увиденной странице. Я сознательно не считаю такой рецепт универсальной истиной для всех устройств: разметка может отличаться из-за экспериментов магазина, региона, состояния сессии или постепенного обновления интерфейса. Поэтому локальную оптимизацию можно просто удалить и восстановить, не меняя серверный профиль.

Исполнитель в сильно упрощённом виде получился таким:

def check_product(product, profile):    for method in profile.execution_order:        if method == "recipe":            result = run_local_recipe(product)        elif method == "playwright":            result = run_browser_profile(product, profile)        else:            continue        if result.access_denied:            continue        if result.price is not None or result.out_of_stock:            if method == "playwright":                save_local_recipe(result)            return result    raise ExtractionError("Обязательный результат не получен")

В реальном коде вокруг этого есть тайм-ауты, журналирование, нормализация адресов и строгая проверка типов. Но принцип остаётся именно таким: локальный рецепт ускоряет обычный сценарий, а управляемый профиль сохраняет возможность восстановиться после изменения страницы.

Такой контракт пригодился и для расширения набора данных. Помимо цены со временем могут понадобиться продавец, бренд, рейтинг, число отзывов или срок доставки. Делать отдельную миграцию базы и выпускать новую версию клиента ради каждого свойства неудобно. Поэтому дополнительные поля описываются в профиле декларативно. Для них отдельно задаётся, можно ли передавать значение на сервер, показывать его в интерфейсе и использовать в правилах уведомлений. Сам блок дополнительных значений ограничен по размеру, глубине и допустимым типам, чтобы расширяемость не превратилась в отправку произвольного содержимого страницы.

Что означает «50 товаров за проход»

После появления массового добавления обнаружилась неожиданная UX-проблема. В настройках были интервал проверки и размер пачки. Формулировку «50 товаров за проход» легко прочитать как «раз в пять минут приложение проверит только 50 товаров».

На самом деле интервал относится к полному циклу каталога, а размер пачки — к способу выполнения этого цикла. Если у пользователя 230 активных товаров и пачка равна 50, приложение последовательно обработает 50 + 50 + 50 + 50 + 30 товаров без пяти отдельных пятиминутных ожиданий. Следующий полный цикл начнётся уже по расписанию.

Разбиение понадобилось не для растягивания проверки во времени, а для контроля нагрузки на память, браузер и сеть. Заодно оно дало удобную точку остановки: между пачками можно увидеть команду завершения и не начинать следующую работу.

Эта деталь позже оказалась важной для системного трея.

Несколько аккаунтов выявили более опасную ошибку

После входа под вторым пользователем в интерфейсе внезапно остались товары первого. Серверная выборка была корректной — проблема находилась в локальном состоянии интерфейса и кэше прошлой сессии.

Это неприятный класс ошибок. Даже если приложение ничего не отправило «чужому» пользователю, само отображение данных предыдущего аккаунта уже недопустимо.

После этого локальное хранение пришлось подчинить более строгому инварианту: пользовательские данные принадлежат не установленному экземпляру PriceTrack вообще, а паре «адрес сервера + идентификатор аккаунта». При смене пользователя текущая проверка останавливается, старое представление и пользовательские кэши очищаются, приложение переключает локальное пространство данных и только после этого загружает новый каталог.

Такую же проверку получила сохранённая авторизация. Если учётные данные были сохранены для другого сервера, использовать их молча нельзя.

Почему системный трей оказался сложнее планировщика

На бумаге поведение трея выглядит почти тривиально: закрыли окно — приложение продолжает работать; выбрали «Открыть» — окно вернулось; выбрали «Выход» — весь процесс завершился.

Первая реализация быстро опровергла эту простоту. После скрытия окно иногда не возвращалось, открытие во время проверки могло заморозить интерфейс, а команда выхода появлялась в журнале, хотя в Windows оставались два процесса. После такой неудачной попытки повторное открытие иногда уже зависало окончательно.

Причиной оказалась граница потоков. Системный трей, окно pywebview, планировщик и локальный HTTP-интерфейс жили в разных контурах событий. Вызвать произвольную операцию окна непосредственно из обработчика иконки было небезопасно.

В итоге у приложения появился один владелец жизненного цикла, а действия трея превратились в команды этому владельцу. Остановка планировщика получила ограниченное ожидание, завершение стало идемпотентным, а защита от повторного запуска не позволяет поднять второй экземпляр поверх первого. В самом меню осталось только то, что нужно пользователю: «Открыть» и «Выход».

Защита от второго экземпляра здесь не косметика. Без неё можно получить два планировщика, две иконки в трее и конкурирующую запись в одну локальную базу.

Когда настольное приложение приходится обновлять принудительно

Есть ещё одна особенность гибридной архитектуры: серверный контракт развивается вместе с клиентом. Если формат профиля или протокол синхронизации изменился несовместимым образом, бесконечно поддерживать старые настольные версии невозможно.

Поэтому сервер хранит для каждого выпуска номер версии, канал обновлений, минимальную поддерживаемую версию клиента, примечание, параметры файла и SHA-256. Клиент периодически спрашивает состояние обновления. Обычный новый выпуск можно отложить, но если установленная версия стала ниже минимально допустимой, новые проверки блокируются до обновления.

Для альфы это кажется жёстким, зато предсказуемым: лучше явно остановить несовместимый клиент, чем позволить ему собирать некорректные данные старым профилем. Дистрибутив умеет скачиваться частями, а полученный файл сверяется с опубликованным SHA-256. Цифровая подпись самого установщика при этом остаётся отдельной задачей — в текущей альфе Windows всё ещё может показывать предупреждение SmartScreen.

Диагностика, которую не нужно расшифровывать пользователю

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

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

Автоматические диагностические отчёты принимает отдельный серверный контур. Его принципиальное ограничение — туда не должны попадать cookies, пароль, токен доступа и содержимое браузерной сессии. Мне хотелось получить достаточно данных для воспроизведения ошибки, не превращая диагностику в копирование пользовательского окружения на сервер.

Зачем сервер всё-таки остался

После переноса браузера на компьютер пользователя сервер не стал лишним. Просто его ответственность стала намного понятнее.

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

Продакшен-контур небольшой открытой альфы сейчас работает на FastAPI, PostgreSQL, Redis и Caddy в Docker Compose. Для него настроены проверки готовности, почтовые оповещения и резервные копии базы и выпущенных установщиков.

Причём в процессе запуска я отдельно пересмотрел само понятие «резервная копия». Архива на том же VPS оказалось недостаточно. Домашний Linux-сервер теперь забирает проверенный архив по ограниченному SSH-ключу и сохраняет его в зашифрованный репозиторий Restic. По расписанию выполняется не только проверка целостности, но и пробное восстановление последнего архива. Для меня именно успешное восстановление стало полезной метрикой: сообщение «backup created» само по себе почти ничего не гарантирует.

Что получилось

В текущей альфе пользователь добавляет одиночные ссылки или сразу список товаров, после чего приложение проверяет каталог в фоне, сохраняет историю, строит графики и может уведомлять о снижении цены или возвращении товара в наличие. Окно при этом необязательно держать открытым: работа продолжается через системный трей. Если быстрый локальный рецепт перестал извлекать обязательные данные, приложение может вернуться к браузерному пути и восстановить его. Сервер, в свою очередь, управляет совместимыми профилями и выпусками клиента, а технические ошибки могут попасть в обезличенную диагностику.

Ограничений пока достаточно. Поддерживается Windows, из магазинов полноценно подключён только Ozon, интерфейс маркетплейса способен измениться в любой момент, а локальный Chromium заметно увеличивает размер дистрибутива. Установщик пока не подписан коммерческим сертификатом. Поэтому я и называю текущую версию открытой альфой, а не готовым универсальным решением.

Бесплатный лимит сейчас составляет 10 товаров. Для меня на этом этапе важнее не количество функций, а проверка основного предположения: действительно ли распределённая локальная проверка оказывается устойчивее централизованного серверного браузера на реальных компьютерах и в разных сетях.

Итог

Главная ошибка первой архитектуры была не в конкретном CSS-селекторе и даже не в выборе HTTP-библиотеки. Я поместил исполнение туда, где окружение хуже всего соответствовало реальному пользовательскому сценарию, и вместе с этим получил единую точку отказа.

После переноса проверки на настольный клиент компонентов стало больше, но границы ответственности стали яснее. Сервер централизует доверие, конфигурацию, версии, аккаунты и результаты. Клиент владеет локальной браузерной сессией и самим исполнением. Базовый профиль задаёт управляемый контракт, а рецепт остаётся локальной заменяемой оптимизацией. Браузерный путь при этом не исчезает и служит способом восстановиться, когда оптимизация устарела.

Для меня главный вывод из этой переделки такой: если результат сильно зависит от пользовательского окружения, централизовать имеет смысл не обязательно само исполнение. Иногда надёжнее централизовать управление, а работу оставить ближе к источнику данных.

Если вам интересна живая альфа или хочется посмотреть на эту схему со стороны пользователя, она доступна на странице PriceTrack. Особенно полезной для меня сейчас будет обратная связь о поведении на разных версиях Windows и в разных сетях.

И отдельно интересно сравнить подходы: где вы проводите границу между централизованным управлением и локальным исполнением, как обновляете правила без выпуска клиента и какие инварианты используете для изоляции локальных данных нескольких аккаунтов?

ссылка на оригинал статьи https://habr.com/ru/articles/1067564/