Практикум. Создаем телеграм бота.

Видео отсутствует

Пишем бота Telegram с управлением через файловую систему.

Задача.

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

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

Реализация.

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

У нас будет общий каталог, в котором каждый бот будет иметь свой собственный, названный его именем.

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

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

Например.

<message>Hello! Welcome</message>
<button value="path_to_one_step">
    Do you want to see the catalog?
</button>

<button value="path_to_second_step">
   No thank you
</button>

В теге message указывается текстовое сообщение бота.

Из тегов button будут созданы кнопки, по которым бот будет прыгать в другие директории, указанные в их атрибуте value.

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

python-telegram-bot - для использования API Telegram;

bs4 - для парсинга xml текстового файла.

Создадим рабочий каталог.

mkdir tbot
cd tbot

В нем файл requirements.txt где перечислим необходимые зависимости.

python-telegram-bot==12.0.0b1
bs4

Установим виртуальное окружение и установим в него пакеты.

virtualenv -p python3 venv
source ./venv/bin/activate
pip install -r requirements.txt

Напишем скрипт инсталляции бота install.py.

В начале создадим новую пустую директорию storage, где будем хранить каталоги ботов, т. к. их может быть несколько.

Потом, зададим пользователю 2 вопроса с просьбой указать:

  1. Имя бота.

  2. Секретный ключ.

По имени бота будем создавать подкаталоги в папке storage.

Код инсталлятора.

#!/usr/bin/env python
import os
print('Устанавливаем бот!')
DATA_DIR = 'storage'

if __name__ == '__main__': # если запуск из консоли

    # Создаем каталог storage если его нет
    if not os.path.exists(DATA_DIR):
        print('Создаю директорию %s' % DATA_DIR)
        os.makedirs(DATA_DIR)

    name = input('Укажите имя бота: ')
    # Формируем путь к каталогу бота
    bot_path = '%s/%s' % (DATA_DIR, name)
    if not os.path.exists(bot_path):
        os.makedirs(bot_path)
        if not os.path.exists(bot_path+'/index'):
            print('Создаю директорию для бота %s' % name)
            os.makedirs(bot_path+'/index')
    else:
        print("Такой бот с именем %s уже есть!" % bot_path)

    key = input('Укажите ключ: ')
    # записываем секретный ключ в файл
    f = open('%s/key' % bot_path, 'w+')
    f.write(key)
    f.close()
    print("Все готово. Для активации бота запустите команду ./run.py %s" % name)

Результат работы с введенным не валидным ключом.

python requests

Как зарегистрировать бота и получить секретный ключ (токен).

Необходимо найти контакт BotFather.

Написать команду /newbot

Подобрать уникальное имя, после чего вам отправят сообщение такого вида.

Done! Congratulations on your new bot. You will find it at t.me/zdimon77_bot. You can now add a description, 
about section and profile picture for your bot, see /help for a list of commands. 
By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. 
Just make sure the bot is fully operational before you do this.

Use this token to access the HTTP API:
829228816:AAGSjgoh0hfj-fwyMBo4UlGvGxHtnp6Z_сs
Keep your token secure and store it safely, it can be used by anyone to control your bot.

For a description of the Bot API, see this page: https://core.telegram.org/bots/api

Начнем писать бота в файле run.py.

Он будет запускаться с одним параметром - именем бота.

Сначала импортируем все, что нам необходимо.

import sys
import os
from telegram.ext import Updater
from telegram.ext import CommandHandler, CallbackContext, CallbackQueryHandler
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram import Bot
from bs4 import BeautifulSoup    
from install import DATA_DIR

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

try:
    botname = sys.argv[1]
except:
    print("Введите в качестве аргумента имя бота например ./run.py my_bot")
    sys.exit()

Найдем секретный ключ и используя его, создадим объект бота.

print("Запускаю бота %s" % botname)
bot_path = '%s/%s' % (DATA_DIR,botname)
print("Читаю настройки из %s" % bot_path)
f = open(bot_path+'/key','r')
key = f.read()
f.close()
print("Ключ: %s" % key)
bot = Bot(token=key)

Определим функцию start, которая будет срабатывать когда пользователь нажмет на кнопку Start в программе Telergam.

def start(update: Updater, context: CallbackContext):
    print("Start command!")

В эту функцию будет передано два объекта update и context.

Мы будем использовать update для получения информации о пришедшем пользователе.

