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

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

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

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

CLI-скрапер: связка Cobra и Colly

CLI-скрапер на Go начинается там, где скрипт перестал слушаться: URL и пауза захардкожены, каждая правка — пересборка, каждый запуск — руками. Связка cobra и colly превращает такой скрипт в инструмент: флаги --depth, --delay, --out и --verbose, конфиг через viper, структурные логи zap и Ctrl+C, который не теряет собранное. Ниже — каркас проекта, полный cmd/scrape.go и разбор решений построчно.

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

Сценарий знакомый: раз в сутки нужно обойти каталог поставщика и сложить цены в CSV для отдела закупок. Вы написали скрипт за вечер — он работает. Через неделю просят тот же список, но с другого каталога: меняете константу, пересобираете, запускаете. Ещё через неделю — «а можно медленнее, нас начали резать»: правка, сборка, запуск. Через месяц у вас пять веток одного скрипта и ни одной, которая делает ровно то, что нужно сейчас. Это точка, где скрипт становится консольной утилитой: параметры приходят флагами и переменными окружения, поведение выбирается при запуске, а не при компиляции. Скрапер — удачный кандидат на такой переезд, у него и так есть ручки: глубина обхода, задержка, файл вывода, подробность логов. Одна оговорка перед кодом, единственная в уроке: скрапер ходит по сети, а браузерная песочница курса сетевые вызовы не исполняет — примеры запускаются локально после go get github.com/gocolly/colly/v2 и go get github.com/spf13/cobra. Дальше — каркас, флаги, логи, отмены и коды выхода; всё это уже встречалось по частям, здесь оно соберётся целиком. Кстати, флаги здесь не украшение: каждый закрывает конкретную жалобу из сценария выше — глубина для «собери только витрину», пауза для «нас режут», файл вывода для «пришли в Excel», подробность для «расскажи, что он там делал».

Каркас CLI-скрапера: cmd/, internal/ и main.go#

Раскладка берётся из урока про структуру проекта без изменений — и это лучший тест той раскладки: она не про cobra, она про консольные программы вообще. В корне main.go на пару десятков строк: он читает конфиг, строит логгер и запускает корневую команду. Каталог cmd держит по файлу на команду: root.go с общими флагами и регистрацией, scrape.go с рабочей логикой сбора. Домен живёт в internal/scraper — коллектор, колбэки, структура Product — и не импортирует ни cobra, ни viper: флаги и конфиг до него не доезжают, доезжают готовые величины. Рядом internal/store — сохранение результата, сейчас CSV, дальше база. Польза деления проверяется в момент появления второго потребителя: команда check из следующего урока переиспользует scraper как есть, потому что тот ничего не знает про терминал. Если у вас на руках скрипт из прошлых уроков, переезд занимает полчаса: точка входа оседает в main.go и cmd, сбор — в internal/scraper, файловый вывод — в internal/store. Причём тесты селекторов переезжают вместе с кодом и продолжают работать без правок. Имя модуля придумайте до go mod init: импорты internal-пакетов в примерах ниже — github.com/you/skraper, и переименование после пяти файлов превращается в механическую рутину по всему репозиторию.

cmd/scrape.go целиком

GO
package cmd

import (
        "fmt"
        "os"
        "os/signal"
        "text/tabwriter"
        "time"

        "github.com/spf13/cobra"
        "github.com/spf13/viper"
        "go.uber.org/zap"

        "github.com/you/skraper/internal/scraper"
        "github.com/you/skraper/internal/store"
)

