← Blog
Frontend14 min czytania

Readability — biblioteka stojąca za trybem czytania w Firefoksie

Readability wyciąga ze strony sam artykuł, odrzucając menu, reklamy i stopki. Pokazujemy pełne API z domyślnymi wartościami, dwie pułapki, które psują wdrożenia, i dlaczego autorzy wprost odmawiają roli sanitizera.

Prawie każdy projekt, który cokolwiek robi z treścią z internetu, napotyka w pewnym momencie ten sam problem. Masz HTML strony i chcesz z niego sam artykuł — bez menu, bez stopki, bez trzech banerów zgody, bez sekcji „polecane dla ciebie" i bez skryptu analitycznego. Czytnik RSS tego potrzebuje. Narzędzie do zapisywania na później tego potrzebuje. Potok RAG karmiący model treścią artykułów tego potrzebuje najbardziej, bo modelowi karmionemu nawigacją wychodzą odpowiedzi o nawigacji.

Rozwiązania własne zawsze zaczynają się tak samo: „weźmiemy największy blok tekstu". Potem dochodzi obsługa stron, które mają artykuł w dwóch kolumnach. Potem stron z komentarzami dłuższymi niż tekst. Potem paginacji. Po pół roku masz własną, gorszą wersję Readability.

Readability to biblioteka, którą Firefox stosuje w swoim trybie czytania, wydzielona do samodzielnego użytku. Jest de facto standardem w tej dziedzinie i warto ją znać, zanim zaczniesz pisać własną heurystykę.

Stan projektu

Repozytorium: mozilla/readability. 11 434 gwiazdki, 729 forków, licencja Apache-2.0. Plik NOTICE podaje dwie linie praw autorskich i one dobrze streszczają historię tego kodu: Copyright (c) 2010 Arc90 Inc oraz Copyright (c) 2010-2026 Mozilla and Contributors. Pierwotna Readability była projektem firmy Arc90, a Mozilla prowadzi ją dalej — nieprzerwanie, jak widać po dacie.

Tempo wydań wygląda na pierwszy rzut oka niepokojąco: najnowsza wersja na npm to 0.6.0 opublikowana 3 marca 2025, czyli osiemnaście miesięcy temu. Ale zestawienie z poprzednimi zmienia obraz: 0.5.0 ukazała się 15 grudnia 2023, a 0.4.4 31 marca 2023. Wydanie raz na kilkanaście miesięcy jest więc normalnym rytmem tego projektu, a nie odstępstwem. Nie jest to biblioteka, w której coś się dzieje co tydzień, i nie musi być.

