Юмористический Веб-Изображения Скребок


Я написал мой первый парсер, который (на удивление) работает. Я выскабливание популярный комический сайт для изображений (насчитывается более 950 из них), но проблема скребок-это слишком медленно.

Например, если я скачать образец 10 комиксов в среднем занимает от 4 до 5 секунд для каждого изображения (всего более 40 секунд для примера), который слишком медленно если вы спросите меня, потому что каждое изображение прибл. размер 80 КБ до 800КБ.

Я читал, я мог бы переключиться на lxml, чтобы сделать соскоб асинхронно, но пакет не совместим с Python 3.6.

Я решил представить скребок для кода и анализа эффективности. Любое понимание на обе темы будут высоко оценены.

import time
import os
import sys
import re
import requests
import itertools
from requests import get
from bs4 import BeautifulSoup as bs

HOME_DIR = os.getcwd()
DEFAULT_DIR_NAME = 'poorly_created_folder'

def show_logo():
  print("""
a Python comic(al) scraper for poorlydwarnlines.com
                         __
.-----.-----.-----.----.|  |.--.--.
|  _  |  _  |  _  |   _||  ||  |  |
|   __|_____|_____|__|  |__||___  |
|__|                        |_____|
                __ __   __
.--.--.--.----.|__|  |_|  |_.-----.-----.
|  |  |  |   _||  |   _|   _|  -__|     |
|________|__|  |__|____|____|_____|__|__|

.-----.----.----.---.-.-----.-----.----.
|__ --|  __|   _|  _  |  _  |  -__|   _|
|_____|____|__| |___._|   __|_____|__|
                      |__|
version: 0.2 | author: baduker | https://github.com/baduker
  """)

def handle_menu():
  print("\nThe scraper has found {} comics.".format(len(found_comics)))
  print("How many comics do you want to download?")
  print("Type 0 to exit.")

  while True:
    try:
      global n_of_comics
      n_of_comics = int(input(">> ").strip())
    except ValueError:
      print("Error: incorrect value. Try again.")
      continue
    if n_of_comics > len(found_comics) or n_of_comics < 0:
      print("Error: incorrect number of comics to download. Try again.")
      continue
    elif n_of_comics == 0:
      sys.exit()
    else:
      break
  return n_of_comics

def move_to_dir(title):
  if os.getcwd() != HOME_DIR:
    os.chdir(HOME_DIR)
  try:
    os.mkdir(title)
    os.chdir(title)
  except FileExistsError:
    os.chdir(title)
  except:
    print("Couldn't create directory!")

def generate_comic_link(array, num):
  for link in itertools.islice(array, 0, num):
    yield link

def grab_image_src_url(link):
  req = requests.get(link)
  comic = req.text
  soup = bs(comic, 'html.parser')
  for i in soup.find_all('p'):
    for img in i.find_all('img', src=True):
      return img['src']

def download_image(link):
  file_name = url.split('/')[-1]
  with open(file_name, "wb") as file:
    response = get(url)
    file.write(response.content)

def fetch_comic_archive():
  url = 'http://www.poorlydrawnlines.com/archive/'
  req = requests.get(url)
  page = req.text
  soup = bs(page, 'html.parser')
  all_links = []
  for link in soup.find_all('a'):
    all_links.append(link.get('href'))
  return all_links

def filter_comic_archive(archive):
  pattern = re.compile(r'http://www.poorlydrawnlines.com/comic/.+')
  filtered_links = [i for i in archive if pattern.match(i)]
  return filtered_links

show_logo()

all_comics = fetch_comic_archive()
found_comics = filter_comic_archive(all_comics)

handle_menu()

start = time.time()
for link in generate_comic_link(found_comics, n_of_comics):
  print("Downloading: {}".format(link))
  move_to_dir(DEFAULT_DIR_NAME)
  url = grab_image_src_url(link)
  download_image(url)
end = time.time()

print("Successfully downloaded {} comics in {:.2f} seconds.".format(n_of_comics, end - start))


Комментарии
2 ответа

Это похоже на крутой проект, и хорошее первое знакомство с веб-выскабливание! Я в первую очередь некоторые общие советы, а затем адрес вашей главной заботой: скорость.


