feat: 媒体机直传 + 向量回填

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

262
internal/media/upload.go Normal file
View File

@ -0,0 +1,262 @@
// 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"
}
}