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