Например, получить его логин и идентификатор комнаты можно так:

username = update.message.from_user['username']
room_id = update.message.chat_id

Мы будем использовать этот идентификатор дальше для отправки сообщений в канал пользователя.

Для того, чтобы привязать эту функцию к обработчику события нажатия на кнопку “Старт”, нужно создать объект-обработчик класса CommandHandler и «скормить» ему нашу функцию start.

start_handler = CommandHandler('start', start)

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

Контейнером для обработчиков будет объект updater.

updater = Updater(token=key, use_context=True)

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

updater.dispatcher.add_handler(start_handler)
updater.start_polling()

Полный код программы.

#!/usr/bin/env python
import sys
import os
from telegram.ext import Updater
from telegram.ext import CommandHandler, CallbackContext, CallbackQueryHandler
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram import Bot
from bs4 import BeautifulSoup

from install import DATA_DIR
try:
    botname = sys.argv[1]
except:
    print("Введите в качестве аргумента имя бота например ./run.py my_bot")
    sys.exit()

print("Запускаю бота %s" % botname)
bot_path = '%s/%s' % (DATA_DIR,botname)
print("Читаю настройки из %s" % bot_path)
f = open(bot_path+'/key','r')
key = f.read()
f.close()
bot = Bot(token=key)

def start(update: Updater, context: CallbackContext):
    print("Start command!")
    username = update.message.from_user['username']
    room_id = update.message.chat_id 
    print("Новый пользователь %s room_id: %s" % (username, room_id))

start_handler = CommandHandler('start', start)
updater = Updater(token=key, use_context=True)
updater.dispatcher.add_handler(start_handler)
updater.start_polling()

Результат работы.

python requests

Пробуем послать ботом сообщение приветствия.

Делается это функцией bot.send_message(), в которую кроме сообщения, передается идентификатор комнаты.

def start(update: Updater, context: CallbackContext):
    print("Start command!")
    username = update.message.from_user['username']
    room_id = update.message.chat_id 
    print("Новый пользователь %s room_id: %s" % (username, room_id))
    bot.send_message(room_id, 'Привет!')

python requests

Если надо послать картинку, то сделать это можно другой функцией.

bot.send_photo(chat_id=room_id, photo=open(img_path, 'rb'))

Теперь мы знаем как посылать сообщения и картинки.

Давайте разбираться с кнопками и тем как их посылать?

В начале необходимо определиться с их компоновкой.

Допустим, мы закинули кнопки в список, например:

lst = ['btn1', 'btn2', 'btn3', 'btn4']

Чтобы расположить их в два ряда по две, необходимо из этого списка сформировать такую структуру:

[['btn1', 'btn2'], ['btn3', 'btn4']]

Вот небольшая функция которая это делает.

def build_menu(buttons,n_cols):
    menu = [buttons[i:i + n_cols] for i in range(0, len(buttons), n_cols)]
    return menu

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

Сама кнопка создается такой конструкцией.

from telegram import InlineKeyboardButton
btn = InlineKeyboardButton('Нажми меня',callback_data='button_pressed')

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

Отправим 4 тестовые кнопки по 2 в ряд.

# список с кнопками
btn_list = []
# забрасываю 4 штучки
for cnt in range(0,4):
    btn_list.append(InlineKeyboardButton('Нажми меня %s' % cnt,callback_data='button_%s_pressed' % cnt))
# формирую объект разметки из класса InlineKeyboardMarkup
markup_list = InlineKeyboardMarkup(build_menu(btn_list,n_cols=2))
# посылаю кнопки
bot.send_message(room_id, 'Кнопки', reply_markup=markup_list)

python requests

Сигнатура функции-обработчика для кнопок выглядит по аналогии со start.

def press_button(update: Updater, context: CallbackContext):
    print("Pressing button %s" % update.callback_query.data)

Только связывание обработчика отличается использованием класса CallbackQueryHandler, который примет нашу функцию.

updater.dispatcher.add_handler(CallbackQueryHandler(press_button))

Теперь осталось связать это все вместе, и заставить бота перемещаться по каталогам и подбирать их содержимое.

В новой функции navigate, которая будет вызываться при нажатии на кнопку, я буду делать следующее:

  1. Переходить в нужный каталог.

  2. Посылать картинку image.png если она там есть.

  3. Считывать файл message.txt.

  4. Посылать содержимое тега message и кнопки в тегах button.

