Перейти к основному содержимому

Практикум GoHTMLParser

Разработчику Начальный уровень

О практикуме​

GoHTMLParser — консольная утилита (CLI), которая по адресу страницы находит в HTML все элементы заданного тега и печатает их текст. Типичный сценарий — скачать страницу и вывести все заголовки <h2> или подписи ссылок <a>.

Утилита проходит три шага:

  1. HTTP-запрос — GET по URL, чтение тела ответа (net/http).
  2. Разбор HTML — построение дерева узлов DOM (golang.org/x/net/html).
  3. Обход дерева — поиск элементов с именем тега и сбор видимого текста.

Проект показывает типичную структуру Go-приложения — main, пакеты internal/, одна внешняя зависимость, table-driven тесты.

См. также: Первая программа на Go · Простые приложения · Веб на stdlib · Обработка ошибок · GoEmailVerifier — практикум.

Для кого материал

Нужны Go 1.21+, пройденная первая программа и базовое понимание обработки ошибок. Полезно знать основы HTTP.

Реальные сайты

Некоторые сайты блокируют запросы без User-Agent или отдают контент через JavaScript — утилита работает с "сырым" HTML ответа сервера.

Карта этапов​

ЭтапФокусРезультат
0Модуль GoКаталог с go.mod и заготовкой main
1HTTPПакет internal/fetch
2Парсинг HTMLПакет internal/parse
3CLImain.go с flag
4Тестыparse_test.go

Оценка времени — 1,5–2 часа.


Этап 0 — модуль и каркас​

Цель​

Получить рабочий Go-модуль с одной точкой входа. Модуль — единица версионирования в Go: файл go.mod фиксирует имя модуля и версию языка. Подробнее о layout проекта — в первой программе.

Команды​

mkdir GoHTMLParser && cd GoHTMLParser
go mod init gohtmlparser
  • go mod init создаёт go.mod с именем модуля gohtmlparser.
  • Это имя должно совпадать с префиксом в import path (gohtmlparser/internal/fetch на этапе 1).

Файл main.go​

package main

import "fmt"

func main() {
fmt.Println("GoHTMLParser — заготовка")
}
  • package main — исполняемый пакет: из него собирается бинарник.
  • func main() — точка входа, которую вызывает runtime при запуске.

Запуск​

go run .

go run . компилирует все .go файлы в текущем каталоге и сразу запускает программу — удобно на этапе обучения.

Разбор​

ЭлементЗачем
go mod init gohtmlparserЗадаёт module path для всех import внутри проекта
package mainОтличает программу от библиотечного пакета
go run .Быстрая проверка без go build

Самопроверка​

  • go version показывает Go 1.21+.
  • go run . печатает заготовку без ошибок.
  • В каталоге есть файл go.mod.

Этап 1 — загрузка страницы (internal/fetch)​

Цель​

Вынести HTTP-логику в отдельный пакет. Функция fetch.HTML принимает URL и возвращает тело ответа как строку или ошибку.

Теория — HTTP GET​

Браузер при открытии страницы отправляет GET-запрос. Сервер отвечает статус-кодом (200 — OK, 404 — не найдено) и телом — часто это HTML. В Go клиентская сторона живёт в net/http. Базовые понятия протокола — в статье HTTP как основа веб-интеграций.

Что важно для нашей утилиты:

  • Таймаут — без него программа может зависнуть на недоступном хосте.
  • User-Agent — идентификатор клиента; часть сайтов отклоняет запросы с пустым заголовком.
  • Закрытие тела ответа — иначе утечка соединений из пула http.Client.

Файл internal/fetch/fetch.go​

Создайте каталог internal/fetch/ и файл:

Код ITЗагрузка примера кода…

Разбор fetch.go​

Клиент и запрос

  • &http.Client{Timeout: 15 * time.Second} — один клиент на все вызовы HTML; таймаут действует на весь обмен (DNS, TCP, чтение тела).
  • http.NewRequest(http.MethodGet, url, nil) — GET без тела; третий аргумент nil, потому что мы ничего не отправляем.
  • req.Header.Set("User-Agent", userAgent) — добавляет заголовок до client.Do.

