Парсинг HTML, ссылок и текста: проблемы, кодировки и примеры на разных языках

Извлечение текста и ссылок из HTML: кодировки, битая разметка, относительные URL и примеры решений на популярных языках.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 14 марта 2025

Парсинг — это извлечение нужных данных из веб-страниц: разбор HTML-разметки, сбор ссылок, выделение текстового контента. На первый взгляд задача простая, но на практике почти всегда всплывают подводные камни: «грязная» разметка, динамический контент, блокировки и — особенно болезненно для рунета — проблемы с кодировками и кириллицей.

В этой статье разберём три базовых сценария (HTML, ссылки, текст), типичные проблемы и приведём примеры реализации на нескольких языках.


1. Парсинг HTML

Парсинг HTML — это построение из строки разметки дерева элементов (DOM), по которому можно перемещаться и из которого можно выбирать нужные узлы. Чаще всего работают через:

  • CSS-селекторы.product .price, div#content > p;
  • XPath//div[@class="product"]//span[@class="price"];
  • регулярные выражения — допустимы только для очень простых случаев, для полноценного HTML их использовать не стоит (HTML не регулярный язык).

Ключевая идея: не разбирайте HTML регулярками. Используйте специализированный парсер, который умеет восстанавливать «сломанную» разметку так же, как это делает браузер.

2. Парсинг ссылок

