缩略图代理实现方法详解(AI 视频下载总结器的图片加速)

列表页二十张图要么 403、要么加载慢到转圈——上线第一天,直接引用的 B 站和 YouTube 原始图片链接就集体翻车。最初我直接在前端引用 B 站、YouTube 的原始图片链接,上线第一天就翻车:列表页二十张图要么 403,要么加载慢到转圈。排查下来是防盗链和 CDN 限速,这才决定自己写缩略图代理。它统一承担防盗链绕过、尺寸缩放、格式统一、缓存加速四件事,下面把完整实现、性能数据和踩过的坑拆开讲。

一、为什么需要缩略图代理

直接拿平台 CDN 链接展示,问题比想象中多:

  1. 防盗链:YouTube/B 站 Referer 检查,跨站访问 403;
  2. 限速:CDN 对单 IP 限速,高并发打挂;
  3. 缩放:不同位置要不同尺寸,原图 4K 浪费带宽;
  4. 格式统一:各家格式不统一(jpg/webp/avif);
  5. CORS:跨域直接 img 显示受 CORS 限制。

五类问题里,防盗链 403 和限速是直接可见的故障,缩略图加载不出来,用户第一印象就差了;格式和尺寸问题则让带宽成本白白上涨。

二、整体架构

代理服务的架构不复杂,核心就一条链路:前端请求统一打到后端,由后端判断缓存、回源、压缩:

前端用 img 标签展示缩略图
    ↓
后端代理服务
    ├─ 检查缓存(Redis)
    ├─ 缓存命中:直接返回
    └─ 缓存未命中:下载原图 → 压缩 → 缓存 → 返回

这条链路里缓存是命门:命中时 Redis 直出,毫秒级;未命中才回源下载。热点视频的缩略图命中率上来之后,回源量非常小,源站 CDN 也不会被我们打限速。

三、FastAPI 实现

主体逻辑写在 FastAPI 的一个接口里,参数就两个:原始图片 URL 和目标宽度:

@app.get('/api/thumb')
async def thumbnail(url: str, w: int = 480):
    """视频缩略图代理(带缓存 + 压缩)"""
    # 1) 校验 URL(防止 SSRF)
    if not is_safe_url(url):
        raise HTTPException(400, "Invalid URL")
    
    # 2) 缓存 key
    cache_key = f'thumb:{hashlib.md5(url.encode()).hexdigest()}:{w}'
    
    # 3) 查缓存
    cached = await redis.get(cache_key)
    if cached:
        return Response(content=cached, media_type='image/webp')
    
    # 4) 下载原图
    async with httpx.AsyncClient() as client:
        resp = await client.get(url, headers={
            'User-Agent': 'Mozilla/5.0 ...',
            'Referer': extract_referer(url)
        })
        img_bytes = resp.content
    
    # 5) 压缩(可选)
    if w < 1280:
        img_bytes = await resize_image(img_bytes, width=w)
    
    # 6) 缓存
    await redis.setex(cache_key, 86400, img_bytes)  # 24h
    
    return Response(content=img_bytes, media_type='image/webp')

接口的顺序很关键:先校验 URL,再查缓存,然后回源下载,按需压缩,最后写缓存。缓存判断永远在下载之前,否则并发一上来,回源请求会把源站打爆。

四、SSRF 防护

代理能把任意 URL 变成”服务器帮你下载”,这正是 SSRF 的温床。上线前我把内网地址段全部拉黑:

import ipaddress
from urllib.parse import urlparse

BLOCKED_NETWORKS = [
    ipaddress.ip_network('127.0.0.0/8'),    # 回环
    ipaddress.ip_network('10.0.0.0/8'),     # 私网
    ipaddress.ip_network('172.16.0.0/12'),
    ipaddress.ip_network('192.168.0.0/16'),
    ipaddress.ip_network('169.254.0.0/16'),  # 链路本地
    ipaddress.ip_network('::1/128'),
    ipaddress.ip_network('fc00::/7'),       # 仅本地地址段
]

