- 正文参数支持语义值(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 签名说明,示例更新
281 lines
7.8 KiB
Go
281 lines
7.8 KiB
Go
package fqapi
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fanqie/internal/common"
|
||
"fmt"
|
||
"math"
|
||
)
|
||
|
||
var videoLogger = common.NewLogger("video", "INFO", "")
|
||
|
||
// HandleVideo 处理获取视频(支持批量和单个接口)
|
||
func HandleVideo(itemIDs interface{}) (string, error) {
|
||
if itemIDs == nil || itemIDs == "" {
|
||
return BuildSuccessResponse([]interface{}{}), nil
|
||
}
|
||
|
||
// 处理itemIDs - 使用ParseStringParam
|
||
itemIDStr := ParseStringParam(itemIDs, "")
|
||
|
||
// 判断是单个ID还是多个ID(用逗号分隔)
|
||
isSingle := true
|
||
if str, ok := itemIDs.(string); ok {
|
||
for _, ch := range str {
|
||
if ch == ',' {
|
||
isSingle = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 根据ID数量选择不同的接口
|
||
var url string
|
||
if isSingle {
|
||
// 单个视频接口 - 使用带签名的URL
|
||
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/novel/player/video_model/v1")
|
||
} else {
|
||
// 批量视频接口
|
||
url = BuildURL("https://reading.snssdk.com/novel/player/multi_video_model/v1/")
|
||
}
|
||
|
||
// 构建请求体,添加更多设备参数,请求所有清晰度
|
||
requestBody := fmt.Sprintf(`{"biz_param":{"device_level":3,"need_all_video_definition":true,"need_mp4_align":false,"use_os_player":false,"video_platform":1024},"video_id":"%s"}`,
|
||
itemIDStr)
|
||
|
||
// 使用 GorgonRequest 添加签名和设备信息
|
||
resp, err := GorgonRequest(url, map[string]interface{}{
|
||
"method": "POST",
|
||
"body": requestBody,
|
||
"headers": map[string]string{
|
||
"User-Agent": "com.dragon.read",
|
||
"Content-Type": "application/json",
|
||
},
|
||
})
|
||
|
||
if err != nil {
|
||
videoLogger.Error(fmt.Sprintf("获取视频失败: %v", err))
|
||
return BuildErrorResponse("FAILED"), err
|
||
}
|
||
|
||
respJSON, err := resp.JSON()
|
||
if err != nil {
|
||
videoLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
|
||
return BuildErrorResponse("FAILED"), err
|
||
}
|
||
|
||
// 检查响应码(可能返回 Code 或 code)
|
||
var respCode float64
|
||
var okCode bool
|
||
|
||
if code, ok := respJSON["code"].(float64); ok {
|
||
respCode, okCode = code, code == 0
|
||
} else if code, ok := respJSON["Code"].(float64); ok {
|
||
respCode, okCode = code, code == 0
|
||
}
|
||
|
||
if okCode {
|
||
// 尝试获取 data(可能在不同位置)
|
||
var data map[string]interface{}
|
||
if d, ok := respJSON["data"].(map[string]interface{}); ok {
|
||
data = d
|
||
} else if d, ok := respJSON["Data"].(map[string]interface{}); ok {
|
||
data = d
|
||
}
|
||
|
||
if data != nil {
|
||
if videoModelStr, ok := data["video_model"].(string); ok && isSingle {
|
||
decodedModel := decodeVideoModelAsJSON(videoModelStr)
|
||
|
||
// 单个接口直接返回 video_model
|
||
response := map[string]interface{}{
|
||
"code": 200,
|
||
"message": "SUCCESS",
|
||
"data": decodedModel,
|
||
}
|
||
|
||
return marshalNoEscape(response), nil
|
||
} else {
|
||
// 批量接口:data 是一个map,key是ID,value包含video_model
|
||
result := make(map[string]interface{})
|
||
|
||
for itemID, itemData := range data {
|
||
if itemMap, ok := itemData.(map[string]interface{}); ok {
|
||
if videoModelStr, ok := itemMap["video_model"].(string); ok {
|
||
decodedModel := decodeVideoModelAsJSON(videoModelStr)
|
||
result[itemID] = map[string]interface{}{
|
||
"video_model": decodedModel,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 构建返回结果
|
||
response := map[string]interface{}{
|
||
"code": 200,
|
||
"message": "SUCCESS",
|
||
"data": result,
|
||
}
|
||
|
||
return marshalNoEscape(response), nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// 返回错误信息
|
||
errMsg := "获取视频失败"
|
||
if msg, ok := respJSON["message"].(string); ok {
|
||
errMsg = msg
|
||
} else if msg, ok := respJSON["Message"].(string); ok {
|
||
errMsg = msg
|
||
}
|
||
videoLogger.Error(fmt.Sprintf("获取视频失败: code=%v, msg=%s", respCode, errMsg))
|
||
return BuildErrorResponse(errMsg), nil
|
||
}
|
||
|
||
// decodeVideoModelAsJSON 解析并解码 video_model,返回关键字段的 JSON 对象
|
||
func decodeVideoModelAsJSON(videoModelStr string) interface{} {
|
||
// 解析 video_model JSON 字符串
|
||
var videoModel map[string]interface{}
|
||
if err := json.Unmarshal([]byte(videoModelStr), &videoModel); err != nil {
|
||
// 解析失败,返回空对象
|
||
return map[string]interface{}{}
|
||
}
|
||
// 解包嵌套的 JSON 字符串字段(category_schema、recommend_info 等)
|
||
if unwrapped, ok := UnwrapJSONStrings(videoModel).(map[string]interface{}); ok {
|
||
videoModel = unwrapped
|
||
}
|
||
|
||
// 构建精简的返回结果,只包含关键字段
|
||
result := make(map[string]interface{})
|
||
|
||
// 提取关键字段
|
||
if videoID, ok := videoModel["video_id"].(string); ok {
|
||
result["video_id"] = videoID
|
||
}
|
||
if duration, ok := videoModel["video_duration"].(float64); ok {
|
||
result["video_duration"] = formatDuration(duration)
|
||
}
|
||
if posterURL, ok := videoModel["poster_url"].(string); ok {
|
||
result["poster_url"] = posterURL
|
||
}
|
||
// 加密信息:视频为 CENC 加密(encrypt=true),解密需要 key_seed/kid
|
||
if keySeed, ok := videoModel["key_seed"].(string); ok {
|
||
result["key_seed"] = keySeed
|
||
}
|
||
|
||
// 处理 video_list
|
||
if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok {
|
||
processedVideoList := make(map[string]interface{})
|
||
|
||
for quality, qualityData := range videoList {
|
||
if qualityMap, ok := qualityData.(map[string]interface{}); ok {
|
||
// 只保留关键字段
|
||
qualityInfo := make(map[string]interface{})
|
||
|
||
if definition, ok := qualityMap["definition"].(string); ok {
|
||
qualityInfo["definition"] = definition
|
||
}
|
||
if vtype, ok := qualityMap["vtype"].(string); ok {
|
||
qualityInfo["vtype"] = vtype
|
||
}
|
||
if vwidth, ok := qualityMap["vwidth"].(float64); ok {
|
||
qualityInfo["vwidth"] = int(vwidth)
|
||
}
|
||
if vheight, ok := qualityMap["vheight"].(float64); ok {
|
||
qualityInfo["vheight"] = int(vheight)
|
||
}
|
||
if bitrate, ok := qualityMap["bitrate"].(float64); ok {
|
||
qualityInfo["bitrate"] = formatBitrate(bitrate)
|
||
}
|
||
if size, ok := qualityMap["size"].(float64); ok {
|
||
qualityInfo["size"] = formatFileSize(size)
|
||
}
|
||
if fps, ok := qualityMap["fps"].(float64); ok {
|
||
qualityInfo["fps"] = int(fps)
|
||
}
|
||
if encrypt, ok := qualityMap["encrypt"].(bool); ok && encrypt {
|
||
qualityInfo["encrypt"] = true
|
||
if em, ok := qualityMap["encryption_method"].(string); ok {
|
||
qualityInfo["encryption_method"] = em
|
||
}
|
||
if kid, ok := qualityMap["kid"].(string); ok {
|
||
qualityInfo["kid"] = kid
|
||
}
|
||
}
|
||
|
||
// 解码 main_url
|
||
if mainURL, ok := qualityMap["main_url"].(string); ok {
|
||
if decoded, err := base64.StdEncoding.DecodeString(mainURL); err == nil {
|
||
qualityInfo["main_url"] = string(decoded)
|
||
}
|
||
}
|
||
|
||
// 解码 backup_url_1
|
||
if backupURL, ok := qualityMap["backup_url_1"].(string); ok {
|
||
if decoded, err := base64.StdEncoding.DecodeString(backupURL); err == nil {
|
||
qualityInfo["backup_url_1"] = string(decoded)
|
||
}
|
||
}
|
||
|
||
processedVideoList[quality] = qualityInfo
|
||
}
|
||
}
|
||
|
||
result["video_list"] = processedVideoList
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// formatDuration 将秒数转换为可读的时间格式
|
||
func formatDuration(seconds float64) string {
|
||
totalSeconds := int(math.Round(seconds))
|
||
|
||
hours := totalSeconds / 3600
|
||
minutes := (totalSeconds % 3600) / 60
|
||
secs := totalSeconds % 60
|
||
|
||
if hours > 0 {
|
||
return fmt.Sprintf("%d小时%d分%d秒", hours, minutes, secs)
|
||
} else if minutes > 0 {
|
||
return fmt.Sprintf("%d分%d秒", minutes, secs)
|
||
}
|
||
return fmt.Sprintf("%d秒", secs)
|
||
}
|
||
|
||
// formatFileSize 将字节数转换为可读的文件大小格式
|
||
func formatFileSize(bytes float64) string {
|
||
const unit = 1024.0
|
||
if bytes < unit {
|
||
return fmt.Sprintf("%.0f B", bytes)
|
||
}
|
||
|
||
kb := bytes / unit
|
||
if kb < unit {
|
||
return fmt.Sprintf("%.2f KB", kb)
|
||
}
|
||
|
||
mb := kb / unit
|
||
if mb < unit {
|
||
return fmt.Sprintf("%.2f MB", mb)
|
||
}
|
||
|
||
gb := mb / unit
|
||
return fmt.Sprintf("%.2f GB", gb)
|
||
}
|
||
|
||
// formatBitrate 将比特率转换为可读的格式
|
||
func formatBitrate(bitrate float64) string {
|
||
const unit = 1000.0
|
||
kbps := bitrate / unit
|
||
|
||
if kbps < unit {
|
||
return fmt.Sprintf("%.0f Kbps", kbps)
|
||
}
|
||
|
||
mbps := kbps / unit
|
||
return fmt.Sprintf("%.2f Mbps", mbps)
|
||
}
|