Files
fanqie-go/internal/fqapi/content_utils.go
T
chenxin 84bd3238ab ✨ feat(api): 新增榜单书架历史与搜索续传并重构正文缓存链路
- 新增 /rank 免签名榜单接口(多域名容错)、/bookshelf 与 /read-history(sessionid 登录态,书架支持分组过滤)
- 搜索重写:翻页透传上游 passback/search_id/next_offset/corrected_query 并按 book_id 去重,15~20 位纯数字关键词直查详情(ID 直搜)
- 删除 /login/check 登录校验端点(login.go、路由与文档同步移除,sessionid 说明改为直接供书架/历史使用)
- 正文链路重构:singleflight 合并同章节并发回源防击穿,缓存与解析解耦统一在出口处理,decodeContentBody 按 crypt_status 分支并兜底 500003 明文
- 缓存层:SQL 持久层 TTL 与业务 TTL 解耦(不可变内容过期后由 SQL 回填),动态短缓存只走 Redis,withCache 合并并发 miss
- 密钥池:KeyTs 随正文请求上传降低密钥不匹配重试,密钥超 24 小时异步刷新并防刷新风暴,last_used_at 批量落库
- 签名补 X-Helios(与 X-Ladon 同值,对齐书源实现);设备注册版本号统一走 ClientVersionCode/Name
- 分类 landing 透传新版标签 selected_items;multi-detail 批量详情供书架/历史复用
- README 与 openapi.yaml 补充新接口文档
2026-10-12 14:49:14 +08:00

410 lines
11 KiB
Go

