python, web scraping

Web Scraping w Pythonie

Web scraping w Pythonie z wykorzystaniem bibliotek Requests i BeautifulSoup

Wyniki ankiety przeprowadzanej przez JetBrains wśród programistów Pythona pokazują, że 1/3 developerów używa Pythona do tworzenia web parserów, crawlerów i scraperów. Z całą pewnością programu tego typu są niezwykle przydatne, dlatego chciałbym podzielić się z Wami jednym ze sposobów ich tworzenia.jet brains survey results
link do wyników ankiety: JetBrains survey results.
Zanim przejdziemy do części poradnikowej chciałbym aby wszyscy zrozumieli pojęcia wymienione w ankiecie i dostrzegli różnice między nimi.

web parsing – samo pojęcie parsowania odnosi się do przetwarzania obszernego tekstu na mniejsze fragmenty, łatwiejsze do czytania lub analizowania. Parsowanie może dotyczyć dokumentów np w formacie XML czy HTML lub nawet po prostu obszernego tekstu.

web scraping – W przeciwieństwie do parsowania, web scraping polega na pobieraniu ze stron internetowych konkretnych interesujących nas informacji. Wyobraźmy sobie sytuacje, że przeprowadzając się do innego miasta chcemy poznać ceny mieszkań w jednej z dzielnic. Dobrym rozwiązaniem takiego problemu będzie napisanie scrapera (czyli programu do web scrapingu), który będzie pobierał ceny z portalu z ogłoszeniami, a następnie zapisywał je do odpowiedniego pliku.

web crawling – pojęcie oznacza „chodzenie” po stronach internetowych. Dobrym przykładem crawlerów są boty Google, które cały czas przemierzają internet w celu indeksowania odwiedzonych stron. Web crawler wchodząc na stronę znajduje na niej linki do innych stron, które może odwiedzić.

Wszystkie wyżej wymienione pojęcia najczęściej występują razem. Tworzenie crawlera, który po prostu chodzi po stronach i nic z nich nie pobiera mija się z celem. Mówiąc, że piszemy web scrapera często mamy na myśli, że jest to jednocześnie crawler. Każdy scraper na pewnym poziomie abstrakcji musi również stronę parsować. Brzmi skomplikowanie, nieprawdaż? 😉 Na szczęście z punktu widzenia programisty wcale takie nie jest 🙂

Kolejnym ważnym pojęciem w tej tematyce jest request. Jest to inaczej żądanie dostępu do strony internetowej. Klient, czyli na przykład nasza przeglądarka, wysyła wysyła do serwera żądanie poprzez protkół HTTP. Po otrzymaniu żądania serwer wysyła do klienta odpowiedź, która zawiera odpowiednią wiadomość (treść strony), kod odpowiedzi oraz jej nagłówek. Jest to oczywiście bardzo ogólne wyjaśnienie, jednak zależy mi, aby każdy zrozumiał rolę requestu w kontekście web scrapingu.

request diagram

Biblioteki i frameworki do web scrapingu

    • Requests – według mnie to najlepsza biblioteka do wykonywania requestów (żądań dostępu do strony internetowej). W naszym przypadku request ma zakończyć się pobraniem zawartości strony. Używanie jej jest proste i przyjemne, a jej możliwości są ogromne.
    • urllib – podobnie jak biblioteka wyżej, urllib służy do obsługi requestów. Aktualnie jest to biblioteka wbudowana w nowsze wersje Pythona, jednak osobiście do wykonywania requestów zawsze wybieram bibliotekę Requests. Biblioteka ta przyda nam się do parsowania adresów URL.
    • aiohttp – nieco bardziej skomplikowana biblioteka, jej największą zaletą jest obsługa asynchronicznych requestów.
    • BeautifulSoup – tym razem nie jest to biblioteka do wykonywania żądań do stron internetowych, jednak do parsowania kodu HTML i XML. Jest to zdecydowanie najwygodniejsze narzędzie parsujące dostępne dla Pythona.
    • Requests-HTML – jest to dość świeża sprawa, biblioteka łączy w sobie Requests oraz BeautifulSoup, dodając od siebie kilka ciekawych funkcji, o których napiszę inny post:)
    • Scrapy – w poprzednich punktach mówiliśmy o bibliotekach, tutaj mamy do czynienia z zaawansowanym frameworkiem, którego największymi zaletami są szybkość oraz brak konieczności martwienia się na przykład o kodowanie strony. W tym przypadku próg wejścia i zrozumienia kodu jest dość wysoki, co na początku zdecydowanie może odstraszyć.
    • Selenium Web Driver – narzędzie wykorzystywane do testów automatycznych, jednak można za jego pomocą również tworzyć scrapery.

