31.10.2026 796 материалов

Оповещение о ценах на iPhone 15 в Shopee сработало впустую: previousPrice оказался null

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

Оповещение о ценах на iPhone 15 в Shopee сработало впустую: previousPrice оказался null

Трекер цен бесполезен, если сравнивать несопоставимые списки: оповещение «цена упала» сработало на карточке, которую система видела впервые, а поле previousPrice было null — то есть никакого падения цены не было вообще.

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

Проверить это на практике удалось на актёре Shopee Price Tracker для Apify: инструмент публикует разработчик, который помимо прочего занимается визовым агентством на Бали, а парсинг под заказ — половина его рабочей недели. Запрос на отслеживание цен Shopee он получает чаще всего, поэтому прогнал собственный инструмент пять раз подряд и разобрал выводы так, как их увидел бы посторонний пользователь. Результаты применимы к любому трекеру цен — от самописного скрипта до готового сервиса.

Что именно запускали

Входные данные были заморожены на все запуски — стандартная демо-конфигурация: поиск iphone 15, страна ID (Индонезия), лимит 40 товаров и именованное хранилище цен priceHistoryStore. Хранилище — это обычное key-value хранилище в аккаунте Apify: каждый запуск пишет цену каждого увиденного товара, следующий запуск с тем же именем подставляет previousPrice, priceChange, priceChangePercent, priceTrend и крайние значения за всю историю. Ни базы данных, ни миграций схемы.

Четыре запуска — в 21:08, 21:09, 21:11 и 21:12, все внутри четырёх минут. Пятый — сразу после, с флагом оповещений.

160 строк, 63 товара и никакой стабильности

Каждый запуск вернул ровно 40 строк — ровно столько, сколько и просили. Но если сложить четыре выдачи вместе, это не «40 товаров, отслеженных четыре раза». Это 63 уникальных itemId:

  • появлялись во всех 4 запусках — 22 товара;
  • в трёх из четырёх — 7;
  • в двух — 17;
  • всего один раз — 17.

То есть больше половины «отслеженного каталога» мелькнуло один-два раза и исчезло. Пересечения попарно подтверждают ту же картину: от 24 общих товаров из 40 до 34 — и это при разнице между запусками в одну-две минуты.

Здесь ничего не сломано. Запрос iphone 15 в индонезийском Shopee соответствует тысячам карточек, и в топ-40 попадает то, что ранжирователь выбрал в конкретную секунду. Повторите запрос через минуту — и четверть первой страницы будет другой.

Ни одна цена не сдвинулась

Единственная корректная проверка — сравнение товаров, присутствующих в обоих соседних запусках. Здесь результат однозначный: 32 общих товара между первым и вторым запуском — 0 изменений цены; 24 общих между вторым и третьим — 0; 34 между третьим и четвёртым — 0. Девяносто пар «товар-в-двух-запусках», ноль движений.

Это нормально и ожидаемо: продавцы на Shopee не меняют цену дважды в минуту. Поэтому любопытнее смотрится колонка тренда — она вела себя совсем не спокойно. Во втором запуске 8 строк были помечены как new, в третьем — 11, в четвёртом — 4. Итого 23 строки «новых» товаров.

Каждую из них сверили с набором ID, уже сохранённым в памяти трекера: все 23 действительно встречались впервые. Память не врёт. Просто new означает «этот country:itemId отсутствует в моём хранилище», а при поисковом запросе это почти всегда значит сдвинулся ранжирователь, а не появился новый товар. Это два разных события, и колонка их не различает.

Оповещение сработало не на то

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

Результат — одна строка. Разберём её по полям:

{
  "title": "IPHONEE 15 128GB/256GB FULLSET SECOND LIKE NEW NO SIM LOCK ...",
  "itemId": "29591951945",
  "price": 6984000,
  "originalPrice": 11000000,
  "discountPercent": 37,
  "previousPrice": null,
  "priceChange": null,
  "priceChangePercent": null,
  "priceTrend": "new",
  "lowestPrice": 6984000,
  "highestPrice": 6984000,
  "timesChecked": 1
}

previousPrice: null, priceChange: null, timesChecked: 1. Это не изменение цены. Это карточка, впервые занесённая в топ-40, — и это весь результат запуска, чья задача состояла в том, чтобы сообщать об изменениях цен. Тридцать девять неизменившихся товаров корректно и бесплатно пропущены, а единственное доставленное — новичок с тем же ярлыком.

