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

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

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

Урок 19 из 25 ~18 мин +20 XP

Сохраняем результат: JSON и CSV

Сохранение данных парсера — этап, на котором отлаженные селекторы превращаются в файл, который откроется и через месяц, и в таблицу, которую примет аналитик. Печать в stdout проверяет селекторы, но не хранит: программа завершилась — и всё собранное исчезло. Разберём три штатных формата вывода на Go — JSON через json.MarshalIndent и json.NewEncoder, построчный JSONL для больших выгрузок и CSV через csv.Writer — плюс правила отчёта, который переживёт месяц на диске.

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

Типовой вечер: скрапер наконец печатает в терминал названия и цены — одна карточка за другой, всё правильно. А потом программа заканчивается, и от результата остаются только строки в истории терминала. Вывод в консоль отвечает на вопрос «работают ли селекторы», но не на вопрос «где данные»: у отчёта должен быть файл, у файла — формат, который переживёт и месяц на диске, и передачу в другую программу. Стандартная библиотека Go закрывает весь набор: encoding/json даёт читаемые дампы и потоковую запись, encoding/csv — таблицы для табличного редактора, а построчный JSONL стоит между ними. Выбор формата — это выбор читателя: JSON для программ, CSV для людей в Excel, JSONL для потока записей, который не помещается в память целиком. Хороший скрапер часто пишет в два формата сразу: машинный для пайплайна и человеческий для заказчика — оба собираются из одних и тех же структур в памяти, так что вторая выгрузка почти бесплатна. Файлы держите в отдельном каталоге рядом с кодом — out/, exports/, data/ — и не забывайте про .gitignore: данные, собранные с чужого сайта, в репозитории живут плохо. Ниже — механика каждого варианта на одном и том же каталоге, плюс гигиена, которая отличает выгрузку от одноразового дампа.

Аккумулирование и json.MarshalIndent#

Данные собираются так, как разбирали в уроке про структуры для спарсенного: колбэк OnHTML создаёт структуру Product и добавляет её в общий слайс, а запись в файл — один раз в конце main, когда Visit отработал и слайс наполнен. json.MarshalIndent(v, "", " ") превращает слайс в []byte с отступами — такой файл читается глазами, открывается в редакторе и красиво смотрится в diff при отладке схемы. Ошибка маршалинга проверяется честно: Marshal не справляется с каналами, функциями и циклическими ссылками, но модель скрапера из строк и чисел в эту категорию не попадает — если только в структуру не попало поле-функция от какой-нибудь рефлексии. os.WriteFile кладёт байты на диск одним вызовом; права 0644 означают rw-r--r-- — владелец пишет, остальные читают. Имя файла — не мелочь: одна и та же выгрузка каждый день в один products.json затирает вчерашнюю, а вам потом захочется сравнить. Дата в имени решает это за секунду. Третий вариант — json.Marshal без отступов: компактный документ для пайплайна, где читает его программа, а не человек. Для сценария «собрал — сохранил — вышел» MarshalIndent — самый быстрый путь от слайса к файлу, и именно с него стоит начинать каждый новый скрапер.

Одна выгрузка в конце обхода

GO
package main

import (
        "encoding/json"
        "log"
        "os"

        "github.com/gocolly/colly/v2"
)

type Product struct {
        Name string `json:"name"`
        URL  string `json:"url"`
}

func main() {
        var products []Product

        c := colly.NewCollector()

        c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
                p := Product{
                        Name: e.ChildText("h3 a"),
                        URL:  e.Request.AbsoluteURL(e.ChildAttr("h3 a", "href")),
                }
                products = append(products, p)
        })

        if err := c.Visit("https://shop.example.com/catalog/"); err != nil {
                log.Fatal(err)
        }

        data, err := json.MarshalIndent(products, "", "  ")
        if err != nil {
                log.Fatal(err)
        }
        if err := os.WriteFile("products.json", data, 0644); err != nil {
                log.Fatal(err)
        }
}

Слайс products наполняется в колбэках, файл пишется один раз после обхода — не на каждую карточку. Теги json задают имена полей в файле: name и url вместо Name и URL. MarshalIndent с отступом в два пробела делает дамп читаемым; ошибка каждого шага проверяется, потому что тихий ноль после неудавшейся записи — самый дорогой баг отчёта: узнаете о нём по пустому файлу через месяц.