W tym poście pokażę Wam tworzenie scrapera z wykorzystaniem bibliotek Requests oraz BeautifulSoup. Dlaczego nie Scrapy? W większości przypadków dużo łatwiej i szybciej pożądany efekt można uzyskać korzystając ze wspomnianego zestawu bibliotek, a dodatkowe funkcje Scrapy nie zawsze są konieczne.

Korzystanie z Requests

Bibliotekę Requests będziemy wykorzystywać do pobierania zawartości strony.

import requests
r = requests.get('http://kamil.kwapisz.pl')  # obiekt klasy Response

działanie pokazanej powyżej metody get można objaśnić jako wejście na stronę, wyświetlenie źródła strony (ctrl+U w Google Chrome) i zapisanie całego znalezionego tam tekstu.
Mając zwrócony obiekt klasy Response możemy pobrać z niego tekst strony, odczytać i zmienić jej kodowanie.

html_text: str = r.text  # całość kodu HTML strony
json_text: dict = r.json()  # jeżeli wykonujemy request do API zwracającego JSON możemy wczytać to do pythonowego słownika w ten sposób
r.encoding = 'utf-8'  # możemy również wymusić dowolne kodowanie znaków

Obiekt ten pozwala również na sprawdzenie kodu zwróconego przez stronę.

print(r)  # wynik: <Response [200]>
print(r.status_code)  # wynik: 200
# W ten sposób można sprawdzić kody 2xx, 4xx, 5xx.
# Zapisany kod nigdy nie będzie kodem oznaczającym przekierowanie
# Aby rozpoznać przekierowania (kody 3xx, głównie 301 i 302) należy użyć atrybutu history
status_code_history: list = r.history
# każde przekierowanie do czasu natrafienia na kod 2xx, 4xx lub 5xx znajduje się na tej liście
# zwrócona lista będzie pusta, jeżeli nie wystąpiły żadne przekierowania

Za pomocą biblioteki Requests można wykonywać również requesty metodami POST, PUT, DELETE, na przykład:

post_request = requests.post('https://example.com/api', data={'username': 'kamil'})

User-agent

Omawiana biblioteka pozwala również na wykonywanie requestów ze zdefiniowanymi przez nas nagłówkami. Szczególnie interesującym nagłówkiem jest user-agent. User-agent jest parametrem requestu, po którym strona poznaje z jakim typem użytkownika ma do czynienia. Każda przeglądarka na różnych systemach operacyjnych ma zdefiniowane różne user-agent’y, co pozwala na prowadzenie statystyk przeglądarek i systemów jakich używają użytkownicy strony.
ie6-ping
Po nagłówku user-agent rozpoznawane są także boty Google.
Kiedy może przydać się zmiana domyślnej wartości nagłówka user-agent? Korzystając z publicznie dostępnych API możemy otrzymać kod 429 i odpowiedź o treści Too Many Requests. Dzieje się tak na przykład w przypadku korzystania z Reddita zwracającego dane w formacie json.

r = requests.get('https://www.reddit.com/r/python/new.json?sort=new')
print(r.text)  # wynik: '{"message": "Too Many Requests", "error": 429}'