Ответ и ошибки

  • defer resp.Body.Close() — тело нужно закрыть всегда, даже если дальше будет ошибка чтения.
  • Проверка StatusCode != http.StatusOK — редиректы 3xx клиент может следовать автоматически; нас интересует финальный 200.
  • io.ReadAll(resp.Body) — читает весь поток в []byte; для учебной страницы этого достаточно.
  • fmt.Errorf("...: %w", err) — обёртка ошибки с сохранением причины; разбор паттерна — Обработка ошибок в Go.

Сигнатура (string, error)

  • Успех — HTML и nil.
  • Любой сбой — пустая строка и осмысленное сообщение; вызывающий код решает, завершать программу или повторить запрос.

Проверка из main.go​

Временно замените main.go:

Код ITЗагрузка примера кода…

  • Import gohtmlparser/internal/fetch работает, потому что модуль в go.mod называется gohtmlparser.
  • log.Fatal печатает ошибку и завершает процесс с ненулевым кодом — типичный паттерн для CLI.

Самопроверка​

  • go run . загружает example.com без ошибок.
  • Неверный URL или 404 возвращают понятную ошибку с текстом "неожиданный статус" или "выполнение запроса".
  • В логе видно количество байт больше нуля.

Этап 2 — разбор HTML (internal/parse)​

Цель​

Превратить строку HTML в дерево узлов и собрать текст всех элементов с заданным именем тега.

Теория — DOM и узлы​

HTML-парсер не ищет подстроки вручную (strings.Contains по <h2> ломается на вложенности и атрибутах). Он строит дерево узлов — упрощённый DOM:

Тип узла (html.NodeType)Пример
ElementNode<div>, <a href="...">
TextNodeтекст между тегами
DocumentNodeкорень документа

У каждого узла есть связи FirstChild, NextSibling, Parent. Обход — классический depth-first — зайти в узел, обработать, рекурсивно пройти детей.

Пакет golang.org/x/net/html — расширение стандартной экосистемы Go (не stdlib, но де-факто стандарт для HTML в Go).

Установка зависимости​

go get golang.org/x/net/html

Команда добавит запись в go.mod и go.sum. Версии фиксируются для воспроизводимых сборок — как описано в первой программе про модули.

Файл internal/parse/parse.go​

Код ITЗагрузка примера кода…

Разбор parse.go​

Нормализация тега

  • strings.ToLower — HTML не чувствителен к регистру имён тегов; H2 и h2 эквивалентны.
  • Пустой тег после TrimSpace — ошибка на входе, а не молчаливый пустой результат.

html.Parse

  • Принимает io.Reader; strings.NewReader(htmlContent) не копирует лишний раз весь HTML в новый буфер без нужды.
  • Возвращает корень дерева; ошибка маловероятна для типичного HTML, но мы её пробрасываем.

Функция walk

  • Локальная переменная var walk func(*html.Node) + присваивание нужна для рекурсии на замыкании.
  • Условие n.Type == html.ElementNode && n.Data == tag — Data для элемента это имя тега без угловых скобок.
  • collectText(n) берёт текст внутри элемента, не включая дочерние теги как отдельные записи в results.

collectText и strings.Builder

  • На TextNode возвращаем n.Data как есть.
  • На элементе — конкатенация текстов всех потомков. strings.Builder эффективнее repeated + для длинных фрагментов.

Пример на фрагменте

Для разметки:

<a href="/x">Link <b>bold</b></a>
  • ExtractByTag(..., "a") вернёт один элемент "Link bold".
  • ExtractByTag(..., "b") вернёт "bold".
  • Атрибут href мы пока не извлекаем — только видимый текст.

Проверка из main.go​

Код ITЗагрузка примера кода…

Слой main только склеивает fetch и parse — так проще тестировать parse отдельно на этапе 4.

Самопроверка​

  • На example.com находится один <h1> с текстом про Example Domain.
  • Вызов ExtractByTag(html, " ") возвращает ошибку "тег не может быть пустым".
  • Тег xyz, которого нет на странице, даёт пустой слайс без ошибки.

Этап 3 — CLI с flag​

Цель​

Собрать финальную точку входа — пользователь передаёт URL и тег через аргументы командной строки.

Теория — пакет flag​

Пакет flag из stdlib регистрирует флаги до flag.Parse(). После парсинга значения доступны через указатели (*url, *tag). Для одной команды и пары опций flag достаточно; дерево подкоманд — CLI на cobra и viper.

