Хранение данных для парсинга сайтов

Хотя вывод на терминал может быть веселым занятием, это не особенно полезно, когда речь идет о сборе и анализе данных. Чтобы сделать большинство веб-скрейперов действительно полезными, нужно иметь возможность сохранять собираемую информацию.

В этой главе рассматриваются три основных метода управления данными, которые достаточны для практически любого приложения. Нужно обеспечить функционирование бэкэнда веб-сайта или создать собственный API? Вероятно, вам потребуется, чтобы ваши скрейперы записывали данные в базу данных. Нужен быстрый и простой способ собрать документы из Интернета и сохранить их на вашем жестком диске? Возможно, вам потребуется создать потоковый файл для этого. Нужны периодические уведомления или агрегированные данные раз в день? Отправьте себе электронное письмо!

Помимо веб-скрейпинга, способность хранить и взаимодействовать с большим объемом данных крайне важна для практически любого современного программного приложения. Фактически, информация в этой главе необходима для реализации многих примеров в последующих разделах книги. Я настоятельно рекомендую вам хотя бы пробежаться по этой главе, если вы не знакомы с автоматизированным хранением данных.

Медиафайлы

Медиафайлы можно хранить двумя основными способами: по ссылке и путем загрузки самого файла. Вы можете хранить файл по ссылке, храня URL, по которому находится файл. Этот вариант имеет несколько преимуществ:

  • Скрейперы работают намного быстрее и требуют намного меньше полосы пропускания, когда им не нужно загружать файлы.
  • Вы экономите место на своем собственном компьютере, храня только URL.
  • Более легко писать код, который хранит только URL и не требует дополнительной загрузки файлов.
  • Вы можете снизить нагрузку на сервер хостинга, избегая загрузки больших файлов.

Вот недостатки:

  • Встраивание этих URL-адресов на вашем собственном веб-сайте или в приложении известно как хотлинкинг, и это быстрый способ попасть в неприятности в интернете.
  • Вы не хотите использовать циклы сервера кого-то еще для хостинга медиафайлов для своих собственных приложений.
  • Файл, размещенный по определенному URL, может измениться. Это может привести к неловким последствиям, если, например, вы встраиваете хотлинкнутое изображение на публичный блог. Если вы храните URL с намерением сохранить файл позже для дальнейших исследований, он может в конечном итоге исчезнуть или быть изменен на что-то совершенно неважное в последующие даты.
  • Настоящие веб-браузеры не просто запрашивают HTML-страницу и идут дальше. Они также загружают все ресурсы, необходимые для страницы.
  • Загрузка файлов может помочь сделать ваш скрейпер похожим на то, что его использовал человек, что может быть преимуществом.

Если вы размышляете над тем, стоит ли хранить файл или URL к файлу, вам следует спросить себя, вероятно ли, что вы просмотрите или прочтете этот файл более одного-двух раз, или же эта база файлов будет просто пылиться, собирая электронную пыль, большую часть своей жизни. Если ответ — последнее, вероятно, лучше просто хранить URL. Если это первое, читайте дальше!

Библиотека urllib, используемая для получения содержимого веб-страниц, также содержит функции для получения содержимого файлов. Вот пример программы, использующей urllib.request.urlretrieve для загрузки изображений с удаленного URL-адреса:

from urllib.request import urlretrieve
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen('http://www.pythonscraping.com')
bs = BeautifulSoup(html, 'html.parser')
imageLocation = bs.find('a', {'id': 'logo'}).find('img')['src']
urlretrieve(imageLocation, 'logo.jpg')

Скрипт загружает логотип с http://pythonscraping.com и сохраняет его как logo.jpg в том же каталоге, из которого запускается скрипт.

Это хорошо работает, если вам нужно загрузить только один файл и знать, как его назвать и какое у него расширение файла. Но большинство скрейперов не загружают один файл и не заканчиваются. Ниже приведен пример загрузки всех внутренних файлов, на которые ссылается атрибут src любого тега, со стартовой страницы http://pythonscraping.com:

import os
from urllib.request import urlretrieve
from urllib.request import urlopen
from bs4 import BeautifulSoup

downloadDirectory = 'downloaded'
baseUrl = 'http://pythonscraping.com'

def getAbsoluteURL(baseUrl, source):
    if source.startswith('http://www.'):
        url = 'http://{}'.format(source[11:])
    elif source.startswith('http://'):
        url = source
    elif source.startswith('www.'):
        url = source[4:]
        url = 'http://{}'.format(source)
    else:
        url = '{}/{}'.format(baseUrl, source)
    if baseUrl not in url:
        return None
    return url

def getDownloadPath(baseUrl, absoluteUrl, downloadDirectory):
    path = absoluteUrl.replace('www.', '')
    path = path.replace(baseUrl, '')
    path = downloadDirectory+path
    directory = os.path.dirname(path)
    if not os.path.exists(directory):
        os.makedirs(directory)
    return path

html = urlopen('http://www.pythonscraping.com')
bs = BeautifulSoup(html, 'html.parser')
downloadList = bs.findAll(src=True)