package fqapi
import (
"bytes"
"compress/gzip"
"compress/zlib"
"encoding/json"
"fanqie/internal/config"
"io"
"net/url"
"regexp"
"strings"
)
// 客户端版本统一(与 2026-10 可用的书源实现一致;旧值 58932/5.8.9.32 混用易触发风控)
const (
ClientVersionCode = "72732"
ClientVersionName = "7.2.7.32"
)
// PermanentNovelImgHost 番茄小说图片的永久 CDN 域名(原始链接带临时参数会过期失效)
const PermanentNovelImgHost = "https://p6-novel.byteimg.com"
// novelPicMd5Re 匹配图片路径中的 32 位十六进制资源 ID
var novelPicMd5Re = regexp.MustCompile(`^/novel-pic/([a-f0-9]{32})`)
// NormalizeNovelImageURL 将临时图片链接永久化:
// - /novel-pic/{md5} → p6-novel.byteimg.com/origin/novel-pic/{md5}(正文插图原图)
// - 含 ~tplv 的模板图(封面缩放)→ p6-novel.byteimg.com 保留原路径
func NormalizeNovelImageURL(raw string) string {
u := strings.TrimSpace(strings.ReplaceAll(raw, "&", "&"))
if u == "" {
return ""
}
if strings.HasPrefix(u, "//") {
u = "https:" + u
}
idx := strings.Index(u, "/novel-pic/")
if idx < 0 {
return u
}
path := u[idx:]
if m := novelPicMd5Re.FindStringSubmatch(path); m != nil && !strings.Contains(path, "~tplv") {
return PermanentNovelImgHost + "/origin/novel-pic/" + m[1]
}
return PermanentNovelImgHost + path
}
// gunzipMaybe 自适应解压上游正文(gzip 与 zlib 帧都可能出现),非压缩数据原样返回
func gunzipMaybe(data []byte) []byte {
if r, err := gzip.NewReader(bytes.NewReader(data)); err == nil {
if out, err2 := io.ReadAll(r); err2 == nil {
r.Close()
return out
}
}
if r, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
if out, err2 := io.ReadAll(r); err2 == nil {
r.Close()
return out
}
}
return data
}
// buildSortedQuery 将若干 "k=v" 段合并为 form 编码的查询串(按键排序)。
// 设备机型等值含空格时必须编码,否则实际请求的 URL 与签名输入不一致,
// registerkey 等接口会注册失败(书源 2026-10-10 修复的同类问题)
func buildSortedQuery(parts ...string) string {
vals := url.Values{}
for _, part := range parts {
for _, seg := range strings.Split(part, "&") {
if seg == "" {
continue
}
k, v, _ := strings.Cut(seg, "=")
vals.Add(k, v)
}
}
return vals.Encode()
}
// getContentConfig 获取内容配置(带默认值)
func getContentConfig() config.ContentConfig {
if config.GetAppConfig() != nil {
return config.GetAppConfig().Fanqie.Content
}
// 返回默认配置
return config.ContentConfig{
RemoveImages: false,
RemoveImageDesc: false,
RemoveBlankLines: true,
RemoveHTMLTags: false,
RemoveChapterTitle: false,
AddParagraphIndent: false,
}
}
// decodeHTMLEntities 解码HTML实体
func decodeHTMLEntities(content string) string {
// 解码常见HTML实体
content = strings.ReplaceAll(content, "&amp;", "&")
content = strings.ReplaceAll(content, "&lt;", "<")
content = strings.ReplaceAll(content, "&gt;", ">")
content = strings.ReplaceAll(content, "&quot;", "\"")
content = strings.ReplaceAll(content, "&#34;", "\"")
content = strings.ReplaceAll(content, "&#39;", "'")
content = strings.ReplaceAll(content, "&apos;", "'")
content = strings.ReplaceAll(content, "&#x27;", "'")
content = strings.ReplaceAll(content, "&#x2F;", "/")
// 解码转义的反斜杠
content = strings.ReplaceAll(content, "\\\"", "\"")
return content
}
// normalizeImgAttributes 将img标签中的img-width和img-height属性替换为标准的width和height属性
func normalizeImgAttributes(content string) string {
// 替换 img-width 为 width
content = strings.ReplaceAll(content, "img-width=", "width=")
// 替换 img-height 为 height
content = strings.ReplaceAll(content, "img-height=", "height=")
return content
}
// removeBlankLines 移除多余空行,但保留段落间的双换行
func removeBlankLines(content string) string {
// 先将多个连续换行(3个及以上)规范化为双换行
for strings.Contains(content, "\n\n\n") {
content = strings.ReplaceAll(content, "\n\n\n", "\n\n")
}
// 移除首尾的空白
content = strings.TrimSpace(content)
return content
}
// ============ 参数解析工具函数 ============
// ParseBoolParam 解析布尔参数,支持 1/0、true/false 及语义别名
// text_mode: plain(纯文本)=true / html=false
// image_mode、review: keep(保留/启用)=true / remove(移除/禁用)=false
func ParseBoolParam(param interface{}) bool {
switch v := param.(type) {
case string:
switch strings.ToLower(v) {
case "1", "true", "plain", "keep":
return true
}
case bool:
return v
}
return false
}
// parseBoolParamDefault 解析布尔参数(带默认值)
func parseBoolParamDefault(param interface{}, defaultVal bool) bool {
if param == nil || param == "" {
return defaultVal
}
return ParseBoolParam(param)
}
// ============ JSON 处理工具函数 ============
// escapeJSON 转义JSON中的特殊字符
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "<", "\\u003c")
s = strings.ReplaceAll(s, ">", "\\u003e")
return s
}
// buildJSONResponse 构建JSON响应并转义
func buildJSONResponse(data interface{}) string {
resultBytes, _ := json.Marshal(data)
return escapeJSON(string(resultBytes))
}
// marshalNoEscape 序列化 JSON 时不做 HTML 转义。
// json.Marshal 默认把 URL 中的 & < > 转义为 & 等,直接从响应文本复制
// URL 到浏览器会因字面转义序列打不开;含播放/下载链接的响应用本函数
func marshalNoEscape(v interface{}) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "{}"
}
return strings.TrimSuffix(buf.String(), "\n")
}
// UnwrapJSONStrings 递归解包字符串形式的 JSON 值。上游常把对象/数组
// 双重编码为字符串(如 category_schema: "[{\"category_id\":718,...}]"),
// 本函数将其还原为真正的对象/数组。仅处理以 { 或 [ 开头且能完整解析
// 为 object/array 的字符串,普通文本与数字字符串不受影响;嵌套解析使用
// UseNumber,大整数 ID 不丢精度。
func UnwrapJSONStrings(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
t[k] = UnwrapJSONStrings(val)
}
return t
case []interface{}:
for i, val := range t {
t[i] = UnwrapJSONStrings(val)
}
return t
case []map[string]interface{}:
for i, val := range t {
t[i] = UnwrapJSONStrings(val).(map[string]interface{})
}
return t
case map[string]map[string]interface{}:
m := make(map[string]interface{}, len(t))
for k, val := range t {
m[k] = UnwrapJSONStrings(val)
}
return m
case string:
s := strings.TrimSpace(t)
if len(s) > 1 && (s[0] == '{' || s[0] == '[') {
dec := json.NewDecoder(strings.NewReader(s))
dec.UseNumber()
var parsed interface{}
if err := dec.Decode(&parsed); err == nil {
switch parsed.(type) {
case map[string]interface{}, []interface{}:
return UnwrapJSONStrings(parsed)
}
}
}
return t
default:
return v
}
}
// reencodeUnwrapped 解析上游响应文本,解包 JSON 字符串后重新序列化。
// 解析失败时原样返回;UseNumber 保证大整数 ID 不丢精度
func reencodeUnwrapped(text string) string {
dec := json.NewDecoder(strings.NewReader(text))
dec.UseNumber()
var v interface{}
if err := dec.Decode(&v); err != nil {
return text
}
return marshalNoEscape(UnwrapJSONStrings(v))
}
// ============ 设备和密钥管理 ============
// getDeviceHeader 获取设备header(从设备池)
func getDeviceHeader() string {
// 从设备池获取任意有效设备header
return GetAnyValidDeviceHeader()
}
// removeVersionFromHeader 移除header中的version_name参数
func removeVersionFromHeader(header string) string {
return strings.Replace(header, "&version_name="+ClientVersionName, "", -1)
}
// ============ API参数构建工具 ============
// BuildURL 构建API请求URL
// 用法: BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id=123", "tab_type=3")
func BuildURL(fullURL string, customParams ...string) string {
// 基础参数
allParams := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=" + ClientVersionCode,
}
// 添加设备header
header := getDeviceHeader()
if header != "" {
allParams = append(allParams, header)
}
// 添加自定义参数
allParams = append(allParams, customParams...)
// form 编码 + 排序(机型含空格等特殊字符时保证 URL 与签名一致)
return fullURL + "?" + buildSortedQuery(allParams...)
}
// ============ 缓存处理工具函数 ============
// buildCacheData 构建缓存数据结构
func buildCacheData(data interface{}) map[string]interface{} {
return map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": data,
}
}
// saveCacheJSON 保存JSON到缓存
func saveCacheJSON(key string, data interface{}, ttl int) {
cacheJSON, _ := json.Marshal(data)
cacheStr := escapeJSON(string(cacheJSON))
UpdateCache(key, cacheStr, ttl)
}
// ============ 错误响应工具函数 ============
// BuildErrorResponse 构建统一的错误响应
func BuildErrorResponse(msg string) string {
return buildJSONResponse(map[string]interface{}{
"code": -1,
"msg": msg,
})
}
// BuildErrorResponseWithCode 构建带自定义错误码的响应
func BuildErrorResponseWithCode(code int, msg string) string {
return buildJSONResponse(map[string]interface{}{
"code": code,
"msg": msg,
})
}
// BuildSuccessResponse 构建成功响应
func BuildSuccessResponse(data interface{}) string {
return buildJSONResponse(map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": data,
})
}
// ============ 参数转换工具函数 ============
// ParseIntParam 解析整数参数(支持多种类型)
func ParseIntParam(param interface{}, defaultVal int) int {
switch v := param.(type) {
case string:
if v != "" {
// 使用strconv.Atoi
if result, err := parseStringToInt(v); err == nil {
return result
}
}
case int:
return v
case float64:
return int(v)
case int64:
return int(v)
}
return defaultVal
}
// parseStringToInt 辅助函数:将字符串转换为整数
func parseStringToInt(s string) (int, error) {
// 简单实现,避免导入strconv
result := 0
negative := false
if len(s) == 0 {
return 0, nil
}
start := 0
if s[0] == '-' {
negative = true
start = 1
} else if s[0] == '+' {
start = 1
}
for i := start; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
break
}
result = result*10 + int(s[i]-'0')
}
if negative {
result = -result
}
return result, nil
}
// ParseStringParam 解析字符串参数(支持数组类型自动Join)
func ParseStringParam(param interface{}, defaultVal string) string {
if param == nil || param == "" {
return defaultVal
}
switch v := param.(type) {
case string:
if v != "" {
return v
}
case []string:
// 数组类型,用逗号连接
return strings.Join(v, ",")
}
return defaultVal
}