Содержание урока · 12 разделов
- Тз: монитор цен с алертами
- Каркас репозитория: три команды и три пакета
- Селекторы и цена: из разметки в копейки
- SQLite: текущий срез и append-only история
- Алерты: --target, zap и alerts.jsonl
- check и report: две команды по соседству
- Чеклист первой сборки
- Идеи доработок
- Поздравление и куда дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тз: монитор цен с алертами#
Ситуация, ради которой всё затевалось: поставщик меняет цены без объявлений, отдел закупок просит «сообщай, когда кружка опустится ниже трёхсот», а вы к этому моменту умеете обходить каталог, сохранять результат и тестировать селекторы. Соберём из этого монитор — программу 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
GOpackage 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 и история
GOpackage 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
GOpackage 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, контекст, коды выхода
GOpackage 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 есть и скрытая польза для отладки: если полный прогон находит товар, а точечный — нет, разница сидит в глубине и пагинации, и виноват селектор реже, чем подсказывает интуиция.
Первый прогон этого проекта я запустил с гордостью и без LimitRule: каталог на сорок тысяч страниц, colly по умолчанию ходит последовательно и без пауз — то есть на полной скорости сети. Через четыре минуты сайт ответил 429, ещё через две — бан по IP, а отдел закупок получил письмо «монитор не работает». Сайт был прав: сорок тысяч запросов без пауз — не мониторинг, а атака. Правило с тех пор железное: первый прогон по новому сайту — всегда --depth 1 и --delay от двух секунд; глубину и скорость поднимаем постепенно, глядя на коды ответов. Пол задержки в конструкторе коллектора — страховка от собственного нетерпения.
Проверка: две таблицы
Зачем 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 по строкам
Впишите открытие транзакции, страховку и фиксацию.
Пайплайн команды 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 на месте
Карточки: детали проекта
Идеи доработок#
Пайплайн собран — куда его растить. Расписание: 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 и реализуйте одну идею доработки из списка в отдельной ветке.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему SQLite, а не PostgreSQL?
Для домашнего монитора SQLite — один файл и ноль админки: бэкап копированием, драйвер без CGO, кросс-компиляция без лишних инструментов. Граница — конкуренция за запись: несколько параллельных прогонов или второй процесс-писатель упираются в одного писателя SQLite — там и переезжайте на PostgreSQL.
--target задан, а alerts.jsonl пустой. Что сломалось?
Возможно, ничего: цены выше порога, алертов нет. Проверьте единицы — --target в копейках, триста рублей это 30000. Затем убедитесь, что Change доходит до Check: при первом прогоне все товары новые, Old равен нулю, и дешёвая позиция тоже попадает в алерт.
Можно ли скрапить чаще раза в час?
Технически да, практически — спросите, что это даёт: цена кружки меняется пару раз в день, а каждый прогон — нагрузка на чужой сервер и износ вашей репутации IP. Разумный ритм для каталога — раз в несколько часов ночью; check по якорным товарам можно чаще, это единицы запросов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Testify: тестирование Go с assert и require
Интерактивный курс Testify для Go: assert и require, табличные тесты, suite, mock-объекты, Eventually, HTTP-тесты. 25 уроков с примерами и практикой в браузере.
- Писать читаемые проверки через assert и require, а не лестницы if
- Проверять ошибки, паники и краевые случаи без ручного кода
- Сравнивать структуры, слайсы, карты и числа с плавающей точкой
- Строить табличные тесты и сьюты с хуками подготовки