У меня возникла задача спарсить данные с веб-сайта aboutyou.de. Я провел быстрый анализ страниц и обнаружил, что сайт не имеет серьезной защиты и вся необходимая информация доступна в HTML. На первый взгляд всё казалось окей. Но это, между прочим, не окей. Это

Обратная разработка

Я уже мысленно оценил задачу в “изян”, но потом дошло дело до пагинации. Она реализована на JS и никакие параметры в URL не давали открыть определенную страницу. В списке запросов я нашёл тот который отвечал за загрузку следующей страницы, но вопросов он вызвал больше, чем дал ответов.

Декодируем protobuf загружая чанки loadable-components в NodeJS

Content-Type: application/grpc-web+proto Для меня это было что то новенькое.

Коротко говоря, gRPC – система удалённого вызова процедур (RPC), а proto (Protocol Buffers) способ сериализации структурированных данных в двоичное представление. При разработке создаётся специальный файл .proto в котором описывается структура данных . При сборке файл .proto компилируется в код на целевом языке с методами кодирования и декодирования. Если хотите познакомится с gRPC подробнее рекомендую эту статью

Работать с этим API будет намного быстрее чем загружать и разбирать HTML. Другие данные, такие как информация о каталоге и продукте, можно было получить также. Поэтому было решено использовать этот API по максимуму.

Первой мыслью было воспользоваться protobuf-decoder, а имена полей отгадать. Спустя N часов пришло осознание что для восстановления всех необходимых сервисов уйдёт уйма времени т.к. у многих структура вариативна и полей много.

Структура запроса страницы категории

Декодируем protobuf загружая чанки loadable-components в NodeJS

Я понадеялся что сборщик фронтенда не обфусцирует .proto при компиляции и в исходниках можно найти хотя бы имена полей. Ставим точку останова на XHR и …

Декодируем protobuf загружая чанки loadable-components в NodeJS

мы нашли не только имена полей, но и методы кодирования и декодирования. Они передаются в метод 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

Декодируем protobuf загружая чанки loadable-components в NodeJS

Из отладчика видно что 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.

На этом всё, а если вас интересует тема парсинга, буду рад видеть вас в своем телеграм-канале.