Posted on

To było tak: jest sobie jakaś losowa strona drogerii: https://www.dm.pl/wlosy/pielegnacja-wlosow/szampon. Ta strona - jak dosłownie każda jedna strona każdego jednego serwisu sprzedażowego w całym internecie - ma, (delikatnie mówiąc) PRZEKLEŃSTWO sortowanie. Co mam konkretnie na myśli? Że to nigdy nie działa sensownie. W przywołanym przykładzie sortowanie odbywa się po cenie danego produktu, więc kolejność ceny jest wypadkową ceny jednostkowej oraz ilości. Super, dokładnie o to mi chodziło, żeby szampon 10 ml za 500 zł za litr (czyli 5 zł) pojawił się przed szamponem 500 ml za 20 zł za litr (czyli 10 zł). Już pomijam fakt, że ceny jednostkowe są podane raz w kwocie za 1 litr, raz za 100 mililitrów, albo za kilogram, albo za pół, albo za 100 gramów, więc nie można ich na szybko porównać. Ja już się na tym blogu denerwowałem z dokładnie tego powodu pięć lat temu!

Na szczęście strona drogerii jest... stroną internetową - co oznacza, że możemy potraktować ją JavaScriptem, czyli tym, na co zasługuje. Plan jest taki, jak zawsze: skrobiemy dane, zmieniamy format z internetowego na ludzki, sortujemy jak człowiek, aktualizujemy DOM, jemy pomarańcze.

Krok pierwszy. Potrzebujemy, żeby wszystkie elementy były pobrane i załadowane. Strona wyświetla 15 produktów na stronę, a żeby zobaczyć kolejne, trzeba kliknąć przycisk "Więcej". Albo nie trzeba. Inspekcja przycisku "Więcej" pokazuje, ze ma on nadany identyfikator load-more-products-button. A to oznacza, że możemy:

let retries = 0;
while (true) {
  await new Promise((resolve) => setTimeout(resolve, 100));
  const loadMoreButton = document.getElementById("load-more-products-button");
  if (loadMoreButton) {
    loadMoreButton.click();
    retries = 0;
  } else retries++;
  if (retries > 10) break;
}

Co tu się dzieje? Mamy sobie nieskończoną pętlę, która robi następujące rzeczy:

  • czeka 100 milisekund, żeby przeglądarka nie zestresowała się za bardzo
  • wyszukuje na stronie element o identyfikatorze load-more-products-button
  • jeśli go znajdzie, to go klika - a jeśli nie, to odnotowuje nieudaną próbę
  • jeśli nieudanych prób będzie 10, nieskończona pętla kończy się

Przycisk pojawia się dla każdej kolejnej strony oprócz ostatniej, stąd mamy takie wyrafinowane mechanizmy powtarzania. Podejść do tego fragmentu kodu miałem wiele. Próbowałem takich cudów jak MutationObserver, który sprawdza, czy jakiś element pojawił się na stronie; próbowałem nasłuchiwać wywołań metod history.pushState oraz window.fetch korzystając z techniki monkey patch, o której dosłownie dzisiaj się dowiedziałem; próbowałem skomplikowane układu Promise oraz window.setTimeout, sprawdzając wartość location.href. Jednak nic nie zastąpi prostego łomotania pięścią w drzwi aż do skutku. Kod działa spektakularnie. Można go sobie wkleić w konsoli przeglądarki i patrzeć, jak problemy rozwiązują się same.

Kolejny krok to odpowiednie formatowanie i przeliczanie cen. Znowu w ruch wchodzi inspektor przeglądarkowy, z którego dowiadujemy się, że pole z ceną to span zamknięty w div z atrybutem data-dmid="price-infos". No to zobaczmy, co tam siedzi:

» document.querySelectorAll("[data-dmid=\"price-infos\"] :first-child")
  .forEach((e) => console.log(e.textContent))
"1 l (39,95 zł za 1 l)"
"250 ml (7,98 zł za 100 ml)"
"250 ml (7,98 zł za 100 ml)"
...