Код функции.

def navigate(command, chat_id):
    path = '%s/%s' % (bot_path,command)
    img_path = '%s/image.png' % path
    print(img_path)
    # шлю картинку если нашел
    if os.path.isfile(img_path):
        bot.send_photo(chat_id=chat_id, photo=open(img_path, 'rb'))
    message_path = path+'/message.txt'
    # находим файл message.txt
    if os.path.isfile(message_path):
        with open(message_path) as f:
            but_txt = f.read()
        # парсим xml 
        soup = BeautifulSoup(but_txt, 'html.parser')
        msg = soup.find('message')
        btns = soup.findAll('button')
        btn_lst = []
        # формируем кнопки
        for bt in btns:
            btn_lst.append(InlineKeyboardButton(bt.text,callback_data=bt['value']))
        button_list = InlineKeyboardMarkup(build_menu(btn_lst,n_cols=1))
        # посылаем кнопки и сообщение
        bot.send_message(chat_id, msg.text, reply_markup=button_list)

Последний пункт использует библиотеку BeautifulSoup и реализован следующим образом.

    with open(message_path) as f:
        but_txt = f.read()
    soup = BeautifulSoup(but_txt, 'html.parser')
    msg = soup.find('message') # один элемент
    btns = soup.findAll('button') # несколько

Полный код бота:

#!/usr/bin/env python3
import sys
import os
from telegram.ext import Updater
from telegram.ext import CommandHandler, CallbackContext, CallbackQueryHandler
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram import Bot
from bs4 import BeautifulSoup

from install import DATA_DIR
try:
    botname = sys.argv[1]
except:
    print("Введите в качестве аргумента имя бота например ./run.py my_bot")
    sys.exit()

print("Запускаю бота %s" % botname)
bot_path = '%s/%s' % (DATA_DIR,botname)
print("Читаю настройки из %s" % bot_path)
f = open(bot_path+'/key','r')
key = f.read()
f.close()
bot = Bot(token=key)

def navigate(command, chat_id):
    path = '%s/%s' % (bot_path,command)
    img_path = '%s/image.png' % path
    print(img_path)
    # шлю картинку если нашел
    if os.path.isfile(img_path):
        bot.send_photo(chat_id=chat_id, photo=open(img_path, 'rb'))
    message_path = path+'/message.txt'
    # находим файл message.txt
    if os.path.isfile(message_path):
        with open(message_path) as f:
            but_txt = f.read()
        # парсим xml 
        soup = BeautifulSoup(but_txt, 'html.parser')
        msg = soup.find('message')
        btns = soup.findAll('button')
        btn_lst = []
        # формируем кнопки
        for bt in btns:
            btn_lst.append(InlineKeyboardButton(bt.text,callback_data=bt['value']))
        button_list = InlineKeyboardMarkup(build_menu(btn_lst,n_cols=1))
        # посылаем кнопки и сообщение
        bot.send_message(chat_id, msg.text, reply_markup=button_list)


def build_menu(buttons,n_cols):
    menu = [buttons[i:i + n_cols] for i in range(0, len(buttons), n_cols)]
    return menu

def press_button(update: Updater, context: CallbackContext):
    print("Pressing button %s" % update.callback_query.data)
    navigate(update.callback_query.data,update.callback_query.message.chat_id)

def start(update: Updater, context: CallbackContext):
    print("Start command!")
    username = update.message.from_user['username']
    room_id = update.message.chat_id 
    navigate('index',update.message.chat_id)

start_handler = CommandHandler('start', start)

updater = Updater(token=key, use_context=True)
updater.dispatcher.add_handler(start_handler)
updater.dispatcher.add_handler(CallbackQueryHandler(press_button))
updater.start_polling()

Результат работы.

python requests

Выводы

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

Перспективы

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

Ссылка на репозиторий GIT

Видео отсутствует

Проверяем домены с истекающим сроком.

Недавно от “насяльника” мне поступила такая задача:

“Димыч скажи пож вот отсюда - https://www.reg.ru/domain/deleted/?&free_from=07.06.2019&free_to=07.06.2019&order=ASC&sort_field=dname_idn%20%20%20%20&page=5 можно сграбить названия доменов?”

Я зашел по ссылке, увидел список доменов у которых истекает срок, постраничную навигацию и все дела.

start page