Сбор ссылок — основа любого краулера. Обычно это:

  1. Выборка всех тегов <a> с атрибутом href.
  2. Приведение относительных ссылок к абсолютным (/pagehttps://site.com/page).
  3. Фильтрация: убрать mailto:, tel:, javascript:, якоря #, дубликаты.
  4. Нормализация URL (регистр хоста, завершающий слеш, порядок query-параметров).

Главная ошибка новичков — работать с href как с готовым абсолютным адресом. На реальных сайтах ссылки бывают относительными, протокол-относительными (//cdn.site.com/...) или с учётом тега <base href>.

3. Парсинг текста

Извлечение текста — это получение «человеческого» содержимого без тегов. Здесь важно:

  • убирать <script>, <style>, скрытые элементы;
  • корректно обрабатывать пробелы, переносы строк, неразрывные пробелы (&nbsp;);
  • декодировать HTML-сущности (&amp;&, &#1040;А, &laquo;«);
  • по возможности отделять основной контент от навигации, рекламы и футера (задача «content extraction»).

4. С какими проблемами можно столкнуться

«Грязный» и невалидный HTML. Незакрытые теги, неправильная вложенность, отсутствие кавычек. Хороший парсер прощает такие ошибки, регулярки — нет.

Динамический контент (JavaScript). Если данные подгружаются через AJAX/JS, их нет в исходном HTML. Нужен либо headless-браузер (Selenium, Playwright, Puppeteer), либо обращение напрямую к API/XHR-запросам, которые делает страница.

Изменчивость вёрстки. Селекторы ломаются при редизайне. Стоит выбирать устойчивые признаки (id, семантические атрибуты, микроразметку) вместо длинных хрупких цепочек классов.

Антибот-защита. Капчи, проверка User-Agent, rate-limiting, блокировка по IP, Cloudflare. Требуются паузы между запросами, ротация заголовков/прокси, соблюдение robots.txt и здравого смысла.

Кодировки и кириллица. Самая частая боль рунета — об этом отдельный раздел ниже.

Производительность. На больших объёмах важны асинхронность, пулы соединений, потоковый разбор (SAX-подобный) вместо загрузки всего DOM в память.

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


5. Работа с кириллицей и кодировками

Большинство проблем с «кракозябрами» (Привет или Привет) сводятся к одному: байты прочитаны не в той кодировке, в которой они были записаны.

В рунете встречаются три основные кодировки:

  • UTF-8 — современный стандарт, дефолт для нового веба;
  • Windows-1251 (CP1251) — legacy-кодировка, до сих пор живая на старых сайтах;
  • KOI8-R — совсем старая, встречается редко.

Как определить правильную кодировку

Порядок приоритетов, как это делает браузер:

  1. HTTP-заголовок Content-Type: text/html; charset=windows-1251.
  2. Мета-тег в HTML: <meta charset="utf-8"> или <meta http-equiv="Content-Type" content="text/html; charset=windows-1251">.
  3. BOM (Byte Order Mark) в начале файла — для UTF.
  4. Автоопределение по статистике байтов (библиотеки chardet, charset-normalizer).

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

Типичные ошибки

  • Декодировали Windows-1251 как UTF-8 → ошибка или «мусор».
  • Заявленный в заголовке charset не совпадает с реальным — сервер «врёт».
  • Смешанные кодировки на одной странице.
  • Двойное декодирование/кодирование (mojibake).
  • Забыли указать кодировку при записи результата в файл или БД.

Универсальный рецепт: читаем байты → определяем кодировку → декодируем в Unicode → внутри программы работаем только в Unicode → на выходе кодируем в UTF-8.


6. Примеры реализации на разных языках

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

Python — requests + BeautifulSoup

Самая популярная связка. requests сам пытается определить кодировку, а BeautifulSoup хорошо переваривает «грязный» HTML.

python
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

url = "https://example.com"
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})

# Корректное определение кодировки по содержимому (а не только по заголовку)
resp.encoding = resp.apparent_encoding  # использует charset-normalizer/chardet

soup = BeautifulSoup(resp.text, "lxml")  # парсер lxml быстрый и устойчивый

# Парсинг текста
title = soup.title.get_text(strip=True)
print("Заголовок:", title)

# Парсинг ссылок: относительные -> абсолютные, с фильтрацией
links = set()
for a in soup.select("a[href]"):
    href = a["href"].strip()
    if href.startswith(("mailto:", "tel:", "javascript:", "#")):
        continue
    links.add(urljoin(url, href))

print(f"Найдено ссылок: {len(links)}")

Если кодировку нужно определить вручную по байтам:

python
import charset_normalizer

raw = resp.content                      # сырые байты
best = charset_normalizer.from_bytes(raw).best()
html = str(best)                        # уже корректная Unicode-строка

JavaScript / Node.js — axios + cheerio

cheerio — это jQuery-подобный API для сервера. Кодировку лучше определять явно через iconv-lite, так как Node по умолчанию ждёт UTF-8.

javascript
const axios = require("axios");
const cheerio = require("cheerio");
const iconv = require("iconv-lite");
const chardet = require("chardet");

(async () => {
  const url = "https://example.com";
  // Получаем именно байты
  const { data } = await axios.get(url, {
    responseType: "arraybuffer",
    headers: { "User-Agent": "Mozilla/5.0" },
  });

  // Определяем кодировку и декодируем
  const encoding = chardet.detect(data) || "utf-8";
  const html = iconv.decode(Buffer.from(data), encoding);

  const $ = cheerio.load(html);

  // Текст
  console.log("Заголовок:", $("title").text().trim());

  // Ссылки
  const links = new Set();
  $("a[href]").each((_, el) => {
    const href = ($(el).attr("href") || "").trim();
    if (/^(mailto:|tel:|javascript:|#)/.test(href)) return;
    links.add(new URL(href, url).href); // относительные -> абсолютные
  });
  console.log("Найдено ссылок:", links.size);
})();

PHP — DOMDocument

Встроенный DOMDocument умеет парсить HTML «из коробки». Для кириллицы важно подсунуть ему правильную кодировку — самый надёжный приём показан ниже.

php
<?php
$url = "https://example.com";
$html = file_get_contents($url);

// Если страница в windows-1251, переводим в UTF-8 до парсинга
$encoding = mb_detect_encoding($html, ["UTF-8", "Windows-1251", "KOI8-R"], true);
if ($encoding && $encoding !== "UTF-8") {
    $html = mb_convert_encoding($html, "UTF-8", $encoding);
}

$dom = new DOMDocument();
libxml_use_internal_errors(true); // глушим ошибки «грязного» HTML
// Хак, чтобы DOMDocument не ломал UTF-8: явно указываем кодировку
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Текст
$titleNode = $xpath->query("//title")->item(0);
echo "Заголовок: " . trim($titleNode->textContent) . PHP_EOL;

// Ссылки
$links = [];
foreach ($xpath->query("//a[@href]") as $a) {
    $href = trim($a->getAttribute("href"));
    if (preg_match('/^(mailto:|tel:|javascript:|#)/', $href)) continue;
    $links[$href] = true; // дедупликация по ключу
}
echo "Найдено ссылок: " . count($links) . PHP_EOL;

Go — net/http + goquery

goquery повторяет API jQuery. Для кодировок есть пакет golang.org/x/net/html/charset, который сам читает charset из заголовков и мета-тегов.

go
package main

import (
    "fmt"
    "net/http"
    "net/url"

    "github.com/PuerkitoBio/goquery"
    "golang.org/x/net/html/charset"
)

func main() {
    target := "https://example.com"
    resp, err := http.Get(target)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    // Автоматически переводит поток в UTF-8 по charset из заголовка/мета
    utf8Reader, err := charset.NewReader(resp.Body, resp.Header.Get("Content-Type"))
    if err != nil {
        panic(err)
    }

    doc, err := goquery.NewDocumentFromReader(utf8Reader)
    if err != nil {
        panic(err)
    }

    // Текст
    fmt.Println("Заголовок:", doc.Find("title").First().Text())

    // Ссылки
    base, _ := url.Parse(target)
    links := map[string]bool{}
    doc.Find("a[href]").Each(func(_ int, s *goquery.Selection) {
        href, _ := s.Attr("href")
        if u, err := base.Parse(href); err == nil {
            links[u.String()] = true
        }
    })
    fmt.Println("Найдено ссылок:", len(links))
}

Java — Jsoup

Jsoup — одна из самых удобных библиотек: загрузка, разбор, селекторы и определение кодировки в одном пакете.

java
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.util.HashSet;
import java.util.Set;

public class Parser {
    public static void main(String[] args) throws Exception {
        String url = "https://example.com";
        // Jsoup сам определит кодировку по заголовкам и мета-тегам
        Document doc = Jsoup.connect(url)
                .userAgent("Mozilla/5.0")
                .get();

        // Текст
        System.out.println("Заголовок: " + doc.title());

        // Ссылки (absUrl сам приводит к абсолютным)
        Set<String> links = new HashSet<>();
        for (Element a : doc.select("a[href]")) {
            String href = a.absUrl("href");
            if (href.isBlank()) continue;
            links.add(href);
        }
        System.out.println("Найдено ссылок: " + links.size());
    }
}

7. Краткие рекомендации

  • Не парсите HTML регулярками — используйте полноценный парсер (lxml, cheerio, goquery, Jsoup, DOMDocument).
  • Кодировку определяйте по байтам, а не по строке, и приводите всё к UTF-8 как можно раньше.
  • Относительные ссылки всегда приводите к абсолютным (urljoin, new URL(base), absUrl).
  • Для динамических сайтов готовьтесь к headless-браузеру или работе с внутренним API.
  • Уважайте robots.txt, ставьте паузы и не кладите чужой сервер.

Читайте также

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