Мониторинг отзывов о бренде: парсинг отзывов с маркетплейсов, справочников и отзовиков
- Четыре типа источников и что приходит от каждого
- Карточка маркетплейса: порции по 30 и внутренний интерфейс
- Справочник организаций: карточка компании и порядок порций
- Отзовики: пагинация в адресе и глубина захода
- Агрегаторы: витрина из чужих источников и признак происхождения
- Как отбирать только новые отзывы при ежедневном съёме
- Дубли: идентификатор записи, синтетический ключ и ревизии текста
- Одна таблица на все источники: схема полей и приведение оценок
- Расчёт нагрузки на съёме 40 карточек в день
- Пул адресов под задачу и сопутствующие замеры
Мониторинг отзывов это регулярный съём открытых откликов о товарах и компании с площадок, где их пишут покупатели, с последующим сведением собранного в одну таблицу под общей схемой полей. Технически задача распадается на две половины. Первая: получить порцию записей от каждого источника в том виде, в каком он её отдаёт. Вторая: уложить записи так, чтобы одна и та же реплика покупателя не встала в отчёт дважды.
Рамку очерчу сразу. Разговор идёт про отзывы, которые площадка показывает любому посетителю без входа в аккаунт, и про свой бренд либо про бренд клиента, чьи карточки вы ведёте по договору. Пул адресов у меня приватный серверный, около 12 000 активных IPv4 и SOCKS5 в онлайне, перебор внутри пула автоматический, состав списка обновляется в реальном времени.
Дальше я разбираю четыре типа источников по очереди. У каждого своя механика отдачи, свой посильный темп и свой признак, по которому запись опознаётся при следующем заходе.
Четыре типа источников и что приходит от каждого
Источники я делю по одному техническому признаку: где лежит текст отзыва в момент, когда браузер показал страницу. Либо текст уже сидит в разметке, которую отдал сервер, либо разметка приходит почти пустой и наполняется отдельным обращением к внутреннему интерфейсу площадки. От этого зависит всё остальное: как просить следующую порцию, сколько записей приходит за раз, можно ли задать порядок по дате.
Второй признак почти такой же важный. Есть ли у записи собственный идентификатор, который площадка отдаёт вместе с текстом. Когда идентификатор есть, ежедневный съём превращается в короткую операцию: читаю сверху и останавливаюсь на первой знакомой строке. Когда идентификатора нет, ключ приходится собирать самому из полей записи, и точность такого ключа сразу становится отдельной заботой.
| Источник | Где лежит текст | Как приходит следующая порция | Размер порции | Порядок по дате | Идентификатор записи |
|---|---|---|---|---|---|
| Карточка маркетплейса | Первые записи в разметке, остальные в JSON | Номер страницы в запросе к внутреннему интерфейсу | 30 | Задаётся параметром запроса | Числовой, приходит в порции |
| Справочник организаций | Фрагмент разметки от внутреннего интерфейса | Смещение или курсор из прошлого ответа | 20 | Отдельный параметр сортировки | В атрибуте карточки отзыва |
| Отзовик | Целиком в разметке страницы | Номер страницы в адресе | 25 | Ссылка на сортировку в интерфейсе | Числовой, стоит в адресе отзыва |
| Площадка-агрегатор | Часть в разметке, добор по кнопке | Смещение в теле запроса | 10 | Порядок по умолчанию свежий | У части записей отсутствует |
Таблица выше отвечает на вопрос, ради которого её и стоило рисовать: какой источник обойдётся дороже по числу обращений. Маркетплейс с порцией на 30 записей закрывает карточку с тысячей отзывов за 34 обращения. Агрегатор с порцией на 10 записей на том же объёме потребует больше сотни, и темп у него ниже. Планируя ежедневное окно, я считаю обращения именно так, по размеру порции и по глубине захода.
Отдельно про сортировку. Порядок по дате публикации превращает ежедневный съём из полного обхода в короткое чтение верхушки. Если площадка отдаёт записи в порядке своей внутренней релевантности, свежий отзыв может оказаться на третьей странице, и остановка на первой знакомой строке перестанет работать. Такие источники я обхожу целиком, но реже: раз в трое суток вместо ежедневного захода. Под этот режим я держу адреса под сбор отзывов с запасом по потокам, потому что полный обход собирается в один короткий всплеск обращений.
Карточка маркетплейса: порции по 30 и внутренний интерфейс
Карточка товара отдаёт первые записи прямо в разметке. Их обычно от 10 до 15, и на этом сервер останавливается. Всё, что ниже, приходит отдельным обращением, когда посетитель прокручивает блок отзывов. Найти это обращение просто: открываю карточку, включаю запись сетевых запросов в инструментах браузера, прокручиваю блок и смотрю, какой адрес позвали в ответ на прокрутку.
Выглядит запрос примерно так: путь вида /api/v1/feedbacks, параметр с идентификатором товара, номер страницы и порядок сортировки. Ответ приходит в JSON, внутри массив записей и общее число отзывов по карточке. Общее число мне нужнее самих записей: по нему я вижу, сколько порций предстоит забрать при первом съёме, и по нему же замечаю расхождение, когда часть отзывов уехала в модерацию.
curl -x socks5h://LOGIN:PASS@IP:PORT \
-H "Accept: application/json" \
-H "Accept-Language: ru-RU,ru;q=0.9" \
-H "Referer: https://market.example/catalog/1188342/detail" \
-H "X-Requested-With: XMLHttpRequest" \
-s -w "\n%{http_code} %{time_total}\n" \
"https://market.example/api/v1/feedbacks?imtId=1188342&order=dateDesc&page=2&take=30"
Три заголовка тут обязательные. Без Referer часть площадок возвращает пустой массив, без Accept отдаёт разметку вместо JSON, без признака фонового запроса иногда приходит форма подтверждения. Схема socks5h отправляет разрешение имени на сторону порта, и это стоит проверить на первом же обращении прогона. Заголовки я держу ровно те, что шлёт обычный браузер, и не добавляю к ним ничего лишнего от себя. Помогает и то, что анонимный выход без лишних заголовков не дописывает к запросу служебных полей, по которым обращение отличалось бы от обычного.
Темп у маркетплейса самый терпимый из четвёрки. Я держу 2 обращения в секунду на источник с паузой от 400 до 700 миллисекунд внутри потока. При попытке подняться до 6 обращений в секунду доля кодов 429 у меня выросла с нуля до 14 процентов уже на третьей минуте, и следующие полторы минуты источник отвечал отказом на всё подряд. Вернулся к двум и с тех пор темп не трогаю.
Поля, которые я забираю из порции: идентификатор записи, оценка, текст, дата публикации, имя автора в том виде, в каком его показывает витрина, признак подтверждённой покупки, число приложенных фотографий и ответ продавца, если он есть. Ответ продавца кладу отдельной строкой со ссылкой на родительскую запись: он живёт своей жизнью, редактируется и появляется через несколько суток после самого отзыва.
Справочник организаций: карточка компании и порядок порций
Справочник устроен иначе. Карточка организации почти пустая по разметке, отзывы приезжают фрагментами от внутреннего интерфейса, и следующая порция просится смещением либо курсором, который лежал в прошлом ответе. Курсор живёт недолго, поэтому обход карточки нужно доводить до конца за один заход. Если прогон оборвался на середине, я начинаю карточку заново.
Порядок по умолчанию у справочников почти всегда внутренний. Сортировку по дате приходится просить отдельным параметром, и я делаю это всегда, даже когда собираю карточку впервые. Так порядок записей совпадает с порядком в моей таблице, и повторный заход читает верхушку без сюрпризов.
Идентификатор записи в справочнике обычно лежит в атрибуте карточки отзыва, что-то вроде data-review-id. Он стабилен при правке текста автором, и это ровно то, что мне нужно для сведения. Оценка приходит в виде числа звёзд, дата в человеческом виде, поэтому её я привожу к машинной отметке времени на этапе разбора, до записи в таблицу.
Темп справочника заметно ниже маркетплейсного. Одно обращение в секунду держится стабильно, полтора уже дают редкие отказы, два обращения приводят к форме подтверждения примерно на пятой минуте прогона. Я остановился на одном обращении в секунду и на паузе в 1,2 секунды между порциями одной карточки. Проход по 40 карточкам укладывается в пару минут, и разгонять его незачем.
Ещё один момент, который сэкономил мне много времени при сведении. У организации в справочнике бывает несколько карточек: головная и точки обслуживания. Отзыв, оставленный в точке, попадает и в её карточку, и в сводку головной. Я собираю обе, но помечаю каждую запись идентификатором той карточки, откуда она пришла, и веду отдельную связь между карточками одной организации. Общий счёт по бренду считается по уникальным ключам записей, поэтому дубль из сводки в отчёт не проходит.
Отзовики: пагинация в адресе и глубина захода
Отзовик самый простой источник из четырёх. Обычные страницы, весь текст в разметке, номер страницы стоит прямо в адресе вида /product/5417/reviews/?page=3&sort=date. Никаких внутренних интерфейсов, никаких курсоров. Порция 25 записей, сортировка переключается ссылкой в интерфейсе, и параметр сортировки живёт в адресе, поэтому его удобно зашивать в шаблон запроса.
Тонкость у отзовиков одна и она про длину текста. На странице списка отзыв обрезан примерно на 400 символах, полный текст лежит на своей странице. Второе обращение за полным текстом я делаю только для новых записей, и только когда обрезанная версия заканчивается многоточием. На ежедневном съёме таких записей набирается 10-15 на все 40 карточек, поэтому добор почти ничего не стоит по обращениям.
Вторая тонкость про вёрстку. Селекторы у отзовиков ломаются чаще, чем у маркетплейсов: редизайн блока отзывов проходит незаметно, парсер продолжает работать и возвращает пустоту. Я держу простую проверку на выходе разбора: если со страницы списка снялось меньше 5 карточек при непустом ответе и коде 200, прогон помечает источник как подозрительный и пишет ответ на диск целиком. Разбирать поломку по сохранённой странице получается за минуты. Подойдут для такой работы HTTP-доступы с ровным каналом: когда ответ приходит без обрывов, поломку разметки видно сразу, и я не трачу время на проверку связи.
Глубину захода на отзовике я ограничиваю двадцатью страницами при первом съёме. Дальше идут записи многолетней давности, они уже посчитаны в общем рейтинге и на текущую репутацию бренда влияют слабо. Если карточка нужна целиком, добираю остаток ночью отдельной задачей с низким приоритетом.
Агрегаторы: витрина из чужих источников и признак происхождения
Агрегатор показывает отзывы, собранные с других площадок. Своих авторов у него мало, зато сводка получается широкая, и в отчёт по бренду она добавляет источники, до которых руки дошли бы нескоро. Отдача устроена через кнопку добора: обращение уходит со смещением в теле запроса, ответ приносит 10 записей и признак того, есть ли дальше что-то ещё.
Главная особенность агрегатора в том, что часть записей приходит без собственного идентификатора. Площадка перепечатала текст с чужой витрины и своего номера ему не присвоила. Для таких записей я собираю ключ сам: беру название источника, имя автора, дату публикации и первые 120 символов текста, склеиваю через разделитель и считаю от склейки хеш. Ключ выходит устойчивым, пока автор не переписал начало отзыва.
Второе, что нужно от агрегатора обязательно: поле с происхождением записи. Без него отзыв, снятый и с отзовика напрямую, и с агрегатора в перепечатке, посчитается за два. У меня под это отведён столбец source_origin, куда пишется площадка-первоисточник в том виде, в каком её называет агрегатор, приведённая к внутреннему справочнику названий. Доля перепечаток в моей выборке держится около 11 процентов, и это самый шумный источник дублей из всех четырёх.
Темп агрегатора я держу на уровне одного обращения в секунду. Он мягче маркетплейса по отказам, но отвечает медленнее: среднее время ответа у меня 1,4 секунды против 0,6 секунды на маркетплейсе. Поэтому проход по агрегатору всегда выходит самым долгим по времени при самом скромном числе обращений.
Как отбирать только новые отзывы при ежедневном съёме
Ежедневный съём строится вокруг отметки на карточку. Я храню две величины: идентификатор самой свежей известной записи и максимальную дату публикации по этой карточке. Обе лежат в отдельной таблице отметок, обе обновляются в конце успешного прохода.
Сам заход выглядит так. Прошу первую порцию с сортировкой по дате, иду сверху вниз и сравниваю идентификаторы с известными. На первом совпадении карточку можно закрывать, но я забираю ещё одну порцию сверх того. Этот нахлёст нужен из-за модерации: отзыв проходит проверку сутки и больше, публикуется с датой написания и поэтому встаёт ниже более свежих записей. Без нахлёста такие отзывы теряются целыми пачками, у меня терялось до 8 процентов месячного объёма.
def collect_card(card, fetch_page, known_ids, overlap=1):
fresh, page, seen_known = [], 1, 0
while page <= 40:
batch = fetch_page(card, page)
if not batch:
break
for item in batch:
if item["id"] in known_ids:
seen_known += 1
continue
fresh.append(item)
if seen_known and page >= first_hit_page(seen_known, page) + overlap:
break
page += 1
return fresh
Отметку я двигаю только после успешного прохода целиком. Прогон, оборвавшийся на середине карточки, отметку оставляет прежней, и следующий заход перечитает верхушку заново. Лишние 3 обращения тут дешевле пропущенных записей. Раз в неделю поверх ежедневного режима идёт полный обход карточки: он ловит правки старых текстов и удалённые записи, которых в ежедневном чтении верхушки не видно совсем.
Ещё одна привычка, которая экономит обращения. Карточки я делю на три группы по активности: живые получают ежедневный заход, средние через сутки, спящие раз в неделю. Активность считаю по числу новых отзывов за прошедшие 14 суток. Пересчёт групп идёт каждое воскресенье, и за счёт этого дневное окно держится ровным даже когда список карточек растёт. Непрерывность тут важнее скорости, поэтому я беру пул на месячном сроке доступа и не возвращаюсь к вопросу подключения посреди наблюдения.
Дубли: идентификатор записи, синтетический ключ и ревизии текста
Ключ записи у меня один на все источники и считается по единому правилу: хеш от склейки названия источника, разделителя и идентификатора записи. Название источника в ключе обязательно, потому что числовые идентификаторы у разных площадок пересекаются сплошь и рядом. Хеш беру короткий, шестнадцати байт хватает с большим запасом.
Когда собственного идентификатора нет, в ту же формулу вместо него уходит синтетический ключ из четырёх полей. Полный текст в ключ я не кладу принципиально: автор правит отзыв, добавляет фотографии, дописывает продолжение через неделю. Первые 120 символов меняются редко, дата публикации и автор не меняются вообще, поэтому ключ переживает правки.
Правки я всё равно отслеживаю, отдельным полем. Рядом с ключом лежит хеш полного текста и номер ревизии. Если при очередном заходе текст изменился, номер увеличивается на единицу, прежняя версия уезжает в историю, а в основной таблице остаётся свежая. Это дало неожиданно полезный сигнал: массовое переписывание отзывов на одной карточке обычно означает, что с покупателями поработала поддержка, и по числу ревизий за неделю видно результат такой работы.
| Источник | Основа ключа | Что ломает ключ | Доля дублей у меня |
|---|---|---|---|
| Карточка маркетплейса | Числовой идентификатор из порции | Ничего за полгода наблюдений | Ниже 1 процента |
| Справочник организаций | Атрибут карточки отзыва | Перенос точки в другую карточку | Около 4 процентов |
| Отзовик | Числовой идентификатор из адреса | Удаление и повторная публикация | Около 3 процентов |
| Площадка-агрегатор | Синтетический хеш по четырём полям | Правка начала текста автором | Около 11 процентов |
Последний столбец показывает, где стоит сверять результат руками. Маркетплейс и отзовик сводятся сами, справочник требует внимания к точкам обслуживания, агрегатор проверяю выборочно: беру 50 случайных записей за неделю и сравниваю с первоисточником. Пятидесяти хватает, чтобы поймать сдвиг в правиле ключа до того, как он испортит месячный отчёт.
Одна таблица на все источники: схема полей и приведение оценок
Схема у меня плоская и одна для всех четырёх типов. Поля такие: ключ записи, источник, идентификатор записи в источнике, идентификатор карточки, бренд, оценка в исходном виде, оценка приведённая, текст, хеш текста, номер ревизии, автор в виде хеша, признак подтверждённой покупки, дата публикации, дата съёма, ссылка на родительскую запись для ответов компании и происхождение для перепечаток.
Приведение оценок нужно потому, что шкалы у площадок разные. Маркетплейс и справочник дают пять звёзд, часть отзовиков десять баллов, агрегатор иногда приносит только признак рекомендации. Я держу оба поля: исходное значение в том виде, как его отдал источник, и приведённое к пятибалльной шкале для сводных отчётов. Десятибалльную делю пополам, рекомендацию перевожу в 5 и 1, отсутствие оценки оставляю пустым и в среднее не включаю.
Автора храню хешем от имени и площадки. Само имя в отчёте не нужно. Хеш при этом позволяет заметить, что один и тот же человек написал отзывы по трём карточкам подряд за один вечер. Даты привожу к единой отметке времени в UTC на этапе разбора: половина источников отдаёт человеческую строку, вторая половина отметку с собственным смещением, и разбирать это позже в отчётах было бы тяжело.
INSERT INTO reviews (review_key, source, source_id, card_id, rating_raw,
rating_5, body, body_hash, revision, published_at, fetched_at)
VALUES (:key, :src, :sid, :card, :raw, :r5, :body, :hash, 1, :pub, :now)
ON CONFLICT (review_key) DO UPDATE
SET body = EXCLUDED.body,
body_hash = EXCLUDED.body_hash,
revision = reviews.revision + CASE WHEN reviews.body_hash <> EXCLUDED.body_hash THEN 1 ELSE 0 END,
fetched_at = EXCLUDED.fetched_at;
Такая вставка делает съём безопасным при повторном запуске. Прогон можно перезапустить хоть пять раз подряд, лишних строк в таблице не появится, счётчик ревизий вырастет только у тех записей, где текст действительно изменился.
Расчёт нагрузки на съёме 40 карточек в день
Считаю на конкретном наборе: 40 карточек, каждая присутствует во всех четырёх типах источников. Ежедневный заход по маркетплейсу это обращение к карточке плюс две порции с нахлёстом, итого 3 обращения. Справочник столько же. Отзовик обходится двумя, агрегатор двумя. Получается 400 обращений в сутки на весь набор.
| Источник | Обращений на карточку | В сутки | Мой темп | Потоков | Проход |
|---|---|---|---|---|---|
| Карточка маркетплейса | 3 | 120 | 2 обращения в секунду | 4 | 1 минута |
| Справочник организаций | 3 | 120 | 1 обращение в секунду | 2 | 2 минуты |
| Отзовик | 2 | 80 | 1 обращение в 2 секунды | 1 | 3 минуты |
| Площадка-агрегатор | 2 | 80 | 1 обращение в секунду | 2 | 1,5 минуты |
Четыре источника идут параллельно, поэтому дневное окно закрывается за 3 минуты при 9 занятых потоках. Новых записей за сутки приходит около 130 на весь набор, из них примерно 90 с маркетплейса. Ограничивает окно темп источника, число потоков я подбираю уже под него, и запас по потокам расходуется на другое.
Расходуется он на первый съём. Полная выгрузка тех же 40 карточек это 880 обращений по маркетплейсу, 240 по справочнику, 360 по отзовику и 160 по агрегатору, итого около 1 640 обращений. Держать под ним прежние 9 потоков долго, поэтому первичный проход я запускаю на 60 потоках и укладываюсь примерно в 6 минут с сохранением темпа по каждому источнику. Такой же режим включается при недельном полном обходе.
Масштабирование считается линейно. Одно агентство с 12 брендами по 40 карточек это 4 800 обращений в сутки и порядка 110 потоков на ежедневном окне. Тысяча потоков обычного пакета покрывает такой объём с большим запасом, корпоративный вариант поднимает предел до 3000. Именно ради первичных выгрузок и недельных полных обходов я беру пул для постраничного обхода с запасом. Ежедневное окно живёт на малой доле этого запаса и оставляет свободные потоки под разовые задачи.
Пул адресов под задачу и сопутствующие замеры
Список адресов я забираю в двух формах сразу. Пара IP:PORT работает с привязанной машины, привязок в пакет входит две, менять их можно свободно. Форма IP:PORT:LOGIN:PASS ходит откуда угодно, поэтому именно она уезжает на сервер со сборщиком. Выдача доступна ссылкой и файлом, ссылка при каждом обращении отдаёт актуальный состав пула.
Про потоки одна деталь, о которую я споткнулся при первой настройке. Пакеты по потокам не складываются, и при двух привязанных адресах предел делится между ними пополам. Сборщик отзывов у меня живёт на одной машине, вторая привязка отведена под ручные проверки из браузера, поэтому в расчёт я закладываю половину от заявленного числа. С запасом в тысячу потоков это ни разу не стало ограничением.
Трафик на съёме отзывов ведёт себя интересно. Ежедневное окно почти невесомое: 400 ответов в JSON и HTML это порядка 90 мегабайт. Первичная выгрузка по тем же карточкам вместе с полными текстами и служебными полями вытянула у меня 7 гигабайт за вечер, а недельные полные обходы добавляют по 2 гигабайта каждый. Трафик в пакете безлимитный, поэтому объём выгрузок я вообще не считаю и планирую съём по темпу источников.
Рядом с отзывами идёт вторая половина работы над репутацией: я снимаю частотность брендовых запросов и подсказки поисковых систем, чтобы видеть, с какими словами люди приходят к карточкам. Этот сбор живёт в отдельном прогоне и требует своего темпа, поэтому под него я держу прокси в Key Collector отдельным списком и никогда не смешиваю его с очередью отзывов. Разные очереди с разными паузами гораздо проще отлаживать по логам.
Последнее наблюдение про надёжность. Сборщик отзывов работает годами без присмотра, и главный риск тут тихая поломка: источник поменял формат порции, парсер вернул пустоту, отчёт показал ноль новых записей и никто не заметил. У меня на каждый источник заведён ожидаемый диапазон новых записей за сутки, и выход за нижнюю границу два дня подряд поднимает сигнал. Дешёвая проверка, которая ловит почти все поломки разбора раньше, чем они успевают испортить месячный ряд.
Рядом по вики лежат материалы, которые продолжают тему: разбор работы с досками объявлений, подробности про формат списка адресов в обеих формах выдачи и расчёт лимита потоков под пакет. Про формы подтверждения, которые изредка прилетают на добор порций, написано в заметке о капче при сборе данных.