for download in downloadList:
    fileUrl = getAbsoluteURL(baseUrl, download['src'])
    if fileUrl is not None:
        print(fileUrl)
        urlretrieve(fileUrl, getDownloadPath(baseUrl, fileUrl, downloadDirectory))

Вы знаете все те предупреждения о загрузке неизвестных файлов из Интернета?

Этот скрипт загружает всё, что встречает, на жесткий диск вашего компьютера. Это включает в себя случайные bash-скрипты, .exe файлы и другие потенциальные вредоносные программы. Думаете, что вы в безопасности, потому что никогда не будете запускать что-то из папки загрузок?

Особенно если вы запускаете эту программу с правами администратора, вы просите неприятностей. Что произойдет, если вы встретите файл на веб-сайте, который отправит себя в ../../../../usr/bin/python?

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

Этот скрипт использует лямбда-функцию для выбора всех тегов на главной странице, имеющих атрибут src, а затем очищает и нормализует URL-адреса, чтобы получить абсолютный путь для каждой загрузки (убеждаясь при этом, что внешние ссылки отбрасываются). Затем каждый файл загружается в свой собственный путь в локальной папке downloaded на вашем собственном компьютере.

Обратите внимание, что модуль os Python используется кратковременно для получения целевого каталога для каждой загрузки и создания отсутствующих каталогов по необходимости вдоль пути. Модуль os действует как интерфейс между Python и операционной системой, позволяя ему манипулировать файловыми путями, создавать каталоги, получать информацию о запущенных процессах и переменных среды и многие другие полезные вещи.

Хранение данных в формате CSV

CSV или значения, разделенные запятыми, является одним из самых популярных форматов файлов для хранения данных таблиц. Он поддерживается Microsoft Excel и многими другими приложениями из-за своей простоты. Вот пример абсолютно допустимого файла CSV:

фрукт,цена
яблоко,1.00
банан,0.30
груша,1.25

Как и в Python, здесь важен разделитель: каждая строка разделяется символом новой строки, а столбцы внутри строки разделяются запятыми (отсюда и название «значения, разделенные запятыми»). Другие формы файлов CSV (иногда называемые файлами значений, разделенных символами) используют табуляцию или другие символы для разделения строк, но эти форматы файлов менее распространены и менее поддерживаются.

Если вы хотите загрузить файлы CSV непосредственно из Интернета и сохранить их локально без какого-либо анализа или изменения, вам не нужен этот раздел. Загружайте их, как и любые другие файлы, и сохраняйте их в формате файла CSV, используя методы, описанные в предыдущем разделе.

Изменение файла CSV или даже создание его полностью с нуля крайне просто с помощью библиотеки csv Python:

import csv

csvFile = open('test.csv', 'w+')

try:
    writer = csv.writer(csvFile)
    writer.writerow(('число', 'число плюс 2', 'число умножить на 2'))
    for i in range(10):
        writer.writerow((i, i+2, i*2))
finally:
    csvFile.close()

Предостережение о предосторожности: создание файла в Python довольно надежно. Если test.csv еще не существует, Python автоматически создаст файл (но не каталог). Если он уже существует, Python перезапишет test.csv новыми данными. После выполнения вы должны увидеть файл CSV:

число,число плюс 2,число умножить на 2
0,2,0
1,3,2
2,4,4
...

Одной из распространенных задач веб-скрапинга является извлечение HTML-таблицы и запись ее в файл CSV. На странице Википедии «Сравнение текстовых редакторов» представлена достаточно сложная HTML-таблица, содержащая цветовую кодировку, ссылки, сортировку и другой HTML-мусор, который необходимо отбросить перед записью в CSV. Используя BeautifulSoup и функцию get_text(), это можно сделать менее чем за 20 строк:

import csv
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen('http://en.wikipedia.org/wiki/Comparison_of_text_editors')
bs = BeautifulSoup(html, 'html.parser')

# Основная таблица сравнения в данный момент является первой таблицей на странице
table = bs.find('table', {'class': 'wikitable'})
rows = table.findAll('tr')

csvFile = open('editors.csv', 'wt+')
writer = csv.writer(csvFile)

try:
    for row in rows:
        csvRow = []
        for cell in row.findAll(['td', 'th']):
            csvRow.append(cell.get_text())
        writer.writerow(csvRow)
finally:
    csvFile.close()

Этот код извлекает HTML-таблицу из статьи Википедии о сравнении текстовых редакторов, парсит ее с помощью BeautifulSoup и записывает данные в файл CSV.

Есть более простой способ получить одну таблицу

Этот скрипт отлично подходит для интеграции в скраперы, если вы столкнулись с множеством HTML-таблиц, которые нужно преобразовать в файлы CSV, или с множеством HTML-таблиц, которые нужно собрать в один файл CSV. Однако, если вам нужно сделать это всего лишь один раз, есть более удобный инструмент: копирование и вставка. Выделение и копирование всего содержимого HTML-таблицы и вставка его в Excel или Google Документы позволит вам получить нужный вам файл CSV без запуска скрипта!

MySQL