func newScrapeCmd(log *zap.Logger) *cobra.Command {
        var (
                depth   int
                delayMs int
                out     string
        )

        cmd := &cobra.Command{
                Use:   "scrape",
                Short: "Собрать каталог и напечатать таблицу цен",
                RunE: func(cmd *cobra.Command, args []string) error {
                        if !cmd.Flags().Changed("depth") {
                                depth = viper.GetInt("depth")
                        }
                        if !cmd.Flags().Changed("delay") {
                                delayMs = viper.GetInt("delay")
                        }
                        if !cmd.Flags().Changed("out") {
                                out = viper.GetString("out")
                        }
                        if depth < 1 || delayMs < 0 {
                                return &ExitError{Code: 2,
                                        Err: fmt.Errorf("--depth и --delay: значения вне диапазона")}
                        }

                        ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
                        defer stop()

                        log.Info("старт",
                                zap.Int("depth", depth),
                                zap.Duration("delay", time.Duration(delayMs)*time.Millisecond))

                        s := scraper.New(scraper.Config{
                                Start: viper.GetString("start"),
                                Depth: depth,
                                Delay: time.Duration(delayMs) * time.Millisecond,
                                Log:   log,
                        })

                        products, err := s.Run(ctx)
                        if err != nil {
                                return fmt.Errorf("обход: %w", err) // код 1: сеть
                        }
                        if ctx.Err() != nil {
                                log.Warn("прогон прерван", zap.Int("products", len(products)))
                        }

                        if out != "" {
                                if err := store.SaveCSV(out, products); err != nil {
                                        log.Warn("csv не записан",
                                                zap.String("out", out), zap.Error(err))
                                }
                        }

                        w := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
                        fmt.Fprintf(w, "НАЗВАНИЕ\tЦЕНА\tURL\n")
                        for _, p := range products {
                                fmt.Fprintf(w, "%s\t%s\t%s\n", p.Name, p.Price, p.URL)
                        }
                        return w.Flush()
                },
        }

        cmd.Flags().IntVar(&depth, "depth", 1, "глубина обхода каталога")
        cmd.Flags().IntVar(&delayMs, "delay", 500, "пауза между запросами, мс")
        cmd.Flags().StringVar(&out, "out", "", "путь к CSV; пусто — таблица в stdout")
        return cmd
}

Файл держит четыре контракта. Источники величин: три проверки Changed отдают приоритет флагу, viper подтягивает окружение и конфиг — лестница из следующего блока. Валидация до сети: depth меньше единицы — ExitError с кодом 2, а не тихий странный обход. Контекст: signal.NotifyContext привязывает Ctrl+C, ctx уходит в scraper и там решает, ходить ли дальше. Вывод: tabwriter выравнивает колонки таблицы в stdout, логи при этом живут в stderr — пайп данных остаётся чистым. Флаг --verbose — persistent-флаг root.go: им выбирается прототип логгера, команда получает готовый *zap.Logger.

Флаги и viper: одна величина, три источника#

У depth, delay и out по три законных источника: флаг командной строки, переменная окружения и конфиг-файл. Лестница приоритетов гасит конфликт: флаг сильнее окружения, окружение сильнее файла. Механика в viper: SetConfigFile указывает путь к scraper.yaml, SetEnvPrefix("SCRAPER") вместе с AutomaticEnv делают так, что viper.GetInt("delay") сначала заглядывает в SCRAPER_DELAY. Флаги обрабатывает cobra, и здесь тонкое место: viper о них не знает, поэтому в RunE стоит проверка cmd.Flags().Changed("depth") — флаг задан явно, берём его; нет — спрашиваем viper. Эти подробности Cobra и viper разбирались в курсе cobra на том же каркасе, здесь — только рабочее напоминание. Зачем три источника, когда хватает флагов? Для cron: в расписании короткая строка ./skraper scrape, а задержки и пути задаются окружением сервиса — значения не светятся в списке процессов и не разъезжаются по правкам crontab. Валидация идёт до всякой сети: depth меньше единицы или отрицательная пауза — это ошибка флагов, ExitError с кодом 2, о словаре кодов ниже. Ещё один практичный слой — дефолты: viper.GetInt вернёт ноль для отсутствующего ключа, и нулевая глубина отвалится той же валидации. Выносить три величины в отдельный пакет internal/config рано: три проверки Changed читаются быстрее, чем документация к слою абстракции.

zap вместо fmt.Println: уровни и поля#

Логи скрипта — это смесь fmt.Println и надежды, что в них никто не заглянет. Инструменту нужна структура: пакет go.uber.org/zap, уровни, поля. Подробности логгера разбирались в курсе zap, здесь — рабочий минимум. В root.go по флагу --verbose выбирается прототип: zap.NewProduction() для обычных прогонов или zap.NewDevelopment() для отладки, где строки читаются человеком без парсера. Дальше логгер передаётся фабрике команды аргументом — newScrapeCmd(log) — и живёт в скрапере как обычная зависимость. Уровни распределяются по этапам: Info — старт и итог, zap.Int("products", n) и zap.Duration("delay", d) в одной строке; Debug — каждая карточка и каждое решение обхода; Warn — сбои, не остановившие работу: CSV не записался, часть алертов утеряна; Error — то, что прервало прогон. Поля вместо форматных строк — половина пользы: log.Info("старт", zap.Int("depth", depth)) в production-режиме ляжет JSON-строкой, и поиск по "depth" найдёт все прогоны за секунду. Именованные суб-логгеры из урока про with пригодятся, когда scrape и check начнут писать в один файл: у каждого своё имя, и прогоны не спутаются. Правило на все уровни одно: логгер — зависимость, а не глобал. zap.L с ReplaceGlobals придуман для библиотечного кода, которому некуда положить logger в аргументах; у CLI-инструмента main живёт десять строк, и передать *zap.Logger фабрике команды ничего не стоит.

