Асинхронный парсер сайта, часть 1: переписываем синхронный парсер

В предыдущей статье мы написали синхронный парсер/скрапер web-сайта, попробуем ускорить его.

Одним из способов ускорения программ является переписывание с помощью асинхронных функций, чтобы подпрограммы выполнялись конкурентно.

Напишем асинхронную версию этого же парсера.

Наивная реализация асинхронного парсера

Сразу же продемонстрирую получившийся код, и расскажу отличия этой версии от синхронной версии из предыдущей статьи.

Для работы данной программы необходимо установить сторонние библиотеки aiohttp и beautifulsoup4. Сделать это можно командой pip install aiohttp beautifulsoup4[lxml].
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
from urllib.parse import urljoin, urlparse, urldefrag
import asyncio

import aiohttp
from bs4 import BeautifulSoup


async def fetch_and_validate_page_contents(url, session):
    while True:
        try:
            async with session.get(url) as response:
                if response.content_type != "text/html":
                    return None
                if response.url.host != HOSTNAME:
                    return None
                return await response.text()
        except aiohttp.ClientConnectorError:
            continue


async def parse_page(url, session):
    text = await fetch_and_validate_page_contents(url, session)
    if text is None:
        return None, set()
    soup = BeautifulSoup(text, "lxml")
    title = soup.find("title")
    if title is not None:
        title = title.text
    all_internal_links = set()
    for a in soup.find_all("a"):
        link = a.get("href")
        if link is None:
            continue
        if link.startswith("#"):
            continue
        absolute_link = urljoin(WEBSITE, link)
        if urlparse(absolute_link).hostname == HOSTNAME:
            all_internal_links.add(
                urldefrag(absolute_link)[0]
            )
    return title, all_internal_links


async def process_site(website, session):
    visited_urls = {}
    urls_to_visit = {website}

    while urls_to_visit:
        current_url_to_parse = urls_to_visit.pop()
        title, internal_links = await asyncio.create_task(
            parse_page(current_url_to_parse, session)
        )
        visited_urls[current_url_to_parse] = title
        for url in internal_links:
            if url not in visited_urls:
                urls_to_visit.add(url)

    return visited_urls


async def main():
    async with aiohttp.ClientSession() as session:
        return await process_site(WEBSITE, session)


if __name__ == "__main__":
    WEBSITE = "https://www.python.org"
    HOSTNAME = urlparse(WEBSITE).hostname
    titles = asyncio.run(main())
    print(titles)

fetch_and_validate_page_contents

Делает то же, что и в предыдущей статье, однако функция стала асинхронной (async def).

Также, помимо URL адреса, она принимает в качестве аргумента объект сессии.

Сессия в aiohttp - это инкапсуляция пула соединений, который использует преимущества множества запросов к одному и тому же серверу (keepalive, кэширование и т.д.).

parse_page

Всё то же самое, только функция получения контента интернет-страницы происходит асинхронно.

Разбор же получившегося html и выделение ссылок и заголовка у нас остались синхронными.

Конечно же, можно сделать обработку html асинхронной, однако, поскольку это функционал, завязанный на процессорном времени (а не на внешней обработке, в отличие от запросов в Интернет), это имеет меньше смысла, чем делать асинхронными часть с запросами к странице. Тем не менее, мы ещё вернёмся к этой возможной оптимизации.

process_site

В асинхронной версии у нас появилась дополнительная функция process_site; сделана она исключительно ради удобства, чтобы не городить лишний уровень отступа.

Объект сессии мы создаём в main, и передаём его в process_site, которая теперь и занимается парсингом сайта.

Отличие process_site заключается в том, что мы создаём задачу для каждой страницы с помощью asyncio.create_task, и ожидаем результата от неё.

Главный блок

Вместо простого вызова main() мы запускаем корутину с помощью asyncio.run.

Что не так с наивной реализацией?

Вы можете заметить, что код в текущем виде работает практически также медленно, как и синхронный код из предыдущей статьи. Почему так происходит? Потому что, несмотря на то, что код асинхронный, он НЕ конкурентный. Мы создаём множество задач, однако после сразу же после создания каждой из них мы ожидаем от неё результата (не успев создать другие).

Можно также раскомментировать строки 54-55, чтобы получить хоть какой-то результат за разумное время (будут посещены только первые 100 страниц).

Исправление долгой работы парсера, а также другие улучшения, мы рассмотрим в следующих частях.