MySQL (официально произносится как «май эс-кью-эль», хотя многие говорят «май сиквел») — самая популярная сегодня открытая система управления реляционными базами данных. Довольно необычно для проекта с открытым исходным кодом среди крупных конкурентов, его популярность исторически была наравне с двумя другими крупными закрытыми системами управления базами данных: SQL Server от Microsoft и DBMS от Oracle.

Его популярность не случайна. Для большинства приложений сложно ошибиться с MySQL. Это масштабируемая, надежная и полнофункциональная система управления базами данных, используемая на вершинах интернета: YouTube, Twitter и Facebook, среди многих других.

Из-за своей распространенности, цены («бесплатно» — это довольно хорошая цена) и простоты использования сразу после установки, он отлично подходит для проектов по веб-скрапингу, и мы будем использовать его на протяжении всей оставшейся части этой статьи.

«Реляционная» база данных?

Реляционные данные — это данные, которые имеют отношения. Рады, что разобрались! Шутка! Когда компьютерные ученые говорят о реляционных данных, они имеют в виду данные, которые не существуют в вакууме — они имеют свойства, связанные с другими данными.

Например, «Пользователь А учится в учреждении Б», где Пользователь А может быть найден в таблице Пользователей в базе данных, а Учреждение Б — в таблице Учреждений в базе данных. Позже в этой главе вы рассмотрите моделирование различных типов отношений и эффективное хранение данных в MySQL (или любой другой реляционной базе данных).

Установка MySQL

Если вы новичок в MySQL, установка базы данных может показаться немного сложной (если вы уже опытный, вы можете пропустить этот раздел). На самом деле это так же просто, как установка почти любого другого программного обеспечения. В основе MySQL лежит набор файлов данных, хранящихся на вашем сервере или локальном компьютере, содержащих всю информацию, хранящуюся в вашей базе данных. Программный слой MySQL поверх этого предоставляет удобный способ взаимодействия с данными через интерфейс командной строки. Например, следующая команда просматривает файлы данных и возвращает список всех пользователей в вашей базе данных, у которых имя «Ryan»:

SELECT * FROM users WHERE firstname = "Ryan"

Если вы используете дистрибутив Linux на основе Debian (или что-то с apt-get), установка MySQL так же проста, как и это:

$ sudo apt-get install mysql-server

Просто следите за процессом установки, утверждайте требования к памяти и введите новый пароль для вашего нового root-пользователя при запросе.

Для macOS и Windows ситуация немного сложнее. Если вы еще этого не сделали, вам нужно создать учетную запись Oracle перед загрузкой пакета.

Если вы используете macOS, вам сначала нужно получить установочный пакет. Выберите пакет .dmg и войдите в систему существующей учетной записью Oracle или создайте новую, чтобы загрузить файл. После открытия вы должны будете пройти через довольно простую установочную программу.

Большинство пользователей вполне обойдутся стандартными шагами установки, и для целей этой книги я предполагаю, что у вас установка MySQL по умолчанию.

Если загрузка и запуск установщика кажется вам немного утомительным, а вы используете Mac, вы всегда можете установить менеджер пакетов Homebrew. После установки Homebrew вы также можете установить MySQL, выполнив следующее:

$ brew install mysql

Homebrew — это отличный проект с открытым исходным кодом с хорошей интеграцией пакетов Python. Большинство сторонних модулей Python, используемых в этой книге, можно легко установить с помощью Homebrew. Если у вас его еще нет, я настоятельно рекомендую вам познакомиться с ним!

После установки MySQL на macOS вы можете запустить сервер MySQL следующим образом:

$ cd /usr/local/mysql
$ sudo ./bin/mysqld_safe

На Windows установка и запуск MySQL немного сложнее, но хорошая новость в том, что удобный установщик упрощает процесс. После загрузки он будет проводить вас через необходимые шаги.

Вы должны сможете установить MySQL, используя стандартные настройки, с одним исключением: на странице Тип установки я рекомендую выбрать «Только сервер», чтобы избежать установки большого количества дополнительного программного обеспечения и библиотек Microsoft. Затем вы сможете использовать стандартные настройки установки и следовать инструкциям для запуска вашего сервера MySQL.

Несколько базовых команд SQL

Когда ваш сервер MySQL запущен, у вас есть множество вариантов взаимодействия с базой данных. Множество программных инструментов действуют как посредник, так что вам не нужно иметь дело с командами MySQL (или по крайней мере, иметь с ними дело менее часто). Инструменты, такие как phpMyAdmin и MySQL Workbench, могут облегчить просмотр, сортировку и вставку данных.

Тем не менее, все равно важно знать, как ориентироваться в командной строке. За исключением имен переменных, MySQL нечувствителен к регистру; например, SELECT то же самое, что и sElEcT. Однако по соглашению все ключевые слова MySQL записываются заглавными буквами при написании оператора MySQL. С другой стороны, большинство разработчиков предпочитают называть свои таблицы и базы данных строчными буквами, хотя этот стандарт часто игнорируется. Когда вы впервые входите в MySQL, баз данных для добавления данных еще нет, но вы можете создать одну:

CREATE DATABASE scraping;

