Асинхронный парсер сайта, часть 1: переписываем синхронный парсер
В предыдущей статье мы написали синхронный парсер/скрапер web-сайта, попробуем ускорить его.
Одним из способов ускорения программ является переписывание с помощью асинхронных функций, чтобы подпрограммы выполнялись конкурентно.
Напишем асинхронную версию этого же парсера.
Наивная реализация асинхронного парсера
Сразу же продемонстрирую получившийся код, и расскажу отличия этой версии от синхронной версии из предыдущей статьи.
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 и выделение ссылок и заголовка у нас остались синхронными.
process_site
В асинхронной версии у нас появилась дополнительная функция process_site; сделана она исключительно ради удобства, чтобы не городить лишний уровень отступа.
Объект сессии мы создаём в main, и передаём его в process_site, которая теперь и занимается парсингом сайта.
Отличие process_site заключается в том, что мы создаём задачу для каждой страницы с помощью asyncio.create_task, и ожидаем результата от неё.
Главный блок
Вместо простого вызова main() мы запускаем корутину с помощью asyncio.run.
Что не так с наивной реализацией?
Вы можете заметить, что код в текущем виде работает практически также медленно, как и синхронный код из предыдущей статьи. Почему так происходит? Потому что, несмотря на то, что код асинхронный, он НЕ конкурентный. Мы создаём множество задач, однако после сразу же после создания каждой из них мы ожидаем от неё результата (не успев создать другие).
Можно также раскомментировать строки 54-55, чтобы получить хоть какой-то результат за разумное время (будут посещены только первые 100 страниц).
Исправление долгой работы парсера, а также другие улучшения, мы рассмотрим в следующих частях.