Практикум GoHTMLParser
О практикуме
GoHTMLParser — консольная утилита (CLI), которая по адресу страницы находит в HTML все элементы заданного тега и печатает их текст. Типичный сценарий — скачать страницу и вывести все заголовки <h2> или подписи ссылок <a>.
Утилита проходит три шага:
- HTTP-запрос — GET по URL, чтение тела ответа (
net/http). - Разбор HTML — построение дерева узлов DOM (
golang.org/x/net/html). - Обход дерева — поиск элементов с именем тега и сбор видимого текста.
Проект показывает типичную структуру Go-приложения — main, пакеты internal/, одна внешняя зависимость, table-driven тесты.
См. также: Первая программа на Go · Простые приложения · Веб на stdlib · Обработка ошибок · GoEmailVerifier — практикум.
Нужны Go 1.21+, пройденная первая программа и базовое понимание обработки ошибок. Полезно знать основы HTTP.
Некоторые сайты блокируют запросы без User-Agent или отдают контент через JavaScript — утилита работает с "сырым" HTML ответа сервера.
Карта этапов
| Этап | Фокус | Результат |
|---|---|---|
| 0 | Модуль Go | Каталог с go.mod и заготовкой main |
| 1 | HTTP | Пакет internal/fetch |
| 2 | Парсинг HTML | Пакет internal/parse |
| 3 | CLI | main.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 == 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
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_test— white-box доступ к неэкспортированномуcollectTextпри необходимости. - Кейс "вложенный текст в a" фиксирует поведение
collectText— регрессия не сломает склейку. want: nil— в Gonilслайс и пустой слайс при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 вместо CLI | Gin — 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/fetch | Module 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.