Поскольку каждый экземпляр MySQL может иметь несколько баз данных, прежде чем вы сможете начать взаимодействовать с базой данных, вам нужно указать MySQL, какую базу данных вы хотите использовать:

USE scraping;

С этого момента (по крайней мере, пока вы не закроете соединение с MySQL или не переключитесь на другую базу данных), все введенные команды будут выполняться против новой базы данных scraping.
Все это кажется достаточно простым. Точно так же легко создать таблицу в базе данных, верно? Давайте попробуем создать таблицу для хранения коллекции извлеченных веб-страниц:

CREATE TABLE pages;

Это приводит к ошибке: ERROR 1113 (42000): A table must have at least 1 column

В отличие от базы данных, которая может существовать без каких-либо таблиц, таблица в MySQL не может существовать без столбцов. Чтобы определить столбцы в MySQL, их необходимо ввести в виде списка, разделенного запятыми, в скобках, после оператора CREATE TABLE <tablename>:

CREATE TABLE pages (
         id BIGINT(7) NOT NULL AUTO_INCREMENT,
         title VARCHAR(200), 
         content VARCHAR(10000),
         created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 
         PRIMARY KEY(id));

Каждое определение столбца имеет три части:

  • Название (id, title, created и т. д.)
  • Тип данных (BIGINT(7), VARCHAR, TIMESTAMP)
  • При необходимости любые дополнительные атрибуты (NOT NULL AUTO_INCREMENT)
  • В конце списка столбцов необходимо определить ключ таблицы.

MySQL использует ключи для организации содержимого таблицы для быстрых поисков. Позже в этой главе я опишу, как использовать эти ключи в своих интересах для более быстрых баз данных, но пока использование столбца id таблицы в качестве ключа обычно является лучшим способом.

После выполнения запроса вы можете посмотреть, как выглядит структура таблицы в любое время, используя DESCRIBE:

DESCRIBE pages;
+---------+----------------+------+-----+-------------------+----------------+
| Field   | Type           | Null | Key | Default           | Extra          |
+---------+----------------+------+-----+-------------------+----------------+
| id      | bigint(7)      | NO   | PRI | NULL              | auto_increment |
| title   | varchar(200)   | YES  |     | NULL              |                |
| content | varchar(10000) | YES  |     | NULL              |                |
| created | timestamp      | NO   |     | CURRENT_TIMESTAMP |                |
+---------+----------------+------+-----+-------------------+----------------+
4 rows in set (0.01 sec)

Конечно, эта таблица все еще пуста. Вы можете вставить тестовые данные в таблицу pages, используя следующую команду:

INSERT INTO pages (title, content) VALUES ("Заголовок тестовой страницы", "Это некоторое содержимое тестовой страницы. Оно может содержать до 10 000 символов.");

Обратите внимание, что хотя таблица имеет четыре столбца (id, title, content, created), вам нужно определить только два из них (title и content), чтобы вставить строку. Это потому, что столбец id автоматически инкрементируется (MySQL автоматически добавляет 1 при каждой новой вставке строки), и в общем случае он может позаботиться о себе. Кроме того, столбец timestamp устанавливается на текущее время по умолчанию.

Конечно, вы можете изменить эти значения по умолчанию:

INSERT INTO pages (id, title, content, created) VALUES (3, "Заголовок тестовой страницы", "Это некоторое содержимое тестовой страницы. Оно может содержать до 10 000 символов.", "2014-09-21 10:25:32");

Пока целое число, которое вы предоставляете для столбца id, не существует уже в базе данных, это переопределение будет работать отлично. Однако в целом это плохая практика; лучше позвольте MySQL обрабатывать столбцы id и timestamp, если нет убедительной причины поступить иначе.

Теперь, когда в таблице есть данные, вы можете использовать различные методы для выбора этих данных. Вот несколько примеров операторов SELECT:

SELECT * FROM pages WHERE id = 2;

Этот оператор говорит MySQL: «Выбери все из pages, где id равен 2.» Знак звездочки (*) действует как подстановочный символ, возвращая все строки, для которых условие (где id равно 2) верно. Он возвращает вторую строку в таблице или пустой результат, если нет строки с id равным 2. Например, следующий оператор, нечувствительный к регистру, возвращает все строки, где поле title содержит «test» (символ % действует как подстановочный символ в строках MySQL):

SELECT * FROM pages WHERE title LIKE "%test%";

Но что, если у вас есть таблица с множеством столбцов, и вам нужны только определенные данные? Вместо выбора всего можно сделать что-то вроде этого:

SELECT id, title FROM pages WHERE content LIKE "%page content%";

Этот запрос возвращает только id и title, где содержание содержит фразу «page content».

Операторы DELETE имеют ту же синтаксическую структуру, что и операторы SELECT:

DELETE FROM pages WHERE id = 1;

Поэтому, особенно при работе с важными базами данных, которые нельзя легко восстановить, рекомендуется сначала написать оператор SELECT вместо DELETE (в этом случае SELECT * FROM pages WHERE id = 1), проверить, что возвращаются только те строки, которые вы хотите удалить, а затем заменить SELECT * на DELETE. Многие программисты имеют ужасные истории о неправильном написании условий в операторе DELETE или, что еще хуже, его полном отсутствии, когда им было срочно, и они уничтожали данные клиентов. Не допустите, чтобы это случилось с вами!