Я подумал “Почему бы и нет?” буду пробегать по постраничке, меняя параметр page=1,2,3 в урле, парсить страницы и сохранять домены.

Что может быть проще?

Затем поступило дополнение к задаче. Как оказалось, эти домены нужно проверять на присутствие вот на одном сайте, скажем yandex.ru:

Например вот так:

https://yandex.ru/sabai.tv

Куда вместо sabai.tv подставлять мои сграбленные доменчики.

С этим вроде тоже проблем быть не должно. Тем более, что при отсутствии домена в этом интернет-каталоге, он возвращает 404 ошибку.

start page

Прийдется немного заддосить напрячь этот сайтик.

Итак, поехали.

Мне понадобятся две питоновские библиотеки:

requests - для создания HTTP GET запросов

beautifulsoup4 - для парсинга HTML и поиска полезной информации на странице.

Все это добро сложим в виртуальное окружение.

Создаю и активирую его.

mkdir finedomain
cd finedomain
virtualenv venv
source ./venv/bin/activate

Ставлю пакеты.

pip install requests beautifulsoup4

Пишу код с запросом в файле grabing.py.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# импортируем все что надо для работы
import requests
from bs4 import BeautifulSoup
import json
import time
import sys
# начало программы
print('Start')
# определяю строку с урлом
# к ней с конца буду прилеплять страницы 
url = 'https://www.reg.ru/domain/deleted/?&free_from=12.06.2019&free_to=12.06.2019&order=ASC&sort_field=dname_idn%20%20%20%20&page='

# запрос первой страницы
r = requests.get(url+'0')
print(r.text)

Результат.

python requests

Неплохо, HTML получили, далее нужно распарсить это все добро beautifulsoup-ом.

soup = BeautifulSoup(r.text, 'html.parser')

Теперь, в переменной soup у нас не только весь код страницы, но и инструменты для поиска ее элементов.

Откываем инспектор браузера и смотрим где у нас список.

python requests

Ага, таблица с классом b-table__content. Нет ничего проще ее вытянуть так.

table = soup.find("table",{"class": "b-table__content"})

find() - ищет один элемент findAll() - найдет список

Далее получу tr-ки (строки) из тега tbody.

tbody = table.find("tbody")
trs = tbody.findAll("tr")

Побегу по ним циклом, найду td-шки (ячейки таблицы) и выведу из первой по счету доменное имя.

soup = BeautifulSoup(r.text, 'html.parser')
table = soup.find("table",{"class": "b-table__content"})
tbody = table.find("tbody")
trs = tbody.findAll("tr")
for tr in trs:
    tds = tr.findAll("td")
    print(tds[0].text)

Сработало!

python requests

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

Полный код получился таким.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# импортируем все что надо для работы
import requests
from bs4 import BeautifulSoup
import json
import time
# начало программы
print('Start')
# определяю строку с урлом
# к ней с конца буду прилеплять страницы 
url = 'https://www.reg.ru/domain/deleted/?&free_from=12.06.2019&free_to=12.06.2019&order=ASC&sort_field=dname_idn%20%20%20%20&page='

# запрос первой страницы
r = requests.get(url+'0')

# тут буду хранить домены
data = []

# парсим html
soup = BeautifulSoup(r.text, 'html.parser')

# находим первую таблицу по тегу и css классу
table = soup.find("table",{"class": "b-table__content"})

# в ней блок tbody по имени тега
tbody = table.find("tbody")

# строки
trs = tbody.findAll("tr")
for tr in trs:
    # ячейки
    tds = tr.findAll("td")
    # добавляем домен и срок истечения в список
    data.append({"domain": tds[0].text, "date_expire": tds[2].text})

print(data)

python requests

Время проверить эти домены на сайте.

Сделаю для этого функцию, принимающую доменное имя параметром.

def check_domain(domain):
    # формирую url, адрес подставляя домен в строку
    url = 'https://yandex.ru/%s' % domain
    # пробую сделать запрос
    try:
        # получаю страницу
        r = requests.get(url)
        if r.status_code == 404: # проверяю статус
            print('Domain %s result %s' % (domain, 'FAIL!'))
            return False
        else:
            print('Domain %s result %s' % (domain, 'SUCCESS'))
            # если повезло и домен нашли
            return True
    # это если запрос обрушился по какой то причине        
    except:
        print('Request error %s' % url)
        return False