Общие


  1. Можно использовать два пробела для отступов. Пеп-8 рекомендует 4 места (большинство редакторов кода есть возможность автоматически преобразовать табуляции в пробелы, которые я настоятельно рекомендую).

  2. А по теме, Пеп-8 также рекомендует две пустые строки между топ-уровня функции.

  3. В целом использование глобальных переменных не рекомендуется. Есть некоторые исключения:


    • Глобальные константы-это нормально;

    • При определенных обстоятельствах, во избежание глобального состояния приведет к неприемлемо сложный код.

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


  4. На мой взгляд, show_logo() - Это немного перебор. Я хотел сделать его постоянным, а не (LOGO).

  5. В while петли в handle_menu() можно улучшить:

    while True:
    try:
    global n_of_comics
    n_of_comics = int(input(">> ").strip())

    Нет необходимости называть str.strip() до передачи возвращаемого значения int.

      except ValueError:
    print("Error: incorrect value. Try again.")
    continue

    Сообщение об ошибке-это немного расплывчато. Почему неправильно? Может, что-то вроде 'ошибка: ожидается число. Попробуйте еще раз.' подошло бы лучше.

      if n_of_comics > len(found_comics) or n_of_comics < 0:
    print("Error: incorrect number of comics to download. Try again.")
    continue

    Это сообщение об ошибке вполне ясно :)

      elif n_of_comics == 0:
    sys.exit()
    else:
    break

    Вам не нужно else статья здесь, с elif пунктом останавливает программу, если она выполняется. На самом деле, вы можете просто вернуться n_of_comics есть. Собрал все воедино:

    while True:
    try:
    global n_of_comics
    n_of_comics = int(input(">> "))
    except ValueError:
    print("Error: expected a number. Try again.")
    continue
    if n_of_comics > len(found_comics) or n_of_comics < 0:
    print("Error: incorrect number of comics to download. Try again.")
    continue
    elif n_of_comics == 0:
    sys.exit()
    return n_of_comics

  6. Основной рацион вашего '' не инкапсулированы в функцию, которая означает, что это трудно проверить / исследовать функции отдельности от Python интерактивной сессии (или отдельным файлом). Я предлагаю поставить все это на верхнем уровне кода в main() функции:

    def main():
    show_logo()

    all_comics = fetch_comic_archive()
    found_comics = filter_comic_archive(all_comics)

    handle_menu()

    start = time.time()
    for link in generate_comic_link(found_comics, n_of_comics):
    print("Downloading: {}".format(link))
    move_to_dir(DEFAULT_DIR_NAME)
    url = grab_image_src_url(link)
    download_image(url)
    end = time.time()

    print("Successfully downloaded {} comics in {:.2f} seconds.".format(n_of_comics, end - start))

    Вы можете затем проверить, если __name__ == "__main__"для запуска main если скрипт вызывается в основной программе (см. 'Что если __имя__ == "__основной__" делать?):

    if __name__ == "__main__":
    main()

  7. Я не понимаю смысл move_to_dir(). Это обрабатывает случай, когда текущая рабочая директория была изменена, и он называется за каждый комикс скачать. Это кажется довольно бессмысленным. Вместо этого, я хотел только создать директорию раз, в main():

    DEFAULT_DIR_NAME = "poorly_created_folder"
    COMICS_DIRECTORY = os.path.join(os.getcwd(), DEFAULT_DIR_NAME)

    ...

    def download_image(link):
    ...
    with open(os.path.join(COMICS_DIRECTORY, file_name), "wb") as file:
    ...

    ...

    def main():
    ...
    try:
    os.mkdir(COMICS_DIRECTORY)
    except OSError as exc:
    sys.exit("Failed to create directory (errno {})".format(exc.errno))
    # `sys.exit` will write the message to stderr and return with status code 1


  8. generate_comic_link() это лишнее. Следующие функции делают все же:

    def generate_comic_link(array, num):
    # Using itertools.islice()
    for link in itertools.islice(array, 0, num):
    yield link

    def generate_comic_link2(array, num):
    # Using slicing
    for link in array[:num]:
    yield link

    def generate_comic_link3(array, num):
    # Using slicing with yield from
    yield from array[:num]

    itertools.islice() это перебор (и немного трудно читать). Так generate_comic_link3() это один-лайнер, вы можете, возможно, избавиться от функции в целом, перебирать URL-адреса непосредственно с помощью нарезки.


  9. Немного придираться, но req = <requests>.get(<url>) это неправильно. requests.get не возвращают запрос, он возвращает ответ. Таким образом, response = <requests>.get(<url>) имеет больше смысла.

  10. Некоторые переменные могут (должны) быть константами:

    url = 'http://www.poorlydrawnlines.com/archive/'
    # Might be
    ARCHIVE_URL = "http://www.poorlydrawnlines.com/archive/"

    pattern = re.compile(r'http://www.poorlydrawnlines.com/comic/.+')
    # Might be
    COMIC_PATTERN = re.compile(r"http://www.poorlydrawnlines.com/comic/.+")

    (Я предпочитаю двойные кавычки, как вы могли бы сказать)


  11. В fetch_comic_archive():

    all_links = []
    for link in soup.find_all('a'):
    all_links.append(link.get('href'))
    return all_links

    ... может быть один-лайнер:

    return [link.get("href") for link in soup.find_all("a")]

  12. В filter_comic_archive():

    filtered_links = [i for i in archive if pattern.match(i)]
    return filtered_links

    Нет необходимости в промежуточной переменной.


Используя threading.Thread

Теперь собственно задача: повышение производительности.
Вам не нужно асинхронного lxmlнити будет делать только штрафом здесь!
Путем заключения соответствующего кода в функцию, мы можем наплодить любое количество нитей, чтобы сделать работу:

import threading

def download_comic(link):
print("Downloading: {}".format(link))
move_to_dir(DEFAULT_DIR_NAME)
url = grab_image_src_url(link)
download_image(url)

...

def main():
...
for link in generate_comic_link(found_comics, n_of_comics):
thread = threading.Thread(target=download_comic, args=(link,))
thread.start()
thread.join()
# Join the last thread to make sure all comics have been
# downloaded before printing the time difference
...

Рерайт

Сложив все вместе (я изменил имена, перераспределены некоторые функции и добавлены комментарии, объясняющие некоторые изменения):

import time
import os
import sys
import re
import threading

# PEP-8 recommends a blank line in between
# stdlib imports and third-party imports.

import requests
# Importing `requests` *and* `get` from `requests` is confusing
from bs4 import BeautifulSoup as bs

DEFAULT_DIR_NAME = "poorly_created_folder"
COMICS_DIRECTORY = os.path.join(os.getcwd(), DEFAULT_DIR_NAME)
LOGO = """
a Python comic(al) scraper for poorlydwarnlines.com
__
.-----.-----.-----.----.| |.--.--.
| _ | _ | _ | _|| || | |
| __|_____|_____|__| |__||___ |
|__| |_____|
__ __ __
.--.--.--.----.|__| |_| |_.-----.-----.
| | | | _|| | _| _| -__| |
|________|__| |__|____|____|_____|__|__|

.-----.----.----.---.-.-----.-----.----.
|__ --| __| _| _ | _ | -__| _|
|_____|____|__| |___._| __|_____|__|
|__|
version: 0.2 | author: baduker | https://github.com/baduker
"""
ARCHIVE_URL = "http://www.poorlydrawnlines.com/archive/"
COMIC_PATTERN = re.compile(r"http://www.poorlydrawnlines.com/comic/.+")

def download_comics_menu(comics_found):
print("\nThe scraper has found {} comics.".format(len(comics_found)))
print("How many comics do you want to download?")
print("Type 0 to exit.")

while True:
try:
comics_to_download = int(input(">> "))
except ValueError:
print("Error: expected a number. Try again.")
continue
if comics_to_download > len(comics_found) or comics_to_download < 0:
print("Error: incorrect number of comics to download. Try again.")
continue
elif comics_to_download == 0:
sys.exit()
return comics_to_download

def grab_image_src_url(url):
response = requests.get(url)
soup = bs(response.text, "html.parser")
for i in soup.find_all("p"):
for img in i.find_all("img", src=True):
return img["src"]

def download_and_write_image(url):
# `download_and_write_image` is a bit more accurate, since
# it also writes the image to the disk
file_name = url.split("/")[-1]
with open(os.path.join(COMICS_DIRECTORY, file_name), "wb") as file:
response = requests.get(url)
# Replced `get` with `requests.get`
file.write(response.content)

def fetch_comics_from_archive():
# Merged `fetch_comic_archive` and `filter_comic_archive`
# into a single function
response = requests.get(ARCHIVE_URL)
soup = bs(response.text, "html.parser")
comics = [url.get("href") for url in soup.find_all("a")]
return [url for url in comics if COMIC_PATTERN.match(url)]

def download_comic(url):
print("Downloading: {}".format(url))
url = grab_image_src_url(url)
download_and_write_image(url)

def main():
print(LOGO)

comics = fetch_comics_from_archive()
comics_to_download = download_comics_menu(comics)

try:
os.mkdir(DEFAULT_DIR_NAME)
except OSError as exc:
sys.exit("Failed to create directory (errno {})".format(exc.errno))

start = time.time()
for url in comics[:comics_to_download]:
thread = threading.Thread(target=download_comic, args=(url,))
thread.start()
thread.join()

end = time.time()
print("Successfully downloaded {} comics in {:.2f} seconds.".format(
comics_to_download, end - start)
)

if __name__ == "__main__":
main()

Результаты


  • Непоточный:

    The scraper has found 957 comics.
    How many comics do you want to download?
    Type 0 to exit.
    >> 6
    Downloading: http://www.poorlydrawnlines.com/comic/new-phone/
    Downloading: http://www.poorlydrawnlines.com/comic/new-things/
    Downloading: http://www.poorlydrawnlines.com/comic/return-to-nature/
    Downloading: http://www.poorlydrawnlines.com/comic/phone/
    Downloading: http://www.poorlydrawnlines.com/comic/stars/
    Downloading: http://www.poorlydrawnlines.com/comic/big-dreams/
    Successfully downloaded 6 comics in 37.13 seconds.

  • Резьбовые:

    The scraper has found 957 comics.
    How many comics do you want to download?
    Type 0 to exit.
    >> 6
    Downloading: http://www.poorlydrawnlines.com/comic/new-phone/
    Downloading: http://www.poorlydrawnlines.com/comic/new-things/
    Downloading: http://www.poorlydrawnlines.com/comic/return-to-nature/
    Downloading: http://www.poorlydrawnlines.com/comic/phone/
    Downloading: http://www.poorlydrawnlines.com/comic/stars/
    Downloading: http://www.poorlydrawnlines.com/comic/big-dreams/
    Successfully downloaded 6 comics in 7.07 seconds.


Как вариант: использование concurrent.futures.ThreadPoolExecutor

Если вы запустите код переписан, вы можете заметить, программа занимает пару секунд, чтобы выключить после (якобы) после загрузки всех изображений. Это происходит потому, что последняя ниточка начала не обязательно заканчивается последний (вот и весь смысл темы, в конце концов!). Чтобы избежать этого, и чтобы избавиться от некоторых из шаблонного кода, мы можем использовать ThreadPoolExecutor.map() и ThreadPoolExecutor.shutdown().

Я создал в GitHub суть здесь , который использует ThreadPoolExecutor вместе с requests.Session, который использует базовые TCP-соединение, что может привести к еще более высокой производительности.

12
ответ дан 17 марта 2018 в 12:03 Источник Поделиться

Я добавила несколько пунктов к тому, что @Coal_ писал в своем отличный ответ.


  • Вы можете использовать lxml вместо html.parser как это быстрее (указано в документации). Менять его везде, где вы использовали html.parser.

  • Несколько for петли в grab_image_src_url кажется излишним. Вы можете использовать следующие:

    def grab_image_src_url(url):
    response = requests.get(url)
    soup = bs(response.text, "lxml")
    return soup.find("div", class_="post").find("img")["src"]

  • В fetch_comics_from_archive() функция может быть дополнительно оптимизирован. В настоящее время, он использует один генератор списка найти все URL-адреса, и другой, чтобы отфильтровать их с помощью regex. Сделать это можно в один осмысление список, используя CSS-селектор с частичным совпадением, без использования регулярных выражений. Вы можете изменить функцию:

    def fetch_comics_from_archive():
    response = requests.get(ARCHIVE_URL)
    soup = bs(response.text, "lxml")
    return [url.get("href") for url in soup.select('a[href^="http://www.poorlydrawnlines.com/archive/"]')]

    Или, то же самое можно сделать без использования частичного совпадения (^). Все необходимые ссылки находятся внутри div тег с class="content page". Итак, CSS-селектору будет div[class="content page"] a. Или, даже более короткий селектор как ".content.page a" будет работать, так как нет других тегов class="content page".

    def fetch_comics_from_archive():
    response = requests.get(ARCHIVE_URL)
    soup = bs(response.text, "lxml")
    return [url["href"] for url in soup.select(".content.page a")]

6
ответ дан 6 апреля 2018 в 06:04 Источник Поделиться