Аналогичные меры предосторожности должны быть предприняты с операторами UPDATE:

UPDATE pages SET title="Новый заголовок", content="Новое содержимое" WHERE id=2;

В рамках этой статьи, вы будете работать только с простыми операторами MySQL, осуществляя базовый выбор, вставку и обновление. Если вам интересно изучение других команд и техник с этим мощным инструментом базы данных, я рекомендую книгу Пола Дюбуа «Кулинарная книга MySQL» (O’Reilly).

Кроме того, вы можете изучить материалы по SQL у меня на сайте.

Интеграция с Python MySQL

К сожалению, поддержка MySQL не встроена в Python. Однако существует множество библиотек с открытым исходным кодом, поддерживаемых как в Python 2.x, так и в Python 3.x, которые позволяют вам взаимодействовать с базой данных MySQL. Одной из самых популярных из них является PyMySQL.

На момент написания этого текста текущая версия PyMySQL — 0.6.7, которую можно установить с помощью pip:

$ pip install PyMySQL

После установки у вас должен быть автоматический доступ к пакету PyMySQL.

Когда ваш локальный сервер MySQL запущен, вы должны сможете успешно выполнить следующий скрипт (не забудьте добавить пароль root для вашей базы данных):

import pymysql
conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
user='root', passwd=None, db='mysql')
cur = conn.cursor()
cur.execute('USE scraping')
cur.execute('SELECT * FROM pages WHERE id=1')
print(cur.fetchone())
cur.close()
conn.close()

В этом примере используются два новых типа объектов: объект подключения (conn) и объект курсора (cur).

Модель подключения/курсора часто используется при программировании баз данных, хотя некоторым пользователям может показаться сложным начать различать их. Подключение отвечает за подключение к базе данных, отправку информации в базу данных, обработку откатов (когда запрос или набор запросов должны быть прерваны, и база данных должна вернуться к предыдущему состоянию) и создание новых объектов курсора.

У одного подключения может быть много курсоров. Курсор отслеживает определенное состояние, такое как использование базы данных. Если у вас есть несколько баз данных и вам нужно записать информацию во все из них, вы можете иметь несколько курсоров для обработки этого.

Курсор также содержит результаты последнего запроса, который он выполнил. Вызывая функции на курсоре, такие как cur.fetchone(), вы можете получить доступ к этой информации.

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

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

Работа с текстом Unicode может быть сложной при парсинге веб-страниц. По умолчанию MySQL не обрабатывает Unicode. К счастью, вы можете включить эту функцию (просто помните, что это увеличит размер вашей базы данных). Поскольку вы наверняка столкнетесь с разнообразными символами при работе с Википедией, сейчас — отличное время, чтобы сообщить вашей базе данных, что она может ожидать Unicode:

ALTER DATABASE scraping CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
ALTER TABLE pages CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE pages CHANGE title title VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE pages CHANGE content content VARCHAR(10000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Эти четыре строки изменяют стандартный набор символов для базы данных, для таблицы и для обоих столбцов — с utf8mb4 (технически Unicode, но с плохой поддержкой большинства символов Unicode) на utf8mb4_unicode_ci.

Вы узнаете, что успешно справились с этим, если попробуете вставить несколько эмодзи в поле title или content в базе данных и это будет успешно выполнено без ошибок.

Теперь, когда база данных готова принимать самые разнообразные данные из Википедии, вы можете выполнить следующий код:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import datetime
import random
import pymysql
import re

# Устанавливаем соединение с базой данных
conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
                       user='root', passwd=None, db='mysql', charset='utf8')
cur = conn.cursor()
cur.execute("USE scraping")

random.seed(datetime.datetime.now())

# Функция для сохранения данных в базу
def store(title, content):
    cur.execute('INSERT INTO pages (title, content) VALUES '
                '("%s", "%s")', (title, content))
    cur.connection.commit()

# Функция для получения ссылок на другие статьи
def getLinks(articleUrl):
    html = urlopen('http://en.wikipedia.org'+articleUrl)
    bs = BeautifulSoup(html, 'html.parser')
    title = bs.find('h1').get_text()
    content = bs.find('div', {'id':'mw-content-text'}).find('p').get_text()
    store(title, content)
    return bs.find('div', {'id':'bodyContent'}).findAll('a', href=re.compile('^(/wiki/)((?!:).)*$'))

# Начинаем с одной статьи и переходим к следующей случайной ссылке
links = getLinks('/wiki/Kevin_Bacon')
try:
    while len(links) > 0:
        newArticle = links[random.randint(0, len(links)-1)].attrs['href']
        print(newArticle)
        links = getLinks(newArticle)
finally:
    cur.close()
    conn.close()

В этом коде следует обратить внимание на несколько моментов. Во-первых, в строке подключения к базе данных добавлен параметр «charset=’utf8′». Это сообщает соединению, что все данные должны быть отправлены в базу данных как UTF-8 (и, конечно, база данных уже должна быть настроена на обработку этого).