internal/scraper: конструктор с LimitRule

GO
package scraper

import (
        "context"
        "fmt"
        "net/url"
        "sync"
        "time"

        "github.com/gocolly/colly/v2"
        "go.uber.org/zap"
)

type Config struct {
        Start string
        Depth int
        Delay time.Duration
        Log   *zap.Logger
}

type Scraper struct {
        start string
        depth int
        delay time.Duration
        log   *zap.Logger
}

func New(cfg Config) *Scraper {
        if cfg.Delay < 300*time.Millisecond {
                cfg.Delay = 300 * time.Millisecond // вежливость ниже не опускается
        }
        return &Scraper{
                start: cfg.Start,
                depth: cfg.Depth,
                delay: cfg.Delay,
                log:   cfg.Log,
        }
}

type Product struct {
        Name  string
        Price string
        URL   string
}

func (s *Scraper) Run(ctx context.Context) ([]Product, error) {
        u, err := url.Parse(s.start)
        if err != nil {
                return nil, fmt.Errorf("адрес старта: %w", err)
        }

        c := colly.NewCollector(
                colly.AllowedDomains(u.Hostname()),
                colly.MaxDepth(s.depth),
        )
        if err := c.Limit(&colly.LimitRule{
                DomainGlob:  "*" + u.Hostname(),
                Delay:       s.delay,
                RandomDelay: s.delay / 2,
        }); err != nil {
                return nil, err
        }

        var (
                mu       sync.Mutex
                products []Product
        )
        c.OnHTML(".product-card", func(e *colly.HTMLElement) {
                p := Product{
                        Name:  e.ChildText(".name"),
                        Price: e.ChildText(".price"),
                        URL:   e.Request.AbsoluteURL(e.Attr("href")),
                }
                mu.Lock()
                products = append(products, p)
                mu.Unlock()
        })

        if err := c.Visit(s.start); err != nil {
                return products, fmt.Errorf("visit: %w", err)
        }
        c.Wait()
        return products, nil
}

Конструктор принимает готовые величины и защищает от невежливости: пауза меньше трёхсот миллисекунд поднимается до минимума — флаг не ошибка, но DDoS-ить каталог инструмент не имеет права. Разбор адреса даёт домен для AllowedDomains и DomainGlob: скрапер не разбежится по рекламным сетям, если на странице затесался чужой href. Limit держит темп: Delay между запросами плюс RandomDelay половиной длины — интервал становится случайным, и ритм обхода перестаёт выглядеть метрономом. OnHTML собирает карточки под мьютексом: для синхронного обхода он не нужен, но привычка дешевле гонки, когда кто-то включит Async. Ошибка Visit не отменяет собранного — возвращаем и товары, и причину.

Ctrl+C: контекст доезжает до колбэков#

Скрапер на середине каталога — худшее место для жёсткого kill: соединение рвётся, собранное исчезает, отдел закупок получает пустой файл. Отсюда graceful-схема. В cmd контекст привязывается к сигналу: signal.NotifyContext(cmd.Context(), os.Interrupt) — первый Ctrl+C отменяет ctx, а defer stop() возвращает терминалу обычное поведение после выхода. Контекст передаётся в Run аргументом и уже там, в колбэках, превращается в решение: OnRequest перед каждым запросом проверяет ctx.Err() — если отмена случилась, r.Abort() гасит запрос ещё до того, как он ушёл в сеть. Colly ничего не знает о сигналах и не обязана: ей показывают отмену через контекст, она показывает её через Abort. После Abort новые запросы не отправляются, Wait завершается, Run возвращает всё, что успело собраться. Две детали, которые легко упустить. Первая: проверка стоит внутри OnRequest, а не до Visit — сигнал может прийти в любой момент, включая паузу LimitRule между запросами. Вторая: Async здесь не включён, поэтому в полёте находится максимум один запрос, и после отмены ждать нечего. Схему стоит проверить руками сразу: запустите обход, нажмите Ctrl+C и загляните в лог — строка «запрос отменён» с адресом последнего URL означает, что контекст доехал до колбэка, а не застрял в сигнальном канале.

Фрагмент OnRequest: отмена перед запросом

GO
// контекст уже привязан к Ctrl+C в cmd/scrape.go:
// ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)

