Лабораторная работа №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. Парсинг по поисковому запросу — сбор новостей по ключевому слову «нейротехнологии».
  2. Определение количества страниц в разделе «Главные новости» через анализ пагинации.
  3. Глубокий парсинг каждой новости — переход на страницу каждой статьи и извлечение полной информации (текст, дата, просмотры, теги).

Этап 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 == '#' для неактивной кнопки «вперёд»).
  • Учтена вариативность вёрстки — все обращения к тегам обёрнуты в защитные проверки, что предотвращает падения скрипта.
  • Реализованы случайные задержки между запросами для соблюдения «вежливого» поведения по отношению к серверу.
  • Каждая страница сохраняется отдельно — это надёжнее при работе с большими объёмами и упрощает возобновление после сбоев.
  • Скрипт легко расширяется: можно добавить параллельность, ретраи, объединение в единый датасет, сохранение в БД.

Ссылки