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)`)
+ // 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