diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..06097cf --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/spider.iml b/.idea/spider.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/spider.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cmd/vogue.go b/cmd/vogue.go index 5257df7..584f47a 100644 --- a/cmd/vogue.go +++ b/cmd/vogue.go @@ -1,44 +1,36 @@ package cmd import ( - "log" - "github.com/spf13/cobra" - "my-spiders/internal/config" "my-spiders/internal/spider" ) var ( brandID int64 - forceUpdate bool onlyPlatform bool + maxCo int ) var vogueCmd = &cobra.Command{ Use: "vogue", - Short: "自动采集 vogue.com 网站发布会数据", + Short: "启动 Vogue 时装发布会采集器", Run: func(cmd *cobra.Command, args []string) { - log.Println("[Command] 启动 Vogue 采集指令...") + // 1. 修复参数数量 & 类型强转 uint(brandID) + vogueSpider := spider.NewVogueSpider(DB, maxCo, uint(brandID), onlyPlatform) - // 从全局 YAML 配置中获取并发数 - maxCo := config.GlobalConfig.App.MaxCo - - vogueSpider := spider.NewVogueSpider(DB, maxCo) - vogueSpider.BrandID = brandID - vogueSpider.ForceUpdate = forceUpdate - vogueSpider.OnlyPlatform = onlyPlatform - vogueSpider.Debug = config.GlobalConfig.App.Debug // 赋值 Debug 状态 - - vogueSpider.Execute() + // 2. 修复方法名:将 Execute() 改为 Run() + vogueSpider.Run() }, } func init() { rootCmd.AddCommand(vogueCmd) - vogueCmd.Flags().Int64VarP(&brandID, "brandId", "b", 0, "指定的品牌id.") - vogueCmd.Flags().BoolVarP(&forceUpdate, "forceUpdate", "f", false, "是否对已经保存的数据进行强制更新.") - vogueCmd.Flags().BoolVarP(&onlyPlatform, "onlyPlatform", "o", false, "是否只对当前平台品牌更新.") - -} + // 绑定命令行参数 + vogueCmd.Flags().Int64VarP(&brandID, "brand", "b", 0, "指定抓取的品牌 ID") + vogueCmd.Flags().BoolVarP(&onlyPlatform, "only-platform", "p", false, "是否仅抓取平台关联品牌") + + // 修复:将 "c" 改为 "m"(或者 ""),避免与全局的 -c 参数冲突 + vogueCmd.Flags().IntVarP(&maxCo, "max-co", "m", 5, "最大并发数限制") +} \ No newline at end of file diff --git a/internal/model/models.go b/internal/model/models.go index 7a6f4ae..15765d8 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -11,19 +11,43 @@ func (AppBrand) TableName() string { return "app_brands" // 根据实际表名调整 } -// Article 文章/发布会数据表模型 -type Article struct { - ID int64 `gorm:"primaryKey;column:id"` - Title string `gorm:"column:title"` - Images string `gorm:"column:images"` - Platform string `gorm:"column:platform"` - Brand int64 `gorm:"column:brand"` - Module int `gorm:"column:module"` - Year int `gorm:"column:year"` - SourceURL string `gorm:"column:source_url"` - Cover string `gorm:"column:cover"` +// BrandRunway 时尚发布会主表 +type BrandRunway struct { + ID uint `gorm:"primaryKey;column:id;autoIncrement" json:"id"` + Title string `gorm:"column:title" json:"title"` + Description string `gorm:"column:description" json:"description"` + CreatedAt int64 `gorm:"column:created_at" json:"created_at"` + UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` + ImageCount uint16 `gorm:"column:image_count" json:"image_count"` + BrandID uint `gorm:"column:brand_id" json:"brand_id"` + Year uint16 `gorm:"column:year" json:"year"` + Cover string `gorm:"column:cover" json:"cover"` + SourceURL string `gorm:"column:source_url" json:"source_url"` + CollectionType string `gorm:"column:collection_type" json:"collection_type"` + Season string `gorm:"column:season" json:"season"` + SeasonCode string `gorm:"column:season_code" json:"season_code"` } -func (Article) TableName() string { - return "articles" // 根据实际表名调整 +// TableName 指定主表名 +func (BrandRunway) TableName() string { + return "brand_runway" } + +// BrandRunwayImage 发布会图片表 +type BrandRunwayImage struct { + ID uint `gorm:"primaryKey;column:id;autoIncrement" json:"id"` + CreatedAt int64 `gorm:"column:created_at" json:"created_at"` + UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` + Image string `gorm:"column:image" json:"image"` + RunwayID uint `gorm:"column:runway_id" json:"runway_id"` + BrandID uint `gorm:"column:brand_id" json:"brand_id"` + Name string `gorm:"column:name" json:"name"` + SortOrder uint `gorm:"column:sort_order" json:"sort_order"` +} + +// TableName 指定图片明细表名 +func (BrandRunwayImage) TableName() string { + return "brand_runway_images" +} \ No newline at end of file diff --git a/internal/spider/vogue.go b/internal/spider/vogue.go index b730234..c8f4bf1 100644 --- a/internal/spider/vogue.go +++ b/internal/spider/vogue.go @@ -23,79 +23,77 @@ const ( ) type VogueSpider struct { - DB *gorm.DB Client *http.Client - MaxCo int - BrandID int64 - ForceUpdate bool - OnlyPlatform bool - Debug bool // 新增 Debug 标志 + DB *gorm.DB + MaxCo int // 最大并发数 + BrandID uint // 客户端指定的品牌ID + OnlyPlatform bool // 是否仅抓取该平台关联的品牌 + Debug bool // 供 s.Debug 使用 + ForceUpdate bool // 供 s.ForceUpdate 使用 } -func NewVogueSpider(db *gorm.DB, maxCo int) *VogueSpider { +func NewVogueSpider(db *gorm.DB, maxCo int, brandID uint, onlyPlatform bool) *VogueSpider { + if maxCo <= 0 { + maxCo = 5 // 默认限制 5 个并发,防止打满带宽 + } return &VogueSpider{ - DB: db, - MaxCo: maxCo, + DB: db, + MaxCo: maxCo, + BrandID: brandID, + OnlyPlatform: onlyPlatform, Client: &http.Client{ Timeout: 30 * time.Second, }, } } -// Execute 任务主入口(对应 execute) -func (s *VogueSpider) Execute() { +// Run 爬虫启动总入口 +func (s *VogueSpider) Run() { tasks, err := s.getTask() if err != nil { - log.Printf("[Error] 获取品牌任务失败: %v", err) + log.Printf("[错误] 获取任务失败: %v", err) return } - // 最大查询的品牌数量, 控制并发数 ($maxBrandExecuteCount = $this->maxCo / 2) - maxBrandExecuteCount := s.MaxCo / 2 - if maxBrandExecuteCount < 1 { - maxBrandExecuteCount = 1 - } + log.Printf("[Info] 成功获取 %d 个品牌任务,开始执行...", len(tasks)) - // 使用 Channel 信号量精准控制并发品牌数,无需 while(true) sleep - sem := make(chan struct{}, maxBrandExecuteCount) + // 品牌级别的并发控制 + brandSem := make(chan struct{}, s.MaxCo) var wg sync.WaitGroup for _, task := range tasks { - sem <- struct{}{} // 占用信号量槽位 + brandSem <- struct{}{} wg.Add(1) go func(t model.AppBrand) { defer func() { - <-sem // 释放槽位 + <-brandSem wg.Done() }() - s.SpiderStart(t) }(task) } wg.Wait() - log.Println("[Command] Vogue 所有抓取任务已完成。") + log.Println("[Info] Vogue 所有抓取任务已完成!") } -// _getTask 获取任务列表(对应 _getTask) +// getTask 获取需要抓取的品牌任务 func (s *VogueSpider) getTask() ([]model.AppBrand, error) { - - // 如果没有数据库连接(如本地调试场景) + // 1. 本地无数据库时的 Mock 调试场景 if s.DB == nil { - log.Println("[Debug] 未连接数据库,启动 Mock 测试数据模式") - - // 如果命令行指定了 -b 参数,使用指定的品牌 ID,否则默认给一个示例品牌 + log.Println("[Debug] 当前未连接数据库,启动 Mock 任务数据模式") brandID := s.BrandID if brandID == 0 { - brandID = 108 + brandID = 108 // 默认给一个 Chanel 示例 ID } return []model.AppBrand{ - {ID: brandID, Name: "chanel", SpiderOrigin: VoguePlatform}, + {ID: int64(brandID), Name: "Alexander McQueen", SpiderOrigin: VoguePlatform}, }, nil } + // 2. 有数据库时的查询逻辑 var brands []model.AppBrand query := s.DB.Model(&model.AppBrand{}) @@ -113,26 +111,24 @@ func (s *VogueSpider) getTask() ([]model.AppBrand, error) { return brands, err } -// getTaskName 品牌名称格式化(对应 getTaskName) -func (s *VogueSpider) getTaskName(name string) string { - r := strings.NewReplacer(".", "-", " ", "-", "&", "") - return strings.ToLower(r.Replace(name)) -} - -// SpiderStart 针对单个品牌开始采集 +// SpiderStart 针对单个品牌开启抓取 func (s *VogueSpider) SpiderStart(task model.AppBrand) { brandName := s.getTaskName(task.Name) url := VogueBaseURL + "/fashion-shows/designer/" + brandName log.Printf("[Command] brandName: %s; spiderUrl: %s", brandName, url) showsList := s.getShowsList(url) + if len(showsList) == 0 { + log.Printf("[Warning] 未找到品牌 [%s] 的发布会列表", brandName) + return + } - // 1. 核心修复:限制详情页的最大并发数,受 s.MaxCo 控制 + // 核心修复:限制该品牌下发布会详情页的抓取并发数 detailSem := make(chan struct{}, s.MaxCo) var wg sync.WaitGroup for _, list := range showsList { - detailSem <- struct{}{} // 达到 MaxCo 上限时自动阻塞等待 + detailSem <- struct{}{} // 超过 MaxCo 时会自动卡住等待 wg.Add(1) go func(info gjson.Result) { @@ -143,15 +139,15 @@ func (s *VogueSpider) SpiderStart(task model.AppBrand) { s.getDetail(task.ID, info) - // 2. 建议:每个请求完成后稍微休眠 200ms,既省带宽又能防封 IP - // time.Sleep(200 * time.Millisecond) + // 频控:每次下载后稍作停顿,保护带宽并防封 IP + time.Sleep(200 * time.Millisecond) }(list) } wg.Wait() } -// getShowsList 获取发布会列表(对应 getShowsList) +// getShowsList 获取品牌的发布会列表 JSON 数据 func (s *VogueSpider) getShowsList(url string) []gjson.Result { body, httpCode := s.request(url) @@ -168,37 +164,29 @@ func (s *VogueSpider) getShowsList(url string) []gjson.Result { return nil } -// getDetail 获取发布会详情并保存(对应 getDetail) +// getDetail 获取并解析单个发布会详情 func (s *VogueSpider) getDetail(brandID int64, info gjson.Result) { hed := info.Get("hed").String() - var modelArticle model.Article + var exist model.BrandRunway // 如果处于非 Debug 模式,进行数据库查询判重 if !s.Debug && s.DB != nil { - s.DB.Where("brand = ? AND title = ?", brandID, hed).First(&modelArticle) - if modelArticle.ID > 0 && !s.ForceUpdate { + s.DB.Where("brand_id = ? AND title = ?", brandID, hed).First(&exist) + if exist.ID > 0 && !s.ForceUpdate { return } } // 如果非 forceUpdate 且记录已存在,跳过更新 - if modelArticle.ID > 0 && !s.ForceUpdate { + if exist.ID > 0 && !s.ForceUpdate { return } - modelArticle.Title = hed - modelArticle.Images = "[]" - modelArticle.Platform = VoguePlatform - modelArticle.Brand = brandID - modelArticle.Module = 0 - modelArticle.Year = s.parseYear(hed) - - // 获取图片 + // 获取图片(保持你原本的 URL 拼接与请求逻辑) pageURI := info.Get("url").String() requestURL := VogueBaseURL + pageURI + "/slideshow/collection" log.Printf("正在匹配发布会详情 %s", requestURL) - modelArticle.SourceURL = requestURL body, httpCode := s.request(requestURL) if httpCode != 200 || body == "" { @@ -209,72 +197,131 @@ func (s *VogueSpider) getDetail(brandID int64, info gjson.Result) { re := regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);<`) matches := re.FindStringSubmatch(body) - if len(matches) > 1 { - imagesResult := gjson.Get(matches[1], "transformed.runwayGalleries.galleries.0.items") - - if !imagesResult.Exists() { - log.Printf("[Warning] %s 获取图片失败.", requestURL) - return - } - - type ImageItem struct { - Src string `json:"src"` - } - - var saveURL []ImageItem - var detailURL []ImageItem - - for _, img := range imagesResult.Array() { - xxlURL := img.Get("image.sources.xxl.url").String() - if xxlURL != "" { - saveURL = append(saveURL, ImageItem{Src: xxlURL}) - log.Printf(xxlURL) - } - - // 详情图片提取 - for _, detail := range img.Get("details").Array() { - detailXxlURL := detail.Get("image.sources.xxl.url").String() - if detailXxlURL != "" { - detailURL = append(detailURL, ImageItem{Src: detailXxlURL}) - } - } - } - - if len(saveURL) > 0 { - jsonBytes, _ := json.Marshal(saveURL) - modelArticle.Images = string(jsonBytes) - modelArticle.Cover = saveURL[0].Src - } - } - - s.saveArticle(&modelArticle) -} - -// saveArticle 数据持久化或 Debug 输出控制 -func (s *VogueSpider) saveArticle(article *model.Article) { - // Debug 模式:只打印结果,不入库 - if s.Debug { - indentJSON, _ := json.MarshalIndent(article, "", " ") - log.Printf("\n================ [DEBUG OUTPUT] ================\n%s\n================================================\n", string(indentJSON)) + if len(matches) <= 1 { return } - // 生产模式:保存到数据库 - if s.DB != nil { - s.DB.Save(article) - log.Printf("[Success] 入库成功: %s", article.SourceURL) + imagesResult := gjson.Get(matches[1], "transformed.runwayGalleries.galleries.0.items") + + if !imagesResult.Exists() { + log.Printf("[Warning] %s 获取图片失败.", requestURL) + return + } + + type ImageItem struct { + Src string `json:"src"` + } + + var saveURL []ImageItem + var detailURL []ImageItem + + // 保持你原有的图片与细节图提取逻辑 + for _, img := range imagesResult.Array() { + xxlURL := img.Get("image.sources.xxl.url").String() + if xxlURL != "" { + saveURL = append(saveURL, ImageItem{Src: xxlURL}) + log.Printf(xxlURL) + } + + // 详情图片提取 + for _, detail := range img.Get("details").Array() { + detailXxlURL := detail.Get("image.sources.xxl.url").String() + if detailXxlURL != "" { + detailURL = append(detailURL, ImageItem{Src: detailXxlURL}) + } + } + } + + // ---------------- 下面对接你的双表数据库结构 ---------------- + + // 1. 整理图片与封面 + cover := "" + if len(saveURL) > 0 { + cover = saveURL[0].Src + } + + // 汇总所有图片(主图 + 细节图)准备存入 brand_runway_images 表 + var allImages []string + for _, item := range saveURL { + allImages = append(allImages, item.Src) + } + for _, item := range detailURL { + allImages = append(allImages, item.Src) + } + + now := time.Now().Unix() + + // 2. 构建主表 BrandRunway 结构体 + runway := model.BrandRunway{ + Title: hed, + BrandID: uint(brandID), + Year: uint16(s.parseYear(hed)), + Cover: cover, + SourceURL: requestURL, + ImageCount: uint16(len(allImages)), + CreatedAt: now, + UpdatedAt: now, + } + + // 3. Debug 模式或无数据库连接时控制台输出 + if s.Debug || s.DB == nil { + out, _ := json.MarshalIndent(runway, "", " ") + log.Printf("\n================ [DEBUG OUTPUT] ================\n%s\n提取图片总数: %d 张\n================================================", string(out), len(allImages)) + return + } + + // 4. 执行数据库事务保存(主表 brand_runway + 从表 brand_runway_images) + err := s.DB.Transaction(func(tx *gorm.DB) error { + // 判断是更新还是新增 + if exist.ID > 0 { + runway.ID = exist.ID + if err := tx.Save(&runway).Error; err != nil { + return err + } + // 删除旧关联图片 + if err := tx.Where("runway_id = ?", exist.ID).Delete(&model.BrandRunwayImage{}).Error; err != nil { + return err + } + } else { + if err := tx.Create(&runway).Error; err != nil { + return err + } + } + + // 批量插入图片从表 + var runwayImages []model.BrandRunwayImage + for idx, imgURL := range allImages { + runwayImages = append(runwayImages, model.BrandRunwayImage{ + RunwayID: runway.ID, // 获取上面生成的自增 ID + BrandID: uint(brandID), + Image: imgURL, + SortOrder: uint(idx + 1), + CreatedAt: now, + UpdatedAt: now, + }) + } + + if len(runwayImages) > 0 { + if err := tx.Create(&runwayImages).Error; err != nil { + return err + } + } + + return nil + }) + + if err != nil { + log.Printf("[错误] 数据入库失败 [%s]: %v", hed, err) + } else { + log.Printf("[成功] 保存发布会: %s (共 %d 张图片)", hed, len(allImages)) } } -// 提取年份 (替换 PHP 中的 AppHelper::getYear) -func (s *VogueSpider) parseYear(title string) int { - re := regexp.MustCompile(`\b(19|20)\d{2}\b`) - match := re.FindString(title) - if match != "" { - year, _ := strconv.Atoi(match) - return year - } - return time.Now().Year() +// getTaskName 品牌名称格式化(对应 getTaskName) +func (s *VogueSpider) getTaskName(name string) string { + r := strings.NewReplacer(".", "-", " ", "-", "&", "") + log.Printf("[Debug] 原始品牌名: %s; 格式化后: %s", name, strings.ToLower(r.Replace(name))) + return strings.ToLower(r.Replace(name)) } // HTTP 请求封装 @@ -299,3 +346,14 @@ func (s *VogueSpider) request(url string) (string, int) { return string(bodyBytes), resp.StatusCode } + +// parseYear 从标题中提取 4 位年份数字(如 Fall 2024 Ready-to-Wear -> 2024) +func (s *VogueSpider) parseYear(title string) int { + re := regexp.MustCompile(`\b(19|20)\d{2}\b`) + match := re.FindString(title) + if match != "" { + y, _ := strconv.Atoi(match) + return y + } + return time.Now().Year() +} diff --git a/spider b/spider new file mode 100644 index 0000000..f8a4bfa Binary files /dev/null and b/spider differ