c.OnRequest(func(r *colly.Request) {
        if ctx.Err() != nil {
                s.log.Warn("запрос отменён",
                        zap.String("url", r.URL.String()))
                r.Abort() // HTTP-запрос по r.URL не уйдёт
        }
})

c.OnError(func(r *colly.Response, err error) {
        s.log.Error("запрос не удался",
                zap.String("url", r.Request.URL.String()),
                zap.Int("status", r.StatusCode),
                zap.Error(err))
})

OnRequest срабатывает перед каждым запросом — самое дешёвое место для проверки отмены: один вызов ctx.Err() на запрос, ни одной горутины. Abort внутри OnRequest означает, что colly не отправит этот HTTP-запрос вовсе: OnResponse и OnHTML по нему не вызовутся, обход остановится естественным путём. OnError ловит обратную сторону — таймаут, 500, обрыв соединения; поля в логе позволяют позже собрать картину: сколько URL пало, на каких статусах, до или после отмены. Заметьте, что логгер здесь структурный: одна строка JSON на событие, а не fmt.Println с форматированием на месте.

Частичные результаты: best effort#

Отмена — не авария, и терять сто сорок собранных карточек из-за неё жалко. Договорённость простая: что успело собраться до Ctrl+C — то сохраняется, без обещаний дособрать остальное. После c.Wait() срез products уже наполнен, и Run возвращает его с nil-ошибкой: Visit не падал, обход просто закончился раньше. В cmd после Run проверяют ctx.Err() — если контекст отменён, лог пишет Warn с количеством собранного, CSV дописывается, а процесс выходит с кодом 1: формально прогон не завершён, и cron имеет право это заметить. Слово best effort здесь честное: если отмена случилась на первом же запросе, CSV будет пустым — и это тоже честный результат, пустой файл лучше зависшего процесса. То же правило работает и для ошибок сети: OnError логирует и продолжает со следующего URL, а единица вернётся только тогда, когда не удалось даже стартовать Visit — сайт недоступен целиком, домен не разрезолвился. Нюанс с файлом: SaveCSV перезаписывает вывод, а не дописывает, поэтому прерванный прогон при повторном запуске не оставит в файле половину старого отчёта. Если нужна история прогонов — это работа для базы, она появится в итоговом проекте; CSV здесь остаётся форматом одного среза.

Коды выхода: контракт для cron и CI#

Утилита, которую зовут машины, обязана отвечать кодом выхода — его читают cron, CI и сосед по пайплайну. Словарь из трёх строк: 0 — прогон прошёл, данные записаны; 1 — сбой сети или хранилища, включая отмену пользователем; 2 — ошибка флагов и конфигурации. Механика из курса cobra: RunE возвращает обычную ошибку для сети — main разберёт её и выйдет единицей; для проблем использования возвращается &ExitError{Code: 2}, и errors.As в main доставит двойку до процесса. Разница не косметика: сеть лечат повтором и паузой, флаги — чтением --help; обвязка по коду решает, ретраить или звать человека. Разница видна и в сообщениях: на корне стоит SilenceUsage, поэтому единица не тащит за собой страницу флагов — пользователь и так знает, где --help, а в логе остаётся только причина. Двойка, наоборот, печатает usage: человек, опечатавшийся во флаге, увидит список допустимых значений на том же экране. Быстрая проверка без cron: ./skraper scrape --depth 0; echo $? — двойка приходит на терминал сразу за usage-подсказкой, и это самый дешёвый smoke-тест словаря.

Флаги команды scrape#

ФлагПо умолчаниюЧто делаетЕсли не задан
--depth1глубина обхода, уезжает в colly.MaxDepthviper-ключ depth из scraper.yaml
--delay500пауза между запросами в мс, уезжает в LimitRuleviper-ключ delay, поверх него — SCRAPER_DELAY
--outпустопуть к CSV; пусто — таблица в stdoutviper-ключ out
--verbosefalseподробные логи: Debug-уровень вместо Infopersistent-флаг корня, без конфига и окружения

Проверка: механика отмены

Что делает r.Abort() внутри OnRequest?

Проверка: коды выхода

./skraper scrape --depth 0 — что получит cron?

Ctrl+C на середине обхода

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

// в терминале нажали Ctrl+C, когда было собрано 143 карточки
// OnRequest при ctx.Err() != nil делает r.Abort()

products, err := s.Run(ctx) // Visit не падал, Wait завершился
if err == nil {
        err = store.SaveCSV(out, products)
}

fmt.Println(len(products), err)

Соберите колбэк отмены

Восстановите фрагмент: подписка на событие запроса, проверка контекста и отмена.