Aby temu zapobiec wystarczy, że użyjemy innego user-agenta:

headers = {'user-agent': 'kamil.kwapisz.pl'}
r = requests.get('https://www.reddit.com/r/python/new.json?sort=new', headers=headers)
print(r.status_code)  # wynik: 200

Jeżeli chcemy wygenerować user-agenta podobnego do używanych przez przeglądarki możemy skorzystać z biblioteki user_agent.

from user_agent import generate_user_agent
user_agent = generate_user_agent(os=('mac', 'linux', 'win'))
# losowo wygenerowany user-agent może wskazywać na jeden z podanych systemów operacyjnych

Pamiętajcie jednak, aby nie wykorzystywać user-agenta do celów niezgodnych z warunkami korzystania stron internetowych, do których wysyłacie żądania 😉
Omówione przeze mnie funkcje Requests powinny być wystarczające w większości pisanych programów, jednak zachęcam do przejrzenia oficjalnej dokumentacji biblioteki.

Korzystanie z BeautifulSoup

Kiedy w pamięci RAM mamy już zapisany kod strony należałoby wreszcie go przeparsować i wyekstrahować potrzebne nam elementy. Jak już wcześniej mówiłem użyjemy do tego celu biblioteki BeautifulSoup.
Dla lepszego zrozumienia działania bilbioteki przedstawię wszystko na przykładzie. Naszym zadaniem będzie pobranie adresów subredditów wypisanych na liście znajdującej się pod adresem https://www.reddit.com/r/ListOfSubreddits/wiki/listofsubreddits.

Najpierw musimy oczywiście pobrać zawartość strony oraz załadować ją do parsera.

import requests
from bs4 import BeautifulSoup
url = 'https://www.reddit.com/r/ListOfSubreddits/wiki/listofsubreddits'
r = requests.get(url, headers={'user-agent': 'kamil.kwapisz.pl'})
soup = BeautifulSoup(r.text, "lxml")
# pierwszym argumentem konstruktora klasy BeautifulSoup jest zawartośc dokumentu HTML
# drugim parametrem jest parser i nad nim się chwilę zatrzymamy

Parsery biblioteki BeautifulSoup

Aktualnie biblioteka bs4 pozwala na korzystanie z jednego z czterech parserów:

  • html.parser – wbudowany parser, nie jest jednak najszybszym rozwiązaniem
  • lxml – najszybszy dostępny parser HTML. Nie jest on zainstalowany domyślnie, należy go doinstalować na przykład korzystając z programu pip:
    pip install lxml
  • html5lib – jest on najwolniejszy jednak parsuje stronę identycznie do przeglądarek. Podobnie jak w przypadku lxml’a jest to pakiet zewnętrzny, który można zainstalować poleceniem:
    pip install html5lib
  • xml – jedyny obsługiwany parser przeznaczony do dokumentów XML

Zalecam korzystanie z parsera lxml ze względu na jego szybkość. Parser html5lib przyda się, jeżeli zauważymy, że inne parsery błędnie parsują stronę.
Poszukiwane przez nas linki do subredditów znajdują się wewnątrz tagów body dlatego też dostaniemy się do tej sekcji od razu, aby niepotrzebnie nie parsować sekcji head. Możemy również przejść do diva klasy „md wiki” w którym znajdują się linki. Służy do tego metoda find(), która znajduje tylko jeden element spełniający podane kryteria. We wskazanym divie znajduje się tabelka oraz nieuporządkowana lista z linkami oznaczonymi parametrem nofollow, które nie są nam potrzebne. Aby ich nie pobierać możemy więc usunąć niechcianą tabelkę oraz listę.

