Web scraping może być naprawdę skomplikowanym tematem. W tym artykule jednak będzie mega szybki web scraping.
W tym wpisie będzie szybko. Zgodnie z filozofią „done is better than perfect”.
To nie będzie poradnik o tym jak zrobić web scrapera, który będzie działał zawsze, będzie łatwy w utrzymaniu itd.
Tym razem polecimy po linii najmniejszego oporu.
Dlaczego szybki web scraping?
Czasem po prostu musisz coś zescrapować szybko i jednorazowo. Jeżeli wiesz, że nie będziesz musiał utrzymywać tego kodu, to właśnie takie podejście może być nalepsze – quick and dirty.
Zaczynajmy więc, by było szybko 😉
Dlaczego w ogóle Scrapy?
Scrapy to framework skrojony do web scrapingu. Wiele rzeczy, które nam ułatwiają i przyspieszają pracę.
Dzięki scrapyemu nie martwimy się o przyspieszanie scraperów, tworzenie kodu do zapisu danych.
Dzięki temu Scrapy nadaje się nie tylko do skomplikowanych projektów, lecz także szybkiego i prostego scrapowania
Konfiguracja projektu
Bez zbędnego wstępu zaczynamy 🙂
Jeżeli nie mamy zainstalowanego frameworka, musimy go najpierw zainstalować.
Najlepiej byłoby, abyś zainstalował go w ramach środowiska wirtualnego. W tym artykule jednak robimy szybki web scraping, dlatego zainstalujemy Scrapy w głównym środowisku.
pip install -U scrapy
Najpierw musimy utworzyć projekt Scrapy:
# scrapy startproject [nazwa projektu]
>>> scrapy startproject questions
Następnie tworzymy sam plik spidera. W naszym przykładzie będziemy scrapować dane ze stackoverflow.
>>> cd questions
>>> scrapy genspider stackoverflow stackoverflow.com
Określenie danych
Zastanówmy się które dane musimy pobierać, a następnie przejdźmy do pliku items.py, aby zdefiniować strukturę danych.
Tworzenie Spidera
Weźmy się za główną część, czyli sam kod spidera. Przejdźmy do pliku spiders/stackoverflow.py.
# spiders/stackoverflow.py
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,
)
Definiujemy nazwę klasy i metadane na temat spidera. Pole name oznacza nazwę spidera, za pomocą której będzie on uruchamiany. Dodałem dodatkowo pole url, które będzie przechowywać początkowy adres URL strony, od której zaczynamy web scraping.
Dalej tworzymy metodę o nazwie start_requests (nazwa pozostaje taka sama niezależnie od projektu). Wewnątrz metody za pomocą słowa kluczowego yield zwracamy obiekt scrapy.http.Request. Jako konieczne parametry obiekt przyjmuje adres URL oraz callback.
Callback jest to odwołanie do konkretnej funkcji/metody, która zostanie uruchomiona po wykonaniu żądania HTTP.
Krótko mówiąc: funkcja start_requests spowoduje, że wejdziemy na stronę podaną jako URL, a następnie gdy pobranie treści strony się uda – wywoła funkcję podaną jako callback.
Poniżej, jako kolejną metodę zdefiniujmy nasz callback.
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):
pass
Wewnątrz metody parse_questions_list mamy dostęp do wszystkiego co dostaliśmy jako odpowiedź serwera. W obiekcie response znajdują się takie dane jak kod odpowiedzi, nagłówki HTTP czy wreszcie cała treść strony internetowej.
Możemy przejść do ekstrakcji danych.
Wykorzystamy do tego składnię XPath, o której pisałem już artykuł. Jeżeli nie znasz tej składni, to zdecydowanie przed przeczytaniem tego wpisu zachęcam Cię najpierw do przeczytania artykułu właśnie o XPath.
Ekstrakcja danych
Najpierw pobieramy wszystkie elementy HTML z pytaniami.
Do celu ich zlokalizowania używamy narzędzi deweloperskich (w Google Chrome skrót klawiszowy ctrl+shift+I). Możemy zauważyć, że cała interesująca nas treść każdego z pytań znajduje się w divie, którego ID stworzone jest według konkretnego schematu:
<div id="question-summary-[jakiś numer]" ....
Skorzystajmy więc z tego i pobierzmy listę wszystkich pytań dostępnych na tej stronie.
# spiders/stackoverflow.py
questions = response.xpath(".//div[contains(@id, 'question-summary-')]")
Następnie możemy iterować po wszystkich pytaniach. Jednak rzypomnijmy sobie najpierw jakie dane chcemy pobierać i jak je nazwać w kodzie.
name - nazwa pytania
dt - czas utworzenia pytania
votes_number - liczba głosów
answers_number - liczba odpowiedzi
views - liczba odwiedzin strony z pytaniem
url - adres URL pytania
UWAGA! Zgodnie z dobrymi praktykami Scrapy powinniśmy utworzyć obiekt Item, w którym zdefiniujemy strukturę danych. Teraz robimy szybki web scraping, więc uprościmy to sobie jak tylko się da. Jeżeli chcesz przeczytać o kodzie zgodnym ze standardami frameworka zapraszam Cię do mojego ogólnego artykułu o web scrapingu w Scrapy.
Przejdźmy teraz do pobierania danych. Oczywiście korzystamy ponownie z narzędzi deweloperskich przeglądarki.
Widzimy, że zarówno link do pytania i jego nazwą znajdują się w tym samym znaczniku a.
name = question.xpath(".//a[contains(@class, 's-link')]/text()").get()
W podobny sposób pobieramy również dane dotyczące czasu:
Pobierzmy wartość zapisaną w atrybucie title, która będzie łatwiejsza do przetwarzania i od razu jest zapisana w domyślnej strefie czasowej UTC.
dt = question.xpath(".//span[contains(@class,'relativetime')]/@title").get()
Możemy zauważyć, że wszystkie dane liczbowe jakie potrzebujemy są ukryte w spanach o identycznym klasach. Wykorzystajmy to:
stats = question.xpath(".//span[contains(@class, 'stats-item-number')]/text()").getall()
# zmienna stats wygląda tak: ['26826', '26', '1.8m']
try:
votes_number, answers_number, views = stats
except ValueError:
# Liczba elementów była inna liczba niż 3.
# Dla uproszczenia pomińmy to pytanie i idźmy dalej
continue
Do pobrania linku wykorzystajmy ten sam element co przy nazwie.
>>> link = question.xpath(".//a[contains(@class, 's-link')]/@href").get()
>>> print(link)
/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array
Jak widać, nie jest to pełny adres URL, lecz relatywny link. O rodzajach linków pisałem już we wpisie o web scrapingu w Pythonie.
Aby utworzyć działający adres URL skorzystajmy z dodatkowej biblioteki i z zapisanego w klasie Spidera adresu URL początkowego.
from urllib.parse import urljoin
url = urljoin(self.url, link)
W tym momencie możemy zwrócić dane tak, aby Scrapy je dla nas zapisał. Korzystamy ponownie z funkcji yield
yield {
"name": name,
"dt": dt,
"votes_number": int(votes_number),
"answers_number": int(answers_number),
"views": views,
"url": url,
}
I to tyle kodu 🙂 Możemy teraz uruchomić naszego spidera:
>>> scrapy crawl stackoverflow
Wynik:
2023-01-07 16:48:30 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?tab=Votes>
{'name': 'Why is processing a sorted array faster than processing an unsorted array?', 'dt': '2012-06-27 13:51:36Z', 'votes_number': 26826, 'answers_number': 26, 'views': '1.8m', 'url': 'https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array'}
2023-01-07 16:48:30 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?tab=Votes>
{'name': 'How do I undo the most recent local commits in Git?', 'dt': None, 'votes_number': 25230, 'answers_number': 102, 'views': '12.2m', 'url': 'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'}
2023-01-07 16:48:30 [scrapy.core.scraper] DEBUG: Scraped from <200 https://stackoverflow.com/questions?tab=Votes>
{'name': 'How do I delete a Git branch locally and remotely?', 'dt': '2010-01-05 01:12:15Z', 'votes_number': 19824, 'answers_number': 41, 'views': '10.6m', 'url': 'https://stackoverflow.com/questions/2003505/how-do-i-delete-a-git-branch-locally-and-remotely'}
Takie uruchomienie niestety nie zapisało naszych danych, jednak w prosty sposób możemy to zmienić definiując plik wyjściowy:
>>> scrapy crawl stackoverflow -o questions.json
Utworzony zostanie plik o podanej przez nas nazwie zawierający wszystkie pobrane dane.
Scrapy za nas zapisze wszystkie dane do odpowiedniego formatu. Listę wspieranych formatów możesz podejrzeć w dokumentacji frameworka.
Paginacja
Gdy nasz spider działa dla jednej strony, sprawmy, by bez problemu przeszedł całe wyniki wyszukiwania.
Poszukajmy najpierw linków odpowiedzialnych za paginację.
W przypadku stacka sprawa jest dość prosta, bo znajduje się tam przycisk przenoszący nas zawsze na następną stronę w paginacji.
Wyszukajmy przycisk za pomocą XPath i pobierzmy wartość atrybutu href.
next_btn_link = response.xpath(".//a[@rel='next']/@href").get()
# zwrócona wartość: "/questions?tab=votes&page=2"
Pobrany link jest linkiem relatywnym. Musimy stworzyć z niego adres URL korzystając z metody urljoin wbudowanej w obiekt response.
response.urljoin(next_btn_link)
Musimy również pamiętać, że nie każda strona będzie miała ten przycisk. Ostatnia strona w paginacji nie powinna go mieć lub powinien być nieaktywny.
Sprawdźmy więc, czy selektor XPath nam coś zwrócił, a jeżeli tak – wykonajmy request pobierający kolejną stronę.
if next_btn_link:
yield scrapy.http.Request(
response.urljoin(next_btn_link),
callback=self.parse_questions_list,
)
Jako callback podajemy tę samą funkcję, gdyż parsowanie danych dla każdej podstrony jest identyczne.
Mamy więc tutaj paginacje w sposób a’la rekurencyjny. W tym sposobie następne strony odwiedzamy pojedynczo. Taki zabieg zmniejsza nam potencjalną szybkość scrapowania, jednak zwiększa jego stabilność.
Jeżeli chcesz, abym napisał poradnik o innych sposobach scrapowania – daj znać!
Cały kod spidera
Cały kod naszego scrapera wygląda dokładnie tak:
# spiders/stackoverflow.py
from urllib.parse import urljoin
import scrapy
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:
name = question.xpath(".//a[contains(@class, 's-link')]/text()").get()
dt = question.xpath(".//span[contains(@class, 'relativetime')]/@title").get()
stats = question.xpath(".//span[contains(@class, 'stats-item-number')]/text()").getall()
try:
votes_number, answers_number, views = stats
except ValueError:
continue
link = question.xpath(".//a[contains(@class, 's-link')]/@href").get()
url = urljoin(self.url, link)
yield {
"name": name,
"dt": dt,
"votes_number": int(votes_number),
"answers_number": int(answers_number),
"views": views,
"url": url,
}
next_btn_link = response.xpath(".//a[@rel='next']/@href").get()
if next_btn_link:
yield scrapy.http.Request(
response.urljoin(next_btn_link),
callback=self.parse_questions_list,
)
Chyba było prosto i szybko? 🙂
Oczywiście nawet w tym prostym przykładzie jest naprawdę wiele miejsc do poprawki, wprowadzenia zmian i usprawnienia całego kodu.
Daj znać czy chcesz aby napisał następny artykuł na temat poprawy i rozszerzenia właśnie tego konkretnego spidera.
Dziękuję Ci, że dotrwałeś do tego momentu. Mam nadzieję, że artykuł będzie dla Ciebie pomocny i przyspieszy Twoją pracę.
Pozdrawiam!
Kamil Kwapisz