def is_safe_url(url: str) -> bool:
    """校验 URL 不指向内网"""
    try:
        parsed = urlparse(url)
        if parsed.scheme not in ('http', 'https'):
            return False
        
        # 解析 IP
        import socket
        ip = socket.gethostbyname(parsed.hostname)
        ip_obj = ipaddress.ip_address(ip)
        
        for net in BLOCKED_NETWORKS:
            if ip_obj in net:
                return False
        
        return True
    except Exception:
        return False

防护分两层:协议白名单加内网段拦截。DNS 解析这步不能省,否则攻击者用域名伪装指向内网的 IP,光靠 URL 字符串判断根本拦不住。

五、Referer 注入

防盗链的应对手段是模拟来源,不同平台检查的 Referer 不一样,我按域名做了映射:

def extract_referer(url: str) -> str:
    if 'youtube.com' in url or 'ytimg.com' in url:
        return 'https://www.youtube.com/'
    if 'bilibili.com' in url:
        return 'https://www.bilibili.com'
    if 'douyin.com' in url:
        return 'https://www.douyin.com/'
    if 'xiaohongshu.com' in url:
        return 'https://www.xiaohongshu.com'
    return 'https://example.com'

映射表维护成本很低,新平台接入时加一行就行。个别平台还会校验 User-Agent,我统一带上了桌面浏览器的 UA。

六、图片压缩

原图动辄几 MB,4K 缩略图直接推给移动端太奢侈。压缩放在回源之后、缓存之前:

from PIL import Image
import io

async def resize_image(img_bytes: bytes, width: int) -> bytes:
    """等比缩放图片到指定宽度"""
    img = Image.open(io.BytesIO(img_bytes))
    
    if img.width <= width:
        return img_bytes
    
    ratio = width / img.width
    height = int(img.height * ratio)
    
    img = img.resize((width, height), Image.LANCZOS)
    
    # 转 WebP(比 JPEG 小 30%)
    if img.mode != 'RGB':
        img = img.convert('RGB')
    
    buf = io.BytesIO()
    img.save(buf, format='WEBP', quality=85)
    return buf.getvalue()

缩放优先转 WebP,同质量下比 JPEG 小大约三成。等比缩放只按宽度,避免裁切失真;图片本来就小于目标宽度时直接透传,省 CPU。

七、多尺寸支持

同一个视频在列表页、详情页、背景图要的尺寸完全不同,与其前端各裁各的,不如代理按参数出图:

位置 宽度 场景
列表卡片 320 缩略图
详情页 640 视频介绍
视频背景 1920 模糊背景

前端用参数 ?w=320 指定:

img 标签通过参数指定宽度

后端按 width 缓存不同尺寸:

cache_key = f'thumb:{hash}:{w}'  # 不同尺寸不同缓存

尺寸参数挂进缓存 key,不同宽度的图各自缓存互不覆盖,前端只需要在链接上带 w 参数,改动量很小。

八、CDN 缓存

代理后端再套一层 CDN,边缘节点命中后用户访问几乎无感:

Cache-Control: public, max-age=86400

CDN 边缘命中后用户访问更快。两层缓存各自的过期时间要配合好:CDN 的 max-age 比 Redis 短一点,避免边缘节点集体过期后一窝蜂打到 Redis 回源。

九、批量预热

热门视频的缩略图,等用户第一次访问才生成就慢了。我会在视频进入热门榜时主动预热:

async def preheat_thumbnails(video_urls: list[str]):
    """预热热门视频缩略图"""
    for url in video_urls:
        thumb = get_thumbnail_url(url)
        if thumb:
            # 主动拉一次
            async with httpx.AsyncClient() as client:
                await client.get(f'https://cdn.example.com/api/thumb?url={thumb}')

预热只拉列表页需要的 320 宽度,其他尺寸按需生成。预热任务加了去重,避免定时任务重复拉同一个 URL。

十、性能数据

改造前后各测了一轮首屏数据:

场景 直链 代理
列表页 20 个缩略图 5s 0.5s
首屏加载 3s 0.3s
CDN 缓存命中 N/A < 50ms

