Как обойти ошибку 403 при парсинге

Новогодние праздники — прекрасный повод попрокрастинировать в уютной домашней обстановке и вспомнить дорогие сердцу мемы из 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 Финальная подготовка к грабежу

По аналогии со ссылками можно вытащить что угодно. Для этого надо сделать несколько шагов:

  1. Открываем страничку с мемом
  2. Находим любым способом тег для нужной нам информации
  3. Пихаем всё это в прекрасный суп
  4. ……
  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 Когда работающий код больше не работает

Вот он! Тот самый момент абсолютного триумфа, когда код дописан и всё, что нам, мирным собирателям, остаётся — запустить наш код на одну ночку. Кажется, что через страсть мы преобрели силу. Запускаем наш код по всем $1075$ страницам с мемами. На всякий случай обернём наш цикл в 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
  • Методы анонимности в сети
  • Прокси-сервер с помощью 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 минут после начала работы кода.

  1. Открываем браузер;
  2. Запускаем кусок кода с подгрузкой библиотек;
  3. Запускаем цикл по мемам
  4. …..
  5. 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/. Проследуем по инструкции авторов скрипта.

  1. Генерируем в консоли пароль tor --hash-password mypassword
  2. Открываем torrc файл в редакторе вроде vim, nano или atom
  3. Сохраняем пароль в наш torrc-файл в строку, которая начинается с HashedControlPassword
  4. Раскоментируем строку, начинающуюся с HashedControlPassword
  5. Раскоментируем (если она закоментирована) строку ControlPort 9051
  6. Сохраним изменения.

Запускаем 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 мемов, а также с их исследованием и ноутбуком с парсером.
  • Филин репозиторий с ещё парой хорошо расписаных блокнотов с парсерами. Репозиторий изначально делался как страничка факультатива для студентов.
  • Оригинальный кодекс адепта тёмной стороны силы.

I was trying to scrape a website for practice, but I kept on getting the HTTP Error 403 (does it think I’m a bot)?

Here is my code:

#import requests
import urllib.request
from bs4 import BeautifulSoup
#from urllib import urlopen
import re

webpage = urllib.request.urlopen('http://www.cmegroup.com/trading/products/#sortField=oi&sortAsc=false&venues=3&page=1&cleared=1&group=1').read
findrows = re.compile('<tr class="- banding(?:On|Off)>(.*?)</tr>')
findlink = re.compile('<a href =">(.*)</a>')

row_array = re.findall(findrows, webpage)
links = re.finall(findlink, webpate)

print(len(row_array))

iterator = []

The error I get is:

 File "C:\Python33\lib\urllib\request.py", line 160, in urlopen
    return opener.open(url, data, timeout)
  File "C:\Python33\lib\urllib\request.py", line 479, in open
    response = meth(req, response)
  File "C:\Python33\lib\urllib\request.py", line 591, in http_response
    'http', request, response, code, msg, hdrs)
  File "C:\Python33\lib\urllib\request.py", line 517, in error
    return self._call_chain(*args)
  File "C:\Python33\lib\urllib\request.py", line 451, in _call_chain
    result = func(*args)
  File "C:\Python33\lib\urllib\request.py", line 599, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 403: Forbidden

HTTP Error 403 is a common error encountered while web scraping using Python 3. It indicates that the server is refusing to fulfill the request made by the client, as the request lacks sufficient authorization or the server considers the request to be invalid. This error can be encountered for a variety of reasons, including the presence of IP blocking, CAPTCHAs, or rate limiting restrictions. In order to resolve the issue, there are several methods that can be implemented, including changing the User Agent, using proxies, and implementing wait time between requests.

Method 1: Changing the User Agent

If you encounter HTTP error 403 while web scraping with Python 3, it means that the server is denying you access to the webpage. One common solution to this problem is to change the user agent of your web scraper. The user agent is a string that identifies the web scraper to the server. By changing the user agent, you can make your web scraper appear as a regular web browser to the server.

