pro100chel
@pro100chel
Senior Pomidor Developer | CEO of GOVNOKOD LTD.

Медленный парсинг на Python с BS4?

Нужно парсить по 2000 страниц в секунду. Сайт позволяет и не банит. Использую Python + BS4.
Но столкнулся с тем, что bs4 выполняет команду soup = bs(result.content, 'html.parser') очень долго. В районе 250мс.

Можно ли как то сократить это время?
Или нужно использовать другой парсер?
Какие производительные парсеры есть для Python?
  • Вопрос задан
  • 1087 просмотров
Решения вопроса 1
@danSamara
Задачи, связанные со скраппингом, относятся одновременно и к IO-bound и к CPU-bound (как вам подробно рассказали в предыдущем ответе). С одной стороны это порождает множество проблем для новичков, с другой - знание этих особенностей помогает с архитектурой - вам просто не остаётся выбора :)

Давайте теперь разберём подробно ваш вопрос.

IO-bound
Всё что использует множественный ввод-вывод, должно быть асинхронным, кроме тех случаев, когда абсолютно плевать на время выполнения, а это не ваш случай :) Я думаю, причины этого очевидны - любые внешние сетевые задержки вам неподконтрольны (да и внутренние не очень) - запрос на страницу сайта может занять как несколько миллисекунд, так и минуту (это редкость, но несколько секунд - вполне), то есть разница достигает 4-5 порядков! В случае синхронного однопоточного кода львиная доля работы вашего приложения - ожидание, что, согласитесь, обидно. Пример кода вам привёл Roman Kitaev - необязательно ориентироваться конкретно на эту реализацию - в сети очень много примеров современного питонячего асинхронного кода, скачивающего странички.

CPU-bound
Здесь ещё проще - вам нужно загрузить равномерно все ядра, попутно, если мы говорим про Python, нивелировать влияние GIL. Обычно это решается созданием нескольких потоков - по числу ядер и очередью (или несколькими), из которой потоки берут данные для обработки.

Память
Малое потребление памяти - признак хорошей архитектуры кода.
Обычные веб-страницы весят немного - сотни килобайт - и редко доставляют проблемы, но бывают и задачи обработки очень объёмных XML/HTML документов - десятки и сотни гигабайт. Но даже документ "весом" под сотню мегабайт может принести много радости. В этом случае прибегаю к помощи потоковых парсеров - они работают с небольшим буфером, вызывая обработчики для нужного контента. Опять же - не ваш случай, что хорошо )

Медленный Python
О, как часто можно это услышать и прочитать!
К сожалению, эта легенда легко получает подтверждение у новичков, плохо представляющих себе экосистему питона и плохо понимая его внутреннее устройство, хотя эти знания легко приобретаются через пару месяцев использования языка. Отличительный признак подобных критиков - отсутствие внятной аргументации и советы типа: "пишите на нормальном языке! а нормальный это - [%random_lang%]". В этом треде, как видите, отличился DarthWazer.
Да, имеется достаточно задач, с которыми "чистый" питон справляется очень медленно, но для таких случаев всегда найдётся "батарейка-обёртка" для библиотеки, написанной на C и никаких особых тормозов вы не заметите.
Python - это язык-клей, позволяющий быстро, легко и элегантно соединить несколько низкоуровневых инструментов и получить отличный результат.

В качестве эксперимента давайте сравним Python и Rust ( DarthWazer, это достаточно быстрый язык или только шарп надо использовать?)

Сначала сохраним главную страницу в файл "wiki_front.html" - скачивание страниц сравнивать бессмысленно.

Python
  1. Создаём окружение и устанавливаем lxml:
    pipenv install lxml --python=3.8
  2. Пишем код:
    main.py
    from lxml.html import parse
    import time
    
    
    if __name__ == '__main__':
        with open('wiki_front.html', 'r') as contents:
            begin = time.monotonic()
            doc = parse(contents)
            links = doc.xpath("//a")
            time_total = time.monotonic() - begin
            print(f'Links counts: {len(links)}, time: {time_total:.9} sec')

  3. Запускаем:
    pipenv run ./main.py
    Links counts: 333, time: 0.00371371489 sec

  4. Запоминаем результат: 0.00371 сек. Ваш результат, конечно, будет отличаться, это не критично, важно сравнение.


Ок, переходим к Rust
  1. Создаём проект:
    cargo init
  2. Добавляем в зависимости scraper:
    Cargo.toml

    .....
    [dependencies]
    scraper = "*"

  3. Пишем код:
    src/main.rs
    use std::fs;
    use std::time::Instant;
    
    use scraper::{Html, Selector};
    
    
    fn main() {
    
        let contents = fs::read_to_string("wiki_front.html")
            .expect("Something went wrong reading the file");
    
        let begin = Instant::now();
        let document = Html::parse_document(&contents);
        let selector = Selector::parse("a").unwrap();
        let links_count = document.select(&selector).count();
    
        println!("Links counts: {}, time: {} sec",
                 links_count, begin.elapsed().as_secs_f32());
    
    }

  4. Запускаем:
    cargo run --release
    Links counts: 333, time: 0.002836701 sec

  5. Сравниваем результаты: разница - 0.8 милисекунд.


Вывод: Rust тоже медленный, пишите свои скраберы на шарпе.