Экономика при этом отработала безупречно: $0,0004 за запуск против $0,016 за полноценную выдачу в 40 строк — в сорок раз дешевле, и учёт бесплатных пропусков сходился каждый раз. Проблема не в деньгах, а в семантике. Если подключить onlyPriceChanges напрямую в канал уведомлений и назвать его «падения цен», большая часть того, что туда прилетит, будет мусором от штата выдачи, а отличить его можно только по priceChange: null.

Практический вывод: фильтровать нужно по полям, а не по самому факту прихода строки:

def is_real_move(row):
    return (row.get("priceTrend") in ("up", "down")
            and row.get("previousPrice") is not None)

timesChecked — единственная честная колонка

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

  • запуск 1: у всех 40 строк timesChecked равен 1;
  • запуск 2: 8 товаров с 1, 32 с 2;
  • запуск 3: 11 с 1, 7 с 2, 22 с 3;
  • запуск 4: 4 с 1, 7 с 2, 7 с 3, 22 с 4.

К четвёртому запуску только 22 товара из 40 имеют цену по всем четырём точкам. Остальные 18 несут историю глубиной в один, два или три замера — вперемешку с нормальными данными в той же колонке. Любая метрика вида «минимальная цена за 30 дней», построенная на таких данных, обязана иметь порог timesChecked >= n. Иначе товар, за которым следили тридцать раз, сравнят с товаром, который посмотрели дважды, и торжественно объявят исторический минимум.

Решение скучное: перестать отслеживать поиск

Дальше эксперимент перешёл в конструктивную фазу. Шесть товаров, присутствовавших во всех четырёх запусках, взяли их карточками — точнее, ссылками на них, — вместо поискового слова, направили на свежее хранилище и прогнали трижды:

{ "startUrls": ["https://shopee.co.id/Apple-iPhone-15-i.255563049.23157471408", "..."],
  "priceHistoryStore": "devto-fixed-73" }

Результат: все три раза по 6 строк, идентичный набор ID, тренд new только в первом запуске, затем стабильно same, счётчик аккуратно 1 → 2 → 3, ноль ложных «новых». Вот это уже история цен. Попутно выяснилось, что шесть известных ссылок разрешаются за 9–11 секунд против 47–78 секунд у сорокастраничного поиска — решать шесть адресов быстрее, чем ранжировать ключевое слово.

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

Два нюанса с полями, которые тоже всплыли

Скидка — это мнение продавца. В первом запуске шесть разных карточек несли одинаковую цену 6 950 000 индонезийских рупий, но при этом originalPrice был 7 100 000 (скидка 2%), 12 000 000 (42%), 21 000 000 (67%) и ещё дважды 7 100 000 (2%). Две карточки вообще без originalPrice. Одни и те же деньги, «скидки» от 2% до 67%.

Поле originalPrice — число, которое продавец вводит сам, поэтому discountPercent ранжирует не выгоду, а маркетинговые фантазии. Сортировать и сравнивать нужно по price. Ещё: originalPrice был null на 8 из 40 карточек, вместе с discountPercent.

Карточка товара теряет originalPrice, но сохраняет discountPercent. Ссылки на конкретный товар возвращают записи типа product_detail, и на всех шести таких записях originalPrice был null, тогда как discountPercent остался целым — 18, 10, 10, 15, 8 и 8 процентов, что точно совпало с поисковыми карточками тех же товаров. Если нужны оба значения, их придётся реконструировать по формуле originalPrice ≈ price / (1 - discountPercent / 100) либо сохранять строку из поисковой выдачи.

Сами записи об этом предупреждали: в поле note на всех шести говорилось, что Shopee не публикует полный товарный объект для этих позиций, поэтому цена и рейтинг взяты из поисковой карточки. Поля description, brand, breadcrumb, categoryPath и imageCount были null во всех шести случаях; ratingCount отсутствовал на 32 из 40 поисковых карточек, а brand, priceMin и priceMax — на всех 40. Это стоит учитывать тем, кто планирует строить аналитику по этим полям: их просто нет.

Чего этот инструмент не умеет

Разговор про ограничения тут уместнее разговора про функции.

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

В-третьих, это всё ещё неправильная форма для вопроса «изменилось ли что-нибудь в категории». Как показал эксперимент, категория — это тот же поиск, а поиск меняется быстрее, чем цены. Для таких задач нужно снимать категорию целиком и делать дифф самостоятельно, принимая, что вы измеряете в равной мере поведение ранжироватора и продавцов.

Итог

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

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