body = soup.body  # korzystając z tego skrótu można dostać się do konkretnego znacznika
div = body.find('div', {'class': 'md wiki'})  # szukamy div'ów klasy "md wiki"
div.table.extract()  # usuwamy zawartość znaczników <table>
ul = div.ul.extract()  # usuwamy zawartość znaczników <ul>, którą możemy zapisać do zmiennej
# teraz w zmiennej div nie ma tabeli oraz uporządkowanej listy
print(type(div.find('table'))) # wynik: <class 'NoneType'>

Szukanie konkrentych znaczników

Przyjrzyjmy się najpierw w jaki sposób pożądane linki zapisane są w kodzie HTML strony. Wykorzystamy do tego funkcję przeglądarki „Zbadaj element”.
inspect element
Niestety linki nie mają żadnej charakterystycznej klasy, którą moglibyśmy wskazać naszemu parserowi, jednak w tym przypadku pomocny będzie parametr rel. Warto zauważyć, że wszystkie linki do subredditów mają ustawioną wartość tego parametru na nofollow.
Powinniśmy więc teraz znaleźć wszystkie znaczniki <a> z parametrem rel=”nofollow”. Służy do tego funkcja find_all(). Metody tej możemy używać zarówno na obiekcie soup jak i na obiektach zwracanych przez inne parsujące metody jak np find().

links = div('a', {'rel': 'nofollow'})
# find_all jest najczęściej używaną funkcją biblioteki dlatego twórcy przygotowali skrót
# div('a', {'rel': 'nofollow'}) == div.find_all('a', {'rel': 'nofollow'})

Zwrócony obiekt jest instancją klasy z biblioteki bs4, jednak można po nim iterować.

for link in links[:10]:
    print(link.text)

Odwołując się do własności text pobieramy to co znajduję się pomiędzy znacznikami <a> i </a>. Jeżeli chcemy pobrać wartość konkretnego atrybutu znacznika możemu wykorzystać metodę get. Jej parametrem jest nazwa atrybuty, który chcemy pobrać zapisana jako string.

rel = links[0].get('rel')
href = links[0].get('href')

Jak pewnie zauważyliście wartości atrybutów 'href’ nie są poprawnymi URL’ami. Mamy w tym przypadku do czynienia z relative links. Przykładowo:
relative link: /r/Python
absolute link: https://www.reddit.com/r/Python/
Dla nas dużo bardziej przydatne są linki będące poprawnymi URLami z określonym protokołem. Spróbujemy więc stworzyć je sami z relatywnych linków, które już mamy. Wykorzystamy do tego bibliotekę urllib.

Parsowanie linków przy użyciu urllib

Najłatwiejszym sposobem na zamianę relative and absolute link będzie wykorzystanie funkcji urljoin. Jako pierwszy parametr funkcji nalepiej podać stronę, na której dany link został znaleziony. Drugim parametrem jest oczywiście link relatywny.

from urllib.parse import urljoin
for relative_link in links[:10]:
    absolute_link = urljoin(url, relative_link.get('href'))
    print(absolute_link)

W tym przypadku absolute link możemy stworzyć sami w bardzo prosty sposób. Wykorzystamy do tego funkcję urlparse z biblioteki urllib.parse. Zastanówmy się co różni dwa wyżej wymienione rodzaje linków. Absolute link rozpoczyna się od protokołu, najczęściej od http lub https w zależności od tego czy strona posiada certyfikat SSL. Po protokole zawsze występują znaki „://” a następnie nazwa domeny (a dokładniej mówiąc lokalizacja sieciowa) wraz z końcówkę określającą domenę najwyższego poziomu. Na końcu takiego linku znajduje się już link relatywny. Musimy więc pobrać protokół strony i jej lokalizację sieciową. Podobnie jak w przypadku funkcji urljoin jako parametr funkcji urlparse przekażemy adres URL, z której pobraliśmy linki relatywne.

from urllib.parse import urlparse
protocol = urlparse(url).scheme  # scheme jest protokołem url'a
netloc = urlparse(url).netloc  # netloc to lokacja sieciowa, w tym przypadku www.reddit.com

