У меня возникла задача спарсить данные с веб-сайта 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.
На этом всё, а если вас интересует тема парсинга, буду рад видеть вас в своем телеграм-канале.