CoderCastrov logo
CoderCastrov
Веб-разработка

Давайте разберем веб

Давайте разберем веб
просмотров
12 мин чтение
#Веб-разработка

Введение

Веб полон ценных данных (в основном общедоступных), которые могут быть использованы для исследований или других целей. Теперь, в эпоху искусственного интеллекта и машинного обучения, данные стали более ценными, чем когда-либо раньше! Но большинство этих данных предназначены для чтения людьми (то есть они представлены в формате HTML) и не доступны в форматах, которые легче читать компьютерам (например, XML, JSON или CSV).

Для сбора этих данных для наших целей мы используем парсеры, которые "парсят" эти веб-страницы, которые нас интересуют. Они просто получают страницу, разбирают HTML и извлекают данные (тексты, изображения, ссылки и т. д.) из этого HTML. Мы определяем иерархии или пути (XPath) или просто используем селекторы CSS, чтобы определить, какую часть HTML нас интересует.

В этом уроке мы создадим небольшое веб-приложение (на Flask), которое предоставляет некоторые данные о пищевых привычках разных стран в HTML, и мы напишем паука (или краулера, если хотите) с использованием фреймворка Scrapy.

Почему Scrapy? (Опционально, можно пропустить)

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

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

Обзор

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

Проект

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

Установка

Сначала нам нужно создать папку (мы будем использовать термин "каталог" взаимозаменяемо) с названием Scraper. Затем создайте две подпапки с названиями webapp и scraper. В папке Scraper мы открываем терминал (или командную строку, если вы используете Windows) и пишем следующую команду:

pip install --user flask scrapy

Эта команда установит Flask и Scrapy для нас. Мы использовали параметр --user, чтобы нам не нужны были права администратора (не требуется, если вы используете virtualenv).

Веб-приложение

Для создания веб-приложения мы создаем файл с названием __init__.py и каталог с названием templates внутри каталога webapp.

Теперь мы помещаем следующий код внутрь __init__.py.

from datetime import datetime
from functools import wraps
from flask import Flask, request, redirect, session, url_for, render_template

app = Flask(__name__, template_folder="templates")
app.config["SECRET_KEY"] = "secret secret key"

dummy_data = [
    {
        "country": "Канада",
        "gdp": "Высокий",
        "happiness": "Высокий",
        "food": [
            "Лось",
            "Грибы",
            "Арахисовое масло",
            "Ветчина",
            "Круассаны",
        ]
    },
    {
        "country": "Америка",
        "gdp": "Высокий",
        "happiness": "Средний",
        "food": [
            "Говядина",
            "Говядина",
            "Говядина",
            "Ветчина",
            "Сахар",
            "Сахар"
        ]
    },
    {
        "country": "Уганда",
        "gdp": "Низкий",
        "happiness": "Высокий",
        "food": [
            "Рис",
            "Говядина",
            "Бананы",
            "Мясо льва"
        ]
    },
    {
        "country": "Индия",
        "gdp": "Средний",
        "happiness": "Средний",
        "food": [
            "Рис",
            "Даль",
            "Картофель",
            "Курица",
            "Говядина",
            "Шпинат",
            "Рыба",
            "Рыба",
            "Молоко",
            "Молоко",
            "Молоко",
            "Специи"
        ]
    },
    {
        "country": "Россия",
        "gdp": "Высокий",
        "happiness": "Высокий",
        "food": [
            "Ветчина",
            "Майонез",
            "Рыба",
            "Лед",
            "Хлеб",
            "Водка",
            "Водка"
        ]
    }
]

def login_required(viewfunc):
    @wraps(viewfunc)
    def decorate(*args, **kwargs):
        if "logged_in" not in session:
            return redirect(url_for("login"))
        return viewfunc(*args, **kwargs)
    return decorate

@app.route("/")
@app.route("/index")
@login_required
def index():
    return render_template('index.html')

