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

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

Комментарии (2)


  1. dprotopopov
    20.03.2024 01:18

    Эх - трудна и бесполезна работа парсершика сайтов - вроде работа и есть и вроде её много, а никаких фундаментальных знаний она не даёт.

    Опыт она конечно приносит, Просто копаю здесь и сейчас.

    И кто-то что-то где-то поменяет - и то, на что были потрачены бессонные ночи, полетело в тар-тара-ры.


    1. jtjag Автор
      20.03.2024 01:18

      Готов поспорить через слово

      Работа трудана и её много. Не следует ли из этого то что она востребована из-за своей пользы?

      Для работы со сложными сайтами необходимо достаточно много фундаментальных знаний. Для обхода блокировок нужно понимать по какому принципу они срабатывают, а для этого нужно знать как работает канал связи и уметь разбираться в декомпилированном коде. Вместе с разбором декомпилированного кода не редко изучается устройство часто используемых библиотек. Для быстрой работы с некоторыми сайтами нужны знания об устройстве фронтенд фреймворка.

      Если кто-то что-то где-то меняет, обычно, у многих все идёт в тар-тара-ры. Бэк поменял выдачу - работа фронту. Фронт поменял разметку - работа тестировщику. Да и не меняют обычно всё координально, если только речь не о защите