У меня возникла задача спарсить данные с веб-сайта aboutyou.de. Я провел быстрый анализ страниц и обнаружил, что сайт не имеет серьезной защиты и вся необходимая информация доступна в HTML. На первый взгляд всё казалось окей. Но это, между прочим, не окей. Это
![](https://habrastorage.org/getpro/habr/upload_files/b50/ef8/be0/b50ef8be0d612cccc50517f0a3b425cb.png)
Обратная разработка
Я уже мысленно оценил задачу в "изян", но потом дошло дело до пагинации. Она реализована на JS и никакие параметры в URL не давали открыть определенную страницу. В списке запросов я нашёл тот который отвечал за загрузку следующей страницы, но вопросов он вызвал больше, чем дал ответов.
![](https://habrastorage.org/getpro/habr/upload_files/7c3/6c0/b40/7c36c0b40e723a249966a8b39ff78825.png)
Content-Type: application/grpc-web+proto
Для меня это было что то новенькое.
Коротко говоря, gRPC - система удалённого вызова процедур (RPC), а proto (Protocol Buffers) способ сериализации структурированных данных в двоичное представление. При разработке создаётся специальный файл .proto в котором описывается структура данных . При сборке файл .proto компилируется в код на целевом языке с методами кодирования и декодирования. Если хотите познакомится с gRPC подробнее рекомендую эту статью
Работать с этим API будет намного быстрее чем загружать и разбирать HTML. Другие данные, такие как информация о каталоге и продукте, можно было получить также. Поэтому было решено использовать этот API по максимуму.
Первой мыслью было воспользоваться protobuf-decoder, а имена полей отгадать. Спустя N часов пришло осознание что для восстановления всех необходимых сервисов уйдёт уйма времени т.к. у многих структура вариативна и полей много.
Структура запроса страницы категории
![](https://habrastorage.org/getpro/habr/upload_files/524/4be/02e/5244be02e4fc537215371291a90fb8be.png)
Я понадеялся что сборщик фронтенда не обфусцирует .proto при компиляции и в исходниках можно найти хотя бы имена полей. Ставим точку останова на XHR и ...
![](https://habrastorage.org/getpro/habr/upload_files/cc4/3be/828/cc43be828ca1b53865d5724616cea4aa.png)
мы нашли не только имена полей, но и методы кодирования и декодирования. Они передаются в метод 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
![](https://habrastorage.org/getpro/habr/upload_files/d2b/ea5/bfa/d2bea5bfaac5997f0b4c997e7c0098a1.png)
Из отладчика видно что 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.
На этом всё, а если вас интересует тема парсинга, буду рад видеть вас в своем телеграм-канале.