Files
chenxin 1a4997e031 ✨ feat(api): 统一响应格式并完善参数体系与文档
- 正文参数支持语义值(plain/html/keep/remove)与别名(img_mode/review_mode),URL 参数优先于配置
- 修复纯文本被段评强制包回 HTML、HTML 模式段评注入失效的问题
- 修复 PKCS7 填充弱校验导致漫画/正文偶发解密乱码返回 200 的问题
- 搜索结果展平:去 search_tabs 层与 selected_tab_idx/server_ab_params,提升 has_more/next_offset
- 全接口解包字符串形式的 JSON 字段(category_schema 等),缓存出口幂等处理
- 序列化关闭 HTML 转义,video/audio 播放链接可直接复制播放
- video 响应透传 CENC 加密状态(key_seed/kid/encryption_method)
- 新增 gzip 中间件,JSON/文本响应压缩约 81%,二进制透传
- 漫画默认输出模式改为 proxy(url 模式链接 404 且内容加密不可用)
- openapi.yaml:补全 23 个路径参数定义、tab_type 完整枚举、CDN 签名说明,示例更新
2026-10-09 21:01:18 +08:00

310 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fqapi
import (
"bytes"
"compress/gzip"
"encoding/json"
"fanqie/internal/common"
"fmt"
"regexp"
"strconv"
"strings"
)
var (
reviewLogger = common.NewLogger("review", "INFO", "")
)
// compressJSON 压缩 JSON 数据为 Gzip 格式
func compressJSON(data interface{}) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("JSON序列化失败: %w", err)
}
var compressedData bytes.Buffer
gzw := gzip.NewWriter(&compressedData)
if _, err := gzw.Write(jsonData); err != nil {
gzw.Close()
return nil, fmt.Errorf("Gzip压缩失败: %w", err)
}
if err := gzw.Close(); err != nil {
return nil, fmt.Errorf("Gzip关闭失败: %w", err)
}
return compressedData.Bytes(), nil
}
// postReviewAPI 发送段评相关的 POST 请求
func postReviewAPI(url string, requestBody interface{}) (map[string]interface{}, error) {
// 压缩 JSON
compressedData, err := compressJSON(requestBody)
if err != nil {
return nil, err
}
// 使用 GorgonRequest 添加签名
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "POST",
"body": compressedData,
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
})
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
// 解析响应
var result map[string]interface{}
if err := json.Unmarshal(resp.Content, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 提取 data 字段(并解包 JSON 字符串字段,如 post_data.content)
if data, ok := result["data"].(map[string]interface{}); ok {
if unwrapped, ok := UnwrapJSONStrings(data).(map[string]interface{}); ok {
return unwrapped, nil
}
return data, nil
}
return result, nil
}
// CommentData 评论数据结构
type CommentData struct {
Data map[string]struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
} `json:"data"`
ItemVersion string `json:"item_version"`
}
// GetChapterReview 获取章节的段落评论统计(想法评论)
func GetChapterReview(itemID string) (map[string]interface{}, error) {
// 使用 BuildURL 添加设备参数
url := BuildURL(fmt.Sprintf("https://reading.snssdk.com/novel/commentapi/idea/list/%s/v1", itemID))
requestBody := map[string]interface{}{
"comment_source": 3,
"item_id": itemID,
"item_version": "cdd11695939bcc41d1ff8c3f10c67802_1_e5c2474f6821deb",
}
return postReviewAPI(url, requestBody)
}
// GetChapterMixData 获取章节混合数据(评论)
func GetChapterMixData(bookID, itemID string) (map[string]interface{}, error) {
// 使用 BuildURL 添加设备参数
url := BuildURL("https://api5-normal-sinfonlinec.fqnovel.com/reading/ugc/item/mix_data/get/v")
requestBody := map[string]interface{}{
"count": 5000,
"item_id": itemID,
"source_type": 51,
"book_id": bookID,
}
return postReviewAPI(url, requestBody)
}
// GetParaReview 获取段落评论或评论回复
// sort: 1=热门, 3=最新
// offset: 分页偏移量
// count: 每页数量
func GetParaReview(itemID, bookID, commentID string, para, sort, offset, count int) (map[string]interface{}, error) {
// 默认排序值
if sort != 1 && sort != 3 {
sort = 1 // 默认热门
}
// 默认数量
if count <= 0 {
count = 20
}
var url string
if commentID != "" {
// 获取评论的回复列表 - 使用 BuildURL 添加设备参数
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/reading/ugc/reply/topic/v/",
fmt.Sprintf("offset=%d", offset),
fmt.Sprintf("group_id=%s", itemID),
"service_id=1",
fmt.Sprintf("count=%d", count),
"source_type=63",
fmt.Sprintf("book_id=%s", bookID),
fmt.Sprintf("comment_id=%s", commentID),
)
} else {
// 获取段落的评论列表 - 使用 BuildURL 添加设备参数
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/reading/ugc/idea/comment_list/v/",
"item_version=c63f0e6195259adec926ae3209291854_1_1099f2b8324de5f",
fmt.Sprintf("offset=%d", offset),
"author_user_id=104957777655",
fmt.Sprintf("item_id=%s", itemID),
fmt.Sprintf("count=%d", count),
fmt.Sprintf("para_index=%d", para),
fmt.Sprintf("book_id=%s", bookID),
fmt.Sprintf("sort=%d", sort),
)
}
// 使用 GorgonRequest 添加签名
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
// 解析响应
var result map[string]interface{}
if err := json.Unmarshal(resp.Content, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 提取 data 字段(并解包 JSON 字符串字段,如 post_data.content)
if data, ok := result["data"].(map[string]interface{}); ok {
if unwrapped, ok := UnwrapJSONStrings(data).(map[string]interface{}); ok {
return unwrapped, nil
}
return data, nil
}
return result, nil
}
// GenerateParagraphsWithComments 生成带评论标记的内容
func GenerateParagraphsWithComments(itemID, bookID, content, baseURL string) (string, error) {
// 获取段评数据
data, err := GetChapterReview(itemID)
if err != nil {
reviewLogger.Warning("获取段评数据失败: " + err.Error())
// 如果获取失败,返回原始内容
return content, nil
}
// 解析评论数据
commentData := make(map[string]struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
})
if dataMap, ok := data["data"].(map[string]interface{}); ok {
for key, val := range dataMap {
if valMap, ok := val.(map[string]interface{}); ok {
count := 0
if c, ok := valMap["count"].(float64); ok {
count = int(c)
}
hot := ""
if h, ok := valMap["hot"].(string); ok {
hot = h
}
isAuthor := false
if ia, ok := valMap["is_author_comment"].(bool); ok {
isAuthor = ia
}
userCount := 0
if uc, ok := valMap["user_count"].(float64); ok {
userCount = int(uc)
}
commentData[key] = struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
}{
Count: count,
Hot: hot,
IsAuthorComment: isAuthor,
UserCount: userCount,
}
}
}
}
// 检测内容格式:是否包含HTML标签(检测实际内容,而非配置)
isHTMLContent := strings.Contains(content, "<p>") ||
strings.Contains(content, "<div") ||
strings.Contains(content, "<article")
// 纯文本模式下 <comment> 标签无法承载,保持纯文本原样返回(不注入段评);
// 需要段评标记时配置 remove_html_tags: false 或 text_mode=html
if !isHTMLContent {
return content, nil
}
// HTML 内容:在每个 </p> 前插入段评标记。
// 兼容 <p idx="n">(HTML 模式结构)与无属性的 <p>;段号取 idx,无 idx 时按出现顺序。
// header/article/div/img 等非段落标签不受影响。
pRe := regexp.MustCompile(`(?s)<p\b[^>]*>.*?</p>`)
idxRe := regexp.MustCompile(`idx="(\d+)"`)
order := 0
return pRe.ReplaceAllStringFunc(content, func(m string) string {
defer func() { order++ }()
para := order
if idx := idxRe.FindStringSubmatch(m); idx != nil {
if n, err := strconv.Atoi(idx[1]); err == nil {
para = n
}
}
if commentInfo, exists := commentData[fmt.Sprintf("%d", para)]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, para, commentInfo.Count)
return m[:len(m)-len("</p>")] + commentTag + "</p>"
}
return m
}), nil
}
// extractDeviceID 从 device header 中提取 device_id
func extractDeviceID(header string) string {
if header == "" {
return "0"
}
// 从 header 中提取 device_id
parts := strings.Split(header, "&")
for _, part := range parts {
if strings.HasPrefix(part, "device_id=") {
return strings.TrimPrefix(part, "device_id=")
}
}
return "0"
}
// ParseJSON 解析 JSON 字符串
func ParseJSON(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// ToJSON 将对象序列化为 JSON 字符串
func ToJSON(v interface{}) (string, error) {
buffer := &strings.Builder{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false) // 禁用HTML转义,避免 " 被转义为 \"
err := encoder.Encode(v)
if err != nil {
return "", err
}
// Encode 会在末尾添加换行符,需要去掉
result := buffer.String()
return strings.TrimSuffix(result, "\n"), nil
}