Here is an example code that shows how to change the user agent of your web scraper using the requests library:

import requests

url = 'https://example.com'
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36'}

response = requests.get(url, headers=headers)

print(response.content)

In this example, we set the User-Agent header to a string that mimics the user agent of the Google Chrome web browser. You can find the user agent string of your favorite web browser by searching for «my user agent» on Google.

By setting the User-Agent header, we can make our web scraper appear as a regular web browser to the server. This can help us bypass HTTP error 403 and access the webpage we want to scrape.

That’s it! By changing the user agent of your web scraper, you should be able to fix the problem of HTTP error 403 in Python 3 web scraping.

Method 2: Using Proxies

If you are encountering HTTP error 403 while web scraping with Python 3, it is likely that the website is blocking your IP address due to frequent requests. One way to solve this problem is by using proxies. Proxies allow you to make requests to the website from different IP addresses, making it difficult for the website to block your requests. Here is how you can fix HTTP error 403 in Python 3 web scraping with proxies:

Step 1: Install Required Libraries

You need to install the requests and bs4 libraries to make HTTP requests and parse HTML respectively. You can install them using pip:

pip install requests
pip install bs4

Step 2: Get a List of Proxies

You need to get a list of proxies that you can use to make requests to the website. There are many websites that provide free proxies, such as https://free-proxy-list.net/. You can scrape the website to get a list of proxies:

import requests
from bs4 import BeautifulSoup

url = 'https://free-proxy-list.net/'
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
table = soup.find('table', {'id': 'proxylisttable'})
rows = table.tbody.find_all('tr')

proxies = []
for row in rows:
    cols = row.find_all('td')
    if cols[6].text == 'yes':
        proxy = cols[0].text + ':' + cols[1].text
        proxies.append(proxy)

This code scrapes the website and gets a list of HTTP proxies that support HTTPS. The proxies are stored in the proxies list.

Step 3: Make Requests with Proxies

You can use the requests library to make requests to the website with a proxy. Here is an example code that makes a request to https://www.example.com with a random proxy from the proxies list:

import random

url = 'https://www.example.com'
proxy = random.choice(proxies)

response = requests.get(url, proxies={'https': proxy})
if response.status_code == 200:
    print(response.text)
else:
    print('Request failed with status code:', response.status_code)

This code selects a random proxy from the proxies list and makes a request to https://www.example.com with the proxy. If the request is successful, it prints the response text. Otherwise, it prints the status code of the failed request.

Step 4: Handle Exceptions

You need to handle exceptions that may occur while making requests with proxies. Here is an example code that handles exceptions and retries the request with a different proxy:

import requests
import random
from requests.exceptions import ProxyError, ConnectionError, Timeout

url = 'https://www.example.com'

while True:
    proxy = random.choice(proxies)
    try:
        response = requests.get(url, proxies={'https': proxy}, timeout=5)
        if response.status_code == 200:
            print(response.text)
            break
        else:
            print('Request failed with status code:', response.status_code)
    except (ProxyError, ConnectionError, Timeout):
        print('Proxy error. Retrying with a different proxy...')

This code uses a while loop to keep retrying the request with a different proxy until it succeeds. It handles ProxyError, ConnectionError, and Timeout exceptions that may occur while making requests with proxies.

Method 3: Implementing Wait Time between Requests

When you are scraping a website, you might encounter an HTTP error 403, which means that the server is denying your request. This can happen when the server detects that you are sending too many requests in a short period of time, and it wants to protect itself from being overloaded.

One way to fix this problem is to implement wait time between requests. This means that you will wait a certain amount of time before sending the next request, which will give the server time to process the previous request and prevent it from being overloaded.

Here is an example code that shows how to implement wait time between requests using the time module:

import requests
import time

url = 'https://example.com'

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36'}

for i in range(5):
    response = requests.get(url, headers=headers)
    print(response.status_code)
    time.sleep(1)

