Новогодние праздники — прекрасный повод попрокрастинировать в уютной домашней обстановке и вспомнить дорогие сердцу мемы из 2k17, уходящие навсегда, как совесть Electronic Arts.
Однако даже обильно сдобренная салатами совесть иногда просыпалась и требовала хоть немного взять себя в руки и заняться полезной деятельностью. Поэтому мы совместили приятное с полезным и на примере любимых мемов посмотрели, как можно спарсить себе небольшую базу
данных, попутно обходя всевозможные блокировки, ловушки и ограничения, расставленные сервером на нашем пути. Всех заинтересованных любезно приглашаем под кат.
Машинное обучение, эконометрика, статистика и многие другие науки о данных занимаются поиском закономерностей. Каждый день доблестные аналитики-инквизиторы пытают природу разными методами и вытаскивают из неё сведенья о том, как именно устроен великий процесс порождения данных, создавший нашу вселенную. Испанские инквизиторы в своей повседневной деятельности использовали непосредственно физическое тело своей жертвы. Природа же вездесуща и не имеет однозначного физического облика. Из-за этого профессия современного инквизитора имеет странную специфику — пытки природы происходят через анализ данных, которые надо откуда-то брать. Обычно данные инквизиторам приносят мирные собиратели. Эта статейка призвана немного приоткрыть завесу тайны насчет того, откуда данные берутся и как их можно немножечко пособирать.
Нашим девизом стала знаменитая фраза капитана Джека Воробья: "Бери всё и не отдавай ничего". Иногда для сбора мемов придется использовать довольно бандитские методы. Тем не менее, мы будем оставаться мирными собирателями данных, и ни в коей мере не будем становиться бандитами. Брать мемы мы будем из главного мемохранилища.
1. Вламываемся в мемохранилище
1.1. Что мы хотим получить
Итак, мы хотим распарсить knowyourmeme.com и получить кучу разных переменных:
- Name – название мема,
- Origin_year – год его создания,
- Views – число просмотров,
- About – текстовое описание мема,
- и многие другие
Более того, мы хотим сделать это без вот этого всего:
После скачивания и чистки данных от мусора можно будет заняться строительством моделей. Например, попытаться предсказать популярность мема по его параметрам. Но это все позже, а сейчас познакомимся с парой определений:
- Парсер — это скрипт, который грабит информацию с сайта
- Краулер — это часть парсера, которая бродит по ссылкам
- Краулинг — это переход по страницам и ссылкам
- Скрапинг — это сбор данных со страниц
- Парсинг — это сразу и краулинг и скрапинг!
1.2. Что такое HTML
HTML (HyperText Markup Language) — это такой же язык разметки как Markdown или LaTeX. Он является стандартным для написания различных сайтов. Команды в таком языке называются тегами. Если открыть абсолютно любой сайт, нажать на правую кнопку мышки, а после нажать View page source
, то перед вами предстанет HTML скелет этого сайта.
Можно увидеть, что HTML-страница это ни что иное как набор вложенных тегов. Можно заметить, например, следующие теги:
<title>
– заголовок страницы<h1>…<h6>
– заголовки разных уровней<p>
– абзац (paragraph)<div>
– выделения фрагмента документа с целью изменения вида содержимого<table>
– прорисовка таблицы<tr>
– разделитель для строк в таблице<td>
– разделитель для столбцов в таблице<b>
– устанавливает жирное начертание шрифта
Обычно команда <...>
открывает тег, а </...>
закрывает его. Все, что находится между этими двумя командами, подчиняется правилу, которое диктует тег. Например, все, что находится между <p>
и </p>
— это отдельный абзац.
Теги образуют своеобразное дерево с корнем в теге <html>
и разбивают страницу на разные логические кусочки. У каждого тега могут быть свои потомки (дети) — те теги, которые вложены в него, и свои родители.
Например, HTML-древо страницы может выглядеть вот так:
<html>
<head> Заголовок </head>
<body>
<div>
Первый кусок текста со своими свойствами
</div>
<div>
Второй кусок текста
<b>
Третий, жирный кусок
</b>
</div>
Четвёртый кусок текста
</body>
</html>
Можно работать с этим html как с текстом, а можно как с деревом. Обход этого дерева и есть парсинг веб-страницы. Мы всего лишь будем находить нужные нам узлы среди всего этого разнообразия и забирать из них информацию!
Вручную обходить эти деревья не очень приятно, поэтому есть специальные языки для обхода деревьев.
- CSS-селектор (это когда мы ищем элемент страницы по паре ключ, значение)
- XPath (это когда мы прописываем путь по дереву вот так: /html/body/div[1]/div[3]/div/div[2]/div)
- Всякие разные библиотеки для всяких разных языков, например, BeautifulSoup для питона. Именно эту библиотеку мы и будем использовать.
1.3. Наш первый запрос
Доступ к веб-станицам позволяет получать модуль requests
. Подгрузим его. За компанию подгрузим ещё парочку дельных пакетов.
import requests # Библиотека для отправки запросов
import numpy as np # Библиотека для матриц, векторов и линала
import pandas as pd # Библиотека для табличек
import time # Библиотека для тайм-менеджмента
Для наших благородных исследовательских целей нужно собрать данные по каждому мему с соответствующей ему страницы. Но для начала нужно получить адреса этих страниц. Поэтому открываем основную страницу со всеми выложенными мемами. Выглядит она следующим образом:
Отсюда мы и будем тащить ссылки на каждый из перечисленных мемов. Сохраним в переменную page_link
адрес основной страницы и откроем её при помощи библиотеки requests
.
page_link = 'http://knowyourmeme.com/memes/all/page/1'
response = requests.get(page_link)
response
Out: <Response [403]>
А вот и первая проблема! Обращаемся к главному источнику знаний и выясняем, что 403-я ошибка выдается сервером, если он доступен и способен обрабатывать запросы, но по некоторым личным причинам отказывается это делать.
Попробуем выяснить, почему. Для этого проверим, как выглядел финальный запрос, отправленный нами на сервер, а конкретнее — как выглядел наш User-Agent в глазах сервера.
for key, value in response.request.headers.items():
print(key+": "+value)
Out:
User-Agent: python-requests/2.14.2
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Похоже, мы недвусмысленно дали понять серверу, что мы сидим на питоне и используем библиотеку requests под версией 2.14.2. Скорее всего, это вызвало у сервера некоторые подозрения относительно наших благих намерений и он решил нас безжалостно отвергнуть. Для сравнения, можно посмотреть, как выглядят request-headers у здорового человека:
Очевидно, что нашему скромному запросу не тягаться с таким обилием мета-информации, которое передается при запросе из обычного браузера. К счастью, никто нам не мешает притвориться человечными и пустить пыль в глаза сервера при помощи генерации фейкового юзер-агента. Библиотек, которые справляются с такой задачей, существует очень и очень много, лично мне больше всего нравится fake-useragent
. При вызове метода из различных кусочков будет генерироваться рандомное сочетание операционной системы, спецификаций и версии браузера, которые можно передавать в запрос:
# подгрузим один из методов этой библиотеки
from fake_useragent import UserAgent
UserAgent().chrome
Out: 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2224.3 Safari/537.36'
Попробуем прогнать наш запрос еще раз, уже со сгенерированным агентом
response = requests.get(page_link, headers={'User-Agent': UserAgent().chrome})
response
Out: <Response [200]>
Замечательно, наша небольшая маскировка сработала и обманутый сервер покорно выдал благословенный 200 ответ — соединение установлено и данные получены, всё чудесно! Посмотрим, что же все-таки мы получили.
html = response.content
html[:1000]
Out: b'<!DOCTYPE html>\n<html xmlns:fb=\'http://www.facebook.com/2008/fbml\' xmlns=\'http://www.w3.org/1999/xhtml\'>\n<head>\n<meta content=\'text/html; charset=utf-8\' http-equiv=\'Content-Type\'>\n<script type="text/javascript">window.NREUM||(NREUM={});NREUM.info={"beacon":"bam.nr-data.net","errorBeacon":"bam.nr-data.net","licenseKey":"c1a6d52f38","applicationID":"31165848","transactionName":"dFdfRUpeWglTQB8GDUNKWFRLHlcJWg==","queueTime":0,"applicationTime":59,"agent":""}</script>\n<script type="text/javascript">window.NREUM||(NREUM={}),__nr_require=function(e,t,n){function r(n){if(!t[n]){var o=t[n]={exports:{}};e[n][0].call(o.exports,function(t){var o=e[n][1][t];return r(o||t)},o,o.exports)}return t[n].exports}if("function"==typeof __nr_require)return __nr_require;for(var o=0;o<n.length;o++)r(n[o]);return r}({1:[function(e,t,n){function r(){}function o(e,t,n){return function(){return i(e,[f.now()].concat(u(arguments)),t?null:this,n),t?void 0:this}}var i=e("handle"),a=e(2),u=e(3),c=e("ee").get("tracer")'
Выглядит неудобоваримо, как насчет сварить из этого дела что-то покрасивее? Например, прекрасный суп.
1.4. Прекрасный суп
Пакет bs4, a.k.a BeautifulSoup (тут есть гиперссылка на лучшего друга человека — документацию) был назван в честь стишка про прекрасный суп из Алисы в стране чудес.
Прекрасный суп — это совершенно волшебная библиотека, которая из сырого и необработанного HTML кода страницы выдаст вам структурированный массив данных, по которому очень удобно искать необходимые теги, классы, атрибуты, тексты и прочие элементы веб страниц.
Пакет под названиемBeautifulSoup
— скорее всего, не то, что нам нужно. Это третья версия (Beautiful Soup 3), а мы будем использовать четвертую. Нужно будет установить пакетbeautifulsoup4
. Чтобы было совсем весело, при импорте нужно указывать другое название пакета —bs4
, а импортировать функцию под названиемBeautifulSoup
. В общем, сначала легко запутаться, но эти трудности нужно преодолеть.
С необработанным XML кодом страницы пакет также работает (XML — это исковерканый и превращённый в диалект, с помощью своих команд, HTML). Для того, чтобы пакет корректно работал с XML разметкой, придётся в довесок ко всему нашему арсеналу установить пакет xml
.
from bs4 import BeautifulSoup
Передадим функции BeautifulSoup
текст веб-страницы, которую мы недавно получили.
soup = BeautifulSoup(html,'html.parser') # В опции также можно указать lxml,
# если предварительно установить одноименный пакет
Получим что-то вот такое:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:fb="http://www.facebook.com/2008/fbml">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
<script type="text/javascript">window.NREUM||(NREUM={});NREUM.info={"beacon":"bam.nr-data.net","errorBeacon":"bam.nr-data.net","licenseKey":"c1a6d52f38","applicationID":"31165848","transactionName":"dFdfRUpeWglTQB8GDUNKWFRLHkUNWUU=","queueTime":0,"applicationTime":24,"agent":""}</script>
<script type="text/javascript">window.NREUM||(NREUM={}),__nr_require=function(e,n,t){function r(t){if(!n[t]){var o=n[t]={exports:{}};e[t][0].call(o.exports,function(n){var o=e[t][1][n];return r(o||n)},o,o.exports)}return n[t].exports}if("function"==typeof __nr_require)return __nr_require;for(var o=0;o<t.length;o++)r(t[o]);return r}({1:[function(e,n,t){function r(){}function o(e,n,t){return function(){return i(e,[c.now()].concat(u(arguments)),n?null:this,t),n?void 0:this}}var i=e("handle"),a=e(2),u=e(3),f=e("ee").get("tracer"),c=e("loader"),s=NREUM;"undefined"==typeof window.newrelic&&(newrelic=s);var p=
Стало намного лучше, не правда ли? Что же лежит в переменной soup
? Невнимательный пользователь, скорее всего, скажет, что ничего вообще не изменилось. Тем не менее, это не так. Теперь мы можем свободно бродить по HTML-дереву страницы, искать детей, родителей и вытаскивать их!
Например, можно бродить по вершинам, указывая путь из тегов.
soup.html.head.title
Out: <title>All Entries | Know Your Meme</title>
Можно вытащить из того места, куда мы забрели, текст с помощью метода text
.
soup.html.head.title.text
Out: 'All Entries | Know Your Meme'
Более того, зная адрес элемента, мы сразу можем найти его. Например, можно сделать это по классу. Следующая команда должна найти элемент, который лежит внутри тега a
и имеет класс photo
.
obj = soup.find('a', attrs = {'class':'photo'})
obj
Out: <a class="photo left" href="/memes/nu-male-smile" target="_self"><img alt='The "Nu-Male Smile" Is Duck Face for Men' data-src="http://i0.kym-cdn.com/featured_items/icons/wide/000/007/585/7a2.jpg" height="112" src="http://a.kym-cdn.com/assets/blank-b3f96f160b75b1b49b426754ba188fe8.gif" title='The "Nu-Male Smile" Is Duck Face for Men' width="198"/> <div class="info abs"> <div class="c"> The "Nu-Male Smile" Is Duck Face for Men </div> </div> </a>
Однако, вопреки нашим ожиданиям, вытащенный объект имеет класс "photo left"
. Оказывается, BeautifulSoup4
расценивает аттрибуты class
как набор отдельных значений, поэтому "photo left"
для библиотеки равносильно ["photo", "left"]
, а указанное нами значение этого класса "photo"
входит в этот список. Чтобы избежать такой неприятной ситуации и проходов по ненужным нам ссылкам, придется воспользоваться собственной функцией и задать строгое соответствие:
obj = soup.find(lambda tag: tag.name == 'a' and tag.get('class') == ['photo'])
obj
Out: <a class="photo" href="/memes/people/mf-doom"><img alt="MF DOOM" data-src="http://i0.kym-cdn.com/entries/icons/medium/000/025/149/1489698959244.jpg" src="http://a.kym-cdn.com/assets/blank-b3f96f160b75b1b49b426754ba188fe8.gif" title="MF DOOM"/> <div class="entry-labels"> <span class="label label-submission"> Submission </span> <span class="label" style="background: #d32f2e; color: white;">Person</span> </div> </a>
Полученный после поиска объект также обладает структурой bs4. Поэтому можно продолжить искать нужные нам объекты уже в нём! Вытащим ссылку на этот мем. Сделать это можно по атрибуту href
, в котором лежит наша ссылка.
obj.attrs['href']
Out: '/memes/people/mf-doom'
Обратите внимание, что после всех этих безумных преобразований у данных поменялся тип. Теперь они str
. Это означет, что с ними можно работать как с текстом и пускать в ход для отсеивания лишней информации регулярные выражения.
print("Тип данных до вытаскивания ссылки:", type(obj))
print("Тип данных после вытаскивания ссылки:", type(obj.attrs['href']))
Out:
Тип данных до вытаскивания ссылки: <class 'bs4.element.Tag'>
Тип данных после вытаскивания ссылки: <class 'str'>
Если несколько элементов на странице обладают указанным адресом, то метод find
вернёт только самый первый. Чтобы найти все элементы с таким адресом, нужно использовать метод findAll
, и на выход будет выдан список. Таким образом, мы можем получить одним поиском сразу все объекты, содержащие ссылки на страницы с мемами.
meme_links = soup.findAll(lambda tag: tag.name == 'a' and tag.get('class') == ['photo'])
meme_links[:3]
Out: [<a class="photo" href="/memes/people/mf-doom"><img alt="MF DOOM" data-src="http://i0.kym-cdn.com/entries/icons/medium/000/025/149/1489698959244.jpg" src="http://a.kym-cdn.com/assets/blank-b3f96f160b75b1b49b426754ba188fe8.gif" title="MF DOOM"/> <div class="entry-labels"> <span class="label label-submission"> Submission </span> <span class="label" style="background: #d32f2e; color: white;">Person</span> </div> </a>,
<a class="photo" href="/memes/here-lies-beavis-he-never-scored"><img alt="Here Lies Beavis. He Never Scored." data-src="http://i0.kym-cdn.com/entries/icons/medium/000/025/148/maxresdefault.jpg" src="http://a.kym-cdn.com/assets/blank-b3f96f160b75b1b49b426754ba188fe8.gif" title="Here Lies Beavis. He Never Scored."/> <div class="entry-labels"> <span class="label label-submission"> Submission </span> </div> </a>,
<a class="photo" href="/memes/people/vanossgaming"><img alt="VanossGaming" data-src="http://i0.kym-cdn.com/entries/icons/medium/000/025/147/Evan-Fong-e1501621844732.jpg" src="http://a.kym-cdn.com/assets/blank-b3f96f160b75b1b49b426754ba188fe8.gif" title="VanossGaming"/> <div class="entry-labels"> <span class="label label-submission"> Submission </span> <span class="label" style="background: #d32f2e; color: white;">Person</span> </div> </a>]
Осталось очистить полученный список от мусора:
meme_links = [link.attrs['href'] for link in meme_links]
meme_links[:10]
Out: ['/memes/people/mf-doom',
'/memes/here-lies-beavis-he-never-scored',
'/memes/people/vanossgaming',
'/memes/stream-sniping',
'/memes/kids-describe-god-to-an-illustrator',
'/memes/bad-teacher',
'/memes/people/adam-the-creator',
'/memes/but-can-you-do-this',
'/memes/people/ken-ashcorp',
'/memes/heartbroken-cowboy']
Готово, получили ровно 16 ссылок по числу мемов на одной странице поиска.
Хорошо, то, что можно искать элемент по его адресу, конечно же, круто, но откуда взять этот адрес? Можно установить для своего браузера какую-нибудь утилиту, позволяющую вытаскивать со страницы нужные теги, например, selectorgadget.
Тем не менее, этот путь не подходит для истинного самурая. Для последователей бусидо есть другой способ — искать теги для каждого нужного нам элемента вручную. Для этого придётся жать правой кнопкой мышки по окну браузера и тыкать кнопку Inspect. После всех этих манипуляций браузер будет выглядеть как-то вот так:
Выскочивший кусок html, в котором находится адрес выбранного вами объекта, можно смело копировать в код и наслаждаться своей брутальностью.
Остался последний момент. Когда мы скачаем все мемы с текущей страницы, нам нужно будет каким-то образом забраться на соседнюю. На сайте это можно делать просто пролистывая страницу с мемами вниз, javascript-функции подтянут новые мемы на текущее окно, но сейчас трогать эти функции не хочется.
Обычно, все параметры, которые мы устанавливаем на сайте для поиска, отображаются на структуре хрефа. Мемы не являются исключением. Если мы хотим получить первую порцию мемов, мы должны будем обратиться к сайту по ссылке
http://knowyourmeme.com/memes/all/page/1
Если мы захотим получить вторую поцию с шестнадцатью мемами, нам придётся немного видоизменить ссылку, а именно заменить номер страницы на 2.
http://knowyourmeme.com/memes/all/page/2
Таким незатейливым образом мы сможем пройтись по всем страницам и ограбить мемохранилище. Наконец, обернем в красивую функцию все-все манипуляции, проделанные выше:
def getPageLinks(page_number):
"""
Возвращает список ссылок на мемы, полученный с текущей страницы
page_number: int/string
номер страницы для парсинга
"""
# составляем ссылку на страницу поиска
page_link = 'http://knowyourmeme.com/memes/all/page/{}'.format(page_number)
# запрашиваем данные по ней
response = requests.get(page_link, headers={'User-Agent': UserAgent().chrome})
if not response.ok:
# если сервер нам отказал, вернем пустой лист для текущей страницы
return []
# получаем содержимое страницы и переводим в суп
html = response.content
soup = BeautifulSoup(html,'html.parser')
# наконец, ищем ссылки на мемы и очищаем их от ненужных тэгов
meme_links = soup.findAll(lambda tag: tag.name == 'a' and tag.get('class') == ['photo'])
meme_links = ['http://knowyourmeme.com' + link.attrs['href'] for link in meme_links]
return meme_links
Протестируем функцию и убедимся, что всё хорошо
meme_links = getPageLinks(1)
meme_links[:2]
Out: ['http://knowyourmeme.com/memes/people/mf-doom',
'http://knowyourmeme.com/memes/here-lies-beavis-he-never-scored']
Отлично, функция работает и теперь мы теоретически можем достать ссылки на все 17171 мем, для чего нам придется пройтись по 17171/16 ~ 1074 страницам. Прежде чем расстраивать сервер таким количеством запросов, посмотрим, как доставать всю необходимую информацию о конкретном меме.
1.5 Финальная подготовка к грабежу
По аналогии со ссылками можно вытащить что угодно. Для этого надо сделать несколько шагов:
- Открываем страничку с мемом
- Находим любым способом тег для нужной нам информации
- Пихаем всё это в прекрасный суп
- ......
- Profit
Для закрепления информации в голове любознательного читателя, вытащим число просмотров мема.
А в качестве примера возьмем самый популярный на этом сайте мем — Doge, набравший более 12 миллионов просмотров по состоянию на 1 января 2018 года.
Сама страница, с которой мы будем доставать дорогую нашему исследовательскому сердцу информацию выглядит следуюшим образом:
Как и прежде, для начала сохраним ссылку на страницу в переменную и вытащим по ней контент.
meme_page = 'http://knowyourmeme.com/memes/doge'
response = requests.get(meme_page, headers={'User-Agent': UserAgent().chrome})
html = response.content
soup = BeautifulSoup(html,'html.parser')
Посмотрим, как можно вытащить статистику просмотров, комментариев, а также числа загруженных видео и фото, связанных с нашим мемов. Всё это добро хранится справа вверху под тэгами "dd"
и с классами "views"
, "videos"
, "photos"
и "comments"
views = soup.find('dd', attrs={'class':'views'})
print(views)
Out:
<dd class="views" title="12,318,185 Views">
<a href="/memes/doge" rel="nofollow">12,318,185</a>
</dd>
Очистим от тэгов и пунктуации
views = views.find('a').text
views = int(views.replace(',', ''))
print(views)
Out:
12318185
Снова запихнём всё это в небольшую функцию.
def getStats(soup, stats):
"""
Возвращает очищенное число просмотров/коментариев/...
soup: объект bs4.BeautifulSoup
суп текущей страницы
stats: string
views/videos/photos/comments
"""
obj = soup.find('dd', attrs={'class':stats})
obj = obj.find('a').text
obj = int(obj.replace(',', ''))
return obj
Всё готово!
views = getStats(soup, stats='views')
videos = getStats(soup, stats='videos')
photos = getStats(soup, stats='photos')
comments = getStats(soup, stats='comments')
print("Просмотры: {}\nВидео: {}\nФото: {}\nКомментарии: {}".format(views, videos, photos, comments))
Out:
Просмотры: 12318185
Видео: 59
Фото: 1645
Комментарии: 918
Еще из интересного и исследовательского — достанем дату и время добавления мема. Если посмотреть на страницу в браузере, можно подумать, что максимум информации, который мы можем вытащить — это число лет, прошедших с момента публикации — Added 4 years ago by NovaXP
. Однако мы так просто сдаваться не будем, полезем в кишки html и откопаем там кусок, ответственный за эту надпись:
Ага! Вот и подробности по дате добавления, с точностью до минуты. Элементарно
date = soup.find('abbr', attrs={'class':'timeago'}).attrs['title']
date
Out: '2017-12-31T01:59:14-05:00'
На самом деле, парсеры — дело непредсказуемое. Часто страницы, которые мы парсим, имеют очень неоднородну структуру. Например, если мы парсим мемы, на части страниц может быть указано описание, а на части нет. Как только код впервые натыкается на отсутствие описания, он выдаёт ошибку и останавливается. Чтобы нормально собрать все данные, приходится прописывать исключения. Вроде бы, хранилище мемов хорошо оборудовано и никаких внештатных ситуаций происходить не должно.
Тем не менее, очень не хочется проснуться утром и увидеть, что код сделал 20 итераций, нарвался на ошибку и отрубился. Чтобы такого не произошло, можно, например, использовать конструкцию try - except
и просто обрабатывать неугодные нам ошибки. Про исключения можно почитать на просторах интернета. В нашем же случае до ошибки можно и не доводить, а предварительно проверять, есть ли необходимый элемент на странице или нет при помощи обычного if - else
, и уже после этого пытаться его распарсить.
Например, мы хотим вытащить статус мема, для этого найдем окружающие его тэги:
properties = soup.find('aside', attrs={'class':'left'})
meme_status = properties.find("dd")
meme_status
Out:
<dd>
Confirmed
</dd>
Дальше нужно вытащить из тэгов текст, а после обрубить все лишние пробелы.
meme_status.text.strip()
Out: 'Confirmed'
Однако, если неожиданно выяснится, что у мема нет статуса, метод find
вернёт пустоту. Метод text
, в свою очередь, не сможет найти в тэгах текст и выдаст ошибку. Чтобы обезопасить себя от таких пустот, можно прописать исключение или if - else
. Так как в текущем меме статус все-таки есть, нарочно зададим его как пустой объект, чтобы проверить, что ошибка поймается в обоих случаях
# Делай раз! Ищем статус мема, но не находим его
meme_status = None
# Делай два! Пытаемся вытащить его...
# ... с исключениями
try:
print(meme_status.text.strip())
# Ежели возникает ошибка, статус не найден, выдаём пустоту.
except:
print("Exception")
# ... с проверкой на пустой элемент
if meme_status:
print(meme_status.text.strip())
else:
print("Empty")
Out:
Exception
Empty
Такой код позволяет обезопасить себя от ошибок. В данном случае, мы можем переписать всю конструкцию с if - else
в виде одной удобной строки. Эта строка проверит, полон ли респонса meme_status
и ежели нет, то выдаст пустоту.
# снова найдем настоящий статус
properties = soup.find('aside', attrs={'class':'left'})
meme_status = properties.find("dd")
meme_status = "" if not meme_status else meme_status.text.strip()
print(meme_status)
Out: Confirmed
По аналогии можно вытащить всю остальную информацию со страницы, для чего вновь напишем функцию
def getProperties(soup):
"""
Возвращает список (tuple) с названием, статусом, типом,
годом и местом происхождения и тэгами
soup: объект bs4.BeautifulSoup
суп текущей страницы
"""
# название - идёт с самым большим заголовком h1, легко найти
meme_name = soup.find('section', attrs={'class':'info'}).find('h1').text.strip()
# достаём все данные справа от картинки
properties = soup.find('aside', attrs={'class':'left'})
# статус идет первым - можно не уточнять класс
meme_status = properties.find("dd")
# oneliner, заменяющий try-except: если тэга нет в properties, вернётся объект NoneType,
# у которого аттрибут text отсутствует, и в этом случае он заменится на пустую строку
meme_status = "" if not meme_status else meme_status.text.strip()
# тип мема - обладает уникальным классом
meme_type = properties.find('a', attrs={'class':'entry-type-link'})
meme_type = "" if not meme_type else meme_type.text
# год происхождения первоисточника можно найти после заголовка Year,
# находим заголовок, определяем родителя и ищем следущего ребенка - наш раздел
meme_origin_year = properties.find(text='\nYear\n')
meme_origin_year = "" if not meme_origin_year else meme_origin_year.parent.find_next()
meme_origin_year = meme_origin_year.text.strip()
# сам первоисточник
meme_origin_place = properties.find('dd', attrs={'class':'entry_origin_link'})
meme_origin_place = "" if not meme_origin_place else meme_origin_place.text.strip()
# тэги, связанные с мемом
meme_tags = properties.find('dl', attrs={'id':'entry_tags'}).find('dd')
meme_tags = "" if not meme_tags else meme_tags.text.strip()
return meme_name, meme_status, meme_type, meme_origin_year, meme_origin_place, meme_tags
getProperties(soup)
Out:
('Doge',
'Confirmed',
'Animal',
'2013',
'Tumblr',
'animal, dog, shiba inu, shibe, such doge, super shibe, japanese, super, tumblr, much, very, many, comic sans, photoshop meme, such, shiba, shibe doge, doges, dogges, reddit, comic sans ms, tumblr meme, hacked, bitcoin, dogecoin, shitposting, stare, canine')
Свойства мема собрали. Теперь собираем по аналогии его текстовое описание.
def getText(soup):
"""
Возвращает текстовые описания мема
soup: объект bs4.BeautifulSoup
суп текущей страницы
"""
# достаём все тексты под картинкой
body = soup.find('section', attrs={'class':'bodycopy'})
# раздел about (если он есть), должен идти первым, берем его без уточнения класса
meme_about = body.find('p')
meme_about = "" if not meme_about else meme_about.text
# раздел origin можно найти после заголовка Origin или History,
# находим заголовок, определяем родителя и ищем следущего ребенка - наш раздел
meme_origin = body.find(text='Origin') or body.find(text='History')
meme_origin = "" if not meme_origin else meme_origin.parent.find_next().text
# весь остальной текст (если он есть) можно запихнуть в одно текстовое поле
if body.text:
other_text = body.text.strip().split('\n')[4:]
other_text = " ".join(other_text).strip()
else:
other_text = ""
return meme_about, meme_origin, other_text
meme_about, meme_origin, other_text = getText(soup)
print("О чем мем:\n{}\n\nПроисхождение:\n{}\n\nОстальной текст:\n{}...\n" .format(meme_about, meme_origin, other_text[:200]))
Out:
О чем мем:
Doge is a slang term for “dog” that is primarily associated with pictures of Shiba Inus (nicknamed “Shibe”) and internal monologue captions on Tumblr. These photos may be photoshopped to change the dog’s face or captioned with interior monologues in Comic Sans font.
Происхождение:
The use of the misspelled word “doge” to refer to a dog dates back to June 24th, 2005, when it was mentioned in an episode of Homestar Runner’s puppet show. In the episode titled “Biz Cas Fri 1”[2], Homestar calls Strong Bad his “d-o-g-e” while trying to distract him from his work.
Остальной текст:
Identity On February 23rd, 2010, Japanese kindergarten teacher Atsuko Sato posted several photos of her rescue-adopted Shiba Inu dog Kabosu to her personal blog.[38] Among the photos included a peculi...
Наконец, создадим функцию, возвращающую всю информацию по текущему мему
def getMemeData(meme_page):
"""
Запрашивает данные по странице, возвращает обработанный словарь с данными
meme_page: string
ссылка на страницу с мемом
"""
# запрашиваем данные по ссылке
response = requests.get(meme_page, headers={'User-Agent': UserAgent().chrome})
if not response.ok:
# если сервер нам отказал, вернем статус ошибки
return response.status_code
# получаем содержимое страницы и переводим в суп
html = response.content
soup = BeautifulSoup(html,'html.parser')
# используя ранее написанные функции парсим информацию
views = getStats(soup=soup, stats='views')
videos = getStats(soup=soup, stats='videos')
photos = getStats(soup=soup, stats='photos')
comments = getStats(soup=soup, stats='comments')
# дата
date = soup.find('abbr', attrs={'class':'timeago'}).attrs['title']
# имя, статус, и т.д.
meme_name, meme_status, meme_type, meme_origin_year, meme_origin_place, meme_tags = getProperties(soup=soup)
# текстовые поля
meme_about, meme_origin, other_text = getText(soup=soup)
# составляем словарь, в котором будут хранится все полученные и обработанные данные
data_row = {"name":meme_name, "status":meme_status,
"type":meme_type, "origin_year":meme_origin_year,
"origin_place":meme_origin_place,
"date_added":date, "views":views,
"videos":videos, "photos":photos, "comments":comments, "tags":meme_tags,
"about":meme_about, "origin":meme_origin, "other_text":other_text}
return data_row
А теперь подготовим табличку, чтобы в неё записывать всё награбленные честно полученные данные, добавим в неё первую полученную строку и полюбуемся на результат
final_df = pd.DataFrame(columns=['name', 'status', 'type', 'origin_year', 'origin_place',
'date_added', 'views', 'videos', 'photos', 'comments',
'tags', 'about', 'origin', 'other_text'])
data_row = getMemeData('http://knowyourmeme.com/memes/doge')
final_df = final_df.append(data_row, ignore_index=True)
final_df
Out:
name | status | type | origin_year | ... |
---|---|---|---|---|
Doge | Confirmed | Animal | 2013 | ... |
Первый мем оказался в наших рукак. Еще раз убедимся что всё работает — пройдемся по списку из ссылок на мемы, полученных ранее в перменной meme_links
.
for meme_link in meme_links:
data_row = getMemeData(meme_link)
final_df = final_df.append(data_row, ignore_index=True)
Out:
name | status | type | origin_year | ... |
---|---|---|---|---|
Doge | Confirmed | Animal | 2013 | ... |
Charles C. Johnson | Submission | Activist | 2013 | ... |
Bat- (Prefix) | Submission | Snowclone | 2018 | ... |
The Eric Andre Show | Deadpool | TV Show | 2012 | ... |
Hopsin | Submission | Musician | 2003 | ... |
Отлично! Всё работает, мемы качаются, данные наполняются и всё было бы хорошо, если бы не одно но — количество запросов, которое нам придётся сделать, чтобы всё получить.
2. Прячемся от стражников
2.1 Когда работающий код больше не работает
Вот он! Тот самый момент абсолютного триумфа, когда код дописан и всё, что нам, мирным собирателям, остаётся — запустить наш код на одну ночку. Кажется, что через страсть мы преобрели силу. Запускаем наш код по всем страницам с мемами. На всякий случай обернём наш цикл в try-except
. Мало ли что там с этими мемами бывает.
# Немного красивых циклов. При желании пакет можно отключить и
# удалить команду tqdm_notebook из всех циклов
from tqdm import tqdm_notebook
final_df = pd.DataFrame(columns=['name', 'status', 'type', 'origin_year', 'origin_place',
'date_added', 'views', 'videos', 'photos', 'comments',
'tags', 'about', 'origin', 'other_text'])
for page_number in tqdm_notebook(range(1075), desc='Pages'):
# собрали хрефы с текущей страницы
meme_links = getPageLinks(page_number)
for meme_link in tqdm_notebook(meme_links, desc='Memes', leave=False):
# иногда с первого раза страничка не парсится
for i in range(5):
try:
# пытаемся собрать по мему немного даты
data_row = getMemeData(meme_link)
# и закидываем её в таблицу
final_df = final_df.append(data_row, ignore_index=True)
# если всё получилось - выходим из внутреннего цикла
break
except:
# Иначе, пробуем еще несколько раз, пока не закончатся попытки
print('AHTUNG! parsing once again:', meme_link)
continue
Сон был прекрасным! Солнце только-только взошло из-за горизонта, мы уже бежим за компьютер смотреть мемы и видим, что огромное число мемов не скачалось.
Конечно же, вполне естественной реакцией будет нажать на первую же ссылку, перейти в мемохранилище и увидеть, что нас забанили. Все наши реквесты остались без респонсов.
2.2 Тор — сын Одина
Иногда серверу надоедает общаться с одним и тем же человеком, делающим кучу запросов и сервер банит его. К сожалению, не только у людей запас терпения ограничен.
Приходится маскироваться. Для такой маскировки можно использовать разные способы, более того, один из них мы уже использовали, когда притворились человеком в нашем request-header
. Для текущей же задачи, когда нас вероломно заблокировали по IP, нужно искать способы помощнее, чтобы иметь возможность этот IP менять. Конечно, как вариант можно было бы использовать прокси-сервера, тогда мы бы имели в запасе некоторое количество разных IP адресов, которые можно подставлять по мере "забанивания". Однако в этом подходе есть пара проблем: первая — нужно где-то раздобыть эти прокси, вторая — а что если ограниченного числа адресов нам не хватит и нужно больше?
В таком случае лучше всего нам подойдёт Tor. Вопреки пропагандируемому мнению, Tor используется не только преступниками, педофилами и прочими нехорошими террористами. Это, мягко говоря, далеко не так, и мы, мирные собиратели данных, являемся тому подтверждением. Всем прелестям, связанным с работой Tor, можно было бы посвятить несколько больших статеек, что собственно говоря уже и сделано. Подробнее про это можно почитатать по следующим ссылкам:
Мы же ограничимся только функциональной частью, а именно без углубления в детали опишем шаги, которые нужно предпринять для того, чтобы использовать возможности Tor для обхода блокировки. Для начала полюбуемся на свой ip-адрес. Для этого сделаем get-запрос к сайту, который возвращяет наш IP
def checkIP():
ip = requests.get('http://checkip.dyndns.org').content
soup = BeautifulSoup(ip, 'html.parser')
print(soup.find('body').text)
checkIP()
Out: Current IP Address: 82.198.191.130
Заменить свой ip через Tor можно двумя путями. Простой — через браузер, а сложный — через небольшие махинации с настройками.
Скачаем браузерный tor, чтобы лёгкий путь был совсем прост. Для сложного пути в довесок к браузеру поставим tor через консоль.
- Linux — нам поможет команда
apt-get install tor
, - Mac — сделаем это в рамках brew,
brew install tor
. - Windows — нам поможет установка другой операционной системы.
2.3 Путь первый
Теперь запускаем свежескачанный браузер и оставляем его открытым. Менять ip нам поможет библиотека PySocks
. Конечно же, её нужно установить, скопировав в терминал pip3 install PySocks
.
Браузер тора по умолчанию использует порт номер 9150. В питоне при помощи библиотек socks и socket можно задать дефолтный порт для подключения. В результате текущая сессия будет использовать именно этот порт при отправке любого запроса, а значит – запросы будут посылаться из-под запущенного тора.
import socks
import socket
socks.set_default_proxy(socks.SOCKS5, "localhost", 9150)
socket.socket = socks.socksocket
Посмотрим на свой новый ip-aдрес.
checkIP()
Out: Current IP Address: 51.15.92.24
При сопутствующем желании, можно выяснить из какой страны в данный момент мы сидим в интернете.
Ого… бывший член Европейского союза!
Попробуем обратиться к мемохранилищу с нового ip-адреса.
data_row = getMemeData('http://knowyourmeme.com/memes/doge')
for key, value in data_row.items():
print(key.capitalize()+":", str(value)[:200], end='\n\n')
Out:
Name: Doge
Status: Confirmed
Type: Animal
Origin_year: 2013
Origin_place: Tumblr
Date_added: 2017-12-31T01:59:14-05:00
Views: 12318185
Videos: 59
Photos: 1645
Comments: 918
Tags: animal, dog, shiba inu, shibe, such doge, super shibe, japanese, super, tumblr, much, very, many, comic sans, photoshop meme, such, shiba, shibe doge, doges, dogges, reddit, comic sans ms, tumblr meme
About: Doge is a slang term for “dog” that is primarily associated with pictures of Shiba Inus (nicknamed “Shibe”) and internal monologue captions on Tumblr...
Бан снят. Стражники мемов ничего не заподозрили и пустили нас в сокровищницу. Чашу нашего респонса снова переполняет контент. Через силу мы обрели мощь.
При желании, можно выяснить одну занимательную вещь: при базовых настройках, Тор-браузер меняет ip каждые 10 минут. Но что делать, если сервер банит нас быстрее? Всё просто, в папке, куда был установлен Tor найдём файлик с настройками под названием torrc (на маке он лежит по адресу ~/Library/Application Support/TorBrowser-Data/torrc
, если не получится найти — добро пожаловать сюда) и отредактируем его. Добавим строки:
CircuitBuildTimeout 10
LearnCircuitBuildTimeout 0
MaxCircuitDirtiness 10
Минимально возможный период для обновления ip составляет 10 секунд. Установим туда эту цифру и попробуем поиграться.
for i in range(10):
checkIP()
time.sleep(5)
Out:
Current IP Address: 89.31.57.5
Current IP Address: 93.174.93.71
Current IP Address: 62.210.207.52
Current IP Address: 209.141.43.42
Current IP Address: 209.141.43.42
Current IP Address: 162.247.72.216
Current IP Address: 185.220.101.17
Current IP Address: 193.171.202.150
Current IP Address: 128.31.0.13
Current IP Address: 185.163.1.11
Действительно, смена ip происходит примерно раз в 10 секунд. Для наших целей по скачке мемов было достаточно и базовых настроек. Бан наступал примерно через 20 минут после начала работы кода.
- Открываем браузер;
- Запускаем кусок кода с подгрузкой библиотек;
- Запускаем цикл по мемам
- .....
- Profit
final_df = pd.DataFrame(columns=['name', 'status', 'type', 'origin_year', 'origin_place',
'date_added', 'views', 'videos', 'photos', 'comments',
'tags', 'about', 'origin', 'other_text'])
for page_number in tqdm_notebook(range(1075), desc='Pages'):
# собрали хрефы с текущей страницы
meme_links = getPageLinks(page_number)
for meme_link in tqdm_notebook(meme_links, desc='Memes', leave=False):
# иногда с первого раза страничка не парсится
for i in range(5):
try:
# пытаемся собрать по мему немного даты
data_row = getMemeData(meme_link)
# и закидываем её в таблицу
final_df = final_df.append(data_row, ignore_index=True)
# если всё получилось - выходим из внутреннего цикла
break
except:
# Иначе, пробуем еще несколько раз, пока не закончатся попытки
continue
final_df.to_csv('MEMES.csv')
Все мемы в наших руках. Можно приступать к варке фичей и моделированию. Через мощь мы познали победу. Остался только один вопрос: Что, если мы хотим менять ip каждый реквест?
2.4 Путь второй
Второй путь помогает извращаться со сменой ip как угодно. Зайдём на Github каких-то ребят и скачаем себе их скрипт под названием TorCrawler.py
. Все недостающие библиотеки, используемые в этом скрипте, придётся доставить. Поставим парням на репозиторий за их код звёздочку. Закинем этот скрипт либо в папку со своими библиотеками либо в папку к этому блокноту. Обратите внимание, что скрипт работает только с третьим питоном.
Перед использованием библиотечки, нужно подкрутить настройки в torrc файлике. На маке он будет лежать в папке /usr/local/etc/tor/
, на линуксе в папке /etc/tor/
. Проследуем по инструкции авторов скрипта.
- Генерируем в консоли пароль
tor --hash-password mypassword
- Открываем torrc файл в редакторе вроде vim, nano или atom
- Сохраняем пароль в наш torrc-файл в строку, которая начинается с HashedControlPassword
- Раскоментируем строку, начинающуюся с HashedControlPassword
- Раскоментируем (если она закоментирована) строку ControlPort 9051
- Сохраним изменения.
Запускаем tor в терминале. Линукс: service tor start
, мак: tor
.
Теперь мы готовы парсить.
from TorCrawler import TorCrawler
# Создаём свой краулер, в опциях вводим пароль
crawler = TorCrawler(ctrl_pass='mypassword')
Мы можем сделать get-запрос по аналогии с тем как делали раньше, причем ответ получим сразу в формате bs4.
meme_page = 'http://knowyourmeme.com/memes/doge'
response = crawler.get(meme_page, headers={'User-Agent': UserAgent().chrome})
type(response)
Out: bs4.BeautifulSoup
Находим внутри что-нибудь нужное.
views = response.find('dd', attrs={'class':'views'})
views
Out:
<dd class="views" title="12,318,185 Views">
<a href="/memes/doge" rel="nofollow">12,318,185</a>
</dd>
Проверим IP адрес краулера
crawler.ip
Out: '51.15.40.23'
По дефолту краулер меняет ip каждые 25 запросов. За это отвечает параметр n_requests
. При создании нового краулера, его можно настроить по собственному желанию.
crawler.n_requests
Out: 25
Более того, ip можно при желании поменять вручную.
crawler.rotate()
IP successfully rotated. New IP: 62.176.4.1
Теперь весь код для парсинга можно перевести на рельсы этого небольшого скрипта, заменив все реквесты на торовские, и менять IP адрес с каждым новым запросом. На бедный сервер внезапно обрушится огромное число запросов из всех уголков света.
Победа порвала наши оковы. Великая Сила освободила нас.
Заключение
Напоследок, хотелось бы сказать пару слов о парсинге вообще и при помощи Тора в частности. Добывать себе данные — это стильно, модно и в принципе интересно, можно получить датасеты, которых еще никто никогда не обрабатывал, сделать что-то новое, посмотреть, наконец, на все мемы мира сразу. Однако не стоит забывать, что ограничения, введенные сервером, в том числе баны, появились не просто так, а в целях защиты сайта от недоброжелательных ковровых бомбардировок запросами и DDoS-атак. К чужому труду стоит относится с уважением, и даже если у сервера никакой защиты нет, — это еще не повод неограниченно забрасывать его реквестами, особенно если это может привести к его отключению — уголовное наказание никто не отменял.
К тому же Тор — это всё-таки не первое решение, к которому стоит прибегать в случае проблем с парсингом. Иногда достаточно будет просто поставить в нужных местах time.sleep()
и заполнить request-header
информацией об операционной системе — в большинстве случаев этого должно быть достаточно для аккуратного сбора интересующей вас информации.
Успешных и безопасных вам исследований и да прибудет с вами Сила!
Авторы: Ульянкин Филипп filfonul, Сергеев Дмитрий Skolopendriy
Почиташки
- Годная книга про парсинг на английском языке.
- Неплохая инструкция о самостоятельном парсинге через Tor без использования чужих готовых классов.
- Димин репозиторий с собранным датасетом из более чем 16k мемов, а также с их исследованием и ноутбуком с парсером.
- Филин репозиторий с ещё парой хорошо расписаных блокнотов с парсерами. Репозиторий изначально делался как страничка факультатива для студентов.
- Оригинальный кодекс адепта тёмной стороны силы.
Комментарии (36)
evis_dev
15.01.2018 15:26Может, чем ходить через тор, проще (и лучше по отношению к серверу) было ограничить rps? Вполне возможно, что заблокировали не просто из-за количества запросов, а именно из-за rps.
Кстати говоря, увидев заголовок статьи, изначально ожидал встретить в статье описание обхода 429 Too Many Requests.HDDimon
15.01.2018 15:51можете указать ссылку где можно подробно ознакомиться с предложенным вами вариантом?
evis_dev
15.01.2018 16:05Disclaimer: я не питонист, так что не уверен, что нагуглил правильно.
Использовать пакет ratelimit выглядит подходящим решением.HDDimon
15.01.2018 16:15спасибо, посмотрел ссылку, по сути все сводится к уменьшению числа запросов за единицу времени
evis_dev
15.01.2018 16:21Да. Собственно, rps и расшифровывается как «requests per second».
denaspireone
18.01.2018 13:12Другое дело, если админ берет и банит любые обращения с сети Тор. Вот просто напрочь и фаерволом и на самом Nginx — и тогда только урезание rps спасает, да и то не всегда. Попробуйте использовать ipv6 посдети и списки proxy.
devalone
15.01.2018 23:58А чего там сложного? Просто добавить ограничение на количество запросов, самый простой вариант, добавить банально sleep после каждого запроса
Skolopendriy Автор
15.01.2018 17:31Безусловно, ограничение числа запросов было первым опробованным методом,
time.sleep()
наше всё, однако он совершенно не спасал. Во-первых, 429-я ни разу не всплывала, а во-вторых, даже при увеличении интервала между запросами до одной минуты, бан всё равно приходил. Из чего мы и сделали вывод, что блокировка происходит при любом подозрении на автоматические запросы, и поэтому стали искать новые способы обхода.schwarzstern
15.01.2018 18:16А что если рандомизировать время time.sleep()’а?
Skolopendriy Автор
15.01.2018 18:19Честно говоря, не пробовали, но в предыдущих проектах это не помогало. К тому же время отправки запросов и так не было распределено со строгими интервалами, некоторые страницы подгружались быстрее, некоторые медленнее, что зависело и от скорости сети, и от количества контента на странице.
flint
19.01.2018 10:36Далеко не все банят очевидным 429, к слову. Многие предпочитают, например, начинать показывать капчу. Капча — тот же бан в смысле парсинга: вебсайт защищает свой контент от показа.
KorP
15.01.2018 15:38Сам люблю писать парсеры, просто из интереса и для наблюдения. Нашёл у вас парочку полезных ссылок. Спасибо за статью!
KorP
16.01.2018 10:07Мне казалось вчера тут ещё была часть про распараллеливание процесса?! Или мне совсем уже не хорошо?
Skolopendriy Автор
16.01.2018 14:30Вчера точно не было, хотя распараллелить тут можно достаточно просто — разбить потоки по батчам страниц, например, штук по 50-100, а потом объединить в финальный датасет.
maaGames
15.01.2018 19:33+1Однозначно плюсик! Прикрутил себе прокси через Тор, добавив всего две строчки кода, я рад.
Ещё бы «fake-useragent» для сишки накопать.
Gariks
15.01.2018 20:40Для меня основной критерий — скорость обработки запроса, как у python обстоят дела со скоростью сетевых запросов? Использует ли он неблокирующие сокеты или какие другие механихзмы? Есть ли смысл смотреть в сторону python если мне допустим необходимо обойти 3 000 000 сайтов?
Skolopendriy Автор
16.01.2018 13:36Честно говоря, до промышленных систем в таких масштабах дело не доходило, но можно посмотреть в сторону вот этих ребят — scrapinghub.com. Они как раз разрабатывают платформу, позволяющую быстро, эффективно и масштабируемо собирать данные.
flint
19.01.2018 10:32Ирония заключается в том, что для промышленных скраперов язык совершенно неважен, важно написать его быстро, без ошибок и устойчивым к изменениям со стороны самого сайта.
Дело в том, что многие вебсайты — особенно популярные, которые представляют интерес с точки зрения данных, — так или иначе защищаются от скрапинга, поэтому довольно быстро вы сами себе по рукам начнете давать, ограничивая количество запросов с секунду, чтобы не получить лишний бан. Либо будете ограничивать RPS, чтобы вебсайт не накатал abuse report на ваши действия, которые быстро покажутся подозрительными.
В случае масштабных кравлов (3000000 сайтов) быстро понадобится какая-то инфраструктура, и там язык уж точно не будет ботлнеком. Посмотрите на Frontera, как раз-таки промышленный инструмент для подобной задачи.
Alexsey
15.01.2018 20:49А я даже и не думал использовать тор для автоматизированного обхода ip блокировок. Спасибо за идею!
devalone
15.01.2018 23:49Чем использовать тор, лучше было бы поставить ограничение на количество запросов в единицу времени. А ещё лучше использовать aiohttp, requests блокирующий и как следствие медленный. Также тор часто банят и IP не так много, поэтому лучше взять прокси. Если хорошо, поискать, можно в открытых источниках найти несколько тысяч(ну или купить платные). github.com/DevAlone/proxy_py в зависимости от времени суток количество проксей меняется от тысячи, почти до 9 тысяч. Если сделать пул проксей, где хранить информацию о времени последнего запроса(для ограничения количества запросов) и периодически обновлять его(удалять мёртвые прокси и добавлять свежие), то получится очень производительный парсер.
Skolopendriy Автор
16.01.2018 02:04Ограничения на число запросов в разных конфигурациях конечно же пробовали, к сожалению, не помогло. Тор активно банят, да и переключение между разными выходами — дело медленное, но все-таки выходных узлов достаточно для того, чтобы забаненные IP не повторялись, а в исследовательских целях, когда сам парсер лишь промежуточный инструмент, а не конечный продукт, можно и подождать.
Прокси — замечательный вариант, если действительно хорошо поискать или заплатить надежным поставщикам, но хотелось поэкспериментировать именно с тором и его передресацией запросов. А за ссылку большое спасибо, наверняка не раз в будущем пригодится!
user-vova
16.01.2018 14:52Зачем для простых запросов тянуть библиотеку requests, основанную на другой сторонней библиотеке urllib3, которая, в свою очередь, является надстройкой над стандартными средствами python. Чем стандартный urllib не устроил?
el777
16.01.2018 22:27+1Интересный вариант как введение в скрэпинг, что из чего состоит и как работает. Если делать всерьез, то лучше взять scrapy — она это все умеет.
Пример. Когда-то давно, в качестве развлечения написал парсер задач с веблансера. Она сам запускается в 16 покотоков и за полминуты он выдал мне тысячи задач в виде CSV.
Код простой донельзя (и его можно еще сократить):
from urllib.parse import urljoin from scrapy import Spider, Request class WeblancerParser(Spider): name = 'wbl_prj' allowed_domains = ["www.weblancer.net"] _url = 'http://www.weblancer.net/projects/' _extract_fields = ( ('title', 'h2.title > a'), ('categories', 'a.text-muted'), ('price', 'div.amount.title'), ('apps', '.text-nowrap'), ) def start_requests(self): yield self.make_requests_from_url(self._url) def parse(self, response): for row in response.css('div.cols_table.container-fluid > div.row'): yield dict( (k, '|'.join(c.strip() for c in row.css(v+'::text').extract())) for k, v in self._extract_fields ) for link in response.css('ul.pagination > li > a::attr(href)'): yield Request(urljoin(self._url, link.extract()))
livington
18.01.2018 13:14Отличная статья, но у меня почему-то knowyourmeme.com входит в число запрещенных сайтов, причем при попытке делать запрос используя Tor, вылетает 403 ошибка) Думаю про пути обхода такого рода сайтов задавать вопрос вообще неуместно, или это выходит за рамки этой статьи?)
Skolopendriy Автор
18.01.2018 13:15А можно полюбопытствовать, что значит «входит в число запрещенных» — провайдер блокирует доступ или администратор рабочей сети не даёт мемесы посмотреть? :)
livington
18.01.2018 16:55Провайдер заблокировал, но по по решению органов государственной власти Российской Федерации.
devalone
18.01.2018 22:22Лол, и вправду eais.rkn.gov.ru
livington
19.01.2018 12:33ну теперь собсно вопрос как это обойти, потому что даже используя описываемый в статье способ через tor, приводит к ошибке 403
wiygn
18.01.2018 16:48Теперь запускаем свежескачанный браузер и оставляем его открытым.
Вместо Tor Browser можно использовать обычный tor-сервис (в том числе и под виндой, категоричный отказ от которой выглядит просто нелепо). Не нужно будет ничего держать открытым, что особенно актуально на серверах.
По поводу смены Tor-сессии.
Это делается через передачу на ControlPort сервиса сигнала (NEWNYM
) по TCP-сокету, зачем городить костыли с конфигами совсем не понятно.
Если лень/не умеете работать с сокетами, то всё уже сделано за вас: stem.torproject.org
На большинстве крупных ресурсов подсети выходных нод тора тупо забанены, поэтому часто не панацея.
Если нужны свободные пулы, то легче агрегировать, проверять и ротировать публичные прокси самописным микросервисом и дёргать доступные прокси уже через него.
Совсем ленивые могут купить приватных проксей.
PsyHaSTe
18.01.2018 19:56Перевод отличный, статья неплохая, но вброс про Windows вообще как-то чужеродно видится… Есть разработчики и под эту платформу, и на этой платформе.
flint
19.01.2018 10:40Прикинуться определенным юзерагентом — это здравая мысль, но помогает только в самых простых случаях. Еще нужно отправлять те хедеры, что посылает тот браузер, под который идет закос, причем очень желательно посылать в том же порядке, в котором их сам браузер и расставляет.
scronheim
Спасибо за статью! Фраза про EA просто топ