Programowanie to proces ciągłego ulepszania kodu. Każdą aplikację i każdy program można stworzyć szybko lub dokładnie. W przypadku scrapingu ważne jest, aby program był jak najlepszy, dzięki czemu będzie odporny na błędy i łatwy w utrzymywaniu. Refactoring w Scrapy pomoże Ci stworzyć nie tylko lepsze, ale również łatwe w utrzymaniu scrapery
Ten wpis jest kontynuacją wpisu o Szybkim web scrapingu w Scrapy. Jeżeli nie czytałeś jeszcze wpisu, to zachęcam Cię do tego. W tym artykule pokażę jak ulepszyć napisanego wcześniej spidera na stronę stackoverflow.
Postaramy się usprawnić spidera w dwóch aspektach: zgodności z konwencjami Scrapy oraz odporności na błędy i zmiany,
Refactoring w Scrapy a zasady programowania
W przypadku programowania często pojawia się pojęcie „czysty kod”. Odnosi się ono do czytelności kodu z perspektywy osób, które go czytają.
Jeżeli kod jest czytelny, staje się łatwiejszy do utrzymywania. Jeżeli programista na pierwszy rzut oka rozumie kod, dużo szybciej jest w stanie zmienić go bez dokładania błędów.
Jednym ze sposobów utrzymania czytelności kodu jest pisanie go zgodnie z zasadami danej technologii. Dzięki takiemu podejściu programiści uczący się danej języka lub biblioteki od początku uczą się powszechnie stosowanego schematu nazewnictwa i sposobów implementacji poszczególnych mechanik.
Zasady tworzenia kodu zdefiniowane są zarówno na poziomie całego języka Python, jak i dla frameworka Scrapy.
O zasadach pisania kodu w Pythonie pisałem już w artykule „PEP 8 – czyli gramatyka dla programisty”. Zasady PEP 8 dotyczą głównie formatowania kodu.
Jeżeli chodzi o zasady frameworka Scrapy nie są one tak dobrze zdefiniowane i dotyczą bardziej sposobów realizacji pewnych funkcji, niż samego stylu kodu. O konwencjach używanych w Scrapy można dowiedzieć się z dokumentacji frameworka.
Zacznijmy więc refactoring w Scrapy!
Scrapy Item
Najpierw najprostsza kwestia. W naszym spiderze zwracaliśmy pobrane dane z wykorzystaniem zwykłego słownika:
yield {
"name": name,
"dt": dt,
"votes_number": int(votes_number),
"answers_number": int(answers_number),
"views": views,
"url": url,
}
W frameworku Scrapy, zamiast zwracać dane w ten sposób, korzystamy z klasy `Item, w której definiujemy pola.
# items.py
class QuestionItem(scrapy.Item):
name = scrapy.Field()
dt = scrapy.Field()
url = scrapy.Field()
votes_number = scrapy.Field()
answers_number = scrapy.Field()
views = scrapy.Field()
Do definicji itemu jeszcze wrócimy. Na razie jednak zmiany dokonajmy też w spiderze.
# spiders/stackoverflow.py
from questions.items import QuestionItem
class StackoverflowSpider(scrapy.Spider):
[...]
def parse_questions_list(self, response):
[...]
yield QuestionItem(
name=name,
dt=dt,
url=url,
votes_number=votes_number,
answers_number=answers_number,
views=views
)
ItemLoader
Kolejną zmianą, jaką możemy zastosować jest wykorzystanie mechanizmu ItemLoader wbudowanego w Scrapy. Dzięki nim możemy zastosować wstępne przetwarzanie pobranych danych w czytelny sposób. ItemLoadery pozwalają nam przenieść podstawową logikę czyszczenia danych do pliku items.py.
Przejdźmy więc do zmian w pliku items.py
from datetime import datetime, timezone
import scrapy
import validators
from itemloaders.processors import TakeFirst, MapCompose
def parse_int(value):
try:
return int(value)
except ValueError:
return None
def parse_dt(dt):
return datetime.strptime(dt, "%Y-%m-%d %H:%M:%SZ").replace(tzinfo=timezone.utc)
def valid_url(url_value):
if validators.url(url_value):
return url_value
class QuestionItem(scrapy.Item):
name = scrapy.Field(
output_processor=TakeFirst(),
)
dt = scrapy.Field(
input_processor=MapCompose(parse_dt),
output_processor=TakeFirst(),
)
url = scrapy.Field(
input_processor=MapCompose(valid_url),
output_processor=TakeFirst(),
)
votes_number = scrapy.Field(
input_processor=MapCompose(parse_int),
output_processor=TakeFirst(),
)
answers_number = scrapy.Field(
input_processor=MapCompose(parse_int),
output_processor=TakeFirst(),
)
views = scrapy.Field(
input_processor=MapCompose(parse_int),
output_processor=TakeFirst(),
)
Sporo się zmieniło, więc idźmy od góry
Zaraz po zaimportowaniu potrzebnych bibliotek zdefiniowałem 3 funkcje pomocnicze.
def parse_int(value):
try:
return int(value)
except ValueError:
return None
def parse_dt(dt):
return datetime.strptime(dt, "%Y-%m-%d %H:%M:%SZ").replace(tzinfo=timezone.utc)
def valid_url(url_value):
if validators.url(url_value):
return url_value
Funkcja parse_int
ma za zadanie zamienić domyślnie pobieraną wartość typu str na liczbę całkowitą.
Funkcja parse_dt
to zwykłe przetwarzanie daty. Data pobierana ze strony Stackoverflow była w formacie "%Y-%m-%d %H:%M:%SZ"
(np. 2012-06-27 13:51:36Z). Przetwarzamy ją na pythonowy obiekt datetime
. Na koniec ustawiamy odpowiednią strefę czasową używając metody replace
i zmieniając wartość tzinfo
na obiekt wskazujący na strefę czasową UTC.
Dlaczego przetworzyliśmy dane do tego formatu? Przechowywanie daty jako obiekt datetime w kodzie ułatwia nam dalsze przetwarzanie, gdyż na tak zdefiniowanej dacie możemy wykonywać operacje, zmieniać dowolnie jest format, lub po prostu wrzucić do SQLowej bazy danych.
Funkcja valid_url
to funkcja mająca na celu sprawdzenie, czy pobrana wartość to prawdziwy adres URL. Aby to zweryfikować używamy zewnętrznej biblioteki validators, którą możesz zainstalować poleceniem:
> pip install validators
Zauważ, że wszystkie 3 funkcje są tak skonstruowane, aby zwrócić pożądaną przez nas wartość lub wartość None
, gdy coś pójdzie nie tak. To jedno z dwóch podejść, jakie możemy zastosować. Przetwarzając dane, w przypadku braku konkretnej informacji możemy:
- Ustawić brakujące pole jako
None
- Pominąć cały rekord danych
To, którą metodę wybierzemy, zależy przede wszystkim od tego, czy konkretna informacja jest kluczowa dla istnienia rekordu danych. Jeżeli dla naszych celów biznesowych obecność adresu URL prowadzącego do konkretnego pytania na stacku jest niezbędna, wtedy zastosowane podejście nie jest dobrym rozwiązaniem. W takiej sytuacji powinniśmy wyłapać brak takiej danej, następnie przesłać log z informacją o braku i pominąć cały wiersz danych. Jeżeli jesteś zainteresowany artykułem na temat radzeniem sobie z brakiem danych – daj znać w komentarzu!
W omawianym jednak przypadku uznałem, że adres URL nie jest obligatoryjny w kontekście danych, gdyż pobrane informacje wykorzystam jedynie statystycznie.
Przejdźmy do ponownie zdefiniowanej klasy Item.
# items.py
class QuestionItem(scrapy.Item):
name = scrapy.Field(
output_processor=TakeFirst(),
)
dt = scrapy.Field(
input_processor=MapCompose(parse_dt),
output_processor=TakeFirst(),
)
url = scrapy.Field(
input_processor=MapCompose(valid_url),
output_processor=TakeFirst(),
)
votes_number = scrapy.Field(
input_processor=MapCompose(parse_int),
output_processor=TakeFirst(),
)
answers_number = scrapy.Field(
input_processor=MapCompose(parse_int),
output_processor=TakeFirst(),
)
views = scrapy.Field(
input_processor=MapCompose(parse_int),
output_processor=TakeFirst(),
)
Wykorzystałem tutaj 2 funkcje przydatne do ItemLoadera.
input_processor
to funkcja, która uruchamiana jest na przetworzonych danych w momencie, gdy zostaną one pobrane.
W podanym przykładzie użyłem jej, aby dokonać przetwarzania, lub wstępnej walidacji danych. Do pól ansewers_number, votes_number i views użyta zostają funkcja parse_int, do pola dt funckja parse_dt, a do pola url funkcja walidująca.
Wywołanie poszczególnych funkcji zostało wykorzystane z pomocą MapCompose, którego celem jest zmapowanie podanej funkcji dla każdego pobranego elementu (czyli użycie tej funkcji na każdym elemencie).
output_processor
jest funkcją wywoływaną na końcu przetwarzania danych.
W Scrapym używa się zazwyczaj dwóch klas oznaczonych jak output_processor: TakeFirst()
lub Join()
. Pierwsza z nich określa, że załadowany powinien być pierwszy element pobrany ze strony. Jeżeli skorzystamy z Join, wtedy wszystkie elementy pobrane ze strony zostaną ze sobą połączone.
TakeFirst()
jest odpowiednikiem klasycznego pobierania w Scrapy z użyciem metody get()
a Join()
to odpowiednik użycia metody getall()
wraz z łączeniem wyników.
Zapewne sama budowa kodu, który teraz omawiam, wydaje Ci się trudna. Zdecydowanie bardziej zrozumiesz ten kod samemu go implementując, do czego bardzo Cię zachęcam.
Teraz pozostało nam jedynie podmienić funkcję parsowania w spiderze tak, aby używała item loadera.
# questions/stackoverflow.py
from urllib.parse import urljoin
import scrapy
from scrapy.loader import ItemLoader
from news.items import QuestionItem
class StackoverflowSpider(scrapy.Spider):
name = 'stackoverflow'
url = "https://stackoverflow.com/questions?tab=Votes"
def start_requests(self):
yield scrapy.http.Request(
url=self.url,
callback=self.parse_questions_list,
)
def parse_questions_list(self, response):
questions = response.xpath(".//div[contains(@id, 'question-summary-')]")
for question in questions:
loader = ItemLoader(item=QuestionItem(), selector=question)
loader.add_xpath("name", ".//a[contains(@class, 's-link')]/text()")
loader.add_xpath("dt", ".//span[contains(@class, 'relativetime')]/@title")
stats = question.xpath(".//span[contains(@class, 'stats-item-number')]/text()").getall()
try:
votes_number, answers_number, views = stats
except ValueError:
continue
loader.add_value("votes_number", votes_number)
loader.add_value("answers_number", answers_number)
loader.add_value("views", views)
link = question.xpath(".//a[contains(@class, 's-link')]/@href").get()
loader.add_value("url", urljoin(self.url, link))
yield loader.load_item()
Zauważ, że tworząc obiekt ItemLoader użyłem konstruktora wskazując atrybut selector=question
. W naszym kodzie w zmiennej question
znajduje się kod HTML dotyczący jednego, konkretnego pytania. Dzięki wskazaniu właśnie tej zmiennej ItemLoader będzie pobierał dane tylko z tego zakresu, przez co nie pomyli wartości z różnych pytań.
Pozostaje teraz jedynie uruchomić spidera i sprawdzić, czy działa 😉
>>> scrapy crawl stackoverflow
2023-02-20 12:24:29 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?tab=Votes>
{'answers_number': 26,
'dt': datetime.datetime(2012, 6, 27, 13, 51, 36),
'name': 'Why is processing a sorted array faster than processing an unsorted '
'array?',
'url': 'https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array',
'votes_number': 26885}
2023-02-20 12:24:29 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?tab=Votes>
{'answers_number': 102,
'name': 'How do I undo the most recent local commits in Git?',
'url': 'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git',
'votes_number': 25366}
Działa! Możemy walczyć dalej.
Mamy już kod zgodny ze standardami Scrapy. Teraz możemy przejść do dostosowania go do standardów Pythona.
Dostosowanie do PEP8
Zwykle nie jestem fanem narzucania standardów dotyczących wyglądu kodu czy dokumentów… jednak zasady PEP8 w Pythonie po prostu mają sens 🙂
Zapamiętanie wszystkich zasad początkowo może być problematyczne. Na szczęście istnieją narzędzia, które pozwalają sformatować kod w pożądany sposób w ułamku sekund. Te narzędzia to flake8 oraz black. Oba narzędzia możemy zainstalować przy pomocy instalatora pip:
>>> pip install flake8
>>> pip install black
Flake8 to narzędzie, które analizuje kod i wypunktowuje wszystkie błędy stylistyczne, jakie zostały popełnione. Uruchomię flake8 na przygotowanych plikach.
>>> flake8 items.py
items.py:16:80: E501 line too long (83 > 79 characters)
>>> flake8 .\spiders\stackoverflow.py
.\spiders\stackoverflow.py:20:80: E501 line too long (80 > 79 characters)
.\spiders\stackoverflow.py:24:80: E501 line too long (86 > 79 characters)
.\spiders\stackoverflow.py:25:80: E501 line too long (100 > 79 characters)
.\spiders\stackoverflow.py:29:80: E501 line too long (89 > 79 characters)
.\spiders\stackoverflow.py:34:80: E501 line too long (81 > 79 characters)
Mamy jasny komunikat, że kilka linii w kodzie ma zbyt dużo znaków. Błędy wskazywane są w formacie plik:numer linii:numer znaku
. Dzięki komunikatom wiemy, w którym miejscu powinniśmy wprowadzić poprawki.
Skorzystajmy również z narzędzia black, którego zadaniem nie jest wskazanie błędów, lecz naniesienie poprawek na kod.
>>> black .\items.py
reformatted items.py
All done! ✨ 🍰 ✨
1 file reformatted.
>>> black .\spiders\stackoverflow.py
reformatted spiders\stackoverflow.py
All done! ✨ 🍰 ✨
1 file reformatted.
Obu narzędzi warto używać razem, gdyż dopełniają się funkcjami. Flake8 jedynie wskaże nam błędy, jednak w przeciwieństwie do formatera black potrafi wykryć niepotrzebny import lub za długą linię.
Refactoring w Scrapy a odporność na błędy i zmiany na stronach
Dzięki zmianom dokonanym wcześniej kod stał się dużo lepiej stworzony i zgodny z konwencjami programowania w Scrapy i Pythonie.
Teraz przejdźmy do refactoringu, który zapewni odporność na różnego rodzaju błędy.
Niepoprawne dane i braki w danych
Bardzo ważnym aspektem scrapowania jest zabezpieczenie się na wypadek braków danych lub niepoprawnych danych.
Właściwie część pracy na tym etapie refaktoringu udało nam się osiągnąć dostosowując kod do konwencji Scrapy i używając ItemLoadera.
Dzięki zastosowanym tam regułom mamy już pewność, że pola votes_number, answers_number, views
będą liczbami całkowitymi, w polu dt
znajdzie się obiekt datetime, a w poluurl
będzie prawidłowy adres URL. Przyjrzyjmy się teraz jeszcze jednemu potencjalnie groźnemu miejscu w kodzie.
stats = question.xpath(".//span[contains(@class, 'stats-item-number')]/text()").getall()
try:
votes_number, answers_number, views = stats
except ValueError:
continue
Założyliśmy, że pobrane do zmiennej stats
wartości będą trzema wartościami kolejno odpowiadającymi poszczególnym parametrom pytania. Dzięki użyciu bloku try...except
jesteśmy zabezpieczeni przed sytuacją, w której w zmiennej stats
będzie inna liczba wartości. Dla uproszczenia możemy założyć, że w takiej sytuacji nie chcemy pobierać danych dotyczących pytania, jednak oprócz pominięcia tego wiersza danych, warto również mieć jakiś sygnał, że taka sytuacja się wydarzyła.
Zapiszmy więc ten incydent za pomocą logów.
try:
votes_number, answers_number, views = stats
except ValueError:
self.logger.warning(f"Invalid question statistics at URL {response.url}")
continue
Jeżeli zobaczymy taki log, będziemy mogli wejść na stronę i sprawdzić, dlaczego błąd wystąpił.
UWAGA! Informacja, jaką podaliśmy w logach, może być zbyt mało dokładna. Jeżeli jest to możliwe, najlepiej jak najwięcej szczegółów, które pozwolą powtórzyć sytuację, w której wystąpił błąd. Moglibyśmy ten log uzupełnić o np. bezpośredni odnośnik lub nazwę pytania, jednak to również mogłoby być niewystarczające. Żeby zbędnie nie komplikować, zostawmy to w ten sposób.
Refactoring w Scrapy – Podsumowanie
Oczywiście na tym refactoring w Scrapy nie musi się kończyć. Tak naprawdę nawet nad takim z pozoru prostym projektem można siedzieć godzinami, wiecznie go udoskonalając i usprawniając.
Moim celem w tym artykule było pokazanie Ci sposobu podejścia do refactoringu i usprawniania web scraperów w Scrapy. Mam nadzieję, że ten artykuł pomoże Ci w codziennej pracy.
Dokonując refaktoringu musimy mieć z tyłu głowy 2 najważniejsze zasady: Trzeba wiedzieć, kiedy przestać oraz ciągle sprawdzać, czy czegoś nie popsuliśmy.
Wieczny refactoring nie zawsze ma sens. Jeżeli oprogramowanie spełnia cele biznesowe i da się je utrzymywać, najlepiej nie próbować ciągle go ulepszać, bo jak to mówią „lepsze jest wrogiem dobrego”.
Dokonując refactoringu musimy też ciągle sprawdzać, czy dokonane zmiany nie zepsuły czegoś innego. Do tego celu najlepiej sprawdzają się napisane wcześniej testy.
Dzięki, że dotrwałeś do tego momentu 🙂
Jeżeli chcesz więcej tego typu artykułów – daj znać w komentarzu!
Pozdrawiam Cię!
Kamil