Во-вторых, добавлена функция store. Она принимает две строки — title и content — и добавляет их в оператор INSERT, который выполняется курсором, а затем подтверждается соединением курсора. Это отличный пример разделения курсора и соединения: хотя курсор хранит информацию о базе данных и собственном контексте, он должен работать через соединение, чтобы отправлять информацию обратно в базу данных и вставлять информацию.

Наконец, можно заметить, что в главном цикле программы добавлен блок finally. Это гарантирует, что, независимо от того, как программа прерывается или какие исключения могут возникнуть во время ее выполнения (и так как веб — это непредсказуемое место, всегда стоит предполагать, что будут возникать исключения), курсор и соединение будут закрыты непосредственно перед завершением программы. Хорошей практикой является включение блока try…finally, подобного этому, когда вы парсите веб и имеете открытое соединение с базой данных.

Хотя PyMySQL и не является огромным пакетом, в нем есть достаточное количество полезных функций, которые не могут быть охвачены в этом материале. Вы можете ознакомиться с их документацией на сайте PyMySQL.

Техники и лучшие практики при работе с базами данных

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

Во-первых, за некоторыми исключениями, всегда добавляйте столбцы id в свои таблицы. Все таблицы в MySQL должны иметь по крайней мере один первичный ключ (столбец ключа, по которому MySQL сортирует данные), чтобы MySQL знала, как его упорядочить, и часто бывает сложно выбрать эти ключи разумно.

Дебаты о том, использовать ли искусственно созданный столбец id для этого ключа или уникальный атрибут, такой как имя пользователя, продолжаются среди специалистов по данным и программных инженеров уже много лет, хотя я склонен к созданию столбцов id. Это особенно верно, когда речь идет о веб-скрапинге и хранении данных кого-то еще. Вы не знаете, что на самом деле является уникальным или неуникальным, и я уже не раз был удивлен.

Ваш столбец id должен быть автоинкрементным и использоваться в качестве первичного ключа для всех ваших таблиц.

Во-вторых, используйте разумную индексацию. Словарь (как книга, а не объект Python) — это список слов, отсортированных в алфавитном порядке. Это позволяет быстро находить нужное слово, если вы знаете, как оно написано. Вы можете также представить себе словарь, организованный в алфавитном порядке по определению слова. Это не будет так полезно, если вы не играете в какую-то странную игру «Викторина», где вам представляется определение, и вам нужно назвать слово. Но в мире поиска по базе данных такие ситуации случаются. Например, у вас может быть поле в базе данных, по которому вы часто будете делать запросы:

SELECT * FROM dictionary WHERE definition="Маленькое пушистое животное, которое говорит мяу";
+------+-------+-------------------------------------------+
| id   | word  | definition                                |
+------+-------+-------------------------------------------+
| 200  | cat   | Маленькое пушистое животное, которое говорит мяу |
+------+-------+-------------------------------------------+

Вполне вероятно, что вы захотите добавить индекс к этой таблице (помимо, предположительно, уже существующего индекса на id), на столбец definition, чтобы ускорить запросы к этому столбцу. Однако помните, что добавление индексов требует дополнительного места для нового индекса, а также дополнительного времени при вставке новых строк. Особенно при работе с большими объемами данных вам следует внимательно взвесить преимущества ваших индексов и то, насколько вам необходимо проиндексировать. Чтобы сделать этот индекс «definitions» немного легче, вы можете сказать MySQL проиндексировать только первые несколько символов значения столбца. Эта команда создает индекс на первые 16 символов в поле definition:

CREATE INDEX definition ON dictionary (id, definition(16));

Этот индекс сделает ваши запросы намного быстрее при поиске слов по их полному определению (особенно если первые 16 символов в значениях definition склонны быть сильно отличающимися друг от друга), а также не добавит слишком много дополнительного места и времени обработки на начальном этапе.

При обсуждении времени выполнения запросов по сравнению с размером базы данных (один из основных компромиссов в инженерии баз данных) одной из распространенных ошибок, особенно при веб-скрапинге больших объемов естественных текстовых данных, является хранение большого количества повторяющихся данных. Например, предположим, вы хотите измерить частоту встречаемости определенных фраз на веб-сайтах. Эти фразы могут быть найдены из заданного списка или автоматически сгенерированы с помощью алгоритма текстового анализа. Вы можете быть искушены сохранить данные примерно так:

+--------+--------------+------+-----+---------+----------------+
| Field  | Type         | Null | Key | Default | Extra          |
+--------+--------------+------+-----+---------+----------------+
| id     | int(11)      | NO   | PRI | NULL    | auto_increment |
| url    | varchar(200) | YES  |     | NULL    |                |
| phrase | varchar(200) | YES  |     | NULL    |                |
+--------+--------------+------+-----+---------+----------------+

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

> DESCRIBE phrases
+--------+--------------+------+-----+---------+----------------+
| Field  | Type         | Null | Key | Default | Extra          |
+--------+--------------+------+-----+---------+----------------+
| id     | int(11)      | NO   | PRI | NULL    | auto_increment |
| phrase | varchar(200) | YES  |     | NULL    |                |
+--------+--------------+------+-----+---------+----------------+

