После урока про извлечение у нас есть всё, чтобы достать из карточки заголовок, цену и ссылку. Значения вынимаются, выводятся и умирают — между запусками не живёт ничего. Между тем цель скрапера почти всегда одна и та же: получить данные в форме, которую можно сохранить, сравнить и передать дальше — в файл, базу, мониторинг. Для такой формы в Go есть ровно один естественный кандидат: структура. Один товар — одна структура, страница — слайс структур, месяц наблюдений — история из таких слайсов. В этом уроке соберём модель товара, научим колбэки colly её наполнять и разберём две вещи, на которых спотыкаются почти все: замыкание над общим слайсом и экспортируемые поля.
Модель данных скрапера: Product из трёх полей#
Модель данных скрапера — это ответ на вопрос «что именно мы собираем», записанный типами Go. Для карточки каталога минимальный набор — три поля: Title string — название товара, Price int — цена в копейках, URL string — абсолютная ссылка на страницу товара. Каждое решение здесь осознанное. Price — целое число, а не строка и не float64: строку нельзя сравнить и отсортировать честно, дроби в double неточны, а копейки сравниваются обычным меньше-больше. URL — уже абсолютный: относительные ссылки достраивались через AbsoluteURL в месте извлечения, модель получает готовый адрес. Title — строка с обрезанными краями, потому что ChildText сам сделал TrimSpace. Модель фиксирует договорённости сразу: тот, кто заполнит Product грязными значениями, нарушит контракт ещё до сохранения.
Модель и конструктор из HTMLElement
GOtype Product struct {
Title string
Price int // копейки
URL string
}
func newProduct(e *colly.HTMLElement) Product {
title := e.ChildText("h3 a")
rawPrice := e.ChildText(".price_color")
href := e.Attr("href")
return Product{
Title: title,
Price: priceKopecks(rawPrice),
URL: e.Request.AbsoluteURL(href),
}
}Функция newProduct принимает HTMLElement и возвращает готовую структуру — это конструктор модели. Внутри — весь набор прошлого урока: ChildText для заголовка и цены, priceKopecks для перевода в копейки, AbsoluteURL для ссылки. Колбэку останется одна строка вместо шести, а заполнение модели станет чистой функцией, которую можно проверить на тестовой карточке без сети.
Почему не карта map[string]string и не срез строк, ведь данные те же? Карта безликая: опечатка в ключе не поймается никем до самого отчёта, а тип цены внутри карты — строка со всеми её грязными сравнениями. Структура заставляет компилятор следить за формой: удалили поле из HTML — модель заполнится нулями, и это видно; переименовали поле — все места использования всплывут при сборке. Мне доводилось чистить парсер, где спарсенное жило как map[string]any, и я не желаю этого никому: любая ошибка в ключе всплывала на печати отчёта, в трёх кварталах от места появления. Для быстрого прототипа в одном файле карта сгодится, но как только появляется экспорт или база — только структуры. Заодно упрощается тестирование: модель — обычное значение, её сравнивают с ожидаемой в тесте на равенство, без рефлексии и сравнения карт по ключам.
Слайс-аккумулятор: замыкание над main#
Колбэки colly вызываются изнутри библиотеки: у них нет доступа к локальным переменным main — кроме тех, которые они захватили. Анонимная функция, переданная в OnHTML, видит всё из окружающей области видимости, поэтому слайс объявляют в main прямо перед колбэком: products := []Product{}. Внутри колбэка каждая карточка добавляется привычным products = append(products, newProduct(e)) — переменная из внешней области мутируется, и это обычное замыкание, а не фокус. В синхронном режиме, который мы используем пока, Visit возвращает управление только после того, как все колбэки страницы отработали: сразу после Visit слайс полон, и его можно печатать, считать и сохранять. Схема минимальна и честна: объявили аккумулятор рядом с коллектором, наполнили в колбэке, использовали после Visit. Единственное, чего схема не прощает, — объявить слайс внутри колбэка: тогда каждый вызов получит собственную пустую переменную, и к концу страницы соберётся не список товаров, а двадцать однэлементных слайсов, из которых виден последний.
Полный скрапер со слайсом продуктов
GOpackage main
import (
"fmt"
"github.com/gocolly/colly/v2"
)
type Product struct {
Title string
Price int // копейки
URL string
}
func main() {
products := []Product{}
c := colly.NewCollector(
colly.AllowedDomains("books.toscrape.com"),
)
c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
products = append(products, newProduct(e))
})
if err := c.Visit("http://books.toscrape.com/"); err != nil {
fmt.Println("ошибка:", err)
}
fmt.Println("собрано:", len(products))
for _, p := range products {
fmt.Printf("%s — %d коп.
", p.Title, p.Price)
}
}newProduct здесь из предыдущего блока, priceKopecks — из урока про извлечение. Слайс объявлен в main, колбэк захватывает его замыканием и дописывает по карточке за вызов. Visit в синхронном режиме гарантирует: к моменту печати все колбэки отработали, поэтому len(products) после Visit — финальное число, а не ставка.
Поле модели — селектор — пример значения#
| Поле структуры | Селектор и метод | Пример значения |
|---|---|---|
| Title | e.ChildText("h3 a") | "A Light in the Attic" |
| Price | e.ChildText(".price_color") + перевод в копейки | 5177 |
| URL | e.Request.AbsoluteURL(e.Attr("href")) | "http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html" |
Экспортируемые поля: заглавная буква решает#
Вот ловушка, которая проявляется не в момент написания, а через два урока. В Go поле структуры с маленькой буквы — неэкспортируемое: снаружи пакета оно не видно. encoding/json и csv-писатель из урока про сохранение работают с полями через рефлексию и читают только экспортируемые. Модель product с полями title, price и url компилируется без предупреждений, парсится без ошибок, а на выходе у json.Marshal — пустые объекты {}, а в CSV — колонки без содержимого. Программа нигде не падает, данные просто тихо исчезают на последнем шаге. Модель данных скрапера поэтому всегда пишется с заглавных букв: Title, Price, URL. Теги вроде json:"title" прибавим, когда дойдём до экспорта, но заглавные буквы нужны уже сейчас — привычка переносить модели «как есть» с маленькими полями обходится в вечер отладки, который не вернёшь.
Неэкспортируемые поля против экспортируемых
GO// Так не надо: поля не видны пакету encoding/json
type product struct {
title string
price int
url string
}
// json.Marshal(product{...}) -> {} для каждого товара
// Так надо: заглавная буква открывает поле наружу
type Product struct {
Title string
Price int
URL string
}Оба варианта — валидный Go: компилятор не отличит их по вредности. Разница проявляется на границе пакета — в JSON, CSV, базе. Рефлексия сериализаторов смотрит на первую букву имени поля; нет заглавной — нет поля в выводе. Это самый дешёвый баг из всех, что я встречал: одна буква против целой недели молчаливой потери данных.
Как только включится c.Async = true, колбэки поедут в разных горутинах, и одновременный append в общий слайс без синхронизации — гонка данных: от пропавших записей до испорченной памяти. Пока работаем синхронно, всё безопасно — Visit и колбэки живут в одной горутине. Полный разбор асинхронного режима, WaitGroup и правильных схем — в уроке про параллельный скрапинг; механика горутин — в курсе про горутины смежной программы.
Замыкание над main — рабочая схема ровно до первого Async, и это стоит проговорить вслух. Слайс объявлен в main, колбэк его захватил — данные текут в одном потоке, и append безопасен. Не глобальная переменная, а локальная с явной областью видимости: тест поднимет main-логику без сети, повторный запуск не унаследует мусор от предыдущего. Когда режим станет асинхронным, на этом месте появится либо мьютекс, либо канал-сборщик, либо запись через защищённый аккумулятор — но не сегодня. Менять архитектуру заранее, до реальной потребности, дороже, чем переписать десять строк под Async позже: структура кода при этом почти не изменится, потому что заполнение модели уже вынесено в newProduct.
Тонкий колбэк и тестопригодность#
newProduct(e) — не стилистическая прихоть, а подготовка к уроку про тестирование скрапера. Конструктор — чистая функция от HTMLElement: подал карточку из фикстуры — получил Product, который сравнил с ожидаемым. Никакой сети, никакого sleep, никакого мока всего коллектора. Колбэк при этом остаётся тонким: достал элемент, сконвертировал, дописал в слайс — три строки, которые читаются с одного взгляда. Внутри конструктора место каждой проверке из прошлого урока: пустой ChildText превратится в пустой Title, и модель честно покажет дыру, а не разроет её по коду. Такой подход ещё и масштабируется без боли: добавили в модель поле Weight — правка живёт в одном конструкторе, а не в каждом колбэке каталога. А запускать примеры всё равно понадобится локально — colly работает по сети, песочница курса такие программы не исполняет, о чём уже говорилось и что честно повторим в выводах.
Проверка: неэкспортируемые поля и JSON
Модель product с полями title, price и url скормили json.Marshal. Что получится на выходе?
Проверка: замыкание над слайсом
Почему слайс products из main виден внутри колбэка OnHTML?
Сколько товаров окажется в слайсе?
Прочитайте код и выберите, что он выведет:
c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
products = append(products, newProduct(e))
})
_ = c.Visit("http://books.toscrape.com/")
fmt.Println(len(products))Восстановите модель и аккумулятор
Заполните экспортируемое поле и вызов append в колбэке.
Соберите скрапер со слайсом
Расставьте строки: модель, аккумулятор, коллектор, колбэк с append, запуск Visit.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: модели и слайсы
Что дальше по курсу#
Модель есть, данные лежат в слайсе — дальше их можно везти двумя маршрутами. Первый — расширить территорию: в уроке про навигацию по ссылкам скрапер научится ходить с каталога на детальные страницы, и Product наполнится с каждой из них. Второй — выгрузить: в уроке про JSON и CSV те же структуры превратятся в файлы, и заглавные буквы полей начнут работать на вас. А перед тем как слайс поедет в файлы, стоит увидеть, где он сломается: урок про параллельный скрапинг разберёт Async и синхронизацию на тех же продуктах.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Спарсенные значения живут в структуре: Product с Title, Price и URL — минимальная модель каталога.
- Price храните в копейках целым числом: целые деньги сравниваются и сортируются без ошибок округления.
- Колбэки наполняют общий слайс через замыкание над main; в синхронном режиме после Visit слайс уже полон.
- Поля модели — с заглавной буквы: иначе JSON и CSV из уроков дальше получат пустые объекты и колонки.
- При c.Async = true слайс без синхронизации — гонка данных; пока работаем синхронно, всё безопасно.
- Заполнение модели выносите в функцию от HTMLElement: колбэк остаётся тонким, а конструктор — тестируемым.
Домашнее задание
Добавьте в Product поле InStock bool и заполните его в newProduct из ChildText(".instock.availability"): строка "In stock" превращается в true сравнением, всё остальное — в false. Соберите главную страницу каталога в слайс, выведите количество товаров и первые три пары Title и Price. Проверьте себя: после Visit len(products) равен двадцати, а в модели нет ни одного неэкспортируемого поля.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Price — int, а не float64?
float64 не представляет десятичные дроби точно: 0.1 и 0.2 не дают 0.3. Копейки в int сравниваются и складываются точно, а к фунтам и рублям значение возвращается только на выводе — с форматированием и одним делением.
Слайс в main или глобальная переменная — что правильнее?
Для синхронного скрапера — в main, через замыкание: явная область видимости, никакого общего состояния между запусками. Глобальная переменная станет необходимостью позже, в асинхронных схемах с синхронизацией, и тогда её защитит мьютекс или канал.
Что будет, если оставить поля с маленькой буквы?
Программа соберётся и спарсит всё, но encoding/json и csv-запись пропустят неэкспортируемые поля: на выходе пустые объекты и колонки без данных. Ловится это тестом на маршалинг — модель маршалится в JSON, и результат сравнивается с ожидаемым.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Навигация по сайту: обход ссылок и глубина
- OnRequest и OnResponse: запросы под контролем
- LimitRule: задержки и параллелизм
- Параллельный скрапинг: Async и WaitGroup
- Извлечение данных: Text, Attr, ChildText
- OnHTML: колбэки, порядок вызовов и цепочки
- CSS-селекторы в Colly: от тегов до комбинаций
- HTML для скрапера: DOM, теги, атрибуты
- Все курсы по Go
Следующая ступень учебного пути
Testify: тестирование Go с assert и require
Интерактивный курс Testify для Go: assert и require, табличные тесты, suite, mock-объекты, Eventually, HTTP-тесты. 25 уроков с примерами и практикой в браузере.
- Писать читаемые проверки через assert и require, а не лестницы if
- Проверять ошибки, паники и краевые случаи без ручного кода
- Сравнивать структуры, слайсы, карты и числа с плавающей точкой
- Строить табличные тесты и сьюты с хуками подготовки