diff --git a/bin/spider b/bin/spider index 6de909f..37a8207 100644 Binary files a/bin/spider and b/bin/spider differ diff --git a/internal/ingest/payload.go b/internal/ingest/payload.go index f0cc913..a737133 100644 --- a/internal/ingest/payload.go +++ b/internal/ingest/payload.go @@ -5,8 +5,6 @@ // 于后端代码(不再 import 后端 internal 包),双方仅通过「签名 + JSON 载荷」契约通信。 package ingest -import "strings" - // 入库类型(与后台 dto 保持一致)。 const ( KindRunway = "runway" // 走秀(默认,需 brand_uid) @@ -26,6 +24,7 @@ type RunwayIngest struct { Season string `json:"season"` // spring / fall CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall City string `json:"city"` // 地区/城市(street 用) + PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表 // Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。 // 结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图)。 @@ -38,37 +37,4 @@ type RunwayLook struct { Details []string `json:"details"` // 细节图原始 URL 列表 } -// ParseCollection 从 Vogue 标题推导 collection_type 与 season,供后台补 season_code。 -// -// "Fall 2024 Ready-to-Wear" -> ("rtw", "fall") -// "Spring 2025 Menswear" -> ("menswear", "spring") -// "Resort 2024" -> ("resort", "") -// "Pre-Fall 2024" -> ("pre_fall", "") -// "Couture Fall 2024" -> ("couture", "fall") -func ParseCollection(title string) (collectionType, season string) { - t := strings.ToLower(title) - switch { - case strings.Contains(t, "resort"): - collectionType = "resort" - case strings.Contains(t, "pre-fall"), strings.Contains(t, "pre fall"): - collectionType = "pre_fall" - case strings.Contains(t, "couture"): - collectionType = "couture" - case strings.Contains(t, "menswear"), strings.Contains(t, "men's"): - collectionType = "menswear" - case strings.Contains(t, "ready"): - collectionType = "rtw" - default: - // 仅出现 spring/fall 而无明确品类词时,回落为 rtw(绝大多数季场秀) - if strings.Contains(t, "spring") || strings.Contains(t, "fall") { - collectionType = "rtw" - } - } - switch { - case strings.Contains(t, "spring"): - season = "spring" - case strings.Contains(t, "fall"): - season = "fall" - } - return collectionType, season -} + diff --git a/internal/spider/vogue.go b/internal/spider/vogue.go index b6b0bd5..3299327 100644 --- a/internal/spider/vogue.go +++ b/internal/spider/vogue.go @@ -25,6 +25,12 @@ const ( var ( showsStateRe = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);`) detailStateRe = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);<`) + // ldJsonRe 匹配详情页里的 块, + // 用于抽取 Article 的 datePublished(Vogue 的发布时间,比标题解析的年份更可靠)。 + ldJsonRe = regexp.MustCompile(`(?s)]*type="application/ld\+json"[^>]*>(.*?)`) + // publishDateRe 兜底:不少 Vogue 详情页没有 JSON-LD,发布时间只内联在 + // window.__PRELOADED_STATE__ 的 "publishDate":"" 里,用正则兜底抽取。 + publishDateRe = regexp.MustCompile(`"publishDate"\s*:\s*"([^"]+)"`) ) type VogueSpider struct { @@ -212,6 +218,10 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) { return } + // 抽取发布时间(Vogue 详情页 application/ld+json 的 datePublished)。 + // 缺失或解析失败时返回零值,后端据此跳过(不写空时间)。 + publishedAt := s.parsePublishedAt(body) + imagesResult := gjson.GetBytes(matches[1], "transformed.runwayGalleries.galleries.0.items") if !imagesResult.Exists() { @@ -251,7 +261,7 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) { // Debug 模式:仅控制台输出,不上报 if s.Debug { - log.Printf("\n================ [DEBUG OUTPUT] ================\n标题: %s\n抓取 URL: %s\n主图(look)数: %d 张 / 含细节共 %d 张\n================================================", hed, requestURL, len(looks), totalImages) + log.Printf("\n================ [DEBUG OUTPUT] ================\n标题: %s\n抓取 URL: %s\n主图(look)数: %d 张 / 含细节共 %d 张\n发布时间: %s\n================================================", hed, requestURL, len(looks), totalImages, publishedAt) return } @@ -266,6 +276,7 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) { Year: uint16(s.parseYear(hed)), Season: season, CollectionType: collectionType, + PublishedAt: publishEpoch(publishedAt), // Looks 优先:结构化承载「主图 + 细节图」分组;Images 仅铺平主图做兼容兜底。 Looks: looks, Images: mainImages, @@ -273,13 +284,54 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) { if _, err := s.Ingest.Submit(context.Background(), p); err != nil { log.Printf("[错误] 入库上报失败 [%s]: %v", hed, err) } else { - log.Printf("[成功] 已上报待入库: %s (共 %d 个 look / %d 张图)", hed, len(looks), totalImages) + log.Printf("[成功] 已上报待入库: %s (共 %d 个 look / %d 张图, 发布时间 %s)", hed, len(looks), totalImages, publishedAt) } return } } +// parseVogueDate 容错解析 Vogue 的日期字符串:兼容完整 ISO 时间戳(含/不含时区、含小数秒) +// 与「仅日期」格式(如 2015-08-08,老页面 publishDate 仅有日期)。解析失败返回零值。 +func parseVogueDate(s string) (time.Time, bool) { + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05Z07:00", "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC(), true + } + } + return time.Time{}, false +} + +// parsePublishedAt 从详情页 HTML 抽取 Vogue 的发布时间(文章发布时间,比标题解析的年份更可靠)。 +// 优先用 application/ld+json 的 datePublished;部分页面无 JSON-LD,则兜底从内联 +// "publishDate":""(window.__PRELOADED_STATE__ 等)抽取。解析为 UTC 时间返回(失败返回零值)。 +func (s *VogueSpider) parsePublishedAt(body []byte) time.Time { + // 1) 优先 application/ld+json 的 datePublished(部分页面有)。 + for _, m := range ldJsonRe.FindAllSubmatch(body, -1) { + block := string(m[1]) + if dp := gjson.Get(block, "datePublished"); dp.Exists() { + if t, ok := parseVogueDate(dp.String()); ok { + return t + } + } + } + // 2) 兜底:内联 JSON 里的 "publishDate":""。 + if m := publishDateRe.FindSubmatch(body); m != nil { + if t, ok := parseVogueDate(string(m[1])); ok { + return t + } + } + return time.Time{} +} + +// publishEpoch 把发布时间转成 Unix 时间戳(秒);零值(未抽取到)→ 0(与 created_at 约定一致)。 +func publishEpoch(t time.Time) uint32 { + if t.IsZero() { + return 0 + } + return uint32(t.Unix()) +} + // getTaskName 品牌名称格式化(对应 getTaskName) func (s *VogueSpider) getTaskName(name string) string { r := strings.NewReplacer(".", "-", " ", "-", "&", "") diff --git a/spider_val.out b/spider_val.out new file mode 100644 index 0000000..e69de29