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

Практикум 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/ и файл:

package fetch

import (
"fmt"
"io"
"net/http"
"time"
)

const userAgent = "GoHTMLParser/1.0 (+https://github.com/example/gohtmlparser)"

// HTML скачивает HTML-страницу по URL и возвращает её содержимое как строку.
func HTML(url string) (string, error) {
client := &http.Client{
Timeout: 15 * time.Second,
}

req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("создание запроса: %w", err)
}

// Некоторые сайты отклоняют запросы без User-Agent.
req.Header.Set("User-Agent", userAgent)

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("выполнение запроса: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("неожиданный статус: %s", resp.Status)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("чтение ответа: %w", err)
}

return string(body), nil
}

Разбор 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:

package main

import (
"fmt"
"log"

"gohtmlparser/internal/fetch"
)

func main() {
html, err := fetch.HTML("https://example.com")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Загружено %d байт\n", len(html))
}
  • 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

package parse

import (
"fmt"
"strings"

"golang.org/x/net/html"
)

// ExtractByTag извлекает текстовое содержимое всех элементов с указанным тегом.
// Например, tag="h2" вернёт все заголовки второго уровня.
func ExtractByTag(htmlContent, tag string) ([]string, error) {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag == "" {
return nil, fmt.Errorf("тег не может быть пустым")
}

doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return nil, fmt.Errorf("разбор HTML: %w", err)
}

var results []string
var walk func(*html.Node)

walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == tag {
text := strings.TrimSpace(collectText(n))
if text != "" {
results = append(results, text)
}
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}

walk(doc)
return results, nil
}

// collectText собирает весь текст внутри узла (включая вложенные элементы).
func collectText(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}

var b strings.Builder
for child := n.FirstChild; child != nil; child = child.NextSibling {
b.WriteString(collectText(child))
}
return b.String()
}

Разбор 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 == tagData для элемента это имя тега без угловых скобок.
  • 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

package main

import (
"fmt"
"log"

"gohtmlparser/internal/fetch"
"gohtmlparser/internal/parse"
)

func main() {
html, err := fetch.HTML("https://example.com")
if err != nil {
log.Fatal(err)
}

items, err := parse.ExtractByTag(html, "h1")
if err != nil {
log.Fatal(err)
}
for i, item := range items {
fmt.Printf("%d. %s\n", i+1, item)
}
}

Слой 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 (финальная версия)

package main

import (
"flag"
"fmt"
"log"
"os"

"gohtmlparser/internal/fetch"
"gohtmlparser/internal/parse"
)

func main() {
url := flag.String("url", "", "URL страницы для парсинга (обязательный)")
tag := flag.String("tag", "h2", "HTML-тег для извлечения: h1, h2, h3, a и т.д.")
flag.Parse()

if *url == "" {
fmt.Println("Парсер веб-страниц — извлекает данные из HTML.")
fmt.Println()
fmt.Println("Использование:")
fmt.Println(" go run . -url <адрес> [-tag <тег>]")
fmt.Println()
fmt.Println("Примеры:")
fmt.Println(" go run . -url https://example.com -tag h1")
fmt.Println(" go run . -url https://news.ycombinator.com -tag a")
os.Exit(1)
}

log.Printf("Скачиваем страницу: %s", *url)
htmlContent, err := fetch.HTML(*url)
if err != nil {
log.Fatalf("Ошибка загрузки: %v", err)
}
log.Printf("Загружено %d байт", len(htmlContent))

log.Printf("Ищем элементы <%s>...", *tag)
items, err := parse.ExtractByTag(htmlContent, *tag)
if err != nil {
log.Fatalf("Ошибка парсинга: %v", err)
}

if len(items) == 0 {
log.Printf("Элементы <%s> не найдены.", *tag)
return
}

fmt.Printf("\nНайдено %d элементов <%s>:\n\n", len(items), *tag)
for i, item := range items {
fmt.Printf("%d. %s\n", i+1, item)
}
}

Разбор 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

package parse

import (
"reflect"
"testing"
)

func TestExtractByTag(t *testing.T) {
tests := []struct {
name string
html string
tag string
want []string
wantErr bool
}{
{
name: "один h1",
html: "<html><body><h1>Hello</h1></body></html>",
tag: "h1",
want: []string{"Hello"},
},
{
name: "вложенный текст в a",
html: `<a href="/x">Link <b>bold</b></a>`,
tag: "a",
want: []string{"Link bold"},
},
{
name: "пустой тег",
html: "<h1>x</h1>",
tag: " ",
wantErr: true,
},
{
name: "тег не найден",
html: "<p>text</p>",
tag: "h1",
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ExtractByTag(tt.html, tt.tag)
if (err != nil) != tt.wantErr {
t.Fatalf("ExtractByTag() error = %v, wantErr %v", err, tt.wantErr)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ExtractByTag() = %v, want %v", got, tt.want)
}
})
}
}

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

  • Пакет parse, не parse_testwhite-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.