Внимание! Если у вас возникли проблемы с выполнением предыдущего этапа, то вы сможете зайти в соответствующий урок, скачать архив предыдущего этапа, инсталлировать его, и начать этот урок именно с того самого места, где закончился предыдущий!
Содержание курса:
Конечно же, самым удобным (и, кстати, более надёжным) способом будет взаимодействие нашего сайта с сайтом-поставщиком по API. Правда, для этого необходимо, чтобы
И хотя фреймворк Django позволяет организовать подобное взаимодействия собственными средствами, не прибегая к установке сторонних пакетов, тем не менее, лучше всего с решением этой задачи справится пакет Django REST ftamework (DRF), который обязательно будет рассмотрен в одном из наших ближайших курсов.
Однако, в нашем случае мы воспользуемся другим способом - считыванием необходимой информации непосредственно с HTML-страницы стороннего сайта. Это действие носит название скрейпинг (парсинг) сайта.
Для этой цели будут использованы две популярные Python библиотеки: beautifulsoup4 и requests. Установить их можно с помощью двух терминальных команд:
pip install beautifulsoup4
pip install requests
Обычно данные на странице товаров сгруппированы в блоки. Внутри блоков однотипные данные находятся под одинаковыми селекторами (см. рисунок):
Если скачать и “распарсить” HTML-страницу со списком товаров, мы можем получить структурированный список данных. Конкретно для нашего случая по каждому блоку данных нам нужно получить вот такой словарь:
{
'name': 'Труба профильная 40х20 2 мм 3м',
'image_url': 'https://my-website.com/30C39890-D527-427E-B573-504969456BF5.jpg',
'price': Decimal('493.00'),
'unit': 'за шт',
'code': '38140012'
}
План готов - приступаем к его реализации!
Очевидно, что скрипт, отвечающий за считывание информации с другого сайта следует разместить в отдельном модуле приложения shop: shop/scraping.py. За отправку запроса по адресу URL_SCRAPING, считывание данных и запись этих данных в таблицу Product базы данных проекта, будет отвечать функция scraping().
Прежде всего нам потребуется получить HTML-код страницы с данными по продукции для дальнейшей обработки. Выполнение этой задачи будет поручено модулю requests:
import requests
def scraping():
URL_SCRAPING = 'https://www.some-site.com'
resp = requests.get(URL_SCRAPING, timeout=10.0)
if resp.status_code != 200:
raise Exception('HTTP error access!')
data_list = []
html = resp.text
Есть смысл сразу же посмотреть, что получили. Для этого добавим код, который подсчитает число символов в объекте html и заодно выведет на печать сам этот объект:
html = resp.text
print(f'HTML text consists of {len(html)} symbols')
print(50 * '=')
print(html)
if __name__ == '__main__':
scraping()
Модуль shop/scraaping.py не требует настроек Django (во всяком случае пока), поэтому запустить его можно как обычный Python-скрипт:
HTML text consists of 435395 symbols
==================================================
<!DOCTYPE html>
<html lang="ru">
<head>
<link rel="shortcut icon" type="image/x-icon" href="/bitrix/templates/elektro_light/favicon_new.ico"/>
<meta name="robots" content="index, follow">
<meta name="keywords" content="Профильные трубы, уголки">
<meta name="description" content="Цены на профильные трубы, уголки от руб. Описание. Характеристики. Отзывы. Скидки на профильные трубы, уголки.">
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no"/>
<meta name="msapplication-TileColor" content="#ffffff">
Как видим результат действительно похож на HTML-страницу.
Первая часть задачи решена - доступ к данным сайта получен, и в тех 435 395 символах, что вывелась на экран, содержится вся необходимая нам информация. Всё, что нам теперь требуется - просто извлечь эту информацию и сохранить результат в БД.
Дальнейшую обработку удобнее всего будет вести с помощью модуля beautifulsoup4. Для этого нам потребуется сначала создать объект soup, представляющий собой вложенную структуру данных HTML-документа:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
Более подробную информацию о том, как начать работать с этим пакетом можно найти на странице руководства: BeautifulSoup #quick-start
Также очень полезной может быть дополнительная информация о CSS-селекторах пакета beautifulsoup4, которую можно прочитать здесь: BeautifulSoup #css-selectors
Далее на странице поставщика более всего нас будет интересовать блок товаров - вёрстка повторяющихся карточек товара, имеющих сходную структуру данных. Получить список однотипных элементов из объекта soup можно с помощью метода select(), где в качестве аргумента указан CSS-selector этого блока. В нашем случае это будет class=”catalog-item-card”:
blocks = soup.select('.catalog-item-card ')
В цикле мы можем получить доступ к каждому блоку, и заодно посмотреть, что находится внутри объекта block. Так станет выглядеть изменённый код:
html = resp.text
soup = BeautifulSoup(html, 'html.parser')
blocks = soup.select('.catalog-item-card ')
for block in blocks:
print(f'HTML text consists of {len(block.text)} symbols')
print(50 * '=')
print(block.text)
break
А вот так будет выглядеть распечатанный объект block.text:
HTML text consists of 382 symbols
==================================================
<div class="catalog-item-card" itemprop="itemListElement" itemscope="" itemtype="http://schema.org/Product">
<div class="catalog-item-info">
<div class="item-all-title">
<a class="item-title" href="/catalog/profilnye_truby_ugolki/truba_profilnaya_40kh20_2_mm_3m/" itemprop="url" title="Труба профильная 40х20 2 мм 3м">
<span itemprop="name">Труба профильная 40х20 2 мм 3м</span>
</a>
Как видим, число символов в блоке сократилось до 382-х. Что существенно упрощает нашу задачу.
Мы можем “разобрать” эти блоки на интересующие нас элементы с помощью метода soup.select_one(), который, в отличие от метода select(), выбирает не все элементы страницы, удовлетворяющее условию (аргументу метода), а только первый совпавший элемент. Важно также помнить, что текст, из полученного с помощью soup.select_one() объекта, можно извлечь с помощью метода text. Таким образом, применяя этот метод с определёнными аргументами, мы заполняем практически весь словарь данных, за исключением поля code:
soup = BeautifulSoup(html, 'html.parser')
blocks = soup.select('.catalog-item-card ')
for block in blocks:
"""{
'name': 'Труба профильная 40х20 2 мм 3м',
'image_url': 'https://my-website.com/30C39890-D527-427E-B573-504969456BF5.jpg',
'price': Decimal('493.00'),
'unit': 'за шт',
'code': '38140012'
}
"""
data = {}
name = block.select_one('.item-title[title]').get_text().strip()
data['name'] = name
image_url = URL_SCRAPING_DOMAIN + block.select_one('img')['src']
data['image_url'] = image_url
price_raw = block.select_one('.item-price ').text
# '\r\n \t\t\t\t\t\t\t\t\t\t\t\t\t\t493.00\t\t\t\t\t\t\t\t\t\t\t\t руб. '
price = re.findall(r'\S\d+\.\d+\S', price_raw)[0]
price = Decimal(price)
data['price'] = price # 493.00
unit = block.select_one('.unit ').text.strip()
# '\r\n \t\t\t\t\t\t\t\t\t\t\t\t\t\tза шт\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'
data['unit'] = unit # 'за шт'
При детальном рассмотрении блока товаров block оказалось, что часть информации по товару находится на другой странице сайта поставщика - на странице детализации товара. Сама ссылка для перехода на эту страницу, находится в блоке block.
Поэтому придётся повторить здесь тот же самый алгоритм, который был использован нами несколько шагов тому назад для получения данных по блоку block:
# find and open detail page
url_detail = block.select_one('.item-title')
# <a class="item-title" href="/catalog/profilnye_truby_ugolki/truba_profilnaya_40kh20_2_mm_3m/" itemprop="url" title="Труба профильная 40х20 2 мм 3м">
url_detail = url_detail['href']
# '/catalog/profilnye_truby_ugolki/truba_profilnaya_40kh20_2_mm_3m/'
url_detail = URL_SCRAPING_DOMAIN + url_detail
html_detail = requests.get(url_detail).text
soup = BeautifulSoup(html_detail, 'html.parser')
code_block = soup.select_one('.catalog-detail-property')
code = code_block.select_one('b').text
data['code'] = code
data_list.append(data)
print(data)
Если мы всё сделать правильно, то в итоге получим список словарей с данными по каждому блоку.
Успех скрейпинга сайтов зависит от целого ряда параметров и обстоятельств. И большинство из них не зависит от нашего кода в Django, а именно:
Разумеется, избежать всех этих ошибок не в наших силах. Но в наших силах перехватить эти ошибки, чтобы понять и, по-возможности, минимизировать или обойти причину их возникновения.
Удобнее всего это сделать с помощью класса Исключений (Exceptions). Прежде всего создаём класс исключений скрейпинга ScrapingError. И далее Наследует от него 3 новый пользовательских класса:
class ScrapingError(Exception):
pass
class ScrapingTimeoutError(ScrapingError):
pass
class ScrapingHTTPError(ScrapingError):
pass
class ScrapingOtherError(ScrapingError):
pass
И далее вносим соответствующие изменения в код:
try:
resp = requests.get(URL_SCRAPING, timeout=10.0)
except requests.exceptions.Timeout:
raise ScrapingTimeoutError("request timed out")
except Exception as e:
raise ScrapingOtherError(f'{e}')
if resp.status_code != 200:
raise ScrapingHTTPError(f"HTTP {resp.status_code}: {resp.text}")
Итак, данные по каждому блоку получены и преобразованы в удобный формат (список словарей). Теперь остаётся добавить эти данные в базу данных. Иными словами, заменить строчку кода print(data) на код, заполняющий таблицу Product:
for item in data_list:
if not Product.objects.filter(code=item['code']).exists():
Product.objects.create(
name=item['name'],
code=item['code'],
price=item['price'],
unit=item['unit'],
image_url=item['image_url'],
)
return data_list
Как видно, продукт добавляется только в том случае, если его ещё нет в БД. Поиск ведётся по номеру кода продукта (значение поля code ).
Несмотря на то, что в самой функции scraping.py данные уже записываются в БД, мы всё равно возвращаем список data_list. Просто на всякий случай).
Однако, если сейчас мы попробуем воспроизвести этот скрипт, то получим ошибку:
"/home/su/Projects/django-apps/Projects/drop-ship-store/venv/lib/python3.8/site-packages/django/conf/__init__.py", line 67, in _setup
raise ImproperlyConfigured(
django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.
Process finished with exit code 1
Всё дело в том, что теперь скрипт обращается к БД, а значит, требуется получить установки Django. Можно запустить этот код для проверки в management/commands (более подробно об этом можно найти здесь: https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/) Но мы поступим иначе: сразу же добавим страницу запуска и проверим работу функции scraping() уже там.
Алгоритм добавления новой страницы остаётся прежним:
Если после успешного выполнения всех этих пунктов мы зайдём в админку, то увидим что, после запуска скрипта, таблица Product наполнилась новыми значениями:
Теперь всё готово для последнего шага: добавления непосредственно страниц интернет магазина и кода, который будет ими управлять. Но этим мы займёмся уже на следующем седьмом и последнем занятии.
Более подробно с со всеми деталями этого этапа вы сможете познакомиться из этого видео:
Способ парсинга (скрейпинга), рассмотренный в статье Парсинг (Скрапинг) с помощью Google Apps Script, невероятно прост и удобен. Здесь не нужно ни сложных программ, ни высокой квалификации или специальных знаний, ни большого опыта. Весь код пишется прямо в редакторе Google-таблиц, и затем там же размещается и запускается.
Плюс ко всему этому, скрипт можно запускать по расписанию в любое время в режиме 24 / 7, а полученный результат - прямо тут же обработать и, если надо, отправить на электронную почту или создать на Гугл-диске новый файл с полученными и обработанными данными.
Однако, и здесь могут быть свои проблемы. Прежде всего, этот скрипт не работает с сайтами (точнее, со страницами), где необходима предварительная регистрация. Поскольку, для регистрации на сайте необходимо создать сессию и разместиь её код в специальных файлах - cookies. Однако, стандартная функция UrlFetchApp, используемая в Apps Script для доступа к веб-сайту, не позволяет работать c cookies.
Есть и другая проблема, которая в последее время проявляется всё чаще и чаще. Это - нежелание владельцев сайтов делиться информацией с ботами. Поэтому, нередко можно видеть картину, когда html-код, выдаваемый сервером по запросу скрипта, существенно отличается от кода, который выдаётся браузеру.
Собственно, именно для того, чтобы быстро узнать, можно ли парсить (скрейпить) нужный нам сайт или нет, и была написана эта программа:
function onOpen() {
var ui = SpreadsheetApp.getUi();
// Or DocumentApp or FormApp.
ui.createMenu('Custom Menu')
.addItem('Check', 'fastAnalyzer')
.addSeparator()
.addItem('Create Sheeet CHECK', 'createSheet')
.addToUi();
}
function fastAnalyzer() {
const ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Check');
const urlDomain = ss.getRange(2, 1).getValue();
let url = urlDomain;
let count = 0;
let response = UrlFetchApp.fetch(url);
let lll = response.getContentText().length;
ss.getRange(2, 2).setValue(response.getResponseCode());
ss.getRange(2, 3).setValue(lll);
let html = response.getContentText();
let whatLookingFor = ss.getRange(3, 2).getValue();
let pos = html.indexOf(whatLookingFor);
ss.getRange(3, 3).setValue(pos);
ss.getRange(4, 1, ss.getLastRow(), 2).clearContent();
let totalNumber = html.length
let i = 0;
// Check defoult value of delta - number of symbols in 1 row
// If the cell is empty, print the defole value = 5000
let delta = Number.parseInt(ss.getRange(2, 4).getValue());
if (isNaN(delta)) {
delta = 5000
// Print defoult value of number of symbols in 1 row
ss.getRange(2, 4).setValue(delta);
};
let iStart = 0;
while (true) {
let iEnd = iStart + delta;
if (iEnd > totalNumber) iEnd = totalNumber;
ss.getRange(4 + i, 1).setValue("Символы от " + iStart + " до " + iEnd);
ss.getRange(4 + i, 2).setValue(html.slice(iStart,iEnd));
// let currentRow = (4+i) + ":" + (4+i)
// ss.getRange(3, 3).setValue(currentRow);
// ss.getRange(currentRow).activate();
// ss.setRowHeight(4+i, 200);
i++;
if (iEnd - iStart < delta) break;
iStart = iEnd;
};
}
function createSheet() {
// is exist Check sheet?
let s = SpreadsheetApp.getActiveSpreadsheet();
// create if not exist
if(!s.getSheetByName('Check')) {
s.insertSheet('Check', 0);
ssc = s.getSheetByName('Check');
ssc.getRange(1, 1).setValue("Url сайта");
ssc.getRange(1, 2).setValue("Код ответа сайта");
ssc.getRange(1, 3).setValue("Число символов на выбранной странице");
ssc.getRange(1, 4).setValue("Изменить вывод числа знаков в однй строке");
ssc.getRange(2, 1).setBackground('ACCENT3'); // yellow background cell
ssc.getRange(3, 1).setValue("Какой элемент кода ищем?");
ssc.getRange(3, 2).setBackground('ACCENT3'); // yellow background cell
};
}
Итак, всё, что вам нужна для работы с этой программой - это
Программа готова к работе! Всё, что теперь нужно, это - добавить на страницу Гугл-таблицы url сайта, элемент кода, который вы хотите найти на этой странице, и запустить скрипт. Ответ будет готов через секунду!
Более подробно о работе скрипта рассказано в этом видео:
Скрипт, представленный ниже, позволяют автоматически находить и выгружать информацию об объявлениях на одной из бирж фриланса.
Имя домена берётся со страницы Google Spread Sheets. Туда же выгружаются результаты поиска.
В качестве управляющего скрипта используется функция scraper(), которая содержит два цикла. В первом цикле (for-цикл) идёт обращение к веб-страницам сайта, и их сохранение в переменной html. Во втором цикле (while-цикл) идёт последовательная обработка этой переменной с помощью трёх вспомогательных функций:
function scraper() {
const ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Sheet1');
const urlDomain = ss.getRange(1, 1).getValue();
let url = urlDomain;
let count = 0;
for (let page = 1; page < 5; page++) {
url = urlDomain + page + '/';
if (page == 1) url = urlDomain;
let response = UrlFetchApp.fetch(url);
ss.getRange(2, 1).setValue(response.getResponseCode());
let html = response.getContentText();
let p = 0;
while (true) {
let out = getBlock(html, 'div', html.indexOf('class="JobSearchCard-primary"', p));
let block = out[0];
p = out[1] + 1;
if (p == 0) break;
let title1 = getBlock(block, 'div', 0)[0];
let title = getBlock(title1, 'a', 0)[0];
let link = getOpenTag(title1, 'a', 0);
link = getAttrName(link, 'href', 0)
let formula = '=HYPERLINK("https://www.freelancer.com' +link + '", "' + title + '")';
ss.getRange(3 + 3 * count, 2).setValue(formula);
let price = getBlock(block, 'div', block.indexOf('class="JobSearchCard-primary-price'))[0];
if (price.includes('span')) price = deleteBlock(price, 'span', price.indexOf('span'));
ss.getRange(3 + 3 * count + 1, 2).setValue(price).setHorizontalAlignment('right');
let description = getBlock(block, 'p', block.indexOf('class="JobSearchCard-primary-description"'))[0];
ss.getRange(3 + 3 * count, 1, 3).mergeVertically().setValue(description)
.setBorder(true, true, true, true, null, null, '#000000', SpreadsheetApp.BorderStyle.SOLID)
.setVerticalAlignment('middle')
.setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
ss.getRange(3 + 3 * count, 2, 3).setBorder(true, true, true, true, null, null, '#000000', SpreadsheetApp.BorderStyle.SOLID);
let cat = getBlock(block, 'div', block.indexOf('class="JobSearchCard-primary-tags"'))[0];
cat = cat.split('</a>').map(item => item.split('>')[1]);
cat.pop();
cat = cat.join(', ');
ss.getRange(3 + 3 * count + 2, 2).setValue(cat);
count++;
};
};
}
function getAttrName(html, attr, i) {
let idxStart = html.indexOf(attr +'=' , i);
if (idxStart == -1) return "Can't to find attr " + attr + ' !';
idxStart = html.indexOf('"' , idxStart) + 1;
let idxEnd = html.indexOf('"' , idxStart);
return html.slice(idxStart,idxEnd).trim();
}
function getOpenTag(html, tag, idxStart) {
let openTag = '<' + tag;
let lenOpenTag = openTag.length;
// where we are?
if (html.slice(idxStart, idxStart + lenOpenTag) != openTag) {
idxStart = html.lastIndexOf(openTag, idxStart);
if (idxStart == -1) return "Can't to find openTag " + openTag + ' !';
};
// begin loop after openTag
let idxEnd = html.indexOf('>', idxStart) + 1;
if (idxStart == -1) return "Can't to find closing bracket '>' for openTag!";
return html.slice(idxStart,idxEnd).trim();
}
function deleteBlock(html, tag, idxStart) { // delete opening & closing tag and info between them
let openTag = '<' + tag;
let lenOpenTag = openTag.length;
let closeTag = '</' + tag + '>';
let lenCloseTag = closeTag.length;
let countCloseTags = 0;
let iMax = html.length;
let idxEnd = 0;
// where we are?
if (html.slice(idxStart, idxStart + lenOpenTag) != openTag) {
idxStart = html.lastIndexOf(openTag, idxStart);
if (idxStart == -1) return ["Can't to find openTag " + openTag + ' !', -1];
};
// begin loop after openTag
let i = html.indexOf('>') + 1;
while (i <= iMax) {
i++;
if (i === iMax) {
return ['Could not find closing tag for ' + tag, -1];
};
let carrentValue = html[i];
if (html[i] === '<'){
let closingTag = html.slice(i, i + lenCloseTag);
let openingTag = html.slice(i, i + lenOpenTag);
if (html.slice(i, i + lenCloseTag) === closeTag) {
if (countCloseTags === 0) {
idxEnd = i + lenCloseTag;
break;
} else {
countCloseTags -= 1;
};
} else if (html.slice(i, i + lenOpenTag) === openTag) {
countCloseTags += 1;
};
};
};
return (html.slice(0, idxStart) + html.slice(idxEnd, iMax)).trim();
}
function getBlock(html, tag, idxStart) { // <tag .... > Block </tag>
let openTag = '<' + tag;
let lenOpenTag = openTag.length;
let closeTag = '</' + tag + '>';
let lenCloseTag = closeTag.length;
let countCloseTags = 0;
let iMax = html.length;
let idxEnd = 0;
// where we are?
if (html.slice(idxStart, idxStart + lenOpenTag) != openTag) {
idxStart = html.lastIndexOf(openTag, idxStart);
if (idxStart == -1) return ["Can't to find openTag " + openTag + ' !', -1];
};
// change start - will start after openTag!
idxStart = html.indexOf('>', idxStart) + 1;
let i = idxStart;
while (i <= iMax) {
i++;
if (i === iMax) {
return ['Could not find closing tag for ' + tag, -1];
};
let carrentValue = html[i];
if (html[i] === '<'){
let closingTag = html.slice(i, i + lenCloseTag);
let openingTag = html.slice(i, i + lenOpenTag);
if (html.slice(i, i + lenCloseTag) === closeTag) {
if (countCloseTags === 0) {
idxEnd = i - 1;
break;
} else {
countCloseTags -= 1;
};
} else if (html.slice(i, i + lenOpenTag) === openTag) {
countCloseTags += 1;
};
};
};
return [html.slice(idxStart,idxEnd + 1).trim(), idxEnd];
}
Более продробную информацию вы сможете найти в этом видео: