Files
aura-crm/production/backend/internal/scraper/scraper.go
T

123 lines
4.1 KiB
Go

// Package scraper fetches component listings from supplier sites (Regard
// first; pg.pro/technosuccess.ru/DNS-shop are authenticated or anti-bot
// protected and come later) and caches them in the external_components
// table (migrations/053_external_components.sql). internal/pcbuilder reads
// that cache to offer just-in-time-sourced parts alongside the shop's own
// parts stock — this package never touches parts or talks to pcbuilder
// directly, it only knows how to fill external_components.
package scraper
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"production/internal/pcbuilder"
)
// Item is one listing entry from a supplier site, already resolved to a
// single component type. Spec is best-effort — sites don't expose
// structured specs the way our own parts.pc_spec does, so fields are
// regex-extracted from free-text listing copy and left empty when
// unrecognized. pcbuilder.Check already treats empty spec fields as
// "nothing to compare," so a sparse Spec just means fewer compatibility
// checks apply to that item, not an error.
type Item struct {
ExternalID string
Name string
Price float64
ProductURL string
InStock bool
Spec pcbuilder.Spec
}
// Source is one supplier site's scraper. FetchCategory returns every
// listing it can find for componentType — implementations decide how to
// map that to the site's own category structure.
type Source interface {
Name() string
FetchCategory(ctx context.Context, componentType string) ([]Item, error)
}
// RefreshAll runs every source against every pcbuilder.AllTypes category and
// upserts the results into external_components, then drops rows for that
// source+type that weren't seen this run (delisted/sold-out-and-removed
// items shouldn't linger). Errors from one source/category are logged by
// the caller via the returned map rather than aborting the whole refresh —
// a broken selector on one site/category shouldn't block the others.
func RefreshAll(ctx context.Context, db *pgxpool.Pool, sources []Source) map[string]error {
errs := map[string]error{}
for _, src := range sources {
for _, componentType := range pcbuilder.AllTypes {
items, err := src.FetchCategory(ctx, componentType)
key := src.Name() + ":" + componentType
if err != nil {
errs[key] = err
continue
}
if err := upsertCategory(ctx, db, src.Name(), componentType, items); err != nil {
errs[key] = err
}
}
}
return errs
}
func upsertCategory(ctx context.Context, db *pgxpool.Pool, source, componentType string, items []Item) error {
tx, err := db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
seen := make([]string, 0, len(items))
for _, it := range items {
specJSON, err := specToJSON(it.Spec)
if err != nil {
continue
}
_, err = tx.Exec(ctx, `
INSERT INTO external_components (source, external_id, pc_component_type, name, price, product_url, in_stock, spec, scraped_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now())
ON CONFLICT (source, external_id) DO UPDATE SET
pc_component_type = EXCLUDED.pc_component_type,
name = EXCLUDED.name,
price = EXCLUDED.price,
product_url = EXCLUDED.product_url,
in_stock = EXCLUDED.in_stock,
spec = EXCLUDED.spec,
scraped_at = now()`,
source, it.ExternalID, componentType, it.Name, it.Price, it.ProductURL, it.InStock, specJSON,
)
if err != nil {
return err
}
seen = append(seen, it.ExternalID)
}
_, err = tx.Exec(ctx, `
DELETE FROM external_components
WHERE source = $1 AND pc_component_type = $2 AND NOT (external_id = ANY($3))`,
source, componentType, seen,
)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// StartPeriodic mirrors internal/scheduler's one-goroutine ticker shape —
// a background refresh so the cache doesn't only update when someone
// happens to hit the manual refresh endpoint.
func StartPeriodic(db *pgxpool.Pool, sources []Source, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
RefreshAll(context.Background(), db, sources)
}
}()
}