- 正文参数支持语义值(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 签名说明,示例更新
310 lines
8.7 KiB
Go
310 lines
8.7 KiB
Go
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¶=%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
|
||
}
|