Парсинг сайтов. Tornado микро-фреймворк.

Basics of Python and Django. -> Парсинг сайтов.

Парсинг сайтов.

Предположим, мы продаем шторы и хотим создать интернет магазин штор. Возьмем какой то пример. В выдачи по “магазин штор” у меня первым вышел https://pangardin.com.ua

Примерная структура каталога данных:

. data
..pages
...main.txt
...about.txt
...contact.txt
..pangardin.com.ua
...tul
...shtory
...furnitura

Как видно, мы разбиваем товары по каталогам, будем называть их каталог-категориями,

при этом внутри таких категорий необходим какой-то мета файл с информацией о данной категории.

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

Так и назовем этот файл - meta.yml. С учетом многоязычности этот файл может выглядеть так:

content_type: category
is_published: true
order: 1
title_ru: Тюль.
title_ua: Тюль.
desc_ru: Самая лучшая <strong>тюль</strong>!
desc_ua: Дуже гарна тюль!

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

Подобным образом можно оформить и статичные страницы в файлах *.yml в каталоге pages. Например так:

content_type: page
is_published: true
title_ru: Разработка интернет-магазина штор.
desc_ru: Как разработать интернет магазин на  <strong>python</strong>!

Примерная структура каталога товарной позиции:

.tul-metis
..images
...1.png
...2.png
..meta.yml

Файл описания товарной позиции.

name_slug: shtory-raduga
name_ru: Шторы радуга.
meta_title_ru: ...
meta_keywords_ru: ...
meta_description_ru: ...
is_published: false

Для простоты, пока будем оперировать одной, русской локализацией.

Итак, наша следующая задача - опросить сайт-источник скриптом и создать требуемую структуру каталогов. Для этого я буду использовать библиотеки Python requests и BeatifulSoup.

Библиотекой requests мы будем генерировать HTTP запросы на сервер и получать HTML код страниц.

Библиотекой BeatifulSoup мы этот HTML парсим и находим в нем интересующие элементы (теги) и забираем их содержимое.

В начале, нас интересует сама структура каталога. Ее можно найти на главной странице во всплывающем блоке.

HTML код этого списка выглядит так:

<ul class="sub-menu">
    <li class="menu-item ..."><h4 class='mega_menu_title'>Категории</h4>
    <ul class="sub-menu">
        <li class="menu-item ..."><a href="..."><span class="avia-bullet"></span>Ткани-Компаньоны</a></li>
        ...

Я убрал лишние css классы для наглядности. Что мы имеем - это два вложенных списка ul с категориями и подкатегориями.

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

Все, приведенные ниже примеры, даны для операционной сисnемы на базе Debian.

Создание виртуального окружения.

virtualenv -p python3 venv3

После создания виртуального окружения необходимо его активировать набрав такую команду в консоле.

. ./venv3/bin/activate

Установить в него наши библиотеки можно такими командами.

pip install requests
pip install beatifulsoup4

Если у вас не установлена программа pip, то поставить ее можно так:

sudo apt install python-pip

Ниже приведен Python код с подробными комментариями, который разбирает cтруктуру категорий и выводит ее на экран терминала.

#!/usr/bin/env python
# импортируем библиотеки
import requests
from bs4 import BeautifulSoup
print("Getting catalog")
# делаем запрос на получение главной страницы 
url = 'https://pangardin.com.ua'
r = requests.get(url)
# распознаем HTML, создавая специальный объект soup 
soup = BeautifulSoup(r.text, 'html.parser')
# находим главный (родительский) элемент списка категорий
ulsout = soup.find('ul',{'class': 'sub-menu'})
# в цикле проходим по всем его элементам  
for ulout in ulsout:
    liout = ulout.find('li')
    if liout != -1:
        print('.'+liout.text)
        # для каждой родительской категории находим дочерний список подкатегорий
        ulins = ulout.find('ul')
        # в цикле проходим по подкатегориям
        for liin in ulins.findAll('li'):
            print('..'+liin.text)

Вывод программы:

.Ткани-Компаньоны
..Ткани-Компаньоны
..Тюль
..Портьерные ткани
..Готовые шторы
..Шторы нити
..Постельное белье
..Фурнитура
….

Все что тут происходит - это перебираются ul - списки в двух вложенных циклах.

Теперь осталось перевести названия в транслит и создать нужные каталоги.

Транслитирировать можно библиотекой python-slugify и делается это передачей нужной строки в функцию slugify.

name_slug = slugify(name)