json.NewEncoder: поток без промежуточного буфера#

MarshalIndent собирает весь документ в памяти как []byte — для отчёта это норма, для больших объёмов расточительно. Второй путь — json.NewEncoder(w).Encode(v): кодировщик пишет прямо в io.Writer, без промежуточного слайса байтов. Два отличия, о которых полезно знать заранее. Encode завершает документ переводом строки: файл, собранный энкодером, заканчивается \n, а маршал-дамп — нет, и на этой черте, забегая вперёд, держится весь JSONL. Encoder работает с любым писателем — os.File, os.Stdout, сетевым соединением, буфером в тесте — поэтому один и тот же код выгрузки живёт и в скрапере, и в его тестах. Отступы включаются отдельным вызовом SetIndent("", " ") — без него JSON выйдет компактным, в одну строку, что для пайплайна даже дешевле: меньше байт, тот же смысл. Ещё одна настройка, о которой узнают по странному выводу: по умолчанию энкодер экранирует символы HTML — угловые скобки и амперсанд в тексте товара превратятся в \u003c и \u003e; отключается вызовом SetEscapeHTML(false), если выгрузка читается человеком. Практическое правило такое: одна выгрузка в конец программы — что угодно из двух, разница вкусовая; данные, которые текут мимо уже открытого писателя, — энкодер, потому что ему не нужен весь документ сразу.

JSONL: по записи на строку#

Когда записей десятки тысяч, единый JSON-документ становится обузой: его надо целиком держать в памяти, целиком перечитывать, и докинуть в него запись означает пересобрать весь файл заново. Альтернатива — JSONL, он же NDJSON: один JSON-объект на строку. Файл открывается один раз с флагами os.O_APPEND|os.O_CREATE|os.O_WRONLY и правами 0o644 — дописываем без затирания; создаётся json.NewEncoder(f), и на каждую запись вызывается Encode(item): энкодер сам ставит \n в конец, и ровно эта черта превращает поток объектов в валидный JSONL. Такой файл можно читать построчно любым инструментом — grep, wc -l, awk — и докидывать записи в существующий выгрузкой другого дня, не пересобирая старое: файл с вчерашними строками и сегодня — это корректный JSONL, а не полусборка. Читающая программа разбирает его тем же энкодером наоборот: bufio.Scanner построчно плюс json.Unmarshal на каждую строку — память расходуется на одну запись, а не на весь файл. Цена формата — вложенность: объект с многострочной структурой внутри сломает построчную модель, поэтому в JSONL пишут плоские записи, а глубину оставляют единому JSON. Для действительно больших выгрузок файл разделяют по дате в имени — prices-2025-03-01.jsonl — и при желании жмут gzip-ом: построчный формат прекрасно переживает архивацию, а строка-дата в имени превращает ротацию в вопрос переименования.

Дозапись JSONL по ходу обхода

GO
f, err := os.OpenFile("products.jsonl",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
        log.Fatal(err)
}
defer f.Close()

enc := json.NewEncoder(f)

c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
        p := Product{
                Name: e.ChildText("h3 a"),
                URL:  e.Request.AbsoluteURL(e.ChildAttr("h3 a", "href")),
        }
        if err := enc.Encode(p); err != nil {
                log.Println("не записал:", err)
        }
})

Дескриптор открывается один раз до обхода и закрывается через defer — не на каждую строку. O_APPEND двигает позицию в конец перед каждой записью, O_CREATE создаёт файл, если его нет; 0o644 — права rw-r--r--. Ошибка Encode логируется в колбэке: битая запись не роняет обход, а попадает в журнал для разбора — данные дороже пунктуальности.

csv.Writer: таблица для табличного редактора#

