---
title: "Итоговый проект: монитор цен с алертами"
description: "Итоговый проект colly: монитор цен на Go — команды scrape, check и report, SQLite с историей, алерты в alerts.jsonl и CSV-отчёт. Полный пайплайн с разбором."
course: "Colly: парсинг и веб-скрапинг на Go"
url: "https://golangmaster.ru/courses/colly/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: монитор цен с алертами

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

Итоговый проект курса colly — монитор цен на Go: один бинарник с командами scrape, check и report обходит каталог, пишет срез и историю в SQLite, ловит падения ниже --target в лог и alerts.jsonl и выгружает CSV-отчёт. Colly итоговый проект собирает из готовых блоков: скрапер, хранилище, алерты, CLI — каждый уже был в своём уроке. Ниже тз, четыре файла с построчным разбором и чеклист первой сборки.

### Тз: монитор цен с алертами

Ситуация, ради которой всё затевалось: поставщик меняет цены без объявлений, отдел закупок просит «сообщай, когда кружка опустится ниже трёхсот», а вы к этому моменту умеете обходить каталог, сохранять результат и тестировать селекторы. Соберём из этого монитор — программу pricewatch с тремя командами. Scrape обходит каталог целиком, check проверяет точечно заданные URL, report выгружает последний срез в CSV. Внутри: colly для обхода и карточек .product-card, SQLite через database/sql для среза и истории, zap для логов, cobra для командной строки. Тз короткое. Каждый прогон обновляет текущие цены и дописывает историю; если новая цена опустилась ниже целевой — строка в логе и в alerts.jsonl; отчёт открывается в таблице без конвертаций; Ctrl+C сохраняет собранное и выходит с честным кодом. Одна оговорка, единственная на урок: скрапер ходит по сети, песочница курса сетевые вызовы не исполняет — код запускается локально после go mod init github.com/you/pricewatch и go get четырёх зависимостей из чеклиста ниже. Всё остальное вы уже писали руками, проект склеивает. И дисциплина тут важнее скорости: монитор обязан ходить в сеть по расписанию, писать каждое значение и никогда не врать кодом выхода — именно это проверяет отдел закупок, а не benchmark.

### Каркас репозитория: три команды и три пакета

Раскладка повторяет проверенную схему из [итогового проекта курса cobra](/courses/cobra/lessons/25-itogovyj-proekt/) — она собрана под CLI с несколькими командами и здесь подходит без переделок. В корне main.go: контекст с перехватом сигналов, viper-конфиг, логгер zap по флагу --verbose, запуск Execute. Каталог cmd — по файлу на команду: root.go регистрирует scrape, check и report и держит общие флаги. Домен — три пакета internal: scraper с коллектором и структурой Product, store с двумя таблицами, alert с фильтром порога и JSONL-журналом. Зависимости идут в одну сторону: cmd знает всех, scraper не знает никого — ему не нужны ни cobra, ни viper, ни sqlite, он ходит по сети и возвращает срез карточек. Благодаря этому check переиспользует scraper без единой правки, а store тестируется вовсе без сети. Четвёртая команда — скажем, diff по датам — ляжет рядом без реорганизации, потому что полки заняты осмысленно, а не потому что так было в примере. Отдельный разговор — путь к базе: он приходит из viper-ключа db, в cron это переменная окружения PRICEDB, а тесты открывают временный файл через t.TempDir — живая база не рискует ни при go test, ни при прогоне из другой ветки. Напомню и про окружение: собирается и запускается всё локально — песочница курса сеть не держит, а монитор без сети не живёт.

### internal/scraper/collector.go