从数据看,代理加缓存把列表页加载从 5 秒压到 0.5 秒,CDN 命中时 50ms 以内,对弱网用户尤其明显。

十一、缩略图占位

加载再快也有网络波动的时刻。占位图我用 blurhash 方案,先显示模糊色块,再平滑切到清晰图:

import blurhash

def encode_placeholder(img_bytes: bytes) -> str:
    """生成 blurhash 占位"""
    img = Image.open(io.BytesIO(img_bytes))
    return blurhash.encode(img, x_components=4, y_components=3)

前端:

img 标签 src 指向占位图
img 标签绑 :src 和 @load 事件

blurhash 把整张图编码成几十个字符的字符串,体积小、渲染快。前端在图片加载完成事件里切换,过渡自然不闪烁。

十二、踩过的坑

这套服务踩过的坑不少,按影响面排序:

  • B 站图片防盗链:必须带 Referer: https://www.bilibili.com。
  • YouTube maxresdefault.jpg 可能 404:fallback 到 hqdefault.jpg。
  • 抖音 webp 不支持:用 jpg。
  • 小红书图片加密:URL 是动态签名,每小时过期。平台签名后立即用。
  • 大图片 OOM:1MB+ 图片 Pillow 加载时内存峰值 100MB。限制最大尺寸 4096×4096。
  • 缓存击穿:同一 URL 高并发,缓存 miss 时 100 个请求同时回源。加 singleflight。
  • CORS:直接 img 显示跨域图片一般没问题(图片天然允许),但 canvas 操作会触发 CORS 错误。
  • HTTPS 证书:部分老图片 URL 是 HTTP,混合内容。强制 HTTPS 或代理转 HTTPS。

八个坑里,小红书签名过期和缓存击穿最容易造成线上故障:前者要抢时间拉取,后者靠 singleflight 合并回源。

十三、安全审计

代理服务是公网入口,访问日志和安全审计不能省,每个请求都记录调用者、URL、宽度:

import logging

logger = logging.getLogger('thumb_proxy')

@app.get('/api/thumb')
async def thumbnail(url: str, w: int = 480, user = None):
    # 记录访问日志
    logger.info('thumb_fetch', extra={
        'user_id': user.id if user else None,
        'url': url[:100],  # 截断避免日志爆炸
        'width': w
    })
    
    if not is_safe_url(url):
        logger.warning('thumb_blocked_ssrf', extra={'url': url})
        raise HTTPException(400, "Invalid URL")
    
    # ...

日志里 URL 只截前 100 字符,防止日志库被撑爆。被 SSRF 拦截的请求单独打 warning,配合告警,攻击尝试一眼就能看到。

十四、对比三种方案

项目早期我在三种方案里犹豫过,摊开对比是这样:

方案 优点 缺点 平台选
直链 简单 防盗链/限速 ❌
服务端代理 完全控制 占用带宽 ✅
Cloudflare Workers 全球加速 复杂 ❌(规模不够)

Cloudflare Workers 的边缘节点确实诱人,但当时规模撑不起它的复杂度,服务端代理够用且可控,就先用了。等流量上来再迁不迟,接口层是隔离的,切换成本可控。

十五、未来优化

接下来要做的三件事,都在这条管线的延伸上:

  1. AVIF 格式:比 WebP 还小 20%,但浏览器支持度待提升;
  2. AI 缩略图:自动选最关键帧做封面;
  3. 视频预览动图:截取前 3 秒转 GIF。

AVIF 的收益比较直接,等浏览器兼容数据再更新一版就切;AI 选帧和视频预览动图属于体验升级,排期靠后。

常见问题(FAQ)

Q1:代理会增加延迟吗?

首次会增加 50-100ms。缓存命中后几乎无感。

Q2:能代理任意 URL 吗?

不能。SSRF 防护会拒绝内网地址。

Q3:缓存多大合适?

按用户访问量定。平台缓存 10 万张图,约 5GB Redis 内存。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部