Для удобства, программу всегда разделяют на более мелкие подпрограммы, например функциии.

Функцию сохранения категории на диск можно описать так:

def save_category(cat_name,parent_name):
    ''' сохранение каталогов-категорий  '''
    # транслитерируем русское название категории
    cat_name_slug = slugify(cat_name)
    # транслитерируем русское название подкатегории
    parent_name_slug = slugify(parent_name)
    # выводим отладочное сообщение в консоль
    print("Creating category %s with parent %s" % (cat_name,parent_name))
    # формируем путь для для создания каталога
    dir_name = os.path.join(DATA_DIR,cat_name_slug)
    # создаем каталог, если его не существует
    if not os.path.isdir(dir_name):
        os.mkdir(dir_name)

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

Это будем делать во внутреннем цикле.

    for liin in ulins.findAll('li'):
        save_category(liin.text, liout.text)

Полный код программы и результат его работы приведен ниже.

#!/usr/bin/env python
import requests
from bs4 import BeautifulSoup
import os
from slugify import slugify

### создаю каталоги с данными
if not os.path.isdir('data'):
    os.mkdir('data')

if not os.path.isdir(os.path.join('data','pangardin.com.ua')):
    os.mkdir(os.path.join('data','pangardin.com.ua'))
###

DATA_DIR = os.path.join('data','pangardin.com.ua')

def save_category(cat_name,parent_name):
    ''' сохранение каталогов-категорий  '''
    cat_name_slug = slugify(cat_name)
    parent_name_slug = slugify(parent_name)
    print("Creating category %s with parent %s" % (cat_name,parent_name))
    dir_name = os.path.join(DATA_DIR,cat_name_slug)
    if not os.path.isdir(dir_name):
        os.mkdir(dir_name)

print("Getting catalog")
url = 'https://pangardin.com.ua'
r = requests.get(url)
soup = BeautifulSoup(r.text, 'html.parser')
ulsout = soup.find('ul',{'class': 'sub-menu'})

for ulout in ulsout:
    liout = ulout.find('li')
    if liout != -1:
        ulins = ulout.find('ul')
        for liin in ulins.findAll('li'):
            save_category(liin.text, liout.text)

Дополним функцию save_category созданием meta.yml файлов, минимально описывающих категории.

В эти файлы будем писать имена и псевдонимы текущей и родительской категории.

def save_category(cat_name,parent_name):
    ''' сохранение каталогов-категорий  '''
    # транслитерируем русское название категории
    cat_name_slug = slugify(cat_name)
    # транслитерируем русское название подкатегории
    parent_name_slug = slugify(parent_name)
    # выводим отладочное сообщение в консоль
    print("Creating category %s with parent %s" % (cat_name,parent_name))
    # формируем путь для для создания каталога
    dir_name = os.path.join(DATA_DIR,cat_name_slug)
    # создаем каталог, если его не существует
    if not os.path.isdir(dir_name):
        os.mkdir(dir_name)
    # создаем строку в которую вставляем названия категорий
    meta_info = '''
content_type: category
is_published: true
name_slug: %s
name_ru: %s
parent_slug: %s
parent_name_ru: %s
order: 1
descr: |
    ''' % (cat_name_slug,cat_name,parent_name_slug,parent_name)
    with open(os.path.join(dir_name,'meta.yml'),'w') as f:
        f.write(meta_info)

Функция прохода по странице каталога

def save_goods(url,category):
    # Получаем станицу категории
    r = requests.get(url)
    soup = BeautifulSoup(r.text, 'html.parser')    
    # находим список товаров
    products = soup.find('ul',{'class': 'products'}).findAll('li')    
    for product in products:
        # заголовок
        title = product.find('div',{'class': 'inner_product_header'}).find('h3').text
        title_slug = slugify(title)        
        # фоормируем путь
        path = os.path.join(DATA_DIR,category,title_slug)
        # создаем каталог товара
        print("Saving ... %s" % title_slug)
        if not os.path.isdir(path):
            os.mkdir(path)  
        # находим ссылку на страницу товара
        link = product.find('div',{"class": "inner_cart_button"}).find('a').get('href')
        # сохраняем позицию
        save_position(link,path)

Взглянем на то как выглядит HTML код позиции товара на странице категории

https://pangardin.com.ua/category/complect/artdeco/