```go
package scraper

import (
        "context"
        "fmt"
        "math"
        "net/url"
        "strconv"
        "strings"
        "sync"
        "time"

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

type Product struct {
        Name  string
        Price int64 // копейки
        URL   string
}

type Collector struct {
        start string
        depth int
        delay time.Duration
}

func New(start string, depth int, delay time.Duration) *Collector {
        return &Collector{start: start, depth: depth, delay: delay}
}

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

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

        var (
                mu       sync.Mutex
                products []Product
        )

        c.OnHTML(".product-card", func(e *colly.HTMLElement) {
                p := Product{
                        Name:  e.ChildText(".product-name"),
                        Price: parsePrice(e.ChildText(".product-price")),
                        URL:   e.Request.AbsoluteURL(e.Attr("href")),
                }
                if p.Name == "" || p.Price <= 0 {
                        return // карточка без цены — не товар
                }
                mu.Lock()
                products = append(products, p)
                mu.Unlock()
        })

        c.OnRequest(func(r *colly.Request) {
                if ctx.Err() != nil {
                        r.Abort()
                }
        })

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

func parsePrice(s string) int64 {
        s = strings.NewReplacer(" ", "", "\u00a0", "", "₽", "").Replace(s)
        s = strings.Replace(s, ",", ".", 1)
        rubles, err := strconv.ParseFloat(s, 64)
        if err != nil {
                return 0
        }
        return int64(math.Round(rubles * 100))
}
```

Три защиты стоят в конвейере до всякой записи в базу. AllowedDomains из адреса старта не даёт скраперу убежать за чужой href, LimitRule держит паузы: Delay плюс случайная четверть сверху — ритм обхода не выглядит метрономом. OnHTML собирает карточки .product-card, но пропускает пустые: недогруженная страница не должна породить товар без имени. OnRequest с Abort по ctx.Err() — тот же graceful-механизм, что в предыдущем уроке: Ctrl+C гасит новые запросы, собранное доходит до вызывающего. parsePrice превращает человеческую цену в копейки: счищает шпации и знак валюты, меняет запятую на точку, ParseFloat и math.Round — округление нужно потому, что 1499.10 в float64 — это 149909.999, и без Round копейка терялась бы на каждой второй цене.

### Селекторы и цена: из разметки в копейки

Карточка товара на витрине-доноре выглядит устойчиво: div.product-card, внутри .product-name, .product-price и ссылка на страницу. Селекторы вы выверяли через httptest в уроке 23 — здесь они те же, и тест-фикстуры остаются рядом на случай перевёрстки. Цена — самое коварное поле: в разметке она написана для человека, «1 299,00 ₽», со шпациями, неразрывными пробелами и знаком валюты; в базе ей предстоит быть числом, которое сравнивают. Отсюда два решения. Первое: копейки вместо рублей — int64 без плавающей точки, деньги в плавающих числах не сравнивают, потому что равенство там врущее. Второе: грязь счищается в одном месте, parsePrice, и возвращает ноль на непонятном — ноль отфильтруется проверкой Price <= 0, а не уйдёт в базу. Абсолютную ссылку даёт e.Request.AbsoluteURL: href на витрине часто относительный, а в базе нужен URL, по которому check найдёт товар завтра и через месяц. Имя оставляем как есть — для отчёта важна человеческая форма, а не нормализация. И загляните в разметку донора перед тем, как брать текст: если цена лежит в data-атрибуте, e.Attr("data-price") чище текста — там сырое число без форматирования. Парсинг «1 299,00 ₽» — план Б, а не план А.

### internal/store/store.go: upsert и история

