资源获取
漫画图片资源获取接口,包含客户端签名验证、CDN 鉴权 URL 生成、阅读配额控制和反爬虫策略
概述
sd-image-url 和 vip-image-url 是漫画阅读器的核心图片获取接口。客户端提交漫画 ID、章节 ID 和页码列表,函数验证权限后生成带时效签名的 CDN 图片 URL 返回。
两个函数共享同一套客户端签名验证模块(client-verify.ts),核心区别在于资源等级和配额策略:
| 属性 | sd-image-url | vip-image-url |
|---|---|---|
| 资源等级 | 标清资源 | 高清/VIP 资源 |
| CDN 域名 | cdn.hanabimanga.top | premium.hanabimanga.top |
| JWT 校验 | 是 | 是 |
| 匿名访问 | 允许(有每日配额) | 不允许(必须登录) |
| 非 VIP 限额 | 匿名用户每日 10 章 | 非 VIP 登录用户有每日免费额度 |
| VIP 用户 | 无限制 | 无限制 |
| 配额机制 | IP + 日期去重 | RPC record_free_premium_view |
| 阅读量统计 | 无 | fire-and-forget count_chapter_view_if_new |
客户端签名验证
这是区分「真实客户端」和「非客户端请求(爬虫、抓包工具、伪造请求)」的核心机制。签名验证失败的请求不会被拒绝,而是返回经过混淆处理的降级资源。
验证流程
客户端在发起请求时,需要同时提交 timestamp 和 signature 字段。服务端通过以下步骤验证其合法性:
时间戳检查
验证 timestamp 与服务器当前时间的偏差是否在允许范围内(默认 ±300 秒)。超出则判定为 timestamp_skew,验证失败。
签名格式校验
检查 signature 是否为合法的 64 位十六进制字符串(即 SHA-256 输出格式)。
构建规范化载荷
将请求参数按固定格式拼接为规范化字符串:
v1|comic_id={comicId}|chapter_id={chapterId}|pages={pages}|timestamp={timestamp}其中 pages 是排序后的零填充页码(如 001,002,003)。
遍历指纹白名单
环境变量 APP_ALLOWED_SIGN_SHA256 存储了所有合法客户端版本的签名指纹列表(逗号分隔)。对每个指纹:
- 将
APP_CLIENT_VERIFY_SECRET(基础密钥)与指纹拼接 - 对拼接结果做 SHA-256 得到动态密钥
- 用动态密钥对规范化载荷做 HMAC-SHA256
- 将结果与客户端提交的
signature做常量时间比较
任一指纹匹配即通过验证。
验证结果与路由策略
签名验证的结果决定了返回的资源类型:
返回原始资源路径:
/res/{pinyin_name}/{chapter_folder}/{page}.{format}响应中 metadata.platformRouted = "mobile",客户端直接渲染图片。
返回降级资源路径(经过图片混淆处理):
/web-res/{pinyin_name}/{chapter_folder}/{page}.{format}响应中额外包含 scrambleInfo 字段,提供反混淆参数。metadata.platformRouted = "web"。
/web-res/ 路径下的图片经过网格切片重排处理。合法的 Web 前端可以使用 scrambleInfo 中的 seed、gridX、gridY 参数在 Canvas 上还原图片;而直接下载的爬虫只能得到打乱的图片。
混淆机制(Decoy Response)
当客户端签名验证失败时,函数调用 buildDecoyReaderResponse 构建降级响应:
{
urls: [...], // 指向 /web-res/ 的混淆图片 URL
expiresIn: 3000,
scrambleInfo: {
seed: "a1b2c3d4e5f6g7h8", // 16 位混淆种子(基于 chapter_folder + secret 的 MD5)
gridX: 4, // 横向切片数
gridY: 4 // 纵向切片数
},
metadata: {
platformRouted: "web" // 标记为 Web 路由
}
}混淆种子的生成基于 chapter_folder 和 RENDER_SEED_SECRET 环境变量的组合哈希,保证同一章节的种子稳定不变(Web 前端可以缓存反混淆逻辑),同时不同章节的种子不同。
涉及的 Secrets
| 环境变量 | 用途 |
|---|---|
APP_CLIENT_VERIFY_SECRET | 客户端签名的基础密钥(hex 编码) |
APP_ALLOWED_SIGN_SHA256 | 合法客户端指纹白名单(逗号分隔的 hex 值) |
CLIENT_VERIFY_MAX_SKEW_SECONDS | 时间戳最大偏差(秒),默认 300 |
GOEDGE_SIGN_SECRET | CDN 节点(GoEdge)URL 签名密钥 |
RENDER_SEED_SECRET | 图片混淆种子的生成密钥 |
CDN URL 签名
无论验证是否通过,返回的图片 URL 都携带 GoEdge CDN 的时效签名,防止 URL 被直接分享或盗链:
https://cdn.hanabimanga.top/res/comic-name/chapter-01/001.webp?sign={md5}&t={timestamp}签名算法为 MD5:
signature = MD5(`${filePath}@${timestamp}@${GOEDGE_SIGN_SECRET}`)sd-image-url的时间戳按 3 秒窗口对齐(Math.floor(now / 3000) * 3000),同一窗口内相同路径生成相同签名,利于 CDN 缓存vip-image-url直接使用当前秒级时间戳
URL 有效期为 expiresIn: 3000 秒(约 50 分钟)。
sd-image-url 详细说明
基本信息
| 属性 | 值 |
|---|---|
| Slug | sd-image-url |
| 方法 | POST |
| JWT 校验 | 是(但支持匿名 Token) |
| CDN 域名 | cdn.hanabimanga.top |
请求体
Prop
Type
处理流程
请求 ──→ 参数校验 ──→ ┬─ 客户端签名验证(并行)
├─ 用户身份验证
├─ 查询 comics 表
└─ 查询 chapters 表
│
┌─────────┴──────────┐
│ │
已登录用户 匿名用户
│ │
│ 检查 IP 每日配额
│ (anon_sd_access_log)
│ │
│ ┌──────────┴──────────┐
│ 配额充足 配额耗尽
│ │ 429
│ 记录访问(fire-and-forget)
│ │
└────┬────┘
│
┌─────────┴──────────┐
签名验证通过 签名验证失败
│ │
返回 /res/ URL 返回 /web-res/ URL
(原始资源) (混淆资源 + scrambleInfo)匿名用户配额
匿名用户(未登录或 Token 无效)通过 IP 地址 + 日期进行配额控制:
- 每日每 IP 最多阅读 10 个章节(
ANON_DAILY_CHAPTER_LIMIT) - 使用
anon_sd_access_log表记录,联合唯一键为(client_ip, chapter_id, access_date) - 同一章节重复访问不重复计数
- 访问记录使用 fire-and-forget 写入,不阻塞响应
vip-image-url 详细说明
基本信息
| 属性 | 值 |
|---|---|
| Slug | vip-image-url |
| 方法 | POST |
| JWT 校验 | 是(必须登录) |
| CDN 域名 | premium.hanabimanga.top |
请求体
与 sd-image-url 完全相同。
处理流程
请求 ──→ 参数校验 ──→ ┬─ 客户端签名验证(并行)
└─ 用户身份验证
│
未登录 → 401
│
┬──────┴──────────────┐
├─ 查询 profiles(VIP 状态)
├─ 查询 comics 表 │(并行)
└─ 查询 chapters 表 │
│
┌─────────┴──────────┐
│ │
VIP 用户 非 VIP 用户
│ │
│ 调用 RPC record_free_premium_view
│ │
│ ┌──────────┴──────────┐
│ 配额充足 配额耗尽
│ 返回剩余额度 429
│ │
└────┬────┘
│
阅读量统计(fire-and-forget)
count_chapter_view_if_new
│
┌─────────┴──────────┐
签名验证通过 签名验证失败
│ │
返回 /res/ URL 返回 /web-res/ URLVIP 判断
通过 profiles.vip_expiration_date 判断:
const vipExpiration = profile?.vip_expiration_date
? new Date(profile.vip_expiration_date)
: null
const isVip = vipExpiration && vipExpiration > nowVIP 用户直接跳过配额检查。非 VIP 用户进入免费配额流程。
非 VIP 免费配额(record_free_premium_view)
非 VIP 登录用户有每日免费阅读额度,由 RPC 函数 record_free_premium_view 管理:
- 从
app_settings表读取FREE_DAILY_LIMIT(默认 10) - 检查今天是否已阅读过该章节 — 是则直接放行(不重复计数)
- 检查当日已用额度 — 超出则返回
FREE_QUOTA_EXCEEDED - 记录本次访问到
free_user_daily_premium_views表 - 返回
{ success, used_today, remaining, daily_limit }
阅读量统计(count_chapter_view_if_new)
VIP 和非 VIP 用户的阅读行为都会触发阅读量统计(fire-and-forget,不阻塞响应):
- 检查
chapter_view_logs表中该用户对该章节的最近记录 - 如果在冷却期内(默认 120 秒),跳过计数
- 否则 upsert 日志,并对
comics表的view_count、popularity_daily、popularity_weekly、popularity_monthly各 +1
响应格式
成功响应(签名验证通过)
{
"urls": [
{ "page": "001", "url": "https://cdn.hanabimanga.top/res/comic-name/ch01/001.webp?sign=abc&t=123" },
{ "page": "002", "url": "https://cdn.hanabimanga.top/res/comic-name/ch01/002.webp?sign=def&t=123" }
],
"expiresIn": 3000,
"metadata": {
"comicId": 42,
"chapterId": 100,
"totalPages": 24,
"pinyinName": "comic-name",
"chapterFolder": "ch01",
"imageFormat": "webp",
"platformRouted": "mobile"
},
"quota": {
"usedToday": 3,
"remaining": 7,
"dailyLimit": 10,
"isVip": false
}
}quota 字段仅在 vip-image-url 的非 VIP 用户请求中出现。VIP 用户和 sd-image-url 不返回此字段(或为 null)。
降级响应(签名验证失败)
{
"urls": [
{ "page": "001", "url": "https://cdn.hanabimanga.top/web-res/comic-name/ch01/001.webp?sign=abc&t=123" }
],
"expiresIn": 3000,
"scrambleInfo": {
"seed": "a1b2c3d4e5f6g7h8",
"gridX": 4,
"gridY": 4
},
"metadata": {
"comicId": 42,
"chapterId": 100,
"totalPages": 24,
"platformRouted": "web"
}
}错误响应
| HTTP 状态码 | code | 场景 |
|---|---|---|
| 400 | — | 页码超出范围 |
| 401 | — | 缺少 Authorization 头 / Token 无效(vip-image-url) |
| 404 | — | 漫画或章节不存在 |
| 429 | ANON_QUOTA_EXCEEDED | 匿名用户每日章节配额耗尽(sd-image-url) |
| 429 | FREE_QUOTA_EXCEEDED | 非 VIP 用户每日免费额度耗尽(vip-image-url) |
| 500 | — | 服务端异常 |
关联数据表
chapters
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
comic_id | bigint | 关联漫画 |
title | text | 章节标题 |
idx | integer | 排序索引 |
chapter_folder | text | 存储目录名(构建图片路径用) |
image_count | integer | 该章节的总页数 |
image_format | text | 图片格式,默认 "webp" |
category | chapter_category | 章节分类(normal 等) |
anon_sd_access_log(sd-image-url 专用)
| 字段 | 类型 | 说明 |
|---|---|---|
client_ip | text | 客户端 IP(联合主键之一) |
chapter_id | bigint | 章节 ID(联合主键之一) |
access_date | date | 访问日期(联合主键之一),默认 CURRENT_DATE |
profiles(vip-image-url 相关字段)
| 字段 | 类型 | 说明 |
|---|---|---|
id | uuid | 用户 ID(关联 auth.users) |
vip_expiration_date | timestamptz | VIP 到期时间,null 或过期即为非 VIP |
页码规范化
两个函数共享 normalizePages 逻辑:
- 输入可以是数组或单值,字符串或数字
- 解析为正整数后左补零到 3 位(如
1→"001",12→"012") - 去重并排序
- 通过
filterPagesWithinImageCount过滤掉超出image_count范围的页码
normalizePages([1, 2, 3]) // → ["001", "002", "003"]
normalizePages(["5", "05"]) // → ["005"](去重)
normalizePages([0, -1, "abc"]) // → [](全部非法,被过滤)性能优化
两个函数都采用了并行查询策略,将互不依赖的操作通过 Promise.all 并行执行:
sd-image-url 第一轮并行(4 路):
- 客户端签名验证
supabase.auth.getUser()- 查询
comics表 - 查询
chapters表
vip-image-url 分两轮:
- 第一轮(2 路):签名验证 + 用户身份验证
- 第二轮(3 路):查询 profiles + comics + chapters
此外,访问记录写入和阅读量统计均采用 fire-and-forget 模式(不 await),不阻塞响应返回。