У меня возникла задача спарсить данные с веб-сайта aboutyou.de. Я провел быстрый анализ страниц и обнаружил, что сайт не имеет серьезной защиты и вся необходимая информация доступна в HTML. На первый взгляд всё казалось окей. Но это, между прочим, не окей. Это
Обратная разработка
Я уже мысленно оценил задачу в "изян", но потом дошло дело до пагинации. Она реализована на JS и никакие параметры в URL не давали открыть определенную страницу. В списке запросов я нашёл тот который отвечал за загрузку следующей страницы, но вопросов он вызвал больше, чем дал ответов.
Content-Type: application/grpc-web+proto
Для меня это было что то новенькое.
Коротко говоря, gRPC - система удалённого вызова процедур (RPC), а proto (Protocol Buffers) способ сериализации структурированных данных в двоичное представление. При разработке создаётся специальный файл .proto в котором описывается структура данных . При сборке файл .proto компилируется в код на целевом языке с методами кодирования и декодирования. Если хотите познакомится с gRPC подробнее рекомендую эту статью
Работать с этим API будет намного быстрее чем загружать и разбирать HTML. Другие данные, такие как информация о каталоге и продукте, можно было получить также. Поэтому было решено использовать этот API по максимуму.
Первой мыслью было воспользоваться protobuf-decoder, а имена полей отгадать. Спустя N часов пришло осознание что для восстановления всех необходимых сервисов уйдёт уйма времени т.к. у многих структура вариативна и полей много.
Структура запроса страницы категории
Я понадеялся что сборщик фронтенда не обфусцирует .proto при компиляции и в исходниках можно найти хотя бы имена полей. Ставим точку останова на XHR и ...
мы нашли не только имена полей, но и методы кодирования и декодирования. Они передаются в метод unary модуля grpc-web. Из кода можно понять имена полей, но восстановление всех схем по прежнему заняло бы много времени.
Код кодирования запроса
se = (e,t)=>{
(0,
s.CO)(e.uint32(10).fork(), t.config).ldelim(),
(0,
n.D3)(e.uint32(18).fork(), t.session).ldelim(),
(0,
i.WD)(e.uint32(130).fork(), t.category).ldelim(),
(0,
o.rb)(e.uint32(138).fork(), t.appliedFilters).ldelim(),
(0,
g.sj)(e.uint32(146).fork(), t.pagination).ldelim(),
(0,
m.a)(e.uint32(152), t.sortOptions),
e.uint32(162).fork();
for (const r of t.firstProductIds)
e.int64(r);
return e.ldelim(),
((e,t)=>{
e.int32(t)
}
)(e.uint32(168), t.automaticSizeFilter),
e.uint32(176).bool(t.showSizeFinderBadges),
e.uint32(184).bool(t.showSizeFinderProfileCompletionHints),
ne(e.uint32(194).fork(), t.highlightedProducts).ldelim(),
(0,
k.Jl)(e.uint32(202).fork(), t.selectedDiscount).ldelim(),
e
}
Файл - это чанк сформированный модулем loadable-components. Код чанка достаточно простой. В массив __LOADABLE_LOADED_CHUNKS__
добавляется массив с id чанка и объектом в котором по числовым индексам хранятся функции
Лямбда ve
, которая возвращает метод grpc GetProductStream
, используется в начале функции с индексом 91410.
91410: (e,t,r)=>{
"use strict";
r.r(t),
r.d(t, {
CategoryStreamService_GetAdditionalProductStream: ()=>we,
CategoryStreamService_GetGenderSwitch: ()=>ye,
CategoryStreamService_GetProductStream: ()=>ve,
CategoryStreamService_GetProductStreamPage: ()=>Te,
CategoryStreamService_GetQuickFilters: ()=>me
});
var s = r(45121)
, n = r(7782)
, i = r(38214)
, o = r(66931)
, a = r(22648)
, c = r(17436);
//////////////////////////////////////////////////////////////////
Точка останова в начале функции окончательно убедила что по индексам хранятся модули. В данном участке модуль из чанка 5062 с индексом 91410 экспортирует сервис со всеми методами. Из этого участка также видно что для загрузки модуля по индексу используется r
Из отладчика видно что r
это класс который занимается обработкой модулей. Осталось только повторить за клиентом.
Реализация
Для того что бы быстро подгружать сервисы план такой:
Загрузим runtime loadable-components
Загрузим необходимые чанки
Импортируем сервисы
Вернем работоспособность функциям кодирования/декодирования
Типизируем это всё (ну или any)
Огромное преимущество NodeJS платформы в данном случае в том, что мы без проблем можем выполнить JS код. Мы используем встроенный модуль vm для этого.
Загружаем с сайта файл runtime.*.js и необходимые чанки. Так как отладка кода, исполняющегося в vm - сомнительное удовольствие, я склеил runtime со всеми чанками в одну строку и уже её выполнил в новом контексте vm.
runtime.ts
import { readFileSync, readdirSync } from "fs";
import vm from "vm";
const context = vm.createContext({});
const files = readdirSync("./assets/chunks/");
let code = "";
for (const file of files) {
code += readFileSync("./assets/chunks/" + file).toString() + "\n";
}
vm.runInContext(code, context);
export const runtime = context.runtime;
Теперь мы можем импортировать модуль с сервисом, но есть проблема. Метод нам возвращается как RPCMETHOD Handler. Можно было бы подтянуть grpc зависимостью и передавать в функцию правильные аргументы, но это, кмк, ухудшило бы производительность, т.к. нам из всей библиотеки нужно буквально пару десятков строк. А вот protobufjs подтянуть пришлось. При вызове функции, которая возвращает метод, я передаю mock и оборачиваю полученные encodeRequest и decodeResponse с необходимой логикой protobufjs. Что из этого выходит? Изначально в encodeRequest нужно было передать protobufjs.Writer
и данные, а после обёртки только данные.
service.ts
import { runtime } from "./runtime.js";
import protobufjs from "protobufjs";
export type ServiceMethod<
MethodName = string,
ServiceName = string,
RequestData = any,
ResponseData = any
> = {
methodName: MethodName;
serviceName: ServiceName;
encodeRequest: (data: RequestData) => Buffer;
decodeResponse: (input: Buffer) => ResponseData;
};
export async function GetService(module_id: number, export_id: number) {
const service_module = await runtime
.e(module_id)
.then(runtime.bind(runtime, export_id));
const service = {} as any;
for (const method in service_module) {
if (Object.prototype.hasOwnProperty.call(service_module, method)) {
const service_info = service_module[method]({
unary: (e: any) => e,
stream: (e: any) => e,
});
const encodeRequest = (data: any) => {
const rpc_writer = new protobufjs.Writer();
const encoded_array = service_info
.encodeRequest(rpc_writer, data)
.finish();
const encoded_buffer = Buffer.from([
...[0, 0, 0, 0, 0],
...encoded_array,
]);
encoded_buffer.writeUInt32BE(encoded_buffer.length - 5, 1);
return encoded_buffer;
};
const decodeResponse = (input: Buffer) => {
const length = input.readUint32BE(1);
const rpc_reader = new protobufjs.Reader(input.subarray(5, 5 + length));
return service_info.decodeResponse(rpc_reader, rpc_reader.len);
};
service[service_info.methodName] = {
...service_info,
encodeRequest,
decodeResponse,
};
}
}
return service;
}
Теперь буквально одной функцией можно вытащить весь сервис
export const CategoryStreamService = (await GetService(
5062,
91410
)) as CategoryStreamService;
Заключение
Думаю данный подход можно применить и для других языков. Я уверен найдётся возможность проделать подобное для скомпилированных сервисов мобильного приложения и т.п.
Парсер создаёт намного меньше нагрузки на сервер. CF не ограничивает запросы к API. Благодаря этому скорость парсера с использованием API примерно в 20 раз быстрее варианта с парсингом HTML.
На этом всё, а если вас интересует тема парсинга, буду рад видеть вас в своем телеграм-канале.
dprotopopov
Эх - трудна и бесполезна работа парсершика сайтов - вроде работа и есть и вроде её много, а никаких фундаментальных знаний она не даёт.
Опыт она конечно приносит, Просто копаю здесь и сейчас.
И кто-то что-то где-то поменяет - и то, на что были потрачены бессонные ночи, полетело в тар-тара-ры.
jtjag Автор
Готов поспорить через слово
Работа трудана и её много. Не следует ли из этого то что она востребована из-за своей пользы?
Для работы со сложными сайтами необходимо достаточно много фундаментальных знаний. Для обхода блокировок нужно понимать по какому принципу они срабатывают, а для этого нужно знать как работает канал связи и уметь разбираться в декомпилированном коде. Вместе с разбором декомпилированного кода не редко изучается устройство часто используемых библиотек. Для быстрой работы с некоторыми сайтами нужны знания об устройстве фронтенд фреймворка.
Если кто-то что-то где-то меняет, обычно, у многих все идёт в тар-тара-ры. Бэк поменял выдачу - работа фронту. Фронт поменял разметку - работа тестировщику. Да и не меняют обычно всё координально, если только речь не о защите