Logo花火漫画开发文档
函数详情

资源获取

漫画图片资源获取接口,包含客户端签名验证、CDN 鉴权 URL 生成、阅读配额控制和反爬虫策略

概述

sd-image-urlvip-image-url 是漫画阅读器的核心图片获取接口。客户端提交漫画 ID、章节 ID 和页码列表,函数验证权限后生成带时效签名的 CDN 图片 URL 返回。

两个函数共享同一套客户端签名验证模块(client-verify.ts),核心区别在于资源等级和配额策略:

属性sd-image-urlvip-image-url
资源等级标清资源高清/VIP 资源
CDN 域名cdn.hanabimanga.toppremium.hanabimanga.top
JWT 校验
匿名访问允许(有每日配额)不允许(必须登录)
非 VIP 限额匿名用户每日 10 章非 VIP 登录用户有每日免费额度
VIP 用户无限制无限制
配额机制IP + 日期去重RPC record_free_premium_view
阅读量统计fire-and-forget count_chapter_view_if_new

客户端签名验证

这是区分「真实客户端」和「非客户端请求(爬虫、抓包工具、伪造请求)」的核心机制。签名验证失败的请求不会被拒绝,而是返回经过混淆处理的降级资源。

验证流程

客户端在发起请求时,需要同时提交 timestampsignature 字段。服务端通过以下步骤验证其合法性:

时间戳检查

验证 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 存储了所有合法客户端版本的签名指纹列表(逗号分隔)。对每个指纹:

  1. APP_CLIENT_VERIFY_SECRET(基础密钥)与指纹拼接
  2. 对拼接结果做 SHA-256 得到动态密钥
  3. 用动态密钥对规范化载荷做 HMAC-SHA256
  4. 将结果与客户端提交的 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 中的 seedgridXgridY 参数在 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_folderRENDER_SEED_SECRET 环境变量的组合哈希,保证同一章节的种子稳定不变(Web 前端可以缓存反混淆逻辑),同时不同章节的种子不同。

涉及的 Secrets

环境变量用途
APP_CLIENT_VERIFY_SECRET客户端签名的基础密钥(hex 编码)
APP_ALLOWED_SIGN_SHA256合法客户端指纹白名单(逗号分隔的 hex 值)
CLIENT_VERIFY_MAX_SKEW_SECONDS时间戳最大偏差(秒),默认 300
GOEDGE_SIGN_SECRETCDN 节点(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 详细说明

基本信息

属性
Slugsd-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 详细说明

基本信息

属性
Slugvip-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/ URL

VIP 判断

通过 profiles.vip_expiration_date 判断:

const vipExpiration = profile?.vip_expiration_date
  ? new Date(profile.vip_expiration_date)
  : null
const isVip = vipExpiration && vipExpiration > now

VIP 用户直接跳过配额检查。非 VIP 用户进入免费配额流程。

非 VIP 免费配额(record_free_premium_view)

非 VIP 登录用户有每日免费阅读额度,由 RPC 函数 record_free_premium_view 管理:

  1. app_settings 表读取 FREE_DAILY_LIMIT(默认 10)
  2. 检查今天是否已阅读过该章节 — 是则直接放行(不重复计数)
  3. 检查当日已用额度 — 超出则返回 FREE_QUOTA_EXCEEDED
  4. 记录本次访问到 free_user_daily_premium_views
  5. 返回 { success, used_today, remaining, daily_limit }

阅读量统计(count_chapter_view_if_new)

VIP 和非 VIP 用户的阅读行为都会触发阅读量统计(fire-and-forget,不阻塞响应):

  1. 检查 chapter_view_logs 表中该用户对该章节的最近记录
  2. 如果在冷却期内(默认 120 秒),跳过计数
  3. 否则 upsert 日志,并对 comics 表的 view_countpopularity_dailypopularity_weeklypopularity_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漫画或章节不存在
429ANON_QUOTA_EXCEEDED匿名用户每日章节配额耗尽(sd-image-url)
429FREE_QUOTA_EXCEEDED非 VIP 用户每日免费额度耗尽(vip-image-url)
500服务端异常

关联数据表

chapters

字段类型说明
idbigint主键
comic_idbigint关联漫画
titletext章节标题
idxinteger排序索引
chapter_foldertext存储目录名(构建图片路径用)
image_countinteger该章节的总页数
image_formattext图片格式,默认 "webp"
categorychapter_category章节分类(normal 等)

anon_sd_access_log(sd-image-url 专用)

字段类型说明
client_iptext客户端 IP(联合主键之一)
chapter_idbigint章节 ID(联合主键之一)
access_datedate访问日期(联合主键之一),默认 CURRENT_DATE

profiles(vip-image-url 相关字段)

字段类型说明
iduuid用户 ID(关联 auth.users)
vip_expiration_datetimestamptzVIP 到期时间,null 或过期即为非 VIP

页码规范化

两个函数共享 normalizePages 逻辑:

  1. 输入可以是数组或单值,字符串或数字
  2. 解析为正整数后左补零到 3 位(如 1"001"12"012"
  3. 去重并排序
  4. 通过 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),不阻塞响应返回。

On this page