Files
spider/internal/media/upload.go
toom1996 3fe0b86e11 feat: 媒体机直传 + 向量回填
- media: spider 下载图片后按内容 sha1 生成 key,经后端预签名直传 S4,上报只带 key
  (spider 不持有 AK/SK;单张失败自动回退为上报 URL)
- embed: 新增 embed 子命令,从后端领取缺向量的图片→下载→本地 CLIP 编码→回传
  (媒体拆分后后端拿不到图片字节,向量只能靠回填补齐)
- serve: 服务化常驻模式(领取 crawl 任务 → 抓取 → 回报)
- config: 新增 media.upload/concurrency、vision.endpoint/concurrency
2026-10-25 00:27:46 +08:00

263 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package media 负责「媒体机直传」:把图片下载下来、算内容 sha1、
// 向后端要预签名 PUT 地址、直接传到对象存储,最后只把 key 交给后端。
//
// 为什么由 spider 做这件事:
// - spider 部署在带宽好的机器上,下载 Vogue 图 + 上传存储是带宽密集的活;
// - 后端机器只需写库,不再被出口带宽卡住;
// - 爬虫**不持有**存储密钥:预签名地址由后端签发(15 分钟有效),
// 密钥只留在后端,减少泄露面。
//
// key 采用内容寻址(runway/<sha1>.jpg),同一张图重复上传是幂等的,
// 重爬不会产生孤儿文件。
package media
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"my-spiders/internal/ingest"
)
// Uploader 媒体直传器。
type Uploader struct {
Client *ingest.Client // 复用它来签发预签名 URL(HMAC)
Prefix string // 存储前缀:runway / street
Concurrency int // 下载与上传的并发数
HTTP *http.Client
}
// Result 单张图的直传结果;Err 非空表示这张失败(调用方应回退为上报 URL)。
type Result struct {
Key string
Size int64
Err error
}
// New 创建直传器。concurrency <= 0 时取 6。
func New(client *ingest.Client, prefix string, concurrency int) *Uploader {
if concurrency <= 0 {
concurrency = 6
}
return &Uploader{
Client: client,
Prefix: prefix,
Concurrency: concurrency,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// UploadAll 并发完成「下载 → 生成 key → 预签名 → PUT」,返回与 urls 下标一一对应的结果。
// 单张失败不影响其他张:该下标 Err 非空,调用方据此对这张回退成 URL 上报。
func (u *Uploader) UploadAll(ctx context.Context, urls []string) []Result {
results := make([]Result, len(urls))
if len(urls) == 0 {
return results
}
// ① 并发下载
data := make([][]byte, len(urls))
errs := make([]error, len(urls))
u.parallel(len(urls), func(i int) {
body, err := u.download(ctx, urls[i])
if err != nil {
errs[i] = err
return
}
data[i] = body
})
log.Printf("[media] 下载完成 %d 张(失败 %d)", countOK(data), len(urls)-countOK(data))
// ② 生成本批 key(内容 sha1 + 原扩展名)
for i := range urls {
if errs[i] != nil {
results[i] = Result{Err: errs[i]}
continue
}
results[i] = Result{
Key: u.Prefix + "/" + sha1Hex(data[i]) + extOf(urls[i]),
Size: int64(len(data[i])),
}
}
// ③ 批量申请预签名地址(同一张图可能在同一场里出现多次,按 key 去重)
want := map[string]ingest.UploadItem{}
for i := range urls {
if results[i].Key == "" {
continue
}
if _, ok := want[results[i].Key]; ok {
continue
}
want[results[i].Key] = ingest.UploadItem{Key: results[i].Key, ContentType: contentTypeOf(urls[i])}
}
items := make([]ingest.UploadItem, 0, len(want))
for _, it := range want {
items = append(items, it)
}
presigned := make(map[string]string, len(items))
const chunk = 100 // 后端单次上限 200,这里留一半余量
for start := 0; start < len(items); start += chunk {
end := start + chunk
if end > len(items) {
end = len(items)
}
got, err := u.Client.RequestUploadURLs(ctx, items[start:end])
if err != nil {
// 申请失败:整批回退成 URL 上报(后端自己下载),不中断整场抓取。
for i := range results {
if results[i].Key != "" {
results[i] = Result{Err: fmt.Errorf("申请上传地址失败: %w", err)}
}
}
return results
}
for _, g := range got {
presigned[g.Key] = g.URL
}
}
log.Printf("[media] 已拿到 %d 个预签名地址,开始上传", len(presigned))
// ④ 并发 PUT(内容已在内存里,直接传字节)
u.parallel(len(urls), func(i int) {
if results[i].Key == "" {
return
}
putURL, ok := presigned[results[i].Key]
if !ok {
results[i] = Result{Err: fmt.Errorf("未拿到 %s 的上传地址", results[i].Key)}
return
}
if err := u.put(ctx, putURL, urls[i], data[i]); err != nil {
results[i] = Result{Err: err}
}
})
return results
}
// parallel 以 Concurrency 路并发执行 fn(0..n-1)。
func (u *Uploader) parallel(n int, fn func(i int)) {
if n == 0 {
return
}
if u.Concurrency <= 0 {
// 兜底:并发为 0 会造成无缓冲 channel 死锁(曾把整条抓取卡住)。
u.Concurrency = 1
}
sem := make(chan struct{}, u.Concurrency)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fn(i)
}(i)
}
wg.Wait()
}
func (u *Uploader) download(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := u.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载失败 status=%d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, fmt.Errorf("下载到空文件")
}
return body, nil
}
func (u *Uploader) put(ctx context.Context, putURL, originalURL string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(data))
if err != nil {
return err
}
// Content-Type 必须与申请预签名时一致,否则签名校验失败(403)。
req.Header.Set("Content-Type", contentTypeOf(originalURL))
resp, err := u.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return fmt.Errorf("PUT 失败 status=%d %s", resp.StatusCode, string(raw))
}
return nil
}
// countOK 数一下成功下载的张数(用于日志定位卡在哪一步)。
func countOK(data [][]byte) int {
n := 0
for i := range data {
if len(data[i]) > 0 {
n++
}
}
return n
}
func sha1Hex(b []byte) string {
sum := sha1.Sum(b)
return hex.EncodeToString(sum[:])
}
// extOf 从图片 URL 推断扩展名(取路径最后一段,忽略查询串)。
func extOf(rawURL string) string {
p := rawURL
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i]
}
if i := strings.LastIndex(p, "."); i >= 0 && len(p)-i <= 6 {
ext := strings.ToLower(p[i:])
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif":
if ext == ".jpeg" {
return ".jpg"
}
return ext
}
}
return ".jpg"
}
// contentTypeOf 与 extOf 保持一致,保证预签名与 PUT 的 Content-Type 相同。
func contentTypeOf(rawURL string) string {
switch extOf(rawURL) {
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".avif":
return "image/avif"
default:
return "image/jpeg"
}
}