```go
package store

import (
        "context"
        "database/sql"
        "fmt"
        "time"

        _ "modernc.org/sqlite"

        "github.com/you/pricewatch/internal/scraper"
)

type Change struct {
        Name string
        URL  string
        Old  int64
        New  int64
}

type Store struct {
        db *sql.DB
}

const ddl = `CREATE TABLE IF NOT EXISTS products (
        url     TEXT PRIMARY KEY,
        name    TEXT NOT NULL,
        price   INTEGER NOT NULL,
        seen_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS price_history (
        id      INTEGER PRIMARY KEY AUTOINCREMENT,
        url     TEXT NOT NULL,
        price   INTEGER NOT NULL,
        seen_at TEXT NOT NULL
);`

const upsertSQL = `INSERT INTO products (url, name, price, seen_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE
SET price = excluded.price, name = excluded.name, seen_at = excluded.seen_at`

func Open(path string) (*Store, error) {
        db, err := sql.Open("sqlite", path)
        if err != nil {
                return nil, err
        }
        if _, err := db.Exec(ddl); err != nil {
                db.Close()
                return nil, fmt.Errorf("схема: %w", err)
        }
        return &Store{db: db}, nil
}

func (s *Store) Close() error { return s.db.Close() }

func (s *Store) Save(ctx context.Context, ps []scraper.Product, now time.Time) ([]Change, error) {
        tx, err := s.db.BeginTx(ctx, nil)
        if err != nil {
                return nil, err
        }
        defer tx.Rollback()

        var changes []Change
        for _, p := range ps {
                var old int64
                err := tx.QueryRowContext(ctx,
                        `SELECT price FROM products WHERE url = ?`, p.URL,
                ).Scan(&old)
                isNew := err == sql.ErrNoRows
                if err != nil && !isNew {
                        return nil, fmt.Errorf("select %s: %w", p.URL, err)
                }

                if _, err := tx.ExecContext(ctx, upsertSQL, p.URL, p.Name, p.Price, now); err != nil {
                        return nil, fmt.Errorf("upsert %s: %w", p.URL, err)
                }
                if _, err := tx.ExecContext(ctx,
                        `INSERT INTO price_history (url, price, seen_at) VALUES (?, ?, ?)`,
                        p.URL, p.Price, now); err != nil {
                        return nil, fmt.Errorf("history %s: %w", p.URL, err)
                }

                switch {
                case isNew:
                        changes = append(changes, Change{Name: p.Name, URL: p.URL, New: p.Price})
                case old != p.Price:
                        changes = append(changes,
                                Change{Name: p.Name, URL: p.URL, Old: old, New: p.Price})
                }
        }
        return changes, tx.Commit()
}
```

Open гоняет DDL при каждом старте: IF NOT EXISTS делает это дешёвым, схема приезжает вместе с бинарником и не требует отдельного шага миграции. Save — сердце мониторинга. Сначала смотрим прошлую цену, затем upsert текущего среза, затем строка в history — и всё это в одной транзакции с Commit в конце и Rollback по defer на любом выходе с ошибкой. Ошибка select различается через sql.ErrNoRows: товара нет — норма первого прогона, а не сбой. Движение цены возвращается наружу слайсом Change: новому товару Old остаётся нулём, изменённому — уходит прошлым значением; именно этот слайс фильтрует алерт. История append-only: строка дописывается при каждой встрече, даже если цена не менялась — так видно, что монитор жив, а не молча сломался.

### SQLite: текущий срез и append-only история

Хранилище держит две таблицы, и путать их роли нельзя. products — текущий срез: по строке на URL, цена перезаписывается upsert-ом INSERT ... ON CONFLICT DO UPDATE — механика из урока про [SQL-хранилище](/courses/colly/lessons/20-sql-storage/). price_history — append-only: каждая встреченная цена дописывается строкой, ничего не перезаписывается и не удаляется. Срез отвечает на вопрос «сколько стоит сейчас», история — на «как менялось»: график цены, дельта за неделю, момент, когда поставщик тихо поднял прайс — всё считается по history одним GROUP BY без отдельного аудита. Драйвер — modernc.org/sqlite: чистый Go без CGO, кросс-компиляция не требует toolchain целевой платформы, база — один файл рядом с бинарником. Спорное мнение на финише: для домашнего монитора SQLite — правильный выбор, и я не тяну сюда PostgreSQL до последнего. Граница применимости приходит не с размером базы, а с конкуренцией за запись: несколько параллельных прогонов или второй процесс-писатель упираются в то, что SQLite держит одного писателя за раз, — вот там честнее переезжать. У меня монитор на четырёх тысячах карточек живёт в SQLite годами, потолка не видно; критерий — не гигабайты, а процессы. Про даты: seen_at записывается строкой, которую драйвер формирует из time.Time, а строки такого формата сортируются лексикографически — для дельт и графиков этого хватает. Если позже понадобится часовой пояс в отчётах, миграция одной колонки дешевле, чем обёртка над драйвером с первого дня.

### internal/alert/alert.go

```go
package alert

import (
        "encoding/json"
        "os"

        "go.uber.org/zap"

        "github.com/you/pricewatch/internal/store"
)

type Notifier struct {
        path   string
        target int64 // копейки
        log    *zap.Logger
}

func New(path string, target int64, log *zap.Logger) *Notifier {
        return &Notifier{path: path, target: target, log: log}
}

func (n *Notifier) Check(changes []store.Change) (int, error) {
        if n.target <= 0 {
                return 0, nil // алерты выключены
        }

        f, err := os.OpenFile(n.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
        if err != nil {
                return 0, err
        }
        defer f.Close()

        written := 0
        enc := json.NewEncoder(f)
        for _, ch := range changes {
                if ch.New >= n.target {
                        continue // выше целевой — не алерт
                }
                event := map[string]any{
                        "name":   ch.Name,
                        "url":    ch.URL,
                        "old":    ch.Old,
                        "new":    ch.New,
                        "target": n.target,
                }
                if err := enc.Encode(event); err != nil {
                        n.log.Error("alerts.jsonl: строка не записана",
                                zap.String("url", ch.URL), zap.Error(err))
                        continue
                }
                n.log.Info("цена ниже целевой",
                        zap.String("name", ch.Name),
                        zap.Int64("new", ch.New),
                        zap.Int64("target", n.target))
                written++
        }
        return written, nil
}
```

Порог задаётся флагом --target в копейках: ноль выключает алерты целиком, отрицательное значение отсекается валидацией команды. Файл открывается с O_APPEND — дозапись в JSONL безопасна между прогонами и не требует перечитывания. Каждый алерт — одна JSON-строка: jq '.name' alerts.jsonl разберёт список за секунду, grep посчитает события за месяц, а год такого журнала весит килобайты. Сбой записи одной строки не роняет прогон: zap.Error в лог и continue — остальные алерты важнее одного битого события. Возврат written уходит в итоговую строку прогона рядом с zap.Int("alerts", n).

### Алерты: --target, zap и alerts.jsonl

Алерт — это фильтр поверх Change: цена опустилась ниже --target — событие, остальное шум. Два канала уведомления, каждый со своей ролью. zap пишет Info с полями name, new и target — живой поток в stderr, который виден в терминале и в journald, если монитор крутится как сервис. alerts.jsonl — долговая книга: по JSON-объекту на строку, дозапись через O_APPEND. Формат выбран не случайно: строки разделены переносами, поэтому tail -f, grep и jq работают без парсеров, а хранить журнал можно годами. Сбой записи — Error в лог, но не падение: монитор, умерший из-за переполненного диска в момент, когда нашёл скидку, никого не предупредил — лучше частичный журнал, чем никакой. Новым товаром ниже порога алерт тоже считается: Change с нулевым Old — легальный первый сигнал о появлении дешёвой позиции. Возвращённое из Check число встаёт в итоговую строку прогона рядом с products: картина прогона — в одном Info. Порог — это политика, а не техника: триста на кружку и тридцать тысяч на термос — разные --target, и одно число на весь каталог живёт ровно до первого ложного срабатывания. Дальше появляется справочник целевых цен — таблица в той же базе, и Check читает порог по URL из неё, а не из флага.

### cmd/scrape.go: cobra, контекст, коды выхода

```go
package cmd

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

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

        "github.com/you/pricewatch/internal/alert"
        "github.com/you/pricewatch/internal/scraper"
        "github.com/you/pricewatch/internal/store"
)

func newScrapeCmd(log *zap.Logger) *cobra.Command {
        var target int64

        cmd := &cobra.Command{
                Use:   "scrape",
                Short: "Обойти каталог, обновить цены, поднять алерты",
                RunE: func(cmd *cobra.Command, args []string) error {
                        if target < 0 {
                                return &ExitError{Code: 2,
                                        Err: fmt.Errorf("--target: отрицательная цена не имеет смысла")}
                        }

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

                        st, err := store.Open(viper.GetString("db"))
                        if err != nil {
                                return fmt.Errorf("база: %w", err) // код 1
                        }
                        defer st.Close()

                        col := scraper.New(
                                viper.GetString("start"),
                                viper.GetInt("depth"),
                                time.Duration(viper.GetInt("delay"))*time.Millisecond,
                        )
                        products, err := col.Run(ctx)
                        if err != nil {
                                return fmt.Errorf("обход: %w", err) // код 1
                        }
                        if ctx.Err() != nil {
                                log.Warn("прогон прерван", zap.Int("products", len(products)))
                        }

                        changes, err := st.Save(ctx, products, time.Now())
                        if err != nil {
                                return fmt.Errorf("запись в базу: %w", err)
                        }

                        n, err := alert.New(viper.GetString("alerts"), target, log).Check(changes)
                        if err != nil {
                                log.Warn("файл алертов не открыт", zap.Error(err))
                        }

                        log.Info("прогон завершён",
                                zap.Int("products", len(products)),
                                zap.Int("alerts", n))
                        return nil
                },
        }
        cmd.Flags().Int64Var(&target, "target", 0, "целевая цена в копейках; 0 — без алертов")
        return cmd
}
```

RunE идёт по пайплайну в одном порядке, и порядок не случайность. База открывается до обхода: зачем собирать каталог, если писать некуда. Обход — единственный сетевой этап, всё остальное локально; контекст от signal.NotifyContext доезжает до колбэков коллектора, Ctrl+C гасит новые запросы, собранное доходит до базы. Запись и алерты — после, в транзакции и дозаписи. Коды выхода те же, что в курсе cobra: обычная ошибка из RunE — main выйдет с единицей, ExitError — с двойкой по валидации флагов. Лог Info в конце — то место, где zap.Int("products", n) встречается с zap.Int("alerts", n): одна строка на прогон, из которой понятен весь результат.

### check и report: две команды по соседству

Check принимает URL-аргументы и проверяет точечно: те же селекторы, та же база, но без обхода каталога — его cron зовёт чаще, когда нужен контроль пары якорных товаров. Внутри это тот же конвейер до строки Save, минус пагинация: MaxDepth ограничен нулём, аргументы вместо ключа start из viper. Report — команда без сети вообще: SELECT из products, encoding/csv, файл. Колонки те же, что в CSV из урока про [JSON и CSV-вывод](/courses/colly/lessons/19-json-csv-vyvod/): name, url, price, seen_at — отдел закупок открывает его в таблице без конвертаций и макросов. Отчёт берёт последний срез, а не историю: история живёт в price_history, куда можно зайти sqlite3-ем и построить дельты, отчёт же — для людей, которым нужна таблица «как есть сейчас». Все три команды делят логгер, viper и словарь кодов выхода: различие — в длине пути, а не в контракте. Именно поэтому добавление четвёртой команды не требует реорганизации: полки уже размечены. У check есть и скрытая польза для отладки: если полный прогон находит товар, а точечный — нет, разница сидит в глубине и пагинации, и виноват селектор реже, чем подсказывает интуиция.

> **Монитор, который сам себе DDoS.** Первый прогон этого проекта я запустил с гордостью и без LimitRule: каталог на сорок тысяч страниц, colly по умолчанию ходит последовательно и без пауз — то есть на полной скорости сети. Через четыре минуты сайт ответил 429, ещё через две — бан по IP, а отдел закупок получил письмо «монитор не работает». Сайт был прав: сорок тысяч запросов без пауз — не мониторинг, а атака. Правило с тех пор железное: первый прогон по новому сайту — всегда --depth 1 и --delay от двух секунд; глубину и скорость поднимаем постепенно, глядя на коды ответов. Пол задержки в конструкторе коллектора — страховка от собственного нетерпения.

### Проверка: две таблицы

**Вопрос:** Зачем price_history, если в products уже лежит актуальная цена?

- Дублирует products на случай потери базы
- ✅ Upsert в products перезаписывает прошлую цену; append-only история хранит все значения — по ней считаются график и дельты
- Хранит HTML-страницы карточек
- Ускоряет SELECT из products за счёт индекса

Срез и история решают разные вопросы: products — «сколько стоит сейчас», history — «как менялось». Перезапись upsert-ом стирает прошлое, поэтому история дописывается строками и не удаляется — от графика цены до момента, когда поставщик тихо поднял прайс.

### Проверка: транзакция

**Вопрос:** Почему select, upsert и запись истории идут в одной транзакции?

- Так быстрее: SQLite любит длинные транзакции
- ✅ Сбой на середине не оставит базу со смесью старых и новых цен: всё применится вместе или ничего
- Без транзакции INSERT в SQLite запрещён
- Транзакции нужны только для миграций схемы

Три операции образуют одно логическое действие над карточкой. Транзакция с Commit в конце и Rollback по defer гарантирует: либо срез, история и Change согласованы, либо база осталась в предыдущем состоянии — и следующий прогон ничего не потерял.

### Второй прогон по той же карточке

```go
// первый прогон, 10:00
st.Save(ctx, []scraper.Product{
        {Name: "Кружка", Price: 34900, URL: "https://shop.example.com/mug"},
}, now)

// второй прогон, 10:30 — цена упала
changes, _ := st.Save(ctx, []scraper.Product{
        {Name: "Кружка", Price: 32900, URL: "https://shop.example.com/mug"},
}, now2)

fmt.Println(changes)
```

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

- ✅ products.price = 32900; в price_history две строки; changes: Old 34900, New 32900
- products.price = 34900 — upsert не трогает существующие строки
- В price_history одна строка — история хранит только последнюю цену
- changes пусто — Save сравнивает только имена товаров

Upsert перезаписал текущую цену, история дописалась второй строкой, а сравнение внутри транзакции вернуло Change со старым и новым значением. Именно этот Change фильтрует алерт: если --target стоит выше 32900, кружка попадёт и в zap-лог, и в alerts.jsonl.

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

Впишите открытие транзакции, страховку и фиксацию.

```go
tx, err := s.db.___(ctx, nil)
if err != nil {
        return nil, err
}
defer tx.___()

for _, p := range ps {
        if _, err := tx.ExecContext(ctx, upsertSQL, p.URL, p.Name, p.Price, now); err != nil {
                return nil, err
        }
}
return changes, tx.___()
```

Подсказка: Транзакцию открывает BeginTx; страховка на ошибку — Rollback; фиксация в конце — Commit.

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

### Пайплайн команды scrape

Расставьте шаги прогона в порядке выполнения RunE.

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

1. `ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)`
2. `st, err := store.Open(viper.GetString("db"))`
3. `products, err := col.Run(ctx)`
4. `changes, err := st.Save(ctx, products, time.Now())`
5. `n, err := notifier.Check(changes)`
6. `log.Info("прогон завершён", zap.Int("products", len(products)), zap.Int("alerts", n))`

### Чеклист первой сборки

- go mod init github.com/you/pricewatch && go mod tidy — в go.sum ложатся colly, cobra, viper, zap, modernc.org/sqlite
- gofmt -l . и go vet ./... — оба молчат
- go build -o pricewatch . — бинарник собирается без CGO: modernc.org/sqlite — чистый Go
- ./pricewatch scrape --depth 1 --delay 2000 — первый прогон: один уровень, две секунды между запросами
- sqlite3 pricewatch.db ".tables" — products и price_history на месте
- sqlite3 pricewatch.db "SELECT name, price FROM products LIMIT 5;" — срез читается глазами
- ./pricewatch check https://shop.example.com/mug — точечная проверка без обхода каталога
- ./pricewatch scrape --target 30000 — алерт ловится, строка дописана в alerts.jsonl
- ./pricewatch report -o snapshot.csv — CSV открывается в таблице, колонки name, url, price, seen_at на месте

### Карточки: детали проекта

- **upsert** — INSERT ... ON CONFLICT(url) DO UPDATE: новая запись или обновление существующей одним запросом. Текущий срез живёт в products.
- **price_history** — Append-only таблица: каждая встреченная цена добавляется строкой и не удаляется. График, дельты и аудит считаются по ней.
- **alerts.jsonl** — Журнал алертов: по JSON-объекту на строку. Дозапись через O_APPEND, читается grep и jq без парсеров.
- **Change** — Запись о движении цены из store.Save: имя, URL, старое и новое значение. Новому товару Old равен нулю. Алерт фильтрует её по --target.
- **modernc.org/sqlite** — Драйвер SQLite на чистом Go для database/sql: без CGO, кросс-компиляция без toolchain. Импортируется с подчёркиванием.
- **--target** — Целевая цена в копейках: ниже порога — Info в zap и строка в alerts.jsonl. Ноль выключает алерты целиком.

### Идеи доработок

Пайплайн собран — куда его растить. Расписание: cron раз в несколько часов ночью, когда каталог спит и трафик не мешает никому; в crontab-строке остаётся только путь к бинарнику, остальное — в окружении сервиса. Доставка алертов: Telegram-бот через sendMessage — тот же net/http, JSON-тело, токен в переменной окружения; alerts.jsonl при этом остаётся журналом, а не каналом, потому что журнал должен жить даже когда ботовод спит. Прокси: когда сайт начинает резать по IP, пригодятся пул и ротация из урока про прокси — у коллектора для них готовая точка входа, конструктор. Кэш из урока про CacheDir экономит трафик и нервы при отладке: отчёт пересобирается без повторного визита на сайт. Тесты: селекторный конвейер накрывается httptest-фикстурами из урока 23, а Store — обычными тестами database/sql во временной базе. И последнее по списку, но не по силе: конкурентный обход с пулом воркеров — тема курса go-advanced, там же каналы для потока карточек между коллектором и записью. Ещё одна идея на вырост — команда diff: две даты на входе, разница срезов на выходе; всё нужное уже лежит в price_history, и команда ляжет в cmd без нового пакета.

### Поздравление и куда дальше

Если чеклист зелёный — на диске живой инструмент: один бинарник собирает каталог, пишет срез и историю в SQLite, ловит падения ниже целевой цены и выгружает отчёт, который открывается без конвертаций. Курс Colly на этом закончен; все двадцать пять уроков остались на странице [курса](/courses/colly/), и репозиторий сам показывает пройденный путь — поздравляю. Куда дальше: курс zap — логи у мониторинга уже на ходу, а там уровни, ротация и наблюдаемость; курс cobra — если захочется вынести конфиг в файл, добавить completion и версию; go-advanced — горутины и каналы, без которых обход не станет быстрым. Монитор прощает медленность, но не невежливость: LimitRule включена даже тогда, когда кажется, что можно быстрее. Удачных алертов — и пусть --target срабатывает только на хорошие скидки.

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

- Монитор собирается из четырёх полок: cmd с командами scrape, check, report и internal с scraper, store, alert.
- products хранит текущий срез через upsert, price_history — append-only историю: график и дельты считаются по ней.
- Цены живут в копейках int64: парсинг через ParseFloat с math.Round, плавающие сравнения исключены.
- Save работает в транзакции: select, upsert и строка истории применяются вместе, Rollback стоит по defer.
- Алерт — фильтр Change по --target: Info в zap и строка в alerts.jsonl через O_APPEND; сбой записи не роняет прогон.
- Коды выхода прежние: 0 успех, 1 сбой сети или базы, 2 ошибка флагов через ExitError.
- Первый прогон по новому сайту — всегда --depth 1 и большой --delay: монитор не должен сам себе устраивать DDoS.

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

Соберите pricewatch по чеклисту и прогоните по любому каталогу с карточками товаров: первый запуск — --depth 1 и --delay 2000. Задайте --target ниже реальной цены и убедитесь, что в alerts.jsonl появилась строка, а в логе — Info с полями. Затем добавьте в report колонку seen_at и реализуйте одну идею доработки из списка в отдельной ветке.

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

### Почему SQLite, а не PostgreSQL?

Для домашнего монитора SQLite — один файл и ноль админки: бэкап копированием, драйвер без CGO, кросс-компиляция без лишних инструментов. Граница — конкуренция за запись: несколько параллельных прогонов или второй процесс-писатель упираются в одного писателя SQLite — там и переезжайте на PostgreSQL.

### --target задан, а alerts.jsonl пустой. Что сломалось?

Возможно, ничего: цены выше порога, алертов нет. Проверьте единицы — --target в копейках, триста рублей это 30000. Затем убедитесь, что Change доходит до Check: при первом прогоне все товары новые, Old равен нулю, и дешёвая позиция тоже попадает в алерт.

### Можно ли скрапить чаще раза в час?

Технически да, практически — спросите, что это даёт: цена кружки меняется пару раз в день, а каждый прогон — нагрузка на чужой сервер и износ вашей репутации IP. Разумный ритм для каталога — раз в несколько часов ночью; check по якорным товарам можно чаще, это единицы запросов.
