This commit is contained in:
toom1996
2026-09-28 10:54:33 +08:00
parent 40eec4932e
commit d710720c6e
4 changed files with 56 additions and 38 deletions

Binary file not shown.

View File

@ -5,8 +5,6 @@
// 于后端代码(不再 import 后端 internal 包),双方仅通过「签名 + JSON 载荷」契约通信。 // 于后端代码(不再 import 后端 internal 包),双方仅通过「签名 + JSON 载荷」契约通信。
package ingest package ingest
import "strings"
// 入库类型(与后台 dto 保持一致)。 // 入库类型(与后台 dto 保持一致)。
const ( const (
KindRunway = "runway" // 走秀(默认,需 brand_uid) KindRunway = "runway" // 走秀(默认,需 brand_uid)
@ -26,6 +24,7 @@ type RunwayIngest struct {
Season string `json:"season"` // spring / fall Season string `json:"season"` // spring / fall
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
City string `json:"city"` // 地区/城市(street 用) City string `json:"city"` // 地区/城市(street 用)
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表 Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。 // Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
// 结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图)。 // 结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图)。
@ -38,37 +37,4 @@ type RunwayLook struct {
Details []string `json:"details"` // 细节图原始 URL 列表 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
}

View File

@ -25,6 +25,12 @@ const (
var ( var (
showsStateRe = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);</script>`) showsStateRe = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);</script>`)
detailStateRe = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);<`) detailStateRe = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*(.*?);<`)
// ldJsonRe 匹配详情页里的 <script type="application/ld+json">…</script> 块,
// 用于抽取 Article 的 datePublished(Vogue 的发布时间,比标题解析的年份更可靠)。
ldJsonRe = regexp.MustCompile(`(?s)<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>`)
// publishDateRe 兜底:不少 Vogue 详情页没有 JSON-LD,发布时间只内联在
// window.__PRELOADED_STATE__ 的 "publishDate":"<ISO>" 里,用正则兜底抽取。
publishDateRe = regexp.MustCompile(`"publishDate"\s*:\s*"([^"]+)"`)
) )
type VogueSpider struct { type VogueSpider struct {
@ -212,6 +218,10 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) {
return return
} }
// 抽取发布时间(Vogue 详情页 application/ld+json 的 datePublished)。
// 缺失或解析失败时返回零值,后端据此跳过(不写空时间)。
publishedAt := s.parsePublishedAt(body)
imagesResult := gjson.GetBytes(matches[1], "transformed.runwayGalleries.galleries.0.items") imagesResult := gjson.GetBytes(matches[1], "transformed.runwayGalleries.galleries.0.items")
if !imagesResult.Exists() { if !imagesResult.Exists() {
@ -251,7 +261,7 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) {
// Debug 模式:仅控制台输出,不上报 // Debug 模式:仅控制台输出,不上报
if s.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 return
} }
@ -266,6 +276,7 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) {
Year: uint16(s.parseYear(hed)), Year: uint16(s.parseYear(hed)),
Season: season, Season: season,
CollectionType: collectionType, CollectionType: collectionType,
PublishedAt: publishEpoch(publishedAt),
// Looks 优先:结构化承载「主图 + 细节图」分组;Images 仅铺平主图做兼容兜底。 // Looks 优先:结构化承载「主图 + 细节图」分组;Images 仅铺平主图做兼容兜底。
Looks: looks, Looks: looks,
Images: mainImages, 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 { if _, err := s.Ingest.Submit(context.Background(), p); err != nil {
log.Printf("[错误] 入库上报失败 [%s]: %v", hed, err) log.Printf("[错误] 入库上报失败 [%s]: %v", hed, err)
} else { } else {
log.Printf("[成功] 已上报待入库: %s (共 %d 个 look / %d 张图)", hed, len(looks), totalImages) log.Printf("[成功] 已上报待入库: %s (共 %d 个 look / %d 张图, 发布时间 %s)", hed, len(looks), totalImages, publishedAt)
} }
return 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":"<ISO>"(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":"<ISO 或 仅日期>"。
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) // getTaskName 品牌名称格式化(对应 getTaskName)
func (s *VogueSpider) getTaskName(name string) string { func (s *VogueSpider) getTaskName(name string) string {
r := strings.NewReplacer(".", "-", " ", "-", "&", "") r := strings.NewReplacer(".", "-", " ", "-", "&", "")

0
spider_val.out Normal file
View File