CSV — формат для читателя-человека: Excel, Google Sheets, да просто глаза. Пакет encoding/csv: csv.NewWriter(out) оборачивает писателя буфером, w.Write([]string{...}) кладёт строку-запись, w.Flush() выталкивает буфер на диск, err := w.Error() возвращает первую накопившуюся ошибку записи. Три нюанса. Flush обязателен: внутри NewWriter сидит буфер, и без него файл останется пустым или обрезанным ровно на середине. Write возвращает error, но ошибки он накапливает до Flush — надёжнее проверять w.Error() после него. И квотирование автоматическое: поле с запятой, кавычкой или переводом строки само обернётся кавычками, а кавычки внутри удвоятся — руками экранировать ничего не нужно, и в этом главное отличие пакета от склейки строк через strings.Join, которая ломается на первом же названии с запятой. Если Excel показывает кракозябры, допишите в начало файла BOM — три байта EF BB BF, подсказку о кодировке UTF-8. И последнее: числа пишите с точкой, а не с запятой, — ячейка «89,90» в русской локали превратится в дату или текст, и таблица перестанет считаться. У читателя есть и своя страховка: по умолчанию он ждёт в каждой строке столько колонок, сколько в первой, и возвращает ошибку на смещённой записи — тихо съехавших колонок в правильном CSV не бывает.

CSV-отчёт с разделителем под Excel

GO
f, err := os.Create("products.csv")
if err != nil {
        log.Fatal(err)
}
defer f.Close()

w := csv.NewWriter(f)
w.Comma = ';'

w.Write([]string{"name", "url", "scraped_at"})

now := time.Now().UTC().Format(time.RFC3339)
c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
        w.Write([]string{
                e.ChildText("h3 a"),
                e.Request.AbsoluteURL(e.ChildAttr("h3 a", "href")),
                now,
        })
})

if err := c.Visit("https://shop.example.com/catalog/"); err != nil {
        log.Fatal(err)
}
w.Flush()
if err := w.Error(); err != nil {
        log.Fatal(err)
}

w.Comma = ';' меняет разделитель на точку с запятой — это не украшение: Excel с русской локалью разбивает по ';' и не понимает ',', потому что запятая у него занята десятичной частью чисел. Заголовок пишется до обхода, метка времени одна на выгрузку и в формате RFC3339 с UTC. Финал обязателен: без Flush хвост остаётся в буфере, без w.Error() файл молчит о проблеме.

Читаем обратно: вторая программа#

Выгрузка хороша настолько, насколько легко читается обратно. JSONL разбирается построчно: bufio.Scanner поверх файла плюс json.Unmarshal на каждую строку — память занимает одна запись, и файл на миллион строк обрабатывается так же спокойно, как на десять. Единый JSON-документ читается целиком: os.ReadFile плюс json.Unmarshal в []Product — просто, но память пропорциональна файлу. CSV возвращается пакетом же encoding/csv: csv.NewReader(f), затем r.ReadAll() даёт [][]string, где первая строка — заголовок; для больших файлов вместо ReadAll берут r.Read() в цикле — построчно, без всего файла в памяти. Типов в CSV нет: всё текст, и число с запятой в качестве десятичного разделителя придётся чистить strconv-ом — ещё один довод писать числа с точкой и хранить их в отдельной колонке. Из этого следует рабочий вывод: данные отдаются в том формате, в котором их будут есть, — не заставляйте аналитика распаковывать ваш любимый бинарник. И проверяйте обратную сторону до боевого запуска: собранный файл читается второй программой, заголовки сходятся с ожидаемыми, даты парсятся — выгрузка, которую нельзя прочитать обратно, это просто способ потерять данные с лишним шагом.

Форматы: кому какой#

ФорматЧитательСильная сторонаСлабая сторона
JSON (MarshalIndent)программы, человек при отладкетипы и вложенность сохраняютсявесь документ в памяти; читается только целиком
JSONL / NDJSONпотоковые скрипты, вторая программадозапись без пересборки, построчная обработкатабличный редактор напрямую не откроет
CSVExcel, Sheets, отчётные письмаоткрывается везде, есть заголовкитипы теряются: всё текст, числа приходится чистить

Отчёт, который переживёт месяц#

  • стабильные заголовки: имена колонок и полей не меняются между выгрузками — на них сцеплены скрипты и сводные таблицы
  • даты в ISO: time.Now().UTC().Format(time.RFC3339) — сортируются лексикографически и читаются в любой локали
  • версия схемы в имени файла: products-v2-2025-03-01.json — правка структуры не ломает старых читателей тихо
  • числа с точкой, валюта отдельной колонкой: «1 299,90 руб.» в одной ячейке не суммируется ни в одной таблице
  • одна выгрузка — один смысл: смешанный файл «товары плюс логи» не откроет никто
  • ошибки записи проверяются до выхода: пустой файл с нулём в статусе — худший вид отчёта
  • обратное чтение протестировано: собранный файл открывается второй программой, заголовки и даты сходятся