> DESCRIBE urls
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| url   | varchar(200) | YES  |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+

> DESCRIBE foundInstances
+-------------+---------+------+-----+---------+----------------+
| Field       | Type    | Null | Key | Default | Extra          |
+-------------+---------+------+-----+---------+----------------+
| id          | int(11) | NO   | PRI | NULL    | auto_increment |
| urlId       | int(11) | YES  |     | NULL    |                |
| phraseId    | int(11) | YES  |     | NULL    |                |
| occurrences | int(11) | YES  |     | NULL    |                |
+-------------+---------+------+-----+---------+----------------+

Хотя определения таблиц больше, вы можете видеть, что большинство столбцов — это просто целочисленные поля id. Они занимают гораздо меньше места. Кроме того, полный текст каждого URL и фразы хранится ровно один раз.

За исключением установки стороннего пакета или ведения тщательного журнала, может быть невозможно определить, когда кусок данных был добавлен, обновлен или удален из вашей базы данных. В зависимости от доступного места для ваших данных, частоты изменений и важности определения времени этих изменений вы можете захотеть сохранить несколько меток времени: created, updated и deleted.

«Шесть степеней» в MySQL

В одной из прошлых статей, мы решали проблему «Шести степеней Википедии«, в которой целью является найти связь между любыми двумя статьями Википедии через серию ссылок (то есть найти способ попасть из одной статьи Википедии в другую, просто щелкая по ссылкам с одной страницы на другую). Для решения этой проблемы необходимо не только создать ботов, которые могут обходить сайт (что вы уже сделали), но и хранить информацию в архитектурно обоснованном виде, чтобы в дальнейшем было легко проводить анализ данных.

Автоинкрементные столбцы id, временные метки и несколько таблиц — все это имеет значение здесь. Чтобы выяснить, как лучше всего хранить эту информацию, нужно думать абстрактно. Ссылка — это просто то, что соединяет страницу A с страницей B. Это может также соединять страницу B с страницей A, но это будет отдельная ссылка. Вы можете уникально идентифицировать ссылку, сказав: «Существует ссылка на странице A, которая соединяет ее с страницей B. Иными словами, INSERT INTO links (fromPageId, toPageId) VALUES (A, B); (где A и B — уникальные идентификаторы двух страниц).»

Двухтабличная система, предназначенная для хранения страниц и ссылок, вместе с датами создания и уникальными идентификаторами, может быть создана следующим образом:

CREATE TABLE `wikipedia`.`pages` (
`id` INT NOT NULL AUTO_INCREMENT,
`url` VARCHAR(255) NOT NULL,
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`));

CREATE TABLE `wikipedia`.`links` (
`id` INT NOT NULL AUTO_INCREMENT,
`fromPageId` INT NULL,
`toPageId` INT NULL,
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`));

Обратите внимание, что, в отличие от предыдущих веб-скраперов, которые выводили название страницы, вы даже не храните название страницы в таблице pages. Почему так? Ну, запись названия страницы требует ее посещения для получения. Если вы хотите построить эффективный веб-скрапер для заполнения этих таблиц, вам нужно иметь возможность хранить страницу, а также ссылки на нее, даже если вы еще не посетили страницу.

Хотя это не верно для всех сайтов, хорошая часть о ссылках и названиях страниц Википедии в том, что одно можно превратить в другое с помощью простых манипуляций. Например, http://en.wikipedia.org/wiki/Monty_Python указывает, что название страницы — «Monty Python».

Ниже приведен код, который сохранит все страницы на Википедии, у которых есть «число Бэкона» (количество ссылок между ней и страницей для Кевина Бэкона) равное 6 или меньше:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
import pymysql
from random import shuffle

conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
user='root', passwd=None, db='mysql', charset='utf8')
cur = conn.cursor()
cur.execute('USE wikipedia')

def insertPageIfNotExists(url):
    cur.execute('SELECT * FROM pages WHERE url = %s', (url,))
    if cur.rowcount == 0:
        cur.execute('INSERT INTO pages (url) VALUES (%s)', (url,))
        conn.commit()
        return cur.lastrowid
    else:
        return cur.fetchone()[0]

def loadPages():
    cur.execute('SELECT * FROM pages')
    pages = [row[1] for row in cur.fetchall()]
    return pages

def insertLink(fromPageId, toPageId):
    cur.execute('SELECT * FROM links WHERE fromPageId = %s AND toPageId = %s',
    (int(fromPageId), int(toPageId)))
    if cur.rowcount == 0:
        cur.execute('INSERT INTO links (fromPageId, toPageId) VALUES (%s, %s)',
        (int(fromPageId), int(toPageId)))
        conn.commit()

def getLinks(pageUrl, recursionLevel, pages):
    if recursionLevel > 4:
        return
    pageId = insertPageIfNotExists(pageUrl)
    html = urlopen('http://en.wikipedia.org{}'.format(pageUrl))
    bs = BeautifulSoup(html, 'html.parser')
    links = bs.findAll('a', href=re.compile('^(/wiki/)((?!:).)*$'))
    links = [link.attrs['href'] for link in links]
    for link in links:
        insertLink(pageId, insertPageIfNotExists(link))
        if link not in pages:
            pages.append(link)
            getLinks(link, recursionLevel+1, pages)