Uczciwie natomiast trzeba powiedzieć, że ostatnia zmiana merytoryczna na gałęzi main to 16 września 2025 („Improve paragraph wrapping and DOM implementation"). Późniejsze commity to aktualizacje zależności od bota oraz porządki licencyjne z maja 2026 — dołożenie pełnego tekstu Apache-2.0 i pliku NOTICE z atrybucjami. Otwartych zgłoszeń jest 311. To projekt w trybie stabilnego utrzymania: żyje, dostaje poprawki bezpieczeństwa zależności i porządki, ale nie rozwija się szybko.

Warto zauważyć, że w repozytorium nie ma wydań na GitHubie — kanałem dystrybucji jest npm, a listę zmian prowadzi CHANGELOG.md w formacie Keep a Changelog.

Rzecz, którą trzeba przeczytać przed pierwszym wierszem kodu

Na początku pliku ze zmianami jest akapit, który jest najważniejszym zdaniem w całej dokumentacji tego projektu, a łatwo go przeoczyć. Autorzy stwierdzają wprost, że na potrzeby wersjonowania semantycznego obiekt wynikowy zwracany dla danego dokumentu wejściowego nie jest uważany za stabilne API. Podniesienie wersji mniejszej może zmienić to, co dostajesz. Podniesienie wersji łatki zmieni to tylko na lepsze — na przykład z pustego łańcucha albo wyjątku na coś sensowniejszego.

To wyjaśnia numerację poniżej jedynki i ma bardzo praktyczne konsekwencje. Jeśli budujesz na tym potok, w którym wynik ekstrakcji jest zapisywany, indeksowany albo porównywany — na przykład wykrywasz zmiany w artykułach albo trzymasz sumy kontrolne treści — to przypnij dokładną wersję, a aktualizację przeprowadź jako zmianę wymagającą ponownego przetworzenia korpusu, nie jako rutynowy npm update. Ta sama strona po podniesieniu wersji mniejszej może dać inny tekst, i to nie będzie błąd, a zapowiedziane zachowanie.

Podstawowe użycie

W przeglądarce sprowadza się to do jednej linii:

var article = new Readability(document).parse();

Metoda parse() zwraca obiekt z dziesięcioma polami: title (tytuł), content (przetworzona treść jako HTML), textContent (ta sama treść bez znaczników), length (długość w znakach), excerpt (opis lub krótki fragment), byline (autor), dir (kierunek tekstu), siteName (nazwa serwisu), lang (język) oraz publishedTime (czas publikacji).

Zwróć uwagę na dir i lang — to nie ozdoby. Przy zbiorze wielojęzycznym pole lang pozwala skierować treść do właściwego modelu albo indeksu, a dir obsłużyć języki pisane od prawej do lewej bez zgadywania.

Pułapka numer jeden: parse() niszczy dokument

To najczęstsze źródło dziwnych błędów przy tej bibliotece i dokumentacja mówi o tym wprost, tylko w jednym akapicie, który łatwo przewinąć. Metoda parse() działa przez modyfikowanie DOM — usuwa elementy ze strony. Jeśli uruchomisz ją na żywym dokumencie w przeglądarce albo na dokumencie, którego potrzebujesz jeszcze do czegoś innego, zostaniesz z okaleczonym drzewem.

Rozwiązanie jest jednolinijkowe i powinno być odruchem:

var documentClone = document.cloneNode(true);
var article = new Readability(documentClone).parse();

Kosztem jest pamięć na kopię drzewa, co przy dużych stronach nie jest zerowe. Ale alternatywą jest zniszczenie oryginału, więc wybór nie jest trudny. Jeśli w tym samym przebiegu chcesz zrobić coś jeszcze — na przykład wyciągnąć własne metadane albo zrzut ekranu — zrób to przed wywołaniem parse() albo pracuj na klonie.

Opcje i ich wartości domyślne

Konstruktor przyjmuje obiekt opcji i warto znać kilka z nich na pamięć, bo wyjaśniają najczęstsze „dlaczego to nie działa".

  • charThreshold, domyślnie 500 — artykuł musi mieć co najmniej tyle znaków, żeby cokolwiek zostało zwrócone. To odpowiedź numer jeden na pytanie „dlaczego dostaję null". Przy krótkich notkach, komunikatach prasowych albo wpisach na mikroblogu ten próg trzeba obniżyć świadomie.
  • keepClasses, domyślnie false — wszystkie klasy CSS są usuwane, poza wymienionymi w tablicy classesToPreserve. Sensowne domyślnie, ale jeśli twój styl wyświetlania polega na klasach z oryginału (na przykład podświetlanie składni w blokach kodu), musisz je jawnie wypisać.
  • maxElemsToParse, domyślnie 0, czyli bez ograniczenia — to warto przemyśleć przy przetwarzaniu stron z niezaufanych źródeł. Brak limitu oznacza, że złośliwie rozdmuchany dokument zajmie ci procesor na dowolnie długo. Przy usłudze przyjmującej adresy od użytkowników ustaw tu rozsądną wartość.
  • nbTopCandidates, domyślnie 5 — ilu kandydatów na główny blok treści brać pod uwagę przy ocenie, jak ciasna jest konkurencja między nimi.
  • disableJSONLD, domyślnie false — przy wyciąganiu metadanych pierwszeństwo mają pola Schema.org w formacie JSON-LD. Jeśli w twoim zbiorze te dane są notorycznie błędne, można tę ścieżkę wyłączyć i zdać się na znaczniki meta.
  • linkDensityModifier, domyślnie 0 — liczba dodawana do progu gęstości odnośników przy ocenie podejrzanych węzłów. Pozwala karać albo premiować bloki z dużą liczbą linków. Ta opcja pojawiła się w wersji 0.6.0 i jest przydatna przy stronach, które mieszają artykuł z listami odnośników.
  • allowedVideoRegex — wyrażenie regularne dopuszczające adresy wideo do treści. Bez niego stosowany jest wbudowany wzorzec, obejmujący popularne serwisy.

Jedna opcja zasługuje na osobną uwagę, bo rozwiązuje problem, z którym ludzie walczą ręcznie. Domyślnie content powstaje przez el => el.innerHTML, czyli dostajesz łańcuch znaków. Podanie funkcji tożsamościowej jako serializer:

new Readability(documentClone, { serializer: el => el }).parse();

zwraca w content element DOM zamiast łańcucha. Jeśli planujesz dalsze przetwarzanie treści — przepisywanie odnośników, wyciąganie obrazów, wstawianie własnych znaczników — oszczędza to całego cyklu serializacji i ponownego parsowania.

Tania wstępna kontrola

Obok głównej funkcji jest isProbablyReaderable(document, options) — szybkie sprawdzenie, czy w ogóle warto uruchamiać kosztowną logikę. Autorzy opisują ją z rozbrajającą szczerością jako szybki i brudny sposób, który prawdopodobnie daje zarówno fałszywe trafienia, jak i fałszywe pominięcia. Istnieje po to, by nie obciążać procesu wrażliwego na czas — takiego jak ładowanie strony użytkownikowi — pełnym algorytmem.

Jej opcje to minContentLength (domyślnie 140), minScore (domyślnie 20) oraz visibilityChecker, czyli funkcja decydująca, czy węzeł jest widoczny. Wzorzec użycia jest oczywisty:

if (isProbablyReaderable(document)) {
  let article = new Readability(document.cloneNode(true)).parse();
}

Użycie w Node.js

Node nie ma własnej implementacji DOM, więc trzeba dostarczyć zewnętrzną — dokumentacja pokazuje to na jsdom:

const { Readability } = require('@mozilla/readability');
const { JSDOM } = require('jsdom');

const doc = new JSDOM(html, { url: "https://example.pl/artykul" });
const article = new Readability(doc.window.document).parse();

Przekazanie adresu strony jako opcji url nie jest opcjonalne w praktyce: bez niego Readability nie zamieni względnych adresów obrazów i odnośników na bezwzględne. Efekt to zapisany artykuł, w którym żaden obrazek się nie wyświetla, a wszystkie linki prowadzą w nicość — i to jest błąd, którego przyczyny szuka się długo, bo ekstrakcja „zadziałała".

Dokumentacja dodaje przy tym ostrzeżenie warte powtórzenia: jsdom potrafi wykonywać skrypty zawarte w HTML i pobierać zdalne zasoby. Z powodów bezpieczeństwa jest to domyślnie wyłączone, a autorzy stanowczo zalecają zostawienie tego tak, jak jest. Włączenie wykonywania skryptów przy przetwarzaniu cudzych stron oznacza uruchamianie obcego kodu na swoim serwerze.

To nie jest sanitizer i autorzy mówią to wprost

Najważniejsza sekcja dokumentacji dla każdego, kto buduje na tym funkcję widoczną dla użytkowników. Cytując intencję autorów: oczyszczanie niebezpiecznej treści z wejścia jawnie nie jest celem Readability, bo istnieją do tego dobre biblioteki i należy ich używać.

Innymi słowy: pole content to HTML pochodzący z niezaufanej strony, przepuszczony przez heurystykę wyciągania treści — nie przez filtr bezpieczeństwa. Wstawienie go wprost do swojej strony jest podatnością na wstrzyknięcie skryptu. Zalecenia autorów są konkretne i warto je potraktować jako listę kontrolną:

  • Przepuść wynik przez bibliotekę oczyszczającą, na przykład DOMPurify.
  • Dołóż politykę bezpieczeństwa treści (CSP) jako obronę w głąb, ograniczającą to, co wyświetlana treść może zrobić.

Autorzy podkreślają, że integracja trybu czytania w samym Firefoksie stosuje obie te techniki. Jeśli więc zastanawiasz się, czy przy wewnętrznym narzędziu można sobie odpuścić — przeglądarka, która robi to dla milionów użytkowników, sobie nie odpuszcza.

Wypada dodać, że projekt dba o tę higienę także u siebie: w zależnościach deweloperskich siedzi eslint-plugin-no-unsanitized, czyli reguły lintera pilnujące operacji na DOM, które mogłyby wprowadzić nieoczyszczoną treść.

Jak to jest zbudowane

Rzecz, którą docenisz przy audycie zależności: Readability nie ma żadnych zależności produkcyjnych. W package.json jest wyłącznie sekcja deweloperska. Cały algorytm mieszka w jednym pliku Readability.js o wielkości około 91 kB, wstępna kontrola w Readability-readerable.js na 4 kB, a obok leży własny, minimalny parser DOM (JSDOMParser.js, około 37 kB). Definicje typów TypeScriptu są dołączone w index.d.ts. Wymagany Node to wersja 14 lub nowsza.

Jest jeszcze jeden element, który mówi o jakości tego projektu więcej niż liczba gwiazdek: korpus testowy złożony z prawdziwych stron. W katalogu testów leży co najmniej sto trzydzieści zapisanych przypadków z rzeczywistych serwisów — od organizacji pozarządowych przez serwisy technologiczne po strony nieanglojęzyczne — każdy z oczekiwanym wynikiem. Jest też polecenie generate-testcase, które pozwala dodać własną stronę do korpusu. To właściwa odpowiedź na pytanie „skąd wiadomo, że zmiana heurystyki czegoś nie zepsuła", i jednocześnie najlepsza droga zgłoszenia błędu: dołóż stronę, na której biblioteka się myli.

Czego uczy lista zmian

Warto przejrzeć, co przyniosła wersja 0.6.0, bo to najlepszy dostępny opis tego, jakiej klasy problemy w ekstrakcji treści faktycznie występują — i czego się spodziewać przy własnym zbiorze stron.

Sporą część wydania stanowiły poprawki metadanych: dodanie znaczników Parsely jako źródła zapasowego, obsługa obiektów kontekstu Schema.org, obsługa tablic w JSON-LD, uwzględnienie metadanych article:author oraz atrybutu itemprop=name dla autora. Osobno naprawiono przypadek, w którym parsowanie JSON-LD było pomijane, gdy adres kontekstu miał na końcu ukośnik. To dobrze pokazuje, na czym stoi wyciąganie autora i daty: na kilku konkurencyjnych konwencjach, z których każda jest w internecie stosowana niepoprawnie na swój sposób.

Druga grupa dotyczy samej heurystyki treści: poprawiono obsługę tabel z danymi, naprawiono sytuacje, w których krótkie akapity prawdziwej treści były odrzucane, dodano opcję modyfikowania progu gęstości odnośników, a metadane autora przestały powodować usuwanie treści tylko wyglądającej na podpis. Jest nawet poprawka usuwająca konkretną regresję na GitLabie — nagłówki przestały być wycinane.

Trzecia grupa to wydajność: cztery osobne zmiany oznaczone jako poprawa wydajności. Przy bibliotece uruchamianej w przeglądarce przy każdym wejściu w tryb czytania to nie jest kosmetyka.

Wniosek praktyczny dla kogoś, kto zaczyna: nie oczekuj, że zadziała bezbłędnie na każdej stronie z twojego zbioru. Oczekuj, że zadziała dobrze na większości, i zaplanuj mechanizm wyłapywania przypadków, w których wynik jest podejrzanie krótki albo pusty. Pola length i excerpt nadają się do tego wprost — artykuł, który wyszedł na trzysta znaków z serwisu publikującego długie teksty, prawie zawsze znaczy nieudaną ekstrakcję, nie krótki tekst.

Krótko o prawie

Wyciąganie treści artykułu i zapisywanie go u siebie jest zwielokrotnianiem utworu. Do użytku własnego i do wewnętrznych potrzeb — przeglądu prasy, archiwum tego, co przeczytaliście, zasilenia własnego narzędzia wyszukiwania — jest to zwykle sytuacja bez kłopotu. Inaczej wygląda ponowne udostępnianie: serwis pokazujący pełne teksty cudzych artykułów, choćby „tylko dla klientów", robi coś, na co potrzebna jest zgoda albo licencja.

Warto też pamiętać o regulaminach serwisów, z których pobieracie strony, i o tym, że tryb czytania omija warstwę, na której wydawca zarabia. Przy prywatnym czytniku nikt się tym nie przejmie; przy produkcie sprzedawanym klientom to jest pytanie do zadania przed premierą, nie po.

Pułapki w jednym miejscu

  • parse() modyfikuje DOM. Zawsze podawaj document.cloneNode(true).
  • Obiekt wynikowy nie jest stabilnym API. Wersja mniejsza może zmienić wynik dla tej samej strony — przypnij wersję dokładnie.
  • charThreshold to 500 znaków. Krótsze teksty zwracają null, a nie treść.
  • To nie sanitizer. Oczyszczanie wejścia jest jawnie poza zakresem — użyj DOMPurify i CSP.
  • Bez opcji url w jsdom względne adresy obrazów i odnośników zostaną względne, czyli zepsute.
  • maxElemsToParse domyślnie nie ma limitu. Przy niezaufanych źródłach ustaw go.
  • Klasy CSS są domyślnie usuwane — jeśli twój wygląd na nich stoi, wypisz je w classesToPreserve.
  • Nie włączaj wykonywania skryptów w jsdom. Autorzy stanowczo odradzają, i mają rację.
  • Wydania są rzadkie — jedno na kilkanaście miesięcy. To normalny rytm tego projektu, ale poprawki nie przychodzą szybko.
  • Ostatnia zmiana merytoryczna to wrzesień 2025. Późniejsze commity to zależności i porządki licencyjne.
  • 311 otwartych zgłoszeń. Nie licz na szybką reakcję na swoje.

Podsumowanie

Readability jest tym rodzajem biblioteki, którego istnienie oszczędza branży ogromnie dużo zmarnowanej pracy. Problem, który rozwiązuje, wygląda na prosty przez pierwsze dwa dni, a potem okazuje się studnią bez dna — a tu masz szesnaście lat cudzego wpadania do tej studni, zamknięte w jednym pliku bez zależności.

  • Zero zależności produkcyjnych i jeden plik z algorytmem — łatwo zaudytować, łatwo osadzić.
  • Dziesięć pól metadanych z jednego wywołania, wraz z językiem, kierunkiem tekstu, autorem i czasem publikacji.
  • Wsparcie dla JSON-LD i Schema.org z pierwszeństwem nad znacznikami meta, więc metadane są lepsze niż z ręcznego parsowania.
  • Korpus co najmniej stu trzydziestu prawdziwych stron jako siatka bezpieczeństwa przy zmianach heurystyki.
  • Definicje typów TypeScriptu w paczce i tania wstępna kontrola isProbablyReaderable do procesów wrażliwych na czas.
  • Uczciwa dokumentacja, która mówi, że wynik nie jest stabilnym API, że wstępna kontrola się myli i że oczyszczanie treści to nie jej zadanie.

Biblioteka jest wydana na licencji Apache-2.0, z prawami Arc90 Inc oraz Mozilli i kontrybutorów. Apache-2.0 to licencja permisywna, która wobec MIT dokłada dwie rzeczy istotne w kontekście firmowym: wyraźne udzielenie licencji patentowej oraz wymóg odnotowania zmian w plikach, które zmodyfikujesz. Przy zwykłym użyciu jako zależności nie ma to znaczenia — dodanie biblioteki do projektu nie nakłada żadnych obowiązków wobec twojego kodu. Przy własnym forku utrzymywanym u siebie warto pamiętać o obu, a także o pliku NOTICE, który przy redystrybucji należy zachować.