Разделение вывода:

  • log — служебные сообщения (скачиваем, сколько байт); по умолчанию с меткой времени.
  • fmt — результат для пользователя — нумерованный список найденных строк.

Файл main.go (финальная версия)​

Код ITЗагрузка примера кода…

Разбор main.go​

ФрагментПоведение
flag.String("url", "", ...)Обязательный URL; пустая строка по умолчанию — проверяем вручную после Parse
flag.String("tag", "h2", ...)Значение по умолчанию h2 — частый кейс для статей
if *url == ""Справка в stdout, os.Exit(1) — договорённость Unix о ненулевом коде при ошибке использования
log.Fatalf при ошибке fetch/parseПечать и немедленный выход; %v форматирует цепочку обёрнутых ошибок
Пустой itemsНе ошибка — на странице может не быть такого тега; сообщение в log и выход с кодом 0
Цикл for i, item := range itemsПользовательский вывод без префикса log

Сборка бинарника​

go build -o gohtmlparser .
./gohtmlparser -url https://example.com -tag h1

go build создаёт один исполняемый файл — его можно переносить на машину с тем же OS/ARCH без установки Go.

Самопроверка​

  • Запуск без -url — справка и exit code 1.
  • -url https://example.com -tag h1 — один заголовок.
  • go build успешен; в go.mod есть golang.org/x/net.

Этап 4 — table-driven тесты​

Цель​

Автоматически проверить ExtractByTag на коротких HTML-строках без сети. Сетевые тесты медленные и нестабильные; парсер логично покрывать изолированно — принцип из Тестирование в Go.

Теория — table-driven tests​

Идея — описать кейсы таблицей (слайс структур), прогнать в цикле:

  • вход — фрагмент HTML и имя тега;
  • ожидание — слайс строк или флаг wantErr;
  • t.Run(tt.name, ...) — отдельное имя подтеста в выводе go test -v.

reflect.DeepEqual сравнивает слайсы целиком; для простых []string этого достаточно.

Файл internal/parse/parse_test.go​

Код ITЗагрузка примера кода…

Разбор тестов​

  • Пакет parse, не parse_test — white-box доступ к неэкспортированному collectText при необходимости.
  • Кейс "вложенный текст в a" фиксирует поведение collectText — регрессия не сломает склейку.
  • want: nil — в Go nil слайс и пустой слайс при DeepEqual различимы; наш код возвращает nil при отсутствии совпадений.
  • (err != nil) != tt.wantErr — явная проверка "ожидали ошибку / не ожидали".

Запуск​

go test ./internal/parse/ -v
go test ./...

Вторая команда прогонит все пакеты модуля — на этом этапе тесты только в parse.

Самопроверка​

  • go test ./... — все тесты зелёные.
  • Структура проекта: main.go, internal/fetch/, internal/parse/, go.mod, go.sum.

Что дальше​

НаправлениеМатериал
Атрибуты ссылок (href, class)Расширьте walk — перебирайте n.Attr у ElementNode
Несколько URL параллельноГорутины и sync.WaitGroup
HTTP API вместо CLIGin — handler принимает URL, возвращает JSON
Покрытие и бенчмаркиТестирование, go test -cover
Конфиг и подкомандыCLI на cobra и viper

Частые ошибки​

СимптомВероятная причинаРешение
403 Forbidden или пустой ответСервер отверг запросПроверьте User-Agent в fetch.go
connection timed outХост недоступен или firewallУвеличьте таймаут; проверьте URL и сеть
could not import gohtmlparser/internal/fetchModule path не совпадаетИмя в go.mod должно быть gohtmlparser
Элементы не найдены на "живом" сайтеКонтент рисуется JavaScriptНужен headless-браузер или API сайта
undefined: html.ParseНет зависимостиgo get golang.org/x/net/html
Тесты не видят функцииДругой package в *_test.goИспользуйте package parse, не parse_test

Итоговая структура каталогов​

GoHTMLParser/
├── go.mod
├── go.sum
├── main.go
└── internal/
├── fetch/
│ └── fetch.go
└── parse/
├── parse.go
└── parse_test.go

Вы собрали CLI от модуля до тестов — тот же путь, что и в GoEmailVerifier — практикум, но с акцентом на HTTP и разбор HTML вместо DNS и SMTP.