getLinks('/wiki/Kevin_Bacon', 0, loadPages())
cur.close()
conn.close()

Здесь три функции используют PyMySQL для взаимодействия с базой данных:

  • insertPageIfNotExists

Как следует из названия, эта функция вставляет новую запись страницы, если ее еще не существует. Это, вместе с текущим списком всех собранных страниц, сохраненных в pages, гарантирует, что записи страниц не дублируются. Она также служит для поиска номеров pageId для создания новых ссылок.

  • insertLink

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

  • loadPages

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

Вы должны знать об одной потенциально проблематичной тонкости использования loadPages и создаваемого им списка страниц для определения того, нужно ли посещать страницу: как только каждая страница загружена, все ссылки на этой странице сохраняются как страницы, даже если они еще не были посещены — были видны только их ссылки. Если веб-скрейпер останавливается и перезапускается, все эти «просмотренные, но не посещенные» страницы так и останутся не посещенными, и ссылки с них не будут записаны. Это можно исправить, добавив переменную visited к каждой записи страницы и устанавливая ее в True только в том случае, если страница была загружена и на ее исходящие ссылки записаны.

Для наших целей, однако, это решение вполне подходит. Если можно гарантировать довольно долгое время выполнения (или просто одно выполнение), и нет необходимости в полном наборе ссылок (только в большом наборе данных для экспериментов), то добавление переменной visited необязательно.

Отправка email

Отправка электронной почты в Python относительно проста, но требует доступа к серверу, работающему по протоколу SMTP (Простой Протокол Передачи Почты). Установка SMTP-клиента на вашем сервере или локальной машине требует определенных навыков и выходит за рамки данной книги, но существует множество отличных ресурсов, которые могут помочь в этом деле, особенно если вы используете Linux или macOS.

Следующие примеры кода предполагают, что вы запускаете SMTP-клиент локально. (Для изменения этого кода для удаленного SMTP-клиента измените localhost на адрес вашего удаленного сервера.)

Отправка электронной почты в Python требует всего девяти строк кода:

import smtplib
from email.mime.text import MIMEText

msg = MIMEText('Текст письма здесь')
msg['Subject'] = 'Оповещение по электронной почте'
msg['From'] = 'ryan@pythonscraping.com'
msg['To'] = 'webmaster@pythonscraping.com'

s = smtplib.SMTP('localhost')
s.send_message(msg)
s.quit()

Python содержит два важных пакета для отправки электронной почты: smtplib и email. Модуль email Python содержит полезные функции форматирования для создания электронных пакетов для отправки. Объект MIMEText, используемый здесь, создает пустое электронное письмо, отформатированное для передачи с использованием низкоуровневого протокола MIME (Multipurpose Internet Mail Extensions), через который осуществляются более высокоуровневые соединения SMTP. Объект MIMEText, msg, содержит адреса электронной почты отправителя и получателя, а также тело и заголовок, которые Python использует для создания правильно отформатированного электронного письма.

Пакет smtplib содержит информацию для обработки соединения с сервером. Как и соединение с сервером MySQL, это соединение должно быть разорвано каждый раз при создании, чтобы избежать создания слишком многих соединений.

Эта базовая функция отправки электронной почты может быть расширена и сделана более полезной, заключив ее в функцию:

import smtplib
from email.mime.text import MIMEText
from bs4 import BeautifulSoup
from urllib.request import urlopen
import time

def sendMail(subject, body):
    msg = MIMEText(body)
    msg['Subject'] = subject
    msg['From'] ='christmas_alerts@pythonscraping.com'
    msg['To'] = 'ryan@pythonscraping.com'
    
    s = smtplib.SMTP('localhost')
    s.send_message(msg)
    s.quit()

bs = BeautifulSoup(urlopen('https://isitchristmas.com/'), 'html.parser')
while(bs.find('a', {'id':'answer'}).attrs['title'] == 'NO'):
    print('Еще не Рождество.')
    time.sleep(3600)
    bs = BeautifulSoup(urlopen('https://isitchristmas.com/'), 'html.parser')
    
sendMail('Рождество!',
'Согласно http://itischristmas.com, это Рождество!')

Этот скрипт проверяет веб-сайт https://isitchristmas.com (основная функция которого — огромное YES или NO, в зависимости от дня года) каждый час. Если он видит что-то, кроме NO, он отправит вам электронное письмо с оповещением о том, что наступило Рождество.

Хотя этот конкретный программный продукт может показаться не более полезным, чем календарь, висящий на вашей стене, его можно слегка настроить для выполнения различных чрезвычайно полезных задач. Он может отправлять вам оповещения в случае отказа сайта, ошибок тестирования или даже появления товара, который вы ждете в магазине Amazon, который временно отсутствует в наличии — ни один из которых не может сделать ваш стенной календарь.

Понравилась статья? Поделиться с друзьями:
Школа Виктора Комлева
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!:

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.