Проверка: два способа записать JSON

Чем json.NewEncoder(f).Encode(v) отличается от связки json.MarshalIndent плюс os.WriteFile?

Проверка: автоквотирование

Поле товара — Молоко "Дар" 1,5%. Что сделает csv.Writer при w.Write с таким полем?

Что окажется в файле?

Прочитайте код и выберите, что он выведет:

w := csv.NewWriter(f)

w.Write([]string{"product", "price"})
w.Write([]string{"Молоко \"Дар\" 1,5%", "89,90"})

w.Flush()

Восстановите дозапись JSONL

Допишите открытие файла с флагами дозаписи, энкодер и запись структуры.

f, err := os.("products.jsonl", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { log.Fatal(err) } defer f.() enc := json.New(f) c.OnHTML(".product_pod", func(e *colly.HTMLElement) { enc.(product) })

Финал CSV-отчёта

Расставьте шаги записи отчёта от создания writer до закрытия файла. Проверки сокращены до существенных.

Строки

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

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

Карточки: словарь выгрузки

Термин

json.MarshalIndent

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

Функция encoding/json: превращает значение в []byte с отступами. Ошибку маршалинга и записи проверяют на каждом шаге.

1 / 6

Что дальше#

Файлы хороши, пока вопросы звучат как «выгрузи каталог». Как только появляется «сколько товар стоил первого числа», файлы начинают складываться в папки, а папки — в беспорядок. Ответ — база: следующий урок складывает спарсенное в SQLite через database/sql — с upsert вместо дублей и append-only историей цен вместо разовых дамп-файлов; смежный разбор — в уроке про сохранение в базу. Сырьё для неё — ровно те структуры, что вы собирали здесь. А когда выгрузка станет частью пайплайна, ей понадобятся тесты: фикстуры и локальный тест-сервер разбираются в уроке про тестирование скрапера. Карта маршрута курса — на странице Colly.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Файл у отчёта обязателен: вывод в stdout проверяет селекторы, но не сохраняет результат.
  • json.MarshalIndent даёт читаемый дамп слайса; os.WriteFile кладёт его на диск с правами 0644.
  • json.NewEncoder пишет потоково в io.Writer и добавляет \n — на нём стоит построчный JSONL.
  • JSONL — запись на строку: дозапись через O_APPEND, построчное чтение, без всего файла в памяти.
  • csv.Writer квотирует поля автоматически; финал обязателен: Flush, затем w.Error().
  • csv.Comma = ';' — для Excel с русской локалью; даты — RFC3339 в UTC; версия схемы — в имени файла.
  • Дескриптор открывается один раз и закрывается defer-ом: OpenFile в колбэке на каждую строку — антипаттерн с потерей хвоста.

Домашнее задание

Соберите скрапер, который сохраняет каталог двумя способами: слайс продуктов в products.json через MarshalIndent и те же записи в products.jsonl через Encoder с O_APPEND. Откройте оба файла, сравните размер и читаемость, затем добавьте CSV-выгрузку с w.Comma = ';' и откройте её в табличном редакторе. Финал: впишите в имя файла дату и версию схемы и объясните, что изменится у читателей старых выгрузок.

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

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

JSON или CSV для выгрузки каталога?

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

Почему после w.Write файл пустой или обрезанный?

csv.Writer буферизует записи, и без w.Flush() хвост остаётся в буфере — иногда весь файл. Финальная последовательность всегда одна: Flush, затем err := w.Error(), затем Close. То же правило для os.File — Close в defer-е, иначе при падении процесса последние данные теряются насовсем.

Как докинуть записи в уже готовый JSON-файл?

Единый JSON придётся перечитать, дополнить слайс и переписать файл целиком — без пересборки не обойтись. Если дозапись нужна по ходу дела, формат меняют на JSONL: os.OpenFile с O_APPEND и json.NewEncoder позволяют добавлять записи в существующий файл, не трогая старые строки.

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

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

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

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

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

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

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