<li class="...">
  <div class="inner_product wrapped_style ">
    <div class="avia_cart_buttons single_button">
      <div class="inner_cart_button">
        <a href="https://pangardin.com.ua/magazin/ballades/" >Просмотр товара</a>
      </div>
    </div>
    <a href="https://pangardin.com.ua/magazin/ballades/">
      <div class="thumbnail_container">
        <img src="..." class="attachment-shop_catalog wp-post-image" alt="Ballade">
      </div>
      <div class="inner_product_header">
        <h3>Ballades</h3>
          <span class="price"><span class="amount">208грн.</span>
          <span class="amount">366грн.</span>
        </span>
      </div>
    </a>
    </div>
</li>

После получения элемента li

products = soup.find(‘ul’,{‘class’: ‘products’}).findAll(‘li’)

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

title = product.find(‘div’,{‘class’: ‘inner_product_header’}).find(‘h3’).text

Теперь необходимо получить url адрес страницы с описанием товара.

link = product.find(‘div’,{“class”: “inner_cart_button”}).find(‘a’).get(‘href’)

Опишем функцию сохранения позиции.

def save_position(link,path):
    r = requests.get(link)
    with open('log.html','w') as f:
        f.write(r.text)
    soup = BeautifulSoup(r.text, 'html.parser')
    print(link)
    #sys.exit('s')
    # находим ссылку на изображение
    img_link = soup.find('a',{"class": "MagicZoomPlus"}).get('href')
    print("Downloading pic %s" % img_link)
    # забираем картинку
    r = requests.get(img_link, stream=True)
    # создаем каталог images
    path_to_img = os.path.join(path,'images')
    if not os.path.isdir(path_to_img):
        os.mkdir(path_to_img)    
    # сохраняем картинку
    image_full_path = os.path.join(path_to_img,'1.png')
    if r.status_code == 200:
        with open(image_full_path, 'wb') as f:
            r.raw.decode_content = True
            shutil.copyfileobj(r.raw, f)    
    # находим название и описание
    title = soup.find('h1',{"class": "product_title"}).text  
    description = soup.find('div',{"itemprop": "description"}).text
    # формируем содержимое meta.yml
    meta_info = '''
name_slug: %s
name_ru: %s
meta_title_ru: %s
meta_keywords_ru: %s
meta_description_ru: %s
is_published: true
description_ru: |
%s
    ''' % (slugify(title),title,title,title,title,description)
    # запись в файл
    with open(os.path.join(path,'meta.yml'),'w') as f:
        f.write(meta_info)

При поиске картинки, мы вначале находим элемент div с классом inner_cart_button, а потом в нем ссылку, из которой атрибут href. Загрузив страницу описания товара найдем элемент с изображением в исходном коде.

<a class="MagicZoomPlus" href="https://pangardin.com.ua/wp-content/uploads/2013/04/DSC00047.jpg" >
  <img itemprop="image" src="..." >
  <div class="MagicZoomPup">
  </div>
...
</a>

Получить на него ссылку можно такой строчкой кода, где мы ищем по классу MagicZoomPlus.

img_link = soup.find('a',{"class": "MagicZoomPlus"}).get('href')

Для того, чтобы получить более подробную и “чистую” информацию о товаре понадобиться некоторая сноровка в работе со структурой HTML страницы.

Скорее всего, надо будет отсеивать лишнюю информацию и выбирать то что вам нужно для будущего сайта.

Пример нашего сайта-источника написан на wordpress и для каждого нового сайта понадобится написать свой собственный парсер контента.

Basics of Python and Django. -> Домашнее задание. Генераторы. Парсинг сайта.

Домашнее задание. Генераторы. Парсинг сайта.

Дан список url-ов.