In this example, we are sending a request to https://example.com with headers that mimic a browser request. We are then using a for loop to send 5 requests with a 1 second delay between requests using the time.sleep() function.

Another way to implement wait time between requests is to use a random delay. This will make your requests less predictable and less likely to be detected as automated. Here is an example code that shows how to implement a random delay using the random module:

import requests
import random
import time

url = 'https://example.com'

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36'}

for i in range(5):
    response = requests.get(url, headers=headers)
    print(response.status_code)
    time.sleep(random.randint(1, 5))

In this example, we are sending a request to https://example.com with headers that mimic a browser request. We are then using a for loop to send 5 requests with a random delay between 1 and 5 seconds using the random.randint() function.

By implementing wait time between requests, you can prevent HTTP error 403 and ensure that your web scraping code runs smoothly.

Getting a 403 error shouldn’t stop you from extracting publicly available data from any website. But if you’re getting blocked by a 403 error, here are a few things you can do.

What does error 403 mean?

Error 403 (or 403 Forbidden error) is a client error response HTTP status codes from the server side. Another example is the infamous 404 Not Found error, which shows up when a web page can’t be found.

403 Forbidden means that the server has received your request but refuses to approve it. For a regular user, it is usually a sign they don’t have sufficient rights to access a particular web page. A typical example is a request to view the website as an admin if you’re not one.

cat forbidden to illustrate 403 error

Being forbidden by HTTP 403 errors can be frustrating — and not only for web scrapers

However, a 403 error may be a sign of something different when web scraping.

Why am I getting a 403 error when web scraping?

There are several reasons for getting a HTTP 403 error. So like everything in programming, you will need to do some detective work to figure out exactly why 403 Forbidden showed up in your particular case. But if you’re trying not just to visit but also extract the data from a website, the reasons why a 403 Forbidden Error is glaring at you from the screen narrow down to just two:

  1. You need special rights to access this particular web resource — and you don’t have that authorization.
  2. The website recognized your actions as scraping and is politely banning you.

In a way, the second cause is good news: you’ve made a successful request for scraping. The bad news is that these days, websites are equipped with powerful anti-bot protections able to deduce the intentions of their visitors and block them. If the webpage you’re trying to scrape opens normally in a browser but gives you the 403 Forbidden HTTP status code when trying to request access via scraper – you’ve been busted!

So we’ve identified that the 403 error was no accident and there’s a clear connection with scraping; now what? There are at least three ways to go about this issue. You can try alternating:

  • User agents (different devices)
  • Request headers (other browsers)
  • Rotating proxies (various locations)

But before we start with solutions, we need to know the challenge we’re up against: how was the website able to detect our scraper and apply anti-scraping protections to it? The short answer is digital fingerprints. Skip the longer answer if you already know enough about browser or user fingerprints.

Bot detection: what are user fingerprints?

These days, every respectable website with high stable traffic has a few tracking methods in place to distinguish real users and bots like our scraper. Those tracking methods boil down to the information the website gets about the user’s machine sending an access request to the server, namely the user’s device type, browser, and location. The list usually goes on to include the operating system, browser version, screen resolution, timezone, language settings, extensions like an ad blocker, and many more parameters, small and big.

Every user has a unique combination of that data. Once individual browsing sessions become associated with the visitor, that visitor gets assigned an online fingerprint. Unlike cookies, this fingerprint is almost impossible to shake off — even if you decide to clear browser data, use a VPN, or switch to incognito mode.

Next step: the website has a browser fingerprinting script in place to bounce visitors with unwelcome fingerprints. Some scripts are more elaborate and accurate than others as they factor in more fingerprint signals, but the end goal is the same: filter out flagged visitors. So if your fingerprint gets flagged as belonging to a bot (for example, after repeatedly trying to access the same web page within a short amount of time), it’s the script’s duty to cut your access from the website and show you some sort of error message, in our case, error 403.