Za pomocą tej funkcji możemy pobrać również relative link, parametry w urlu, kwerendę, czy nawet nazwę użytkownika, hasło i port gdy takie występują.

relative = urlparse(url).path  # własność path przechowuje relative link z URL'a
query = urlparse('https://www.reddit.com/search?q=python').query  # kwerenda 'q=python'

W ramach lepszego poznania biblioteki napiszemy funkcję, która w naszej sytuacji stworzy poprawny URL z podanego relatywnego linku.

def get_absolute_link(relative_link: str) -> str:
    protocol = urlparse(url).scheme  # url jest zmienną globalną
    netloc = urlparse(url).netloc
    absolute_link: str = protocol + "://" + netloc + relative_link
    return absolute_link

Niestety napisana implementacja jest dość „naiwna” i nie sprawdzi się w każdym przypadku. Nie każdy relative link zaczyna się od ukośnika, czasami wskazuję one ścieżkę do pliku wykorzystując notację z kropkami, na przykład „../r/Python”. Zalecam więc korzystanie z funkcji urljoin, która radzi sobie z różnymi wersjami linków relatywnych. Przetwarzając linki znalezione na stronie należy również sprawdzić czy przetwarzany link nie jest poprawnym URL’em, na przykład linkującym do zewnętrznej domeny. Można to na zweryfikować na przykład przez sprawdzenie czy link nie zaczyna się od „http”. W naszym przypadku chodzi o znalezienie urli do subredditów, dlatego też weźmiemy pod uwagę tylko linki zaczynające się od „/r”.

for link in links:
    href: str = link.get('href')
    if href.startswith("/r"):
        print(urljoin(url, href))

Mamy wypisane wszystkie linki do subredditów, więc nasz cel został osiągnięty 🙂

Podsumowanie

Starałem się w jak najprzystępniejszy sposób przedstawić Wam proces web scrapingu w Pythonie. Pomimo wielu elementów wspólnych każdy scraper tworzy się trochę inaczej, każdy stawia nowe i często ciekawe wyzwania. Dla mnie największą zaletą scraperów jest to, że pozwalają korzystać z ogromu wiedzy zgromadzonej w internecie. Są podstawą do open source intelligence (o czym planuję niebawem napisać post). Szczególnie docenić to można w dobie ciągle rosnącej popularności analizy danych i uczenia maszynowego.
Wiele stron robi wszystko aby scrapowanie utrudnić, na przykład poprzez blokowanie requestów niepochodzących z przeglądarek i banowanie adresów IP, z których takie żądania zostały wykonane. Należy wtedy uszanować wolę strony, zamiast próbowania oszukania występujących zabezpieczeń 😉 Zanim zaczniecie scrapować strony upewniajcie się również czy jest to zgodne z regulaminem korzystania ze strony.

Jeśli chcesz być na bieżąco z najnowszymi materiałami, polub nasz fanpage na Facebooku:

https://www.facebook.com/kamil.kwapisz.python
Tagged , , ,

2 thoughts on “Web Scraping w Pythonie

  1. Jestem totalnie początkująca, ale wydaje mi się, że jest malutki błąd we fragmencie z 'urljoin’ i powinno być: „absolute_link = urljoin(url, relative_link.text)”, w przeciwnym razie wyrzucało mi błąd o próbie łączenia str z nie-str, ale może robiłam coś nie tak 🙂 A ogólnie bardzo dziękuję za artykuł, dużo się z niego nauczyłam!

    1. Faktycznie, dzięki za zwrócenie uwagi 🙂

      Chociaż zamiast relative_link.text powinniśmy raczej napisać relative_link.get(’href’). Akurat w tym przypadku efekt jest identyczny, jednak ogólnie interesuje nas w tym przypadku atrybut href, a nie tekst linku 🙂

      Cieszę się, że artykuł się przydaje 🙂
      Pozdrawiam!

Comments are closed.