---
title: "Структуры для спарсенного: модели и слайсы"
description: "Структуры данных в Go-парсере: модель Product с Title, Price и URL, слайс-аккумулятор в замыкании main и экспортируемые поля для JSON и CSV. С примерами."
course: "Colly: парсинг и веб-скрапинг на Go"
url: "https://golangmaster.ru/courses/colly/lessons/07-struktury-dannyh/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

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

*Курс «Colly: парсинг и веб-скрапинг на Go», урок 7 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/colly/lessons/07-struktury-dannyh/*

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

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

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

| Поле структуры | Селектор и метод | Пример значения |
| --- | --- | --- |
| 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, базе. Рефлексия сериализаторов смотрит на первую букву имени поля; нет заглавной — нет поля в выводе. Это самый дешёвый баг из всех, что я встречал: одна буква против целой недели молчаливой потери данных.

> **Слайс и Async: пока не смешивать.** Как только включится c.Async = true, колбэки поедут в разных горутинах, и одновременный append в общий слайс без синхронизации — гонка данных: от пропавших записей до испорченной памяти. Пока работаем синхронно, всё безопасно — Visit и колбэки живут в одной горутине. Полный разбор асинхронного режима, WaitGroup и правильных схем — в уроке про [параллельный скрапинг](/courses/colly/lessons/11-async-skreping/); механика горутин — в курсе про [горутины](/courses/go-advanced/lessons/04-gorutiny/) смежной программы.

Замыкание над main — рабочая схема ровно до первого Async, и это стоит проговорить вслух. Слайс объявлен в main, колбэк его захватил — данные текут в одном потоке, и append безопасен. Не глобальная переменная, а локальная с явной областью видимости: тест поднимет main-логику без сети, повторный запуск не унаследует мусор от предыдущего. Когда режим станет асинхронным, на этом месте появится либо мьютекс, либо канал-сборщик, либо запись через защищённый аккумулятор — но не сегодня. Менять архитектуру заранее, до реальной потребности, дороже, чем переписать десять строк под Async позже: структура кода при этом почти не изменится, потому что заполнение модели уже вынесено в newProduct.

### Тонкий колбэк и тестопригодность

newProduct(e) — не стилистическая прихоть, а подготовка к уроку про тестирование скрапера. Конструктор — чистая функция от HTMLElement: подал карточку из фикстуры — получил Product, который сравнил с ожидаемым. Никакой сети, никакого sleep, никакого мока всего коллектора. Колбэк при этом остаётся тонким: достал элемент, сконвертировал, дописал в слайс — три строки, которые читаются с одного взгляда. Внутри конструктора место каждой проверке из прошлого урока: пустой ChildText превратится в пустой Title, и модель честно покажет дыру, а не разроет её по коду. Такой подход ещё и масштабируется без боли: добавили в модель поле Weight — правка живёт в одном конструкторе, а не в каждом колбэке каталога. А запускать примеры всё равно понадобится локально — colly работает по сети, песочница курса такие программы не исполняет, о чём уже говорилось и что честно повторим в выводах.

### Проверка: неэкспортируемые поля и JSON

**Вопрос:** Модель product с полями title, price и url скормили json.Marshal. Что получится на выходе?

- Ошибку компиляции: поля должны быть экспортируемыми
- JSON со всеми полями, как обычно
- ✅ Пустой объект {} — неэкспортируемые поля пропускаются молча
- Панику в момент маршалинга

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

### Проверка: замыкание над слайсом

**Вопрос:** Почему слайс products из main виден внутри колбэка OnHTML?

- colly делает все переменные глобальными
- ✅ Колбэк объявлен внутри main и захватывает products замыканием
- append создаёт копию слайса в колбэке
- Это работа механизма колбэков Go: функции видят весь пакет

Анонимная функция, переданная в OnHTML, объявлена внутри main и видит локальные переменные окружающей области — это стандартное замыкание Go, без библиотечной магии. В синхронном режиме колбэки отрабатывают до возврата Visit, поэтому после Visit слайс уже наполнен.

### Сколько товаров окажется в слайсе?

```go
c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
        products = append(products, newProduct(e))
})

_ = c.Visit("http://books.toscrape.com/")
fmt.Println(len(products))
```

**Что выведет программа?**

- 0 — слайс наполнится после выхода из main
- ✅ 20 — по числу карточек на странице каталога
- Заранее неизвестно — зависит от планировщика
- 1 — последний товар перезапишет остальные

Синхронный режим гарантирует: Visit возвращает управление после всех колбэков страницы. На главной каталога books.toscrape.com ровно двадцать карточек .product_pod — и все они к моменту печати уже в слайсе. Аппенд дописывает, а не перезаписывает.

### Восстановите модель и аккумулятор

Заполните экспортируемое поле и вызов append в колбэке.

```go
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, первым аргументом принимая текущий слайс.

Правильные ответы в пропусках: `Title`, `append`

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

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

Правильный порядок строк:

1. `package main`
2. `import "github.com/gocolly/colly/v2"`
3. `type Product struct {`
4. `  Title string`
5. `  Price int`
6. `  URL   string`
7. `}`
8. `func main() {`
9. `  products := []Product{}`
10. `  c := colly.NewCollector()`
11. `  c.OnHTML(".product_pod", func(e *colly.HTMLElement) {`
12. `    products = append(products, newProduct(e))`
13. `  })`
14. `  _ = c.Visit("http://books.toscrape.com/")`
15. `}`

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

- **Модель данных скрапера** — Структура под спарсенные значения: Product с Title string, Price int (копейки) и URL string. Фиксирует форму данных до сохранения и экспорта.
- **Замыкание** — Функция, захватившая переменные из окружающей области видимости. Колбэк OnHTML внутри main видит слайс products именно так.
- **Экспортируемое поле** — Поле с заглавной буквы: доступно вне пакета. encoding/json и csv видят только экспортируемые поля — иначе пустые объекты и колонки.
- **Слайс-аккумулятор** — products := []Product{}, растущий append-ом в колбэках. В синхронном режиме безопасен; после Visit уже наполнен.
- **Гонка данных** — Одновременный доступ к общей памяти без синхронизации. Станет реальностью при c.Async = true — разбор в уроке про параллельный скрапинг.
- **newProduct(e)** — Конструктор Product из HTMLElement: тонкий колбэк плюс чистая функция заполнения, которую можно тестировать на фикстурах без сети.

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

Модель есть, данные лежат в слайсе — дальше их можно везти двумя маршрутами. Первый — расширить территорию: в уроке про [навигацию по ссылкам](/courses/colly/lessons/08-navigaciya-ssylki/) скрапер научится ходить с каталога на детальные страницы, и Product наполнится с каждой из них. Второй — выгрузить: в уроке про [JSON и CSV](/courses/colly/lessons/19-json-csv-vyvod/) те же структуры превратятся в файлы, и заглавные буквы полей начнут работать на вас. А перед тем как слайс поедет в файлы, стоит увидеть, где он сломается: урок про [параллельный скрапинг](/courses/colly/lessons/11-async-skreping/) разберёт Async и синхронизацию на тех же продуктах.

## Коротко: что запомнить

- Спарсенные значения живут в структуре: 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, и результат сравнивается с ожидаемым.
