🔧 chore: 初始化番茄小说 API 项目结构
新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块
This commit is contained in:
commit
6981148727
107 files changed
+12463
No files matched your search
@@ -0,0 +1,371 @@
|
||||
package fqapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fanqie/internal/common"
|
||||
"fmt"
|
||||
"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 字段
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
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 字段
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
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")
|
||||
|
||||
// 关键:如果检测到有段评数据,强制使用HTML模式(包裹<p>标签)
|
||||
// 因为<comment>标签需要XML兼容的格式
|
||||
hasAnyComments := len(commentData) > 0
|
||||
if hasAnyComments && !isHTMLContent {
|
||||
isHTMLContent = true
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
var result strings.Builder
|
||||
paragraphIndex := 0
|
||||
|
||||
for i, line := range lines {
|
||||
isLastLine := i == len(lines)-1
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
|
||||
// 完全空行:直接输出换行符
|
||||
if trimmedLine == "" {
|
||||
if !isLastLine {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过图片标签和div标签
|
||||
if strings.HasPrefix(trimmedLine, "<img") ||
|
||||
strings.HasPrefix(trimmedLine, "<div") ||
|
||||
strings.HasPrefix(trimmedLine, "</div>") ||
|
||||
strings.HasPrefix(trimmedLine, "<article") ||
|
||||
strings.HasPrefix(trimmedLine, "</article") {
|
||||
result.WriteString(line)
|
||||
if !isLastLine {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果原内容已经有<p>标签,在</p>之前插入段评标记
|
||||
if strings.HasPrefix(trimmedLine, "<p>") && strings.HasSuffix(trimmedLine, "</p>") {
|
||||
// 提取<p>和</p>之间的内容
|
||||
innerContent := trimmedLine[3 : len(trimmedLine)-4]
|
||||
result.WriteString("<p>")
|
||||
result.WriteString(innerContent)
|
||||
|
||||
// 添加评论标记
|
||||
key := fmt.Sprintf("%d", paragraphIndex)
|
||||
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
|
||||
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s¶=%d" count="%d" />`,
|
||||
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
|
||||
result.WriteString(commentTag)
|
||||
}
|
||||
|
||||
result.WriteString("</p>")
|
||||
if !isLastLine {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
paragraphIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果是HTML模式,包裹<p>标签
|
||||
if isHTMLContent {
|
||||
result.WriteString("<p>")
|
||||
result.WriteString(line)
|
||||
|
||||
// 添加评论标记
|
||||
key := fmt.Sprintf("%d", paragraphIndex)
|
||||
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
|
||||
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s¶=%d" count="%d" />`,
|
||||
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
|
||||
result.WriteString(commentTag)
|
||||
}
|
||||
|
||||
result.WriteString("</p>")
|
||||
if !isLastLine {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
// 纯文本模式:只在行尾添加comment标记
|
||||
result.WriteString(line)
|
||||
|
||||
// 添加评论标记(纯文本格式)
|
||||
key := fmt.Sprintf("%d", paragraphIndex)
|
||||
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
|
||||
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s¶=%d" count="%d" />`,
|
||||
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
|
||||
result.WriteString(commentTag)
|
||||
}
|
||||
|
||||
if !isLastLine {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
paragraphIndex++
|
||||
}
|
||||
|
||||
return result.String(), 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
|
||||
}
|
||||
Reference in new issue
Block a user