There’s a reason why these techniques are employed in the first place: not all website visitors have good intentions. It is important to keep the spammers, hackers, DDoS attackers, and fraudsters at bay and allow the real users to return as many times as they want. It is also important to let search engine or site monitoring bots do their job without banning them.

🤖

Good bots vs. bad bots — what’s the difference for the website server?

But what if we’re just trying to automate a few actions on the website? There’s no harm in extracting publicly available data such as prices or articles. Unfortunately, abuse of web scraping bots led many websites to set strict measures in place to prevent bots from overloading their servers. And while websites have to be careful with singling out human visitors, there’s no incentive for them to develop elaborate techniques (besides robot.txt) to distinguish good and bad bots. It’s easier to ban them all except for search engines. So if we want to do web scraping or web automation, we’re left with no other option but to try and bypass the restrictions. Here’s how you can do it.

4 ways to get rid of 403 errors when web scraping

Change user agents

The most common telltale sign that can make the website throw you a 403 error is your user agent. User-agent is a boring bit of information that describes your device to the server when you’re trying to connect to the server and access the website. In other words, the website uses your user agent info as a token to identify who’s sending an HTTP request to access it. It typically includes the application, operating system, vendor, and/or version. Here’s an example of a user agent token; as you see, it contains the parameters mentioned above.

💻

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36

User agent example

User agent example

The good news is there are many ways to tinker with and change these parameters manually and try to come off as a different, let’s say, Mozilla+Windows user. But there are rules to it if you want to succeed.

Devices  

A small warning before we start: sometimes the user agent term gets closely associated with the type of device: laptop, tablet, mobile, etc., so you might think if you replace the device type, it will be enough to pass as a different user. That’s not enough. The device type info is not standalone; it’s usually paired with a certain OS and/or browser. You wouldn’t normally see an Android mobile device with an iOS using Opera or an iPad with a Windows OS and Microsoft Edge (unless you’re feeling adventurous). These unlikely combinations can unnecessarily attract website’s attention (because they are that — unlikely), and that’s what we’re trying to avoid here.

So even if you try to change the device type, it will require you to rewrite the whole user agent to come off as an authentic, real user instead of a bot. That’s why you need to know how to combine all those bits of info. There are some user agent combos that are more common than others and many free resources that share that information. Here’s one of them, for instance — you can find your own user agent there, too, and how common it is for the web these days.

Libraries

There are also HTTP libraries that offer many user agent examples. The problem with the libraries usually is that they can be either outdated or created for website testing specifically. While the issue with the former is clear, the latter needs a bit of explanation. The user agents from a testing library usually indicate directly that they are sampled from a library. This serves a purpose when you’re testing your website (clear labeling is important for identifying bugs) but not when you’re trying to come off as a real user.

{
 "headers": {
   "Accept": "*/*",
   "Accept-Encoding": "gzip, deflate",
   "Host": "www.google.com",
   "User-Agent": "got-scraping/1.3.9",
 }
}

In the end, your best chance at web scraping is to come off as different users for the website by randomizing and rotating several sure-proof user agents. Here’s what that could look like:

import request from 'request'; 

const userAgentsList = [
   'Mozilla/5.0 (X11; Linux x86_64; rv:107.0) Gecko/20100101 Firefox/107.0',
   'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36',
   'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
];

let options = { url: 'https://google.com/doodles', headers: { 'User-Agent': userAgentsList[Math.floor(Math.random() * 3)] } }; 

Or, if you step up your game, you can use a library for randomizing user agents, such as modern-random-ua. Here’s an example of how to randomize user-agents from this library:

import request from 'request'; 

const userAgentsList = [
   'Mozilla/5.0 (X11; Linux x86_64; rv:107.0) Gecko/20100101 Firefox/107.0',
   'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36',
   'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
];

let options = { url: 'https://google.com/doodles', headers: { 'User-Agent': userAgentsList[Math.floor(Math.random() * 3)] } }; 

