Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 7 из 25 ~16 мин +50 XP

Структуры для спарсенного: модели и слайсы

Двадцать карточек каталога напечатаны в консоль — и что дальше? Консоль не приложишь к отчёту, строки без контекста расползаются, а сравнить цены между запусками нечем. Структуры данных в Go-парсере решают это на корню: спарсенные значения складываются в модель Product, а колбэки colly наполняют ею общий слайс. Соберём модель, разберём замыкание, на котором она аккумулируется, и поймаем, почему поля обязаны быть с заглавной буквы.

Содержание урока · 9 разделов

После урока про извлечение у нас есть всё, чтобы достать из карточки заголовок, цену и ссылку. Значения вынимаются, выводятся и умирают — между запусками не живёт ничего. Между тем цель скрапера почти всегда одна и та же: получить данные в форме, которую можно сохранить, сравнить и передать дальше — в файл, базу, мониторинг. Для такой формы в Go есть ровно один естественный кандидат: структура. Один товар — одна структура, страница — слайс структур, месяц наблюдений — история из таких слайсов. В этом уроке соберём модель товара, научим колбэки colly её наполнять и разберём две вещи, на которых спотыкаются почти все: замыкание над общим слайсом и экспортируемые поля.

Модель данных скрапера: Product из трёх полей#

Модель данных скрапера — это ответ на вопрос «что именно мы собираем», записанный типами Go. Для карточки каталога минимальный набор — три поля: Title string — название товара, Price int — цена в копейках, URL string — абсолютная ссылка на страницу товара. Каждое решение здесь осознанное. Price — целое число, а не строка и не float64: строку нельзя сравнить и отсортировать честно, дроби в double неточны, а копейки сравниваются обычным меньше-больше. URL — уже абсолютный: относительные ссылки достраивались через AbsoluteURL в месте извлечения, модель получает готовый адрес. Title — строка с обрезанными краями, потому что ChildText сам сделал TrimSpace. Модель фиксирует договорённости сразу: тот, кто заполнит Product грязными значениями, нарушит контракт ещё до сохранения.

Модель и конструктор из HTMLElement

GO
type 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. Единственное, чего схема не прощает, — объявить слайс внутри колбэка: тогда каждый вызов получит собственную пустую переменную, и к концу страницы соберётся не список товаров, а двадцать однэлементных слайсов, из которых виден последний.

Полный скрапер со слайсом продуктов

GO
package 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 — финальное число, а не ставка.

Поле модели — селектор — пример значения#

Поле структурыСелектор и методПример значения
Titlee.ChildText("h3 a")"A Light in the Attic"
Pricee.ChildText(".price_color") + перевод в копейки5177
URLe.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, базе. Рефлексия сериализаторов смотрит на первую букву имени поля; нет заглавной — нет поля в выводе. Это самый дешёвый баг из всех, что я встречал: одна буква против целой недели молчаливой потери данных.

Замыкание над 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 в колбэке.

type Product struct { string Price int URL string } func main() { products := []Product{} c := colly.NewCollector() c.OnHTML(".product_pod", func(e *colly.HTMLElement) { products = (products, newProduct(e)) }) }

Соберите скрапер со слайсом

Расставьте строки: модель, аккумулятор, коллектор, колбэк с append, запуск Visit.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: модели и слайсы

Термин

Модель данных скрапера

нажмите, чтобы перевернуть
Определение

Структура под спарсенные значения: Product с Title string, Price int (копейки) и URL string. Фиксирует форму данных до сохранения и экспорта.

1 / 6

Что дальше по курсу#

Модель есть, данные лежат в слайсе — дальше их можно везти двумя маршрутами. Первый — расширить территорию: в уроке про навигацию по ссылкам скрапер научится ходить с каталога на детальные страницы, и 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) равен двадцати, а в модели нет ни одного неэкспортируемого поля.

Личные заметкиПусто
Хранятся только в вашем браузере (Colly · Структуры для спарсенного: модели и слайсы)

Вопросы и ответы по уроку

Почему Price — int, а не float64?

float64 не представляет десятичные дроби точно: 0.1 и 0.2 не дают 0.3. Копейки в int сравниваются и складываются точно, а к фунтам и рублям значение возвращается только на выводе — с форматированием и одним делением.

Слайс в main или глобальная переменная — что правильнее?

Для синхронного скрапера — в main, через замыкание: явная область видимости, никакого общего состояния между запусками. Глобальная переменная станет необходимостью позже, в асинхронных схемах с синхронизацией, и тогда её защитит мьютекс или канал.

Что будет, если оставить поля с маленькой буквы?

Программа соберётся и спарсит всё, но encoding/json и csv-запись пропустят неэкспортируемые поля: на выходе пустые объекты и колонки без данных. Ловится это тестом на маршалинг — модель маршалится в JSON, и результат сравнивается с ожидаемым.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Testify25 уроков~9 чпрактика25 уроков уже доступны

Testify: тестирование Go с assert и require

Интерактивный курс Testify для Go: assert и require, табличные тесты, suite, mock-объекты, Eventually, HTTP-тесты. 25 уроков с примерами и практикой в браузере.

  • Писать читаемые проверки через assert и require, а не лестницы if
  • Проверять ошибки, паники и краевые случаи без ручного кода
  • Сравнивать структуры, слайсы, карты и числа с плавающей точкой
  • Строить табличные тесты и сьюты с хуками подготовки