Dobra, mamy ceny sklejone w jeden ciąg. Format wygląda na spójny, można go więc spróbować rozpakować regexem. Jesteście gotowi? Oto on:

const PRICE_REGEX = /^.*\(([\d\s,]+)\s\sza\s([\d\s,]+)\s(.*)\)$/;
// przykład:         1 l (  39,95     zł  za    1          l   )

Edytor, w którym piszę tego posta, nie koloruje składni regexa tak ładnie, jak zbudowany HTML, można dostać oczopląsu. Jak to zwykle bywa z regexem, jak już się go rozpisze, to on zaczyna nabierać ludzkich cech. W przykładzie starałem się umieścić znaki odpowiadające danemu fragmentowi regexa. Znów, regex mógłby być lepszy, ale wolę, żeby był prostszy. Prostszy działa.

Skoro już mamy wypakowane dane, dokonujemy analizy składniowej ciągu znaków w celu ustalenia jego struktury i dalszego przetwarzania, liczymy mnożnik dla odpowiedniej jednostki, przeliczamy kwotę, i gotowe - mamy po czym sortować. Dla efektu dodajemy element z przeliczoną ceną jednostkową do DOM.

const UNIT_CONVERSIONS = { 
  "g": { factor: 1000, to: "kg" },
  "ml": { factor: 1000, to: "l" },
};
...
const match = priceElement.textContent.match(PRICE_REGEX);
let [, unitPrice, unitQuantity, unit] = match;
unitPrice = parseFloat(unitPrice...);
const conv = UNIT_CONVERSIONS[unit];
if (conv) {
unitPrice = (unitPrice * conv.factor) / parseInt(unitQuantity);
unitQuantity = 1;
unit = conv.to;
}
const priceElementCopy = priceElement.cloneNode(true);
priceElementCopy.textContent = `${unitPrice} zł za ${unitQuantity} ${unit}`;
priceElement.insertAdjacentElement("afterend", priceElementCopy);

Teraz najważniejszy etap: zamiana kolejności elementów w DOM. Już byłem gotowy napisać jakiś srogi mechanizm do przesuwania elementów w DOM na podstawie wag, kiedy natknąłem się na fascynującą informację: w kontenerach grid oraz flex kolejność elementów można ustawiać za pomocą... CSSa! A konkretnie pola order. A tak się składa, ze nasza skromna lista szamponów jest dokładnie takim kontenerem. Biorąc pod uwagę, że i tak przechodzimy po każdym elemencie w pętli, po to żeby odczytać i przetworzyć jego cenę, wystarczy, że dodatkowo ustawiamy element.style.order = Math.floor(unitPrice * 100) (wartość order musi być liczbą całkowitą) i sortowanie sortuje się samosortująco!

tileElement.style.order = Math.round(unitPrice * 100);

Ostatnią dziwnostką do rozwiązania było to, że klikanie w przycisk "Więcej" powoduje zmianę adresu strony, co następnie powoduje dziwne przygody np. przy jej odświeżeniu. Taki wspaniały pomysł traktujemy prostą klamrą kompozycyjną:

const originalURL = window.location.href;
// cały skrypt //
history.replaceState({}, "", originalURL);

Cały skrypt dostępny jest na moim Codebergu. Wystarczy utworzyć zakładkę, wkleić kod z pliku w pole adresu, wejść na stronę z szamponami i kliknąć zakładkę.

Protip 1: w skryptozakładkach trzeba uważać z komentarzami. Skyptozakładka zapsisuje się w jednej linijce, wiec wstawienie komentarza // wycina cały skrypt od tego momentu do końca. Można taki komentarz wrzucić na sam koniec, ale lepiej po prostu używać /* */

Protip 2: nasza drogeria ma stronę "Nowości", która zawiera chyba wszystkie możliwe produkty, czyli ma nieskończoną liczbę stron, stąd skrypt ma zaimplementowane ograniczenie. Warto pamiętać o takich rzeczach, bo ja nie pamiętałem i wywaliłem przeglądarkę zapełniając dużo gigabajtów RAMu drogeriami.