package watcher import ( "context" "fmt" "github.com/go-resty/resty/v2" "github.com/pkg/errors" "github.com/timerzz/proxypool/pkg/proxy" "gorm.io/gorm" "gorm.io/gorm/clause" "haitao_watcher/pkg/model" "haitao_watcher/pkg/options" pool "haitao_watcher/pkg/pools" "haitao_watcher/pkg/utils" "log/slog" "math/rand/v2" "net/url" "time" ) type CoachOutlet struct { pool *pool.ProxyPool cfg *options.CoachOutletOption detail *model.Product iter func() (proxy proxy.Proxy) db *gorm.DB fCtx context.Context ctx context.Context cancel context.CancelFunc onOrderable chan<- model.PushMsg } func NewCoachWatcher(ctx context.Context, p *pool.ProxyPool, cfg *options.CoachOutletOption, db *gorm.DB) *CoachOutlet { return &CoachOutlet{ fCtx: ctx, pool: p, cfg: cfg, iter: p.RandomIterator(), db: db, } } func (c *CoachOutlet) Option() options.CoachOutletOption { return *c.cfg } func (c *CoachOutlet) SetOption(cfg options.CoachOutletOption) { c.cfg = &cfg } func (c *CoachOutlet) SetOnOrderableMsgChannel(ch chan<- model.PushMsg) Watcher { c.onOrderable = ch return c } func (c *CoachOutlet) Cancel() { c.cancel() } func (c *CoachOutlet) Uid() string { return fmt.Sprintf("coachOutlet_%s", c.cfg.Pid) } func (c *CoachOutlet) getDetail() { if c.detail == nil { subCtx, cancel := context.WithTimeout(c.ctx, time.Minute*5) defer cancel() if err := c.requestProductDetail(subCtx); err != nil { slog.Error(fmt.Sprintf("获取coach %s 详情失败:%v", c.cfg.Pid, err)) } if c.detail != nil && c.detail.Orderable { if c.onOrderable != nil { c.onOrderable <- model.PushMsg{ Title: "coachoutlet 商品补货", Content: fmt.Sprintf("商品 %s 可以购买,链接: %s", c.detail.Name, c.detail.Link), ToPusher: c.detail.PusherIds, } } c.cancel() } } } func (c *CoachOutlet) Restart() { c.Cancel() go c.Watch() } func (c *CoachOutlet) Watch() { c.ctx, c.cancel = context.WithCancel(c.fCtx) c.getDetail() ticker := time.NewTicker(c.cfg.Interval) defer ticker.Stop() for { select { case <-ticker.C: c.getDetail() if c.detail == nil { continue } // 加一些干扰 salt := rand.IntN(70) time.Sleep(time.Second * time.Duration(salt)) // 请求 subCtx, cancel := context.WithTimeout(c.ctx, time.Minute*5) inventory, err := c.requestProductInventory(subCtx) cancel() if err != nil { slog.Warn(fmt.Sprintf("获取coach %s 库存失败:%v", c.cfg.Pid, err)) } c.detail.UpdateErr = err != nil //如果获取库存没出错,那么更新一下状态 if err == nil { c.detail.Orderable = inventory.Orderable if !inventory.AllocationResetDate.IsZero() { c.detail.AllocationResetDate = inventory.AllocationResetDate } if c.detail.Orderable { c.detail.Watch = false c.detail.AllocationResetDate = time.Now() } } if err = c.db.Save(c.detail).Error; err != nil { slog.Error(fmt.Sprintf("更新数据库失败:%v", err)) } // 如果可以预定了,那么退出 if c.detail.Orderable { if c.onOrderable != nil { c.onOrderable <- model.PushMsg{ Title: "coachoutlet 商品补货", Content: fmt.Sprintf("商品 %s 可以购买,链接: %s", c.detail.Name, c.detail.Link), ToPusher: c.detail.PusherIds, } } return } case <-c.ctx.Done(): return } } } type ProductData struct { Id string `json:"id"` Name string `json:"name"` Brand string `json:"brand"` Inventory Inventory `json:"inventory"` Url string `json:"url"` MasterId string `json:"masterId"` Prices struct { CurrentPrice float64 `json:"currentPrice"` } `json:"prices"` Remark string `json:"-"` } func (p *ProductData) Product() *model.Product { return &model.Product{ Uid: fmt.Sprintf("coachOutlet_%s", p.Id), Pid: p.Id, Name: p.Name, Brand: p.Brand, Website: model.CoachOutlet, Watch: true, Orderable: p.Inventory.Orderable, Link: p.Url, Remark: p.Remark, } } type Inventory struct { Id string `json:"id"` Ats int `json:"ats"` PreOrderable bool `json:"preorderable"` BackOrderable bool `json:"backorderable"` Orderable bool `json:"orderable"` AllocationResetDate time.Time `json:"allocationResetDate,omitempty"` Perpetual bool `json:"perpetual"` StockLevel int `json:"stockLevel"` } type ProductDataResponse struct { ProductData []*ProductData `json:"productsData"` } // 获取 func (c *CoachOutlet) requestProductDetail(ctx context.Context) error { urlPath := fmt.Sprintf("https://www.coachoutlet.com/api/get-products?ids=%s&includeInventory=false", url.QueryEscape(c.cfg.Pid)) var resp ProductDataResponse err := tryRequest(ctx, urlPath, &resp, c.iter) if err != nil { return err } if len(resp.ProductData) == 0 { return fmt.Errorf("coachoutlet %s 详情信息长度为0", c.cfg.Pid) } c.detail = resp.ProductData[0].Product() c.detail.Remark = c.cfg.Remark c.detail.Link = fmt.Sprintf("https://www.coachoutlet.com%s", c.detail.Link) if c.detail.Orderable { c.detail.Watch = false if c.detail.AllocationResetDate.IsZero() { c.detail.AllocationResetDate = time.Now() } } c.detail.PusherIds = c.cfg.PusherIds // 更新数据库 return c.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "uid"}}, UpdateAll: true, }).Create(c.detail).Error } type InventoryResponse struct { Inventory struct { Status string `json:"status"` InventoryListID string `json:"inventoryListID"` InventoryInfo Inventory `json:"inventoryInfo"` } `json:"inventory"` } func (c *CoachOutlet) requestProductInventory(ctx context.Context) (Inventory, error) { urlPath := fmt.Sprintf("https://www.coachoutlet.com/api/inventory?vgId=%s&includeVariantData=false", url.QueryEscape(c.cfg.Pid)) var resp InventoryResponse return resp.Inventory.InventoryInfo, tryRequest(ctx, urlPath, &resp, c.iter) } func tryRequest(ctx context.Context, urlPath string, respData any, proxyGetter func() proxy.Proxy) error { for p := proxyGetter(); p != nil; p = proxyGetter() { select { case <-ctx.Done(): return nil default: } _, err := callByProxy(ctx, p, urlPath, respData) if err != nil { slog.Debug(err.Error()) continue } return nil } return errors.New("can not get ") } func callByProxy(ctx context.Context, p proxy.Proxy, urlPath string, result any) (*resty.Response, error) { addr, err := utils.UrlToMetadata(urlPath) if err != nil { return nil, err } subCtx, cancel := context.WithTimeout(ctx, time.Second*5) defer cancel() conn, err := utils.ConnFromProxy(subCtx, p, addr) if err != nil { return nil, errors.Wrap(err, "创建conn失败") } defer conn.Close() var cli = pool.GetRestyClient(conn) defer pool.PutRestyClient(cli) return cli.R().SetResult(result).Get(urlPath) }