Осталось вызвать эту функцию в цикле.

for tr in trs:
    # ячейки
    tds = tr.findAll("td")
    # добавляем домен и срок истечения в список
    data.append({"domain": tds[0].text, "date_expire": tds[2].text})
    # проверяю домен 
    check_domain(tds[0].text)

python requests

Результат не утешает и попаданий на первой странице нет, зато работает!

Теперь мне нужно определить конец постраничной “нафигации” и прогнать процесс по всем доменам на указанную дату.

В лучших традициях функционального программирования делаю функцию.

def check_end(soup):
''' Определяю конец постранички '''
    try:
        # на конечной странице был замечен заголовок h1 с текстом
        '''
                Доменов, отвечающих заданным критериям фильтрации, не&nbsp;найдено. 
        '''
        h2 = soup.find("h2")
        # тут для поиска сказало что нужно вначале кодировать в UTF-8
        if h2.text.encode('utf-8').find('отвечающих заданным критериям')>0:
            return True
        else:
            return False
    except:
        return False

Мучу цикл while пока не дошло до конца со счетчиком страниц.

is_end = False
page = 0
while not is_end:
    # определяю строку с урлом
    # к ней с конца буду прилеплять страницы 
    url = 'https://www.reg.ru/domain/deleted/?&free_from=12.06.2019&free_to=12.06.2019&order=ASC&sort_field=dname_idn%20%20%20%20&page=%s' % page 
    print("Делаю страницу %s" % page)
    # добавляем счетчик страниц
    page = page + 1

    # запрос первой страницы
    r = requests.get(url)

    # тут буду хранить домены
    data = []

    # парсим html
    soup = BeautifulSoup(r.text, 'html.parser')

    # проверим конец постранички
    if check_end(soup):
        is_end = True
        break # ломаем цикл


    # находим первую таблицу по тегу и css классу
    table = soup.find("table",{"class": "b-table__content"})

    # в ней блок tbody по имени тега
    tbody = table.find("tbody")

    # строки
    trs = tbody.findAll("tr")
    for tr in trs:
        # ячейки
        tds = tr.findAll("td")
        # добавляем домен и срок истечения в список
        data.append({"domain": tds[0].text, "date_expire": tds[2].text})
        # проверяю домен 
        check_domain(tds[0].text)

python requests

Решил прервать процесс по ctrl+c, получил такой облом.

python requests

Пришлось тушить терминал, обидное недоразумение.

Случилось оно в этом блоке при проверке домена:

except:
    print('Request error %s' % url)
    return False

А если я отработаю исключение KeyboardInterrupt?

try:
    # получаю страницу
    ...
except KeyboardInterrupt: # выход по ctrl+c
    print("Ну пока!")
    sys.exit() # нагло вываливаемся из проги
# это если запрос обрушился по какой то другой причине        
except:
    print('Request error %s' % url)
    return False

python requests

Намного лучше!

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

print("Вводи дату типа 12.06.2019:     ")
user_date = raw_input() # 
print("Работаем по {}".format(user_date))

Просто input() не канает! Пользуйтесь только raw_input.

И вставить ее в урл запроса.

В конце я выгружу успешные домены в файлик.

def save_success(domain):
    with open('success.txt', 'a+') as of: # открываем для добавления и создаем если нет
        of.write(domain+';')
        print('Domain %s result %s' % (domain, 'SUCCESS'))

Полный код программы.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# импортируем все что надо для работы
import requests
from bs4 import BeautifulSoup
import json
import time
import sys
# начало программы
print('Начинаем грабить домены.')
print("Вводи за какую дату? (типа 12.06.2019):   ")
user_date = raw_input()
print("Работаем по {}".format(user_date))

def save_success(domain):
    with open('success.txt', 'a+') as of: # открываем для добавления и создаем если нет
        of.write(domain+';')
        print('Domain %s result %s' % (domain, 'SUCCESS'))

def check_domain(domain):
    # формирую url, адрес подставляя домен в строку
    url = 'https://yandex.ru/%s' % domain
    # пробую сделать запрос
    try:
        # получаю страницу
        r = requests.get(url)
        if r.status_code == 404: # проверяю статус
            print('Domain %s result %s' % (domain, 'FAIL!'))
            return False
        else:
            print('Domain %s result %s' % (domain, 'SUCCESS'))
            # если повезло и домен нашли
            return True
    except KeyboardInterrupt: # выход по ctrl+c
        print("Ну пока!")
        sys.exit() # нагло вываливаемся из проги
    # это если запрос обрушился по какой то причине        
    except:
        print('Request error %s' % url)
        return False