Что же делать?
Ну и, наконец, ответ на ваш вопрос :)
Примерно год назад я задавал вопрос по вашей теме - Какой выбрать Python фреймворк для системы парсинга сайтов?
Можно использовать один из упомянутых фрейворков. Или написать свой, если достаточно компетенций.
Ответ написан
Пригласить эксперта
Ответы на вопрос 3
@deliro
Агрессивное программирование
1. BS — говно
2. Если прям очень хочется продолжить кушать кактус BS — он может использовать lxml парсер, написанный на Си вместо html.parser на питоне
3. Парсинг страниц легко распараллелить через ProcessPoolExecutor по количеству ядер
4. Вот пример того, как можно не блокируясь качать всё, что хочешь по HTTP, кидать результат в очередь, которую обрабатывает ProcessPoolExecutor. Правда, у скрипта нет возможности остановить парсер, но думаю, это несложно будет дописать. Быстро, модно, эффективно:

Код, который качает всю википедию (вернее, пытается)
import asyncio
from concurrent.futures import ProcessPoolExecutor

import aiohttp
from loguru import logger as loguru
from lxml.html import fromstring


pool = ProcessPoolExecutor()
parser_sem = asyncio.Semaphore(pool._max_workers)
loguru.info(f"CPU workers: {pool._max_workers}")
host = "https://ru.wikipedia.org"
start_from = f"{host}/wiki/Заглавная_страница"
q_d = asyncio.Queue()
q_p = asyncio.Queue()
sem = asyncio.Semaphore(100)
downloaded_urls = set()


class O:
    downloaded = 0
    parsed = 0
    downloading = 0
    down_pending = 0
    waiting_for_download_q = 0


o = O()


async def log_printer(queue_d, queue_p):
    while True:
        loguru.debug(
            f"[PRINTER] to Download: {queue_d.qsize()}, to Parse: {queue_p.qsize()}"
            f" downloaded: {o.downloaded}, parsed: {o.parsed}"
            f" pending: {o.down_pending}, downloading: {o.downloading}"
            f" waiting Q: {o.waiting_for_download_q}"
            f" tasks: {len(asyncio.Task.all_tasks())}"
        )
        await asyncio.sleep(0.33)


def lxml_parse(html):
    try:
        tree = fromstring(html)
        urls = tree.xpath("//a/@href")
        try:
            title = tree.find(".//title").text
        except AttributeError:
            title = "<UNKNOWN>"

        new_urls = []
        for url in urls:
            if url.startswith("/") and not url.startswith("//"):
                new_urls.append(f"{host}{url}")
            elif url.startswith("http"):
                new_urls.append(url)

        return new_urls, title
    except Exception as e:
        loguru.error(f"Parse error: {e}")
        return [], "<ERROR>"


async def parse(html):
    loop = asyncio.get_event_loop()
    urls, title = await loop.run_in_executor(pool, lxml_parse, html)
    o.parsed += 1
    return urls, title


async def start_parse_task(content, queue_d):
    async with parser_sem:
        urls, title = await parse(content)
        # loguru.debug(f"[PARSER]: Parse done {title}")
        o.waiting_for_download_q += 1
        for url in urls:
            if url not in downloaded_urls:
                await queue_d.put(url)
        o.waiting_for_download_q -= 1
        # loguru.debug(f"[PARSER]: Add {len(urls)} to download queue")


async def parser(queue_d, queue_p):
    while True:
        content = await queue_p.get()
        asyncio.create_task(start_parse_task(content, queue_d))


async def downloader(queue_d, queue_p, session):
    while True:
        url = await queue_d.get()
        if url in downloaded_urls:
            continue

        o.down_pending += 1
        async with sem:
            o.down_pending -= 1
            o.downloading += 1
            try:
                async with session.get(url) as resp:
                    downloaded_urls.add(url)
                    # loguru.debug(f"[DOWNLOADER]: got response for {url}")
                    try:
                        text = await resp.text()
                        await queue_p.put(text)
                    except UnicodeDecodeError:
                        pass
                    o.downloaded += 1
            except Exception as e:
                loguru.error(f"Download error: {e}")
            finally:
                o.downloading -= 1


async def main():
    await q_d.put(start_from)
    async with aiohttp.ClientSession() as session:
        ds = []
        for i in range(100):
            ds.append(asyncio.create_task(downloader(q_d, q_p, session)))
        p = asyncio.create_task(parser(q_d, q_p))
        printer = asyncio.create_task(log_printer(q_d, q_p))
        await asyncio.gather(*ds, p, printer)


if __name__ == "__main__":
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())


Не забудь поставить зависимости: loguru, lxml, aiohttp
Ответ написан
@Kirill-Gorelov
С ума с IT
Сайт, может и позволять.

А твое железо может потянуть столько?
А канал позволяет столько передавать данных в секунду?
А как ты парсишь, синхронно, асинхронно или используешь потоки?
А ответ от сайта быстро приходит??
Ответ написан
Jump
@Jump
Системный администратор со стажем.
Нужно парсить по 2000 страниц в секунду
Серьезно?
Средний размер интернет страницы около 2Мб.
2мб * 2000скачиваний в секунду = 4000Мб/с или 4Гб /с
У вас банально канала не хватит на такое, да и ни один сервер не позволит такого, он просто ляжет.
Распределенная система конечно выдержит - но это уже DoS атака.

Или нужно использовать другой парсер?
Попробуйте другой, нам же неизвестно что за страница, и что вы там добываете. Попробуйте lxml например. Разные парсеры в разных задачах хороши.
Какие производительные парсеры есть для Python?
Смотря что парсить.
Ответ написан
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы