Лабораторная работа №8
Скрапинг и анализ текста
Введение
Автоматический сбор данных с веб-сайтов (web scraping) — один из ключевых инструментов в арсенале data scientist'а. Когда нужные данные не предоставляются через API, парсинг HTML-страниц позволяет получить структурированную информацию из открытых источников.
В данной работе реализован двухэтапный парсер новостного портала ИТМО: сначала собираются заголовки новостей по поисковому запросу и со всех страниц раздела «Главные новости», затем для каждой новости извлекается полный набор данных (текст, дата, просмотры, теги).
Цель работы
Разработать скрипт для автоматического сбора новостей с сайта news.itmo.ru с использованием библиотек requests и BeautifulSoup. Реализовать обход всех страниц раздела, переход на каждую отдельную новость и сохранение результатов в структурированном виде.
Используемые инструменты
| Библиотека | Назначение |
|---|---|
requests |
HTTP-запросы к веб-сайтам |
beautifulsoup4 |
Парсинг и навигация по HTML-документу |
pandas |
Формирование таблиц и сохранение в CSV |
time, random |
Случайные задержки между запросами |
os |
Создание директории для выходных файлов |
Установка зависимостей:
pip install requests beautifulsoup4 pandas
Архитектура парсера
Скрипт состоит из трёх последовательных этапов:
- Парсинг по поисковому запросу — сбор новостей по ключевому слову «нейротехнологии».
- Определение количества страниц в разделе «Главные новости» через анализ пагинации.
- Глубокий парсинг каждой новости — переход на страницу каждой статьи и извлечение полной информации (текст, дата, просмотры, теги).
Этап 1. Парсинг по поисковому запросу
Первый блок собирает новости с поисковой страницы по ключевому запросу.
import requests
from bs4 import BeautifulSoup
import pandas as pd
import random
import time
import os
itmo_news_with_query = []
DOMAIN = 'https://news.itmo.ru'
SEARCH_DOMAIN = 'https://news.itmo.ru/ru/search/?search='
query = 'нейротехнологии'
try:
response = requests.get(SEARCH_DOMAIN + query)
response.raise_for_status()
html_content = response.text
soup = BeautifulSoup(html_content, "html.parser")
news = soup.find_all('li', class_='weeklyevent')
for new in news:
url = new.h4.a["href"]
title = new.h4.get_text(strip=True)
try:
date = new.find_all('p')[-1].text
except IndexError:
date = None
code = url.split('/')[-2]
itmo_news_with_query.append({
"Date": date,
"Title": title,
"URL": DOMAIN + url,
"Code": code
})
except requests.exceptions.RequestException as e:
print(f"Ошибка при запросе {SEARCH_DOMAIN}: {e}")
except Exception as e:
print(f"Ошибка при парсинге {SEARCH_DOMAIN}: {e}")
df = pd.DataFrame(itmo_news_with_query)
df.to_csv('itmo_news_with_query.csv', index=False)
Извлекаемые данные (по требованиям пункта 2)
| Поле | Источник |
|---|---|
| Code (идентификатор) | url.split('/')[-2] — числовая часть URL |
| Title (название) | new.h4.get_text(strip=True) |
| Date (дата) | new.find_all('p')[-1].text |
| URL | DOMAIN + new.h4.a["href"] |
Результат сохраняется в itmo_news_with_query.csv.
Этап 2. Определение количества страниц
Для обхода всех новостей раздела необходимо узнать общее число страниц. Скрипт перебирает страницы и анализирует кнопку «следующая страница» в пагинации:
URL = 'https://news.itmo.ru/ru/main_news/'
total_pages = None
for i in range(1, 200, 1):
time.sleep(random.randint(0, 1))
data = requests.get(URL + str(i) + '/')
page = BeautifulSoup(data.text, 'html.parser')
next_page_href = page.find_all('div', {'class': 'pagination'})[0]\
.find('ul')\
.find_all('li')[1]\
.find('a')['href']
if next_page_href == '#':
total_pages = i
break
Логика работы: на каждой странице есть блок пагинации с ссылкой «вперёд». Когда страница последняя, эта ссылка становится неактивной (href="#") — это и есть сигнал остановки. Значение total_pages сохраняет номер последней страницы.
Использование time.sleep(random.randint(0, 1)) создаёт случайные задержки между запросами, чтобы не перегружать сервер и снизить риск блокировки.
Этап 3. Глубокий парсинг каждой новости
Главный этап — обход всех найденных страниц и каждой новости в отдельности.
os.makedirs('news_content', exist_ok=True)
for i in range(1, total_pages + 1):
time.sleep(random.randint(0, 1))
page_news = []
try:
data = requests.get(URL + str(i) + '/')
data.raise_for_status()
page = BeautifulSoup(data.text, 'html.parser')
triplet = page.find('ul', class_='triplet')
if not triplet:
continue
news_headers = triplet.find_all('li')
for _n in news_headers:
href = _n.find('h4').find('a')['href']
post_url = DOMAIN + href
try:
time.sleep(random.randint(0, 1))
response = requests.get(post_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
code = post_url.split('/')[-2]
title = soup.find('h1').text if soup.find('h1') else None
date = None
date_tag = soup.find('time')
if date_tag:
date = date_tag.contents[0].strip()
views = None
views_tag = soup.find('span', class_='icon eye')
if views_tag:
views = views_tag.text.strip()
text = None
text_tag = soup.find('div', class_='post-content')
if text_tag:
text = text_tag.get_text(separator=' ', strip=True)
tags = []
tags_ul = soup.find('ul', class_='tags')
if tags_ul:
for li in tags_ul.find_all('li'):
tags.append(li.get_text(strip=True))
news_item = {
"Code": code,
"Title": title,
"Date": date,
"Views": views,
"Text": text,
"Tags": ', '.join(tags)
}
page_news.append(news_item)
except requests.exceptions.RequestException as e:
print(f"Ошибка при запросе {post_url}: {e}")
except Exception as e:
print(f"Ошибка при парсинге {post_url}: {e}")
except requests.exceptions.RequestException as e:
print(f"Ошибка при запросе страницы {i}: {e}")
except Exception as e:
print(f"Ошибка при парсинге страницы {i}: {e}")
if page_news:
df_page = pd.DataFrame(page_news)
file_path = os.path.join('news_content', f"page_{i}.csv")
df_page.to_csv(file_path, index=False)
print(f'Файл успешно записан: page_{i}.csv')
Извлекаемые поля (по требованиям пункта 3)
| Поле | HTML-селектор | Особенности |
|---|---|---|
| Code | post_url.split('/')[-2] |
Числовая часть URL |
| Title | <h1> |
Проверка if soup.find('h1') на случай отсутствия |
| Date | <time> |
Берётся contents[0] (первый текстовый узел) |
| Views | <span class="icon eye"> |
Может отсутствовать |
| Text | <div class="post-content"> |
separator=' ' — для корректного объединения абзацев |
| Tags | <ul class="tags"> > <li> |
Объединяются через ', ' |
Учёт вариативности вёрстки
Особое внимание уделено тому, что не все новости имеют все поля. Поэтому каждое извлечение обёрнуто в проверку if tag::
title = soup.find('h1').text if soup.find('h1') else None
date = None
date_tag = soup.find('time')
if date_tag:
date = date_tag.contents[0].strip()
Это защищает скрипт от падения при встрече с нестандартной разметкой — в противном случае возникала бы ошибка AttributeError: 'NoneType' object has no attribute ....
Структура выходных данных
После выполнения скрипта появляются:
.
├── itmo_news_with_query.csv # Этап 1: новости по запросу "нейротехнологии"
└── news_content/ # Этап 3: глубокие данные по страницам
├── page_1.csv
├── page_2.csv
├── ...
└── page_N.csv
Структура itmo_news_with_query.csv
| Столбец | Тип | Описание |
|---|---|---|
| Date | str | Дата в формате ДД.ММ.ГГГГ |
| Title | str | Заголовок новости |
| URL | str | Полный URL статьи |
| Code | str | Числовой идентификатор |
Структура файлов в news_content/
| Столбец | Тип | Описание |
|---|---|---|
| Code | str | Числовой идентификатор |
| Title | str | Заголовок (из <h1>) |
| Date | str | Дата публикации |
| Views | str | Количество просмотров |
| Text | str | Полный текст статьи |
| Tags | str | Теги через запятую |
Особенности и проблемы, выявленные при парсинге
Вариативность вёрстки. Не у всех новостей одинаковая разметка: отдельные статьи не содержат тегов, счётчика просмотров или даты. Без проверки if tag: скрипт падал бы с AttributeError: 'NoneType'.
Отсутствие даты у некоторых новостей. Уже на этапе 1 встретилась новость без даты в карточке. На этапе 3 для таких случаев используется альтернативный источник — тег <time> на странице самой новости.
Кириллица в URL. Поисковый запрос «нейротехнологии» передаётся в URL без явного кодирования. Лучше использовать urllib.parse.quote(query) или передавать через параметр params=:
response = requests.get(SEARCH_DOMAIN, params={'search': query})
Защита от блокировки. Случайные задержки time.sleep(random.randint(0, 1)) между запросами имитируют человеческое поведение и снижают нагрузку на сервер.
Раздельное сохранение файлов. Каждая страница пагинации сохраняется в отдельный CSV-файл (page_N.csv). Это удобно: при сбое на 50-й странице первые 49 не теряются, и можно возобновить работу с того места, где остановились.
Ограничение range(1, 200). Перебор страниц ограничен 200-ми итерациями — это страховка от бесконечного цикла, если логика определения «последней» страницы внезапно сломается.
Возможные улучшения
Параллельные запросы. При большом числе страниц последовательные HTTP-запросы становятся узким местом. Можно использовать aiohttp или concurrent.futures.ThreadPoolExecutor.
Заголовки запроса (User-Agent). По умолчанию requests посылает заголовок python-requests/..., который легко детектируется. Использование реалистичного User-Agent снижает вероятность блокировки:
headers = {'User-Agent': 'Mozilla/5.0 ...'}
requests.get(url, headers=headers)
Обработка повторных запросов. При временной сетевой ошибке полезно использовать библиотеку tenacity или urllib3.util.retry.Retry для автоматических ретраев.
Объединение результатов. В конец скрипта можно добавить блок, объединяющий все page_N.csv в один общий файл:
import glob
all_files = glob.glob('news_content/page_*.csv')
combined = pd.concat([pd.read_csv(f) for f in all_files])
combined.to_csv('news_content/all_news.csv', index=False)
Хранение в БД. Для регулярного мониторинга новостей лучше использовать SQLite/PostgreSQL вместо плоских CSV.
Выводы
- Реализован двухэтапный парсер: сбор заголовков по поисковому запросу и глубокий обход всего раздела «Главные новости» с переходом на каждую отдельную статью.
- Автоматически определяется общее количество страниц через анализ пагинации (
href == '#'для неактивной кнопки «вперёд»). - Учтена вариативность вёрстки — все обращения к тегам обёрнуты в защитные проверки, что предотвращает падения скрипта.
- Реализованы случайные задержки между запросами для соблюдения «вежливого» поведения по отношению к серверу.
- Каждая страница сохраняется отдельно — это надёжнее при работе с большими объёмами и упрощает возобновление после сбоев.
- Скрипт легко расширяется: можно добавить параллельность, ретраи, объединение в единый датасет, сохранение в БД.