All this can help you to tinker with your user agent, come up with a list of the most successful cases and use them in rotation. In many cases, diversifying your HTTP requests with different user agents should beat the 403 Forbidden error. But again, it all highly depends on how defensive the website you’re trying to access is. So if the error persists, here is a level-two modification you can apply.

Simple bot identification scripts filter out unwanted website visitors by user agent. But more refined scripts include checking users against HTTP headers – k  namely, their availability and consistency.

Humans don’t usually visit a website without using some sort of browser as a middleman. These days modern browsers include tons of extra HTTP headers sent with every request to deliver the best user experience (screen size, language, dark mode, etc.)

Since your task is to make it harder for the website to tell whether your HTTP requests are coming from a scraper or a real user, at some point, you will have to change not only the user agent but also other browser headers.

💽

Read more about HTTP headers in our Docs

Complexity

A basic HTTP header section, besides User-Agent, usually includes Referer, Accept, Accept-Language, and Accept-Encoding. A lot of HTTP clients send just basic HTTP header with a simple structure like this:

{
"Accept": "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8",
   "Accept-Language": "en",
   "User-Agent": "got-scraping/1.3.9",
}

Compare this with an example of a real-life, complex browser header:

Complex browser header example

Complex browser header example

Note that it is not only about the browser name (that one is indicated in the user-agent), but rather the sidekick section that goes with it.

Consistency

Some HTTP headers are shorter, some are longer. But the important thing is that they also have to be consistent with user-agent. There has to be a correct match between the user-agent and the rest of the header section. If the website is expecting a user visiting from an iPhone via Chrome browser (user-agent info) to be accompanied by a long header that includes a bunch of parameters (header info), it’ll be checking your request for that section and that match. It will look suspicious if that section is absent or shorter than it should be, and you can be banned.

For instance, here’s the browser compatibility table for including the Sec-CH-UA-Mobile parameter into the header.

As you can see, not every browser sends this header. But it does go with Chromium browsers, which means you’ll have to include it in your request if you want to come off as a real Chrome user. Usually, the more elaborate the browser header is, the better is your chance for flying under the radar – if you know how to put it together correctly. Our real Chrome header from the previous screenshot does include that parameter, by the way:

You can create your own collection of working header combos or turn to libraries. For instance, this is how you can send a request with a predefined header from the Puppeteer library:

await page.setExtraHTTPHeaders({ 
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36', 'upgrade-insecure-requests': '1', 
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3', 
'accept-encoding': 'gzip, deflate, br', 
'accept-language': 'en-US,en;q=0.9,en;q=0.8' }) 
await page.goto('...')

By adding an appropriate, expected request header, we’ve reduced the chances of being blocked by the website and getting the dreaded 403. Our request now comes off as from a real user using a real browser, and since most websites are quite careful about keeping human traffic, it gets a pass from the bot-blocking measures.

However, if the 403 error keeps showing up, we have to move on to level 3 issues: IP-rate limiting. Maybe you’ve overdone it with the number of requests to the website, and it flagged your IP address as one belonging to a bot. What now?

Rotating proxies

If the two options above have stopped working for your case, and the 403 error keeps showing up, it’s time to consider the last scenario: your IP address might be getting blocked. This scenario is quite reasonable if you see it from the website’s point of view: it just got a large number of requests coming from the same IP address within an unnaturally short amount of time. Now it could be the case of many people sharing the same IP address by using the same WiFi, but they wouldn’t normally all go on the same website at the same time, right?

The most logical way to approach it is to use a proxy. There are many proxy providers out there, some paid, some free, and varying in type. There are even tools, such as our free Proxy Scraper, that can find free working public proxies for you. But the main point is proxies secure your request with a different IP address every time you access the website you’re trying to scrape. That way, the website will perceive it as coming from different users.

When and how to use datacenter proxies

The ultimate guide to datacenter proxies for web scraping.

Theo Vasilis

You can use an existing proxy pool or create one of your own, like in the example below.

const { gotScraping } = require('got-scraping');
 
const proxyUrls = [
   'http://usernamed:password@myproxy1.com:1234',
   'http://usernamed:password@myproxy2.com:1234',
   'http://usernamed:password@myproxy4.com:1234',
   'http://usernamed:password@myproxy5.com:1234',
   'http://usernamed:password@myproxy6.com:1234',
];
 
proxyUrls.forEach(proxyUrl => {
   gotScraping
   .get({
       url: 'https://apify.com',
       proxyUrl,
   })
   .then(({ body }) => console.log(body))
});

Don’t forget to combine the proxy method with the previous ones you’ve learned: user-agents and HTTP headers. That way, you can secure the chances for successful large-scale scraping without breaching any of the website’s bot-blocking rules.

🛩

Learn more about using proxies and how to fly under the radar with Apify Academy’s anti-scraping mitigation techniques

Skip the hard part

You’re probably not the first one and not the last one to deal with the website throwing you errors when scraping. Tinkering with your request from various angles can take a lot of time and trial-and-error. Luckily, there are open-source libraries such as Crawlee 🔗 built specifically to tackle those issues.

Avoid getting blocked | Crawlee

How to avoid getting blocked when scraping

Crawlee

This library has a smart proxy pool that rotates IP addresses for you intelligently by picking an unused address from the existing number of reliable proxy addresses. In addition, it pairs this proxy with the best user agents and HTTP header for your case. There’s no risk of using expired proxies or even the need to save cookies and auth tokens. Crawlee makes sure they are connected with your IP address, which diminishes the chances of getting blocked, including but not limited to the 403 Forbidden error. Conveniently, there’s no need for you to create three separate workarounds for picking proxies, headers, and user agents.

This is how you can set it up:

import { BasicCrawler, ProxyConfiguration } from 'crawlee';
import { gotScraping } from 'got-scraping';

const proxyConfiguration = new ProxyConfiguration({ /* opts */ });

const crawler = new BasicCrawler({
    useSessionPool: true,
    sessionPoolOptions: { maxPoolSize: 100 },
    async requestHandler({ request, session }) {
        const { url } = request;
        const requestOptions = {
            url,
            proxyUrl: await proxyConfiguration.newUrl(session.id),
            throwHttpErrors: false,
            headers: {
                Cookie: session.getCookieString(url),
            },
        };
        let response;

        try {
            response = await gotScraping(requestOptions);
        } catch (e) {
            if (e === 'SomeNetworkError') {
                session.markBad();
            }
            throw e;
        }
        session.retireOnBlockedStatusCodes(response.statusCode);

        if (response.body.blocked) {
            session.retire();
        }
        session.setCookiesFromResponse(response);
    },
});

🐞

Read more about SessionPool in the Crawlee docs

You also have complete control over how you want to configure your working combination of user agents, devices, OS, browser versions, and all the browser fingerprint details.  

import { PlaywrightCrawler } from 'crawlee';

const crawler = new PlaywrightCrawler({
    browserPoolOptions: {
        useFingerprints: true, // this is the default
        fingerprintOptions: {
            fingerprintGeneratorOptions: {
                browsers: [{
                    name: 'edge',
                    minVersion: 96,
                }],
                devices: [
                    'desktop',
                ],
                operatingSystems: [
                    'windows',
                ],
            },
        },
    },
    // ...
});

Facing errors is the reality of web scraping. If, after trying all these various ways (rotating user agents, headers, and proxies), and even trying Crawlee, the 403 error still doesn’t leave you alone, you can always turn to the community for guidance and some pretty accurate advice. Share your issue with us on Discord and see what our community of like-minded automation and web scraping enthusiasts has to say.

Another frequent issue is, while being focused on something very complex, you could’ve missed something very obvious. Which is why you’re invited to refresh your memory or maybe even learn something new on anti-scraping protections in Apify Academy. Best of luck!

