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

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

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

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

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

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

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

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

Ситуация, ради которой всё затевалось: поставщик меняет цены без объявлений, отдел закупок просит «сообщай, когда кружка опустится ниже трёхсот», а вы к этому моменту умеете обходить каталог, сохранять результат и тестировать селекторы. Соберём из этого монитор — программу 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 — она собрана под 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-хранилище. 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-вывод: name, url, price, seen_at — отдел закупок открывает его в таблице без конвертаций и макросов. Отчёт берёт последний срез, а не историю: история живёт в price_history, куда можно зайти sqlite3-ем и построить дельты, отчёт же — для людей, которым нужна таблица «как есть сейчас». Все три команды делят логгер, viper и словарь кодов выхода: различие — в длине пути, а не в контракте. Именно поэтому добавление четвёртой команды не требует реорганизации: полки уже размечены. У check есть и скрытая польза для отладки: если полный прогон находит товар, а точечный — нет, разница сидит в глубине и пагинации, и виноват селектор реже, чем подсказывает интуиция.

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

Зачем price_history, если в products уже лежит актуальная цена?

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

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

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

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

// первый прогон, 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)

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

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

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.()

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

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

Строки

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

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

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

  • 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.

1 / 6

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

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

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

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

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

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

Вывод

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

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

  • Монитор собирается из четырёх полок: 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 и реализуйте одну идею доработки из списка в отдельной ветке.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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