262 lines
6.9 KiB
Go
262 lines
6.9 KiB
Go
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)
|
|
}
|