В мире программирования парсеры являются очень полезными инструментами, которые помогают автоматизировать сбор данных из других сайтов и веб-ресурсов. Однако, иногда бывает так, что при работе с парсерами возникает ошибка 403 Forbidden. В этой статье мы рассмотрим, что это за ошибка, как ее исправить и какие есть способы обойти ее при работе с парсерами.

Что такое 403 Forbidden?

Код ошибки 403 Forbidden означает, что пользователь не имеет доступа к запрашиваемому ресурсу. Это может произойти по многим причинам, например, если сервер определяет, что запрос пришел от бота, который может являться угрозой безопасности или просто не имеет права на доступ к запрашиваемому ресурсу.

Как исправить ошибку 403 Forbidden?

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

1. Определите права доступа

Один из наиболее вероятных вариантов, почему возникает ошибка 403 Forbidden, это недостаточный уровень доступа пользователя. В таком случае, необходимо убедиться, что у пользователя хватает прав на доступ к запрашиваемому ресурсу. Для этого может потребоваться обратиться к администратору сервера.

2. Измените IP-адрес

Другой возможный вариант — проблема с IP-адресом. Сервер может заблокировать IP-адрес, если он считает его нежелательным или вызывающим сомнения. Для решения этой проблемы можно изменить IP-адрес, например, подключившись через VPN или использовав прокси-сервер.

3. Проверьте настройки безопасности сайта

Ошибка 403 Forbidden может возникнуть, если сервер определяет, что запрос пришел от бота или слишком часто. В таком случае, можно попытаться изменить настройки безопасности сайта, чтобы избежать блокировки. Если это не помогает, можно связаться с администратором сайта или сервера, чтобы узнать, какие настройки безопасности необходимо изменить.

4. Обходите блокировку

Если никакие из вышеперечисленных способов не помогают, можно попробовать обойти блокировку, используя специальные программы и инструменты. Например, можно использовать TOR-браузер или прокси-сервер, чтобы изменить IP-адрес и обойти блокировку.

Как обойти ошибку 403 Forbidden при работе с парсерами?

При работе с парсерами может возникать ошибка 403 Forbidden, когда парсер пытается получить доступ к данным на сайте, на котором он не имеет прав доступа. Вот несколько способов, которые могут помочь обойти эту проблему:

1. Используйте прокси-сервер

Один из наиболее эффективных способов обойти ошибку 403 Forbidden при работе с парсерами — использование прокси-сервера. Для этого необходимо прописать адрес прокси-сервера в настройках парсера. Прокси-сервер позволит изменить IP-адрес и обойти блокировку.

2. Используйте user-agent

Другой способ — измените user-agent парсера. User-agent — это строка, которая передается серверу и содержит информацию о браузере и операционной системе пользователя. Если сервер определяет, что запрос пришел от бота, он может заблокировать доступ. Изменение user-agent может помочь обойти эту блокировку.

3. Используйте задержку между запросами

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

4. Используйте API

Некоторые сайты предоставляют API, который позволяет получать доступ к данным без ограничений. Если это возможно, рекомендуется использовать API для получения данных, чтобы избежать ошибки 403 Forbidden и других ограничений доступа.

Вывод

Ошибка 403 Forbidden может возникнуть во многих случаях и происходит, когда веб-сервер не позволяет пользователю получить доступ к запрашиваемому ресурсу. При работе с парсерами, ошибка 403 Forbidden может возникать, когда парсер пытается получить доступ к данным, которые находятся на сайте, на котором он не имеет прав доступа. Однако, существует несколько способов, которые помогают обойти эту проблему при работе с парсерами, включая использование прокси-сервера, изменение user-agent, установку задержки между запросами и использование API.

Понравилась статья? Поделить с друзьями:
  • Как обойти эту ошибку
  • Как обойти ошибку 403 на сайте
  • Как обозначается грамматическая ошибка
  • Как обойти ошибку 403 forbidden
  • Как обойти ошибку нарушения конфиденциальности