urls = [‘https://www.lifewire.com/’, ‘https://www.geeksforgeeks.org/’,’https://rtfm.co.ua/’]

Используя функцию-генератор в цикле пройтись по этому массиву и собрать все ссылки с главных страниц внутри генератора.

for url in mygenerator():
    print(url)

Результат сохранить в json файлах для каждого адреса.

www.lifewire.com.json

[
   {'title': '...', 'url': '..'}, {} ... 
]
Basics of Python and Django. -> Tornado микро-фреймворк.

Микрофреймворк Tornado.

Tornado — расширяемый, неблокирующий веб-сервер и фреймворк, написанный на Python.

Его в 2009 году приобрела компания Facebook, после чего исходные коды Tornado были открыты.

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

Рассмотрим основные приемы работы с этим фреймворком.

Запуск веб-сервера.

Подключение шаблонов и статики.

Работа с GET и POST запросами.

Шаблонизация.

Работа с базой данных.

Отдача json.

Установка

pip install tornado

Первый сервер.

import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")

def make_app():
    return tornado.web.Application([
        (r"/", MainHandler),
    ])

if __name__ == "__main__":
    app = make_app()
    app.listen(8888)
    tornado.ioloop.IOLoop.current().start()

Шаблонизация.

from tornado import template
loader = template.Loader("./templates")


class MainHandler(tornado.web.RequestHandler):
    def get(self):

        mydict = [
            {'name': 'Dima'},
            {'name': 'Vova'},
            {'name': 'Gena'},
        ]

        tpl = loader.load("index.html").generate(myvalue="XXX", mydict=mydict)
        self.write(tpl)

Шаблон.

<h1>Hello, world {{ myvalue }}  </h1>

<ul>
 {% for d in mydict %}
   {% block d %}
     <li>{{ d['name'] }}</li>
   {% end %}
 {% end %}
</ul>

Наследование шаблонов.

templates/layout.html

<!doctype html>
<html lang="en">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.min.css" >
    <title>Hello, ffff world!</title>
  </head>
  <body>
    <table border="1">
        <tr>
            <td> 
                <a href="/">Main</a> <br/>
                <a href="/contact">Contact</a>
            </td>
            <td>
                {% block content %}{% end %}
            </td>
        </tr>
    </table>
  </body>    
</html>


templates/index.html

{% extends "layout.html" %}

{% block content %}
<h1>Hello, world {{ myvalue }}  </h1>

<ul>
 {% for d in mydict %}
   {% block d %}
     <li>{{ d['name'] }}</li>
   {% end %}
 {% end %}
</ul>

{% end %}



templates/contact.html

{% extends "layout.html" %}

{% block content %}
<h1>Contact</h1>

<form method="POST" action="/contact">
  <input name="title" />
  <input type="submit" name="Submit" value="Submit" />
</form>

{% end %}

Автоматическая перегрузка сервера пи изменении шаблонов и кода сервера.

def make_app():
    return tornado.web.Application([
        ...
    ], autoreload=True)

if __name__ == "__main__":
    app = make_app() 
    app.listen(8888) 
    tornado.autoreload.watch('./templates/index.html')
    tornado.autoreload.watch('./templates/layout.html')
    tornado.ioloop.IOLoop.current().start()

Отработка POST запроса.

class ContactHendler(tornado.web.RequestHandler):
    def get(self):
        tpl = loader.load("contact.html").generate() 
        self.write(tpl) 
    def post(self):
        tpl = loader.load("contact.html").generate() 
        print(self.get_argument("title"))
        self.write(tpl)

   ...

   (r"/contact", ContactHendler),

Редирект.

self.redirect('/tasks')

Работа с базой данных, библиотека tornado_mysql.

Установка.

sudo pip install Tornado-MySQL

Это библиотека асинхронная и экспериментальная.

Используется с корутинами торнадо.

Примеры.

class ContactHendler(tornado.web.RequestHandler):
    @gen.coroutine
    def get(self):
        conn = yield tornado_mysql.connect(host='127.0.0.1', port=3306, user='root', passwd='1q2w3e', db='test')
        cur = conn.cursor()
        yield cur.execute("select * from users")
        cur.close()
        records = cur.fetchall()
        tpl = loader.load("contact.html").generate(records=records) 
        self.write(tpl) 
    @gen.coroutine
    def post(self):
        conn = yield tornado_mysql.connect(host='127.0.0.1', port=3306, user='root', passwd='1q2w3e', db='test')
        cur = conn.cursor()
        yield cur.execute("insert into users (name) values ('%s')" % self.get_argument("title")) 
        conn.commit()
        cur.close()
        self.redirect('/contact')
Basics of Python and Django. -> Создание проекта Django и импорт данных в БД.

Создание интернет-магазина штор.

В предыдущей части мы скопировали с сайта pangardin.com.ua каталог товаров и сохранили его в определенной структуре каталогов.

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

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

./manage.py import

Создание проекта Django.

Создаем проект командой.

django-admin.py startproject prj

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

Переходим в каталог проекта.

cd prj

Создаем базу данных командой:

./manage.py migrate

При этом создается файл db.sqlite3 c таблицами приложений, которые поставляются с Django.

Создать приложение в проекте можно при помощи следующей команды.

./manage.py startapp shop

После выполнения этой команды будет создан новый каталог shop в папке проекта.

Далее нам необходимо добавить это приложение в список переменной INSTALLED_APPS в файле settings.py.

Таким образом мы включаем наше приложение в проект.

Заполним файл shop/models.py классами нашей модели.

from django.db import models
from django.utils.safestring import mark_safe

class Category(models.Model):
    ''' Категории  '''
    name = models.CharField(max_length=250)
    name_slug = models.CharField(max_length=250)


class Subcategory(models.Model):
    ''' Подкатегории  '''
    name = models.CharField(max_length=250)
    name_slug = models.CharField(max_length=250)
    category = models.ForeignKey(Category, on_delete=models.SET_NULL,  null=True)


class Good(models.Model):
    ''' Товары  '''
    name = models.CharField(max_length=250)
    name_slug = models.CharField(max_length=250)
    desc = models.TextField()
    subcategory = models.ForeignKey(Subcategory, on_delete=models.SET_NULL,  null=True)



class Image(models.Model):
    ''' Изображения  '''
    good = models.ForeignKey(Good, on_delete=models.SET_NULL, null=True)
    image = models.ImageField()

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

Таблицы связываются между собой специальным полем типа ForeignKey.

После того как все классы созданы, необходимо создать файл миграции командой:

./manage.py makemigrations

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

./manage.py migrate

После этого таблицы будут физически созданы.

Создание команды импорта.

При создании команды Django необходимо соблюсти требуемую структуру каталогов.

Внутри папки приложения shop необходимо создать каталог management и в нем каталог commands.

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

Теперь внутри каталога commands создадим скрипт import.py который и будет нашей командой.

Для того чтобы написать команду нужно создать класс Command, унаследовав его от базового класса Django BaseCommand.

Затем необходимо переписать метод handle, который является точкой входа в команду.

from django.core.management.base import BaseCommand, CommandError

def import_catalog():
    pass

class Command(BaseCommand):
    def handle(self, *args, **options):
        print('Importing data')
        import_catalog()

Определим переменную с абсолютным путем к папке с данными.

DATA_DIR = os.path.join(BASE_DIR,'..','data','pangardin.com.ua')

После импорта классов модели мы в начале очистим все таблицы.

from shop.models import *

def import_catalog():
    Subcategory.objects.all().delete()
    Category.objects.all().delete()
    Good.objects.all().delete()
    Image.objects.all().delete()

И далее в цикле пройдем по подкаталогам и прочитаем содержимое файла meta.yml.

for item in os.listdir(DATA_DIR):
    if os.path.isdir(os.path.join(DATA_DIR,item)):
        # читаем meta.yml
        with open(os.path.join(DATA_DIR,item,'meta.yml'),'r') as f:
            rez = f.read()

Для того, чтобы превратить строку, в которой находится содержиме файла в словарь, применим библиотеку pyyaml.

pip install pyyaml

Далее в коде распарсим содержимое при помощи функции load().

yml_data = yaml.load(rez)

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

yml_data['name_ru']

Создадим категории в базе, исключая дубли.

# создаем категории в базе если такой нет
try: 
    cat = Category.objects.get(name_slug=yml_data['parent_slug'])
except:
    cat = Category()
    cat.name = yml_data['parent_name_ru']
    cat.name_slug = yml_data['parent_slug']
    cat.save()

Аналогичным образом поступим и с подкатегориями:

# создаем подкатегории в базе если такой нет
try: 
    scat = Subcategory.objects.get(name_slug=yml_data['name_slug'])
except:
    scat = Subcategory()
    scat.name = yml_data['name_ru']
    scat.name_slug = yml_data['name_slug']
    scat.category = cat
    scat.save()

Далее в цикле пробежим по каталогам продуктов и вызовем функцию сохранения товара.

for item_good in os.listdir(os.path.join(DATA_DIR,item)):
    if os.path.isdir(os.path.join(DATA_DIR,item,item_good)):
        import_goods(item_good,os.path.join(DATA_DIR,item),scat)

Делаем сохранение только если в цикл попадает каталог.

Опишем функцию сохранения товара.

def import_goods(name_slug,path,sub_category):
    print('Importing ..... %s' % name_slug)
    # читаем meta.yml
    try:
        with open(os.path.join(path,name_slug,'meta.yml'),'r') as f:
            rez = f.read()
    except:
        return False
    # парсим yml формат
    yml_data = yaml.load(rez) 
    # сохраняем позицию товара
    g = Good()
    g.name = yml_data['name_ru']
    g.name_slug = yml_data['name_slug']
    g.desc = yml_data['description_ru']
    g.subcategory = sub_category
    g.save()

В эту функцию передается транслитное название продукта, путь к каталогу категории и объект категории, в которой находится товар.