@app.route("/login", methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    if request.form.get("username") == "admin" and request.form.get("password") == "admin":
        session["logged_in"] = str(datetime.today)
        return redirect(url_for("index"))
    return """
    Вход не выполнен. Вернуться <a href="{}">назад</a>?
    """.format(url_for("login"))

@app.route("/food_by_country")
@login_required
def food_by_country():
    return render_template("food_by_country.html", data=dummy_data)

if __name__ == '__main__':
    app.run(debug=True)

Теперь, чтобы запустить веб-приложение, мы пишем следующую команду:

python __init__.py

Затем мы создаем и заполняем файлы внутри каталога templates по одному.

Содержимое файла templates/base.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta charset="UTF-8">
    <title>{% block title %}{% endblock %}</title>
</head>
<body>
<div class="container">
    {% block content %}{% endblock %}
</div>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>
</body>
</html>

Содержимое файла templates/index.html:

{% extends 'base.html' %}{% block title %}Добро пожаловать на сервер с едой{% endblock %}{% block content %}
<p> Чтобы увидеть любимую еду по странам, пожалуйста, перейдите по <a href="{{ url_for('food_by_country') }}">этой</a> ссылке</p>
{% endblock %}

Содержимое файла templates/login.html:

{% extends 'base.html' %}{% block title %}Вход{% endblock %}{% block content %}
<div class="align-items-center" style="margin-top: 25%">
<form method="POST" action="/login">
    <div class="form-group row d-flex justify-content-center">
        <label for="username" class="col-sm-2 col-form-label">Имя пользователя</label>
        <div class="col-sm-5">
            <input type="text" name="username" placeholder="Имя пользователя" value="" class="form-control" id="username">
        </div>
    </div>    <div class="form-group row d-flex justify-content-center">
        <label for="password" class="col-sm-2 col-form-label">Пароль</label>
        <div class="col-sm-5">
            <input type="password" name="password" placeholder="Пароль" value="" class="form-control" id="password">
        </div>
    </div>    <div class="d-flex justify-content-center">
        <button type="submit" class="btn btn-primary">Войти</button>
    </div>
</form>
</div>
{% endblock %}

Содержимое файла templates/food_by_country.html:

{% extends 'base.html' %}{% block title %}Кто что любит{% endblock %}{% block content %}
<table class="food-table table table-bordered table-sm text-center">
    <thead>
    <tr>
        <th>Страна</th>
        <th>Доход</th>
        <th>Счастье</th>
        <th>Еда</th>
    </tr>
    </thead>
    <tbody>
    {% for item in data %}
    {% set ll = item['food'] | length %}
    <tr>
        <td rowspan="{{ ll }}">{{ item['country'] }}</td>
        <td rowspan="{{ ll }}">{{ item['gdp'] }}</td>
        <td rowspan="{{ ll }}">{{ item['happiness'] }}</td>
        <td>
            {{ item['food'] | first }}
        </td>
    </tr>
    {% for food in item['food'][1:] %}
    <tr>
        <td>
            {{ food }}
        </td>
    </tr>
    {% endfor %}
    {% endfor %}
    </tbody>
</table>
{% endblock %}

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

webapp/
├── __init__.py
└── templates
    ├── base.html
    ├── food_by_country.html
    ├── index.html
    └── login.html

Теперь в браузере мы переходим по адресу http://localhost:5000.

Входим, используя имя пользователя "admin" и пароль "admin" (без кавычек).

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

Примечание: Мы обсудим Flask в отдельном учебнике.

Примечание: Мы оставляем веб-приложение работающим для скрапера. Однако, если вы хотите его остановить, вы можете просто нажать Control-C.

Паук

Для создания проекта Scrapy мы пишем следующую команду:

scrapy startproject scraper

Затем мы переходим в каталог scraper. Теперь мы можем увидеть содержимое этой папки с помощью команды ls (на Mac, Linux или BSD) или dir (на Windows). Как видим, есть файл с именем scrapy.cfg и каталог с именем scraper. В этом учебнике мы не будем говорить о файле cfg. Однако вы можете прочитать об этом в документации Scrapy здесь. Теперь мы переходим в каталог scraper (теперь мы находимся в Scraper/scraper/scraper) и пишем команду:

scrapy genspider countryfood localhost:5000

Здесь вы можете изменить countryfood на любое другое имя, и он создаст файл Python с шаблоном по умолчанию с этим именем в папке spiders (расположение: Scraper/scraper/scraper/spiders). Паук будет парсить url localhost:5000, и у него будет ограничение на то, какие url он может парсить (ограничение домена).

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

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

scraper/
├── scraper
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       ├── countryfood.py
│       ├── __init__.py
└── scrapy.cfg

Теперь мы изменяем код в countryfood.py следующим образом.

from urllib.parse import urlencode, urlsplit
import scrapy

class CountryfoodSpider(scrapy.Spider):
    name = 'countryfood'
    allowed_domains = ['localhost']
    start_urls = ['http://localhost:5000/']

    def parse(self, response):
        # Проверяем, нужно ли нам войти в систему.
        # После входа в систему сервер перенаправляет нас на главную страницу.
        if urlsplit(response.request.url).path == '/login':
            yield response.follow(
                response.css("form::attr(action)").get(),
                method='POST', body=urlencode({
                    "username": "admin",
                    "password": "admin",
                }), callback=self.parse_index, headers={
                    "Content-Type": "application/x-www-form-urlencoded",
                })
        # Если нам не нужно входить в систему, значит мы уже находимся на главной странице.
        else:
            yield from self.parse_index(response)

    def parse_index(self, response):
        yield response.follow(
            response.css("p > a::attr(href)").get(),
            callback=self.parse_data,
        )

    def parse_data(self, response):
        for tr in response.css("table.food-table tbody tr"):
            tds = tr.css("td")
            yield {
                "country": tds[0].css('::text').get(),
                "income": tds[1].css('::text').get(),
                "happiness": tds[2].css('::text').get(),
                "food": [i.strip() for i in tds[3].css('::text').extract() if i.strip()],
            }

Здесь CountryfoodSpider - это наш парсер (каждый парсер в Scrapy - это класс). Scrapy запускает парсер с url, указанным в атрибуте класса start_urls. Для каждого элемента этого атрибута Scrapy вызывает метод parse паука и передает объект response.

Некоторые атрибуты

Также обратите внимание, что ранее в списке allowed_domains класса был элемент localhost:5000, но он был изменен на localhost, потому что номер порта не является частью домена. Поэтому, когда Scrapy фильтрует запросы по доменам, он может блокировать наши запросы.

Метод parse

Теперь давайте посмотрим внутрь метода parse. Как уже упоминалось ранее, это точка входа для любого паука Scrapy. Сначала мы проверяем, вошли ли мы в систему. Если нет, мы отправляем POST запрос (передавая строку 'POST' в параметр method) на сервер с нашими учетными данными (закодированными в URL-формате, передаваемыми с помощью параметра body). В любом случае мы вызываем функцию parse_index. Также важно установить заголовок content-type в application/x-www-form-urlencoded. В противном случае Flask просто проигнорирует тело запроса, так как он не знает, какой тип данных находится внутри тела. Первый параметр этого метода будет обсуждаться позже в этом руководстве.

НО, мы видим, что есть ветвь else в функции, и в ней вызывается parse_index. Тогда как мы вызываем функцию, если мы не вошли в систему и получаем ответ после входа в систему? Для этого мы используем параметр callback для метода follow (строка 15) объекта response. Каждая функция обработчика ответа в Scrapy работает автономно. Чтобы передавать данные между ними, мы используем параметр cb_args. Функция follow возвращает запрос с установленным URL в абсолютный URL.

Генераторы и ключевые слова yield и yield from

Также обратите внимание, что мы здесь не использовали ключевое слово return. Scrapy использует генераторы для переключения кода. Поэтому нам нужно использовать ключевое слово yield. Подробнее о генераторах Python можно прочитать здесь. Поскольку каждая вызываемая функция в пауке Scrapy возвращает генератор, как мы должны использовать yield from для возврата из этого генератора? Для этого обратите внимание на строку 25. Мы используем yield from, чтобы вернуться из генератора.

Метод parse_index и селекторы.

Как уже упоминалось ранее, нам нужно щелкнуть по ссылке, чтобы увидеть данные после входа в систему. Теперь, как мы можем найти эту ссылку?

Если вы используете Chrome (или даже Chromium) или Firefox, нажмите Control-Shift-C, и вы увидите появление окна в браузере. Затем щелкните на вкладке "Inspector". Или вы можете перейти туда, щелкнув правой кнопкой мыши в любом месте страницы и выбрав пункт меню "Inspect element". Это покажет нам элементы и дерево HTML-документа. Мы видим, что ссылка (или якорь или тег <a>) находится внутри тега <p>. Так что наш путь в основном состоит из p > a. Интерпретируйте это как "перейти к a из p". Теперь у нас есть два варианта. Мы можем использовать CSS-селекторы или XPath. XPath очень мощный и имеет много функциональности. Однако CSS-селекторы могут прекрасно справиться с задачей и намного проще. Поэтому в этом руководстве мы будем использовать CSS-селекторы.

Чтобы выбрать тег <a> внутри тега <p>, мы можем просто использовать строку селектора p a. Это выберет все дочерние элементы якоря тега параграфа (как прямых, так и косвенных). Но нам нужны только прямые дети здесь. Поэтому мы используем p > a. Чтобы получить атрибут выбранного тега в CSS-селекторе, мы используем "псевдоэлементный селектор" с именем attr() и передаем имя внутри скобок атрибута, значение которого нас интересует. Мы используем двоеточие для указания, что мы используем "псевдоэлементный селектор". Так что строка селектора будет "p > a::attr(href)", и теперь мы передаем это в качестве первого аргумента метода css объекта response (который принимает CSS-селектор и возвращает все выбранные элементы, то есть selection). Чтобы получить первое значение из этой selection, мы используем метод get выборки. Есть также метод extract для объектов selection, но он вернет список всех значений из всех элементов выборки, что нам сейчас не нужно.

Надеюсь, теперь вы понимаете, что происходит в строке 15.

Затем, как обычно, мы возвращаем объект request с помощью response.follow с соответствующими значениями (URL и функция обратного вызова).

Метод parse_data

Теперь вы знаете большую часть Scrapy. Но как мы экспортируем данные? Для этого мы либо используем словарь, либо элемент Scrapy. Но я предпочитаю использовать словарь для этого учебника.

Обратите внимание, что в цикле for мы перебираем объект selection. Объект selection является, по сути, списком, и его элементы также имеют метод css и могут использовать селекторы внутри своих дочерних элементов. Это то, что мы сделали в строке 35. Затем в словаре мы извлекли текст из всех выбранных элементов <td> с помощью псевдо-селектора text и вызвали для него метод get. Однако в значении ключа food мы использовали генератор списка. Это потому, что в конце тела элемента <td> был тег <br>, который приведет к пустой строке (потому что теги и тексты обрабатываются отдельно. Я рекомендую вам поиграть и поразмыслить над селекторами).

Запуск паука

Как уже упоминалось ранее, мы оставили веб-приложение работать. Теперь мы сначала перечислим все доступные пауки с помощью следующей команды:

scrapy list
List of spiders

Мы видим, что паук countryfood указан в списке. Теперь мы готовы к работе! Введите следующую команду, чтобы запустить паука:

scrapy crawl countryfood -L WARN -o -:jl
Running the spider

Здесь команда crawl указывает scrapy начать работу паука. Опция -L устанавливает уровень журнала с отладки на WARN. Это полезно, потому что в противном случае scrapy будет выводить много журнальных данных, включая извлеченные элементы. Опция -o указывает файл и формат вывода. Здесь -:jl - это значение, передаваемое для опции -o, которое говорит установить файл вывода на stdout и формат на JSON lines. Двоеточие разделяет эти два значения. Обычно мы используем JSON lines вместо JSON, потому что, если он производит много вывода, JSON не масштабируется очень хорошо.

Вот финальный код для учебника.

Окончательное дерево каталогов:

scraper/
├── scraper
│   ├── scraper
│   │   ├── __init__.py
│   │   ├── items.py
│   │   ├── middlewares.py
│   │   ├── pipelines.py
│   │   ├── settings.py
│   │   └── spiders
│   │       ├── countryfood.py
│   │       ├── __init__.py
│   └── scrapy.cfg
└── webapp
    ├── __init__.py
    └── templates
        ├── base.html
        ├── food_by_country.html
        ├── index.html
        └── login.html

Желаю удачи в вашем путешествии по парсингу!