def check_end(soup):
    ''' Определяю конец постранички '''
    try:
        # на конечной странице был замечен заголовок h1 с текстом
        '''
                Доменов, отвечающих заданным критериям фильтрации, не&nbsp;найдено. 
        '''
        h2 = soup.find("h2")
        # тут для поиска сказало что нужно вначале кодировать в UTF-8
        if h2.text.encode('utf-8').find('отвечающих заданным критериям')>0:
            return True
        else:
            return False
    except:
        return False

is_end = False
page = 0
while not is_end:
    # определяю строку с урлом
    # к ней с конца буду прилеплять страницы 
    url = 'https://www.reg.ru/domain/deleted/?&free_from=%s&free_to=%s&order=ASC&sort_field=dname_idn%20%20%20%20&page=%s' % (user_date,user_date,page)
    print("Делаю страницу %s" % page)
    # добавляем счетчик страниц
    page = page + 1

    # запрос первой страницы
    r = requests.get(url)

    # тут буду хранить домены
    data = []

    # парсим html
    soup = BeautifulSoup(r.text, 'html.parser')

    # проверим конец постранички
    if check_end(soup):
        is_end = True
        break # ломаем цикл


    # находим первую таблицу по тегу и css классу
    table = soup.find("table",{"class": "b-table__content"})

    # в ней блок tbody по имени тега
    tbody = table.find("tbody")

    # строки
    trs = tbody.findAll("tr")
    for tr in trs:
        # ячейки
        tds = tr.findAll("td")
        # добавляем домен и срок истечения в список
        data.append({"domain": tds[0].text, "date_expire": tds[2].text})
        # проверяю домен
        if check_domain(tds[0].text):
            save_success(tds[0].text)

print('The end')

Выводы.

В статье освещены приемы программирования на языке Python на примере реальной, практической задачи.

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

Ссылка на репозиторий GIT

Видео отсутствует

Создание интернет-магазина. Часть 1.

В начале давайте подумаем из чего состоит типовой интернет-магазин? Я выделил 3 основных функциональных блока:

Каталог товаров. Корзина пользователя. Оформление заказа.

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

Каталог товаров.

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

Теперь обсудим структуру каталога данных, назовем его data.

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

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

. 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: |
    Дуже гарна тюль!

meta_title_ru: Купить тюль. Интернет-магазин штор.
meta_title_ua:  Створення iнтернет магазина штор
meta_keywords_ru: тюль шторы купить интернет-магазин
meta_keywords_ua: ….
meta_description_ru: ….
meta_description_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
description.md

Для описания товара я определил файл description.md, использующий формат markdown для разметки текста, но позволяющий использовать и нативный html код.

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

name_slug: shtory-raduga
name_ru: Шторы радуга.
meta_title_ru: Первые шаги Python.
meta_keywords_ru: Первые шаги Python.
meta_description_ru: Первые шаги Python.
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 и для каждого нового сайта понадобится написать свой собственный парсер контента.

Видео отсутствует

Создание интернет-магазина. Часть 2.

Создание Django приложения.

django-admin.py startproject prj
cd prj
./manage.py migrate

Создание приложения shop

./manage.py startapp shop

Добавим приложение в проект.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'shop'
]

Создадим модель (shop/models.py).

from django.db import models

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()

Опишем классы админ интерфейса и привяжем их к модели (shop/admin.py).

from django.contrib import admin
from .models import *


class CategoryAdmin(admin.ModelAdmin):
    pass

class SubcategoryAdmin(admin.ModelAdmin):
    pass

class GoodAdmin(admin.ModelAdmin):
    pass

class ImageAdmin(admin.ModelAdmin):
    pass

admin.site.register(Category, CategoryAdmin)
admin.site.register(Subcategory,SubcategoryAdmin)
admin.site.register(Good, GoodAdmin)
admin.site.register(Image, ImageAdmin)

Создадим новую команду импорта данных (shop/management/commands/import.py).

from django.core.management.base import BaseCommand, CommandError
from shop.models import *

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

Не забываем создавать файлы ini.py в новых каталогах management и commands

Задать вопрос, прокомментировать.