Posted on

Po tym, jak Tesco wyprowadziło się z Krakowa, musiałem znaleźć alternatywny sklep do zamawiania zakupów przez internet. Po krótkich poszukiwaniach natrafiłem na Everli. Everli przypomina działaniem Glovo - jest to platforma kojarząca sklepy spożywcze z kurierami chętnymi dostarczać zakupy. Sklepów jest kilka, więc w teorii jest nawet lepiej, niż było w Tesco.

Problem z aplikacją Everli jest taki, że nie pozwala sortować produktów po cenach. Wpisuję w wyszukiwarkę "mleko", i widzę wyniki w losowej kolejności. Postanowiłem rozwiązać ten problem przy użyciu JavaScriptu i Tampermonkey. Dla tych, co nie wiedzą - Tampermonkey to wtyczka do Chrome'a, która pozwala wykonać kod JavaScript na kodzie właśnie ładowanej strony.

Pierwszy krok to podejrzenie ruchu strony Everli za pomocą inspektora przeglądarki.

Taka ciekawostka: Everli ma jakąś dziwaczną zależność renderowania strony z autoryzacją. Włączając inspektora, zmienia się rozmiar okna przeglądarki, więc strona przechodzi w tryb mobilny. Następne kliknięcie w cokolwiek na stronie wywala mnie na ekran logowania. Zostałem wylogowany, zmieniając rozmiar okienka...

Wracając. Interesują nas dwa rodzaje interakcji, których wyniki chcemy posortować: wyszukiwanie oraz wybór kategorii.

Po wpisaniu do wyszukiwarki hasła "mleko", strona przechodzi na adres

https://pl.everli.com/s#/locations/21674/stores/6027/search/mleko

W inspektorze zauważam zapytanie na adres API

https://api.everli.com/sm/api/v3/locations/21674/stores/6027/search?q=mleko&skip=0&take=40

Po wybraniu kategorii "Nabiał i jaja -> Mleko i śmietana", strona przechodzi na adres

https://pl.everli.com/s#/locations/21674/stores/6027/categories/100852/101270

W inspektorze zauważam zapytanie na adres API

https://api.everli.com/sm/api/v3/locations/21674/stores/6027/categories/101278/101280?skip=0&take=40

Te dwa zapytania API są tutaj kluczowe. To one zwracają listę produktów w postaci JSONa. Dlatego nasz skrypt będzie wyczekiwał tylko ich, a całą resztę ignorował. Stwórzmy funkcję, ktora sprawdzi, czy to właśnie te zapytania są wykonywane.

function isSearchOrCategories(URL) {
    var apiRegex = new RegExp(
        "https://api.everli.com/sm/api/v3/locations/[0-9]+/stores/[0-9]+/"),
        searchRegex = new RegExp("/search[^/]"),
        categoriesRegex = new RegExp("/categories/[0-9]+/[0-9]+[^/]");
    var isApi = apiRegex.test(URL),
        isSearch = searchRegex.test(URL),
        isCategories = categoriesRegex.test(URL);
    return isApi && (isSearch || isCategories);
}

Idźmy dalej. Odpowiedź na obydwa zapytania zawiera listę produktów, która jest wyświetlana zgodnie z kolejnością, w jakiej przyszła. Naszym celem jest przechwycenie tej odpowiedzi, zmodyfikowanie jej - czyli posortowanie, i zwrócenie jej z powrotem przeglądarce.

Przyjrzyjmy się strukturze odpowiedzi. Są one gigantyczne i pozbawione formatowania, dlatego formatuję je w JSON Formatterze. Do analizy potrzebuję tylko listy produktów - resztę ignoruję.

{
  "data":{
    "body":[
      {
        "widget_type":"vertical-list",
        "list":[
          {
            "widget_type":"product",
            "name":"Łaciate Mleko Uht 2,0%",
            "price":"2,79 zł",
            "price_per_type":"2,79 zł/l"
          }
        ]
      }
    ]
  }
}

Żeby dostać się do listy produktów, musimy z obiektu data pobrać listę body, następnie odszukać obiekt, dla którego pole widget_type ma wartość vertical-list*, i dla tego obiektu posortować elementy listy list na podstawie pól price oraz price_per_type.

Tutaj natknąłem się na pierwszy problem. W pierwotnym rozwiązaniu pobrałem po prostu pierwszy element z listy body, zakładając że jest tam zawsze tylko jeden element. Było to błędne założenie, ponieważ przy wyborze kategorii, na tej liście znajduje się jeszcze element breadcrumbs, i jest on właśnie jako pierwszy. Stąd konieczność wyszukania elementu na podstawie wartości pola widget_type.

Oto funkcja, która wyciąga nam cenę z obiektu produktu.

const priceRegex = /^\d+,\d{2}/;

function getPrice(product) {
    var pricePerTypeMatch = product.price_per_type.match(priceRegex);
    if (pricePerTypeMatch != null) {
        var pricePerTypeString = pricePerTypeMatch[0].replace(/,/g, ".");
        return parseFloat(pricePerTypeString);
    }
    var priceMatch = product.price.match(priceRegex);
    if (priceMatch != null) {
        var priceString = priceMatch[0].replace(/,/g, ".");
        return parseFloat(priceString);
    }
    return 99999;
}

Teraz napiszmy funkcję, która otrzyma obiekt odpowiedzi na zapytanie API, odpowiednio go zmodyfikuje, i zwróci w takiej samej formie.

function sortProductsInPayload(payload) {
    var verticalListPredicate = function (bodyObject) {
        return bodyObject.widget_type === "vertical-list";
    }
    var productPriceSort = function (a, b) {
        return getPrice(a) - getPrice(b);
    }
    var verticalListWidget = payload.data.body.find(verticalListPredicate);
    verticalListWidget.list.sort(productPriceSort);
    return payload
}

Na sam koniec dodajemy kod, który przechwytuje zapytania, następnie modyfikuje odpowiedź, używając wcześniejszych funkcji, i zwraca podmienioną odpowiedź przeglądarce.

Przyznaję, że ten kod po prostu znalazłem w internecie - nie jestem pewien, jak on działa, nie znam w ogóle przeglądarkowych SDK ani JavaScriptu. Ale działa, i na ten moment to mi wystarczy.

var _open = XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function (method, URL) {
    var _onreadystatechange = this.onreadystatechange,
        _this = this;
    _this.onreadystatechange = function () {
        /* USER SCRIPT GOES HERE */
        if (isSearchOrCategories(URL) && _this.readyState == 4) {
            var payload = JSON.parse(_this.responseText);
            var sortedPayload = sortProductsInPayload(payload);
            var sortedString = JSON.stringify(sortedPayload);
            Object.defineProperty(_this, "responseText", { value: sortedString });
        }
        /* USER SCRIPT ENDS HERE */
        if (_onreadystatechange) _onreadystatechange.apply(this, arguments);
    };
    Object.defineProperty(this, "onreadystatechange", {
        get: function () { return _onreadystatechange; },
        set: function (value) { _onreadystatechange = value; },
    });
    return _open.apply(_this, arguments);
};

Nie zapomnijmy też o nagłówku Tampermonkey, inaczej wtyczka będzie narzekać, że nie może sparsować kodu.

// ==UserScript==
// @name        Everli Sort
// @author      mtsz
// @description Sort Everli search/categories response
// @match       *://*.everli.com/*
// @version     1.0
// @grant       none
// ==/UserScript==

Gotowe. Funkcja sortowania w Everli dodana. Teraz wystarczy dodać skrypt do Tampermonkey, i cieszyć się z nowych możliwości portalu.