c.On(func(r *colly.Request) { if .Err() != nil { s.log.Warn("запрос отменён", zap.String("url", r.URL.String())) r.() } })

Порядок загрузки конфига

Расставьте строки настройки viper: от создания до чтения величины.

Строки

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

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

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

Термин

LimitRule

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

Правило темпа colly: Delay, RandomDelay, DomainGlob. Ставится через c.Limit и держит паузы между запросами к домену.

1 / 6

Проверяем инструмент#

Селекторы не переехали — их тесты из урока про httptest продолжают работать против internal/scraper: Run принимает адрес старта, тест-сервер подставляет фикстуры, сеть не нужна. Новым слоем становится сама команда: cobra умеет Execute на буфере, поэтому связку флагов и viper проверяют без терминала — задали SCRAPER_DELAY=3000, вызвали scrape, сверили, что пауза доехала до конструктора. Ручной же прогон — отдельный ритуал: первый запуск на живом сайте всегда с --depth 1 и большим --delay, и только после визуальной проверки таблицы — глубже. Заодно проверьте пайп: ./skraper scrape | head — таблица обязана доехать без JSON-мусора между строками, как в истории выше. И последняя оговорка в уроке: go test и go build требуют локального Go с модулями проекта — песочница курса их не исполнит; дальше только терминал и go run. И присмотритесь ко времени: тесты на фикстурах идут миллисекунды, ручной прогон с --depth 1 на живом сайте — секунды. Тест, который вдруг замедлился до секунд, ходит в сеть — это дефект, а не фича, и чинится он возвратом к фикстурам, а не удвоением таймаута.

Что дальше#

Инструмент готов: флаги, конфиг, структурные логи, отмены, коды выхода — набор, который делает скрипт утилитой. В финальном уроке курса этот каркас переживёт вторую жизнь: итоговый проект собирает из тех же деталей монитор цен — с базой SQLite вместо CSV, алертами ниже целевой цены и командой отчёта. Нового там немного: внутренний scraper из этого урока переезжает без правок, store получит две таблицы, alert — порог и JSONL-журнал. Остальное — сборка, и она пройдёт быстрее, чем вы думаете.

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

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

Вывод

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

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

  • Скрипт становится инструментом, когда параметры приходят флагами и конфигом, а не правками кода.
  • Каркас один: main.go, cmd/root.go с флагом --verbose, cmd/scrape.go с RunE, домен в internal/scraper и internal/store.
  • viper снимает величину по лестнице приоритетов: флаг, переменная SCRAPER_*, ключ в scraper.yaml, дефолт.
  • zap пишет уровнями и полями: Info для итогов, Debug для карточек, Warn для мелких сбоев, Error для неисправимых.
  • Ctrl+C доезжает до колбэков через signal.NotifyContext: OnRequest проверяет ctx.Err() и делает r.Abort().
  • Отмена — не потеря: собранное к моменту нажатия сохраняется best effort, процесс выходит с кодом 1.
  • Коды выхода — контракт для cron и CI: 0 успех, 1 сбой сети, 2 ошибка флагов через ExitError.

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

Перенесите скрипт из урока про тесты в каркас cmd/ и internal/scraper: флаги --depth и --delay, viper с префиксом SCRAPER, zap-логгер вместо fmt.Println. Прервите прогон Ctrl+C на середине: частичный CSV должен появиться на диске, а процесс — выйти с кодом 1. Напоследок вызовите ./skraper --help и убедитесь, что подсказки по флагам читаются без заглядывания в исходники.

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

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

Почему логи должны идти в stderr, а не в stdout?

stdout — канал данных: таблицу или CSV из него пайпят в файл, и любая JSON-строка лога превращает отчёт в мусор. zap.NewProduction по умолчанию пишет в stderr; если конфиг переносил вывод на stdout, верните обратно или отправьте логи в файл — а stdout оставьте данным.

Что выбрать: флаг, переменную окружения или файл?

Флаг — для ручного запуска и экспериментов, окружение — для cron и CI, где значения не должны светиться в списке процессов, файл — для постоянных значений вроде адреса каталога. Лестница приоритетов флаг, окружение, файл снимает конфликт без отдельной логики.

После Ctrl+C процесс висит несколько секунд. Это нормально?

Wait дожидается запроса в полёте, а defer stop() снимает перехват сигнала на выходе — обычно это доли секунды. Если висит дольше, ищите собственные горутины: при синхронном обходе colly ждать долго нечему, значит, копите что-то вы — буфер, writer без Close, канал без читателя.

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

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

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

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

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

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

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