✨ 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 签名说明,示例更新
This commit is contained in:
1 parent
bfb4290c15
commit
1a4997e031
25 files changed
+489
-199
No files matched your search
@@ -55,3 +55,4 @@ fanqie-*
|
|||||||
# 环境变量(含真实密码,勿提交)
|
# 环境变量(含真实密码,勿提交)
|
||||||
.env
|
.env
|
||||||
.finesse/
|
.finesse/
|
||||||
|
fanqie.log
|
||||||
@@ -143,7 +143,7 @@ go run ./cmd/fanqie
|
|||||||
|
|
||||||
| 接口 | 方法 | 说明 | 参数 |
|
| 接口 | 方法 | 说明 | 参数 |
|
||||||
| ------ | ------ | ------ | ------ |
|
| ------ | ------ | ------ | ------ |
|
||||||
| `/search` | GET | 搜索书籍 | `query`(或 `key`), `tab_type`, `offset`(`search_tabs` 仅返回匹配 `tab_type` 的 tab) |
|
| `/search` | GET | 搜索书籍 | `query`(或 `key`), `tab_type`, `offset`(结果在顶层 `data`,已按 `tab_type` 过滤展平) |
|
||||||
| `/detail` | GET | 书籍详情 | `book_id` |
|
| `/detail` | GET | 书籍详情 | `book_id` |
|
||||||
| `/info` | GET | 书籍详情(同 detail) | `book_id` |
|
| `/info` | GET | 书籍详情(同 detail) | `book_id` |
|
||||||
| `/catalog` | GET | 章节目录 | `book_id` |
|
| `/catalog` | GET | 章节目录 | `book_id` |
|
||||||
@@ -163,8 +163,8 @@ go run ./cmd/fanqie
|
|||||||
| 接口 | 方法 | 说明 | 参数 |
|
| 接口 | 方法 | 说明 | 参数 |
|
||||||
| ------ | ------ | ------ | ------ |
|
| ------ | ------ | ------ | ------ |
|
||||||
| `/chapter_review` | GET | 章节评论 | `book_id`, `item_id` |
|
| `/chapter_review` | GET | 章节评论 | `book_id`, `item_id` |
|
||||||
| `/review` | GET | 书评列表 | `book_id`, `item_id`, `para`, `comment_id`, `offset`, `limit` |
|
| `/review` | GET | 书评列表 | `book_id`, `item_id`, `para`, `comment_id`, `sort`, `offset`, `count` |
|
||||||
| `/review_html` | GET | 评论 HTML | `book_id`, `item_id`, `para` |
|
| `/review_html` | GET | 评论 HTML | `book_id`, `item_id`, `para`, `sort`, `page`, `page_size` |
|
||||||
|
|
||||||
### 分类相关
|
### 分类相关
|
||||||
|
|
||||||
@@ -205,16 +205,18 @@ GET http://localhost:40180/catalog?book_id=xxx
|
|||||||
### 获取章节内容
|
### 获取章节内容
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET http://localhost:40180/content?book_id=xxx&chapter_id=xxx
|
GET http://localhost:40180/content?item_id=xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
支持以下 URL 参数覆盖默认配置:
|
支持以下 URL 参数覆盖默认配置:
|
||||||
|
|
||||||
| 参数 | 说明 | 可选值 |
|
| 参数 | 说明 | 可选值 |
|
||||||
| ------ | ------ | ------ |
|
| ------ | ------ | ------ |
|
||||||
| `text_mode` | 文本模式 | `plain`(纯文本), `html`(HTML) |
|
| `text_mode` | 文本模式 | `plain`(纯文本), `html`(HTML), 或 `1`/`0` |
|
||||||
| `img_mode` | 图片模式 | `remove`(移除), `keep`(保留) |
|
| `img_mode`(或 `image_mode`) | 图片模式 | `remove`(移除), `keep`(保留), 或 `0`/`1` |
|
||||||
| `review_mode` | 评论模式 | `remove`(移除), `keep`(保留) |
|
| `review_mode`(或 `review`) | 评论模式 | `keep`(启用段评), `remove`(禁用), 或 `1`/`0` |
|
||||||
|
|
||||||
|
纯文本输出(`text_mode=plain` 或配置 `remove_html_tags: true`)时不注入段评标记——`<comment>` 为 XML 标签,纯文本无法承载;需要段评时配置 `remove_html_tags: false`。
|
||||||
|
|
||||||
### 批量获取
|
### 批量获取
|
||||||
|
|
||||||
@@ -268,10 +270,10 @@ fanqie:
|
|||||||
# 漫画处理
|
# 漫画处理
|
||||||
manga:
|
manga:
|
||||||
# 输出模式:
|
# 输出模式:
|
||||||
# - "proxy": 代理模式
|
# - "proxy": 代理模式(图片已解密,推荐;"url" 返回的原始链接 404 且内容加密不可用)
|
||||||
# - "url": 直接返回图片 URL
|
|
||||||
# - "data_uri": Base64 编码
|
# - "data_uri": Base64 编码
|
||||||
output_mode: "url"
|
# - "url": 直接返回图片 URL(不可用)
|
||||||
|
output_mode: "proxy"
|
||||||
|
|
||||||
# 缓存配置
|
# 缓存配置
|
||||||
cache:
|
cache:
|
||||||
@@ -336,7 +338,7 @@ cp .env.example .env
|
|||||||
| `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` |
|
| `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` |
|
||||||
| `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` |
|
| `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` |
|
||||||
| `fanqie.content.*` | 正文内容处理选项 | 见上文 |
|
| `fanqie.content.*` | 正文内容处理选项 | 见上文 |
|
||||||
| `fanqie.manga.output_mode` | 漫画输出模式 | `url` |
|
| `fanqie.manga.output_mode` | 漫画输出模式 | `proxy` |
|
||||||
| `cache.enabled` | 是否启用正文内容缓存 | `true` |
|
| `cache.enabled` | 是否启用正文内容缓存 | `true` |
|
||||||
| `cache.backend` | SQL 存储后端 `sqlite` / `postgres` | `sqlite` |
|
| `cache.backend` | SQL 存储后端 `sqlite` / `postgres` | `sqlite` |
|
||||||
| `cache.sqlite_path` | SQLite 数据库路径 | `./cache.db` |
|
| `cache.sqlite_path` | SQLite 数据库路径 | `./cache.db` |
|
||||||
|
|||||||
+2
-1
@@ -194,7 +194,8 @@ func startServer() {
|
|||||||
|
|
||||||
addr := host + ":" + port
|
addr := host + ":" + port
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
Handler: handlers.Gzip(http.DefaultServeMux),
|
||||||
// 超时保护:防止慢客户端长期占用连接
|
// 超时保护:防止慢客户端长期占用连接
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 60 * time.Second,
|
ReadTimeout: 60 * time.Second,
|
||||||
|
|||||||
+3
-3
@@ -52,10 +52,10 @@ fanqie:
|
|||||||
# 漫画处理配置
|
# 漫画处理配置
|
||||||
manga:
|
manga:
|
||||||
# 输出模式:proxy / data_uri / url
|
# 输出模式:proxy / data_uri / url
|
||||||
# proxy: 返回本地代理URL /manga/image/{itemID}/{index}
|
# proxy: 返回本地代理URL /manga/image/{itemID}/{index}(图片已解密,推荐)
|
||||||
# data_uri: 返回 data:image/jpeg;base64,/9j/4AAQ...
|
# data_uri: 返回 data:image/jpeg;base64,/9j/4AAQ...
|
||||||
# url: 返回原始图片URL数组
|
# url: 返回原始图片URL数组(不可用:链接404且内容加密)
|
||||||
output_mode: "url"
|
output_mode: "proxy"
|
||||||
|
|
||||||
# 缓存配置
|
# 缓存配置
|
||||||
cache:
|
cache:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package fqapi
|
package fqapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fanqie/internal/common"
|
"fanqie/internal/common"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
@@ -118,8 +117,7 @@ func HandleAudio(itemIDs interface{}, toneID interface{}, output interface{}) (s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resultBytes, _ := json.Marshal(respJSON)
|
return marshalNoEscape(UnwrapJSONStrings(respJSON)), nil
|
||||||
return string(resultBytes), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回错误信息
|
// 返回错误信息
|
||||||
|
|||||||
@@ -259,7 +259,9 @@ func isCacheEnabled() bool {
|
|||||||
// ponytail: 空参等防御分支若漏到此处,其错误 JSON 也会被缓存到 TTL 到期——handler 层 validateParams 已拦截
|
// ponytail: 空参等防御分支若漏到此处,其错误 JSON 也会被缓存到 TTL 到期——handler 层 validateParams 已拦截
|
||||||
func withCache(key string, ttl int, fetch func() (string, error)) (string, error) {
|
func withCache(key string, ttl int, fetch func() (string, error)) (string, error) {
|
||||||
if c := GetCache(key); c != "" {
|
if c := GetCache(key); c != "" {
|
||||||
return c, nil
|
// 旧缓存可能存有未解包的 JSON 字符串字段,出口幂等解包
|
||||||
|
// (对新写入的已解包数据无影响)
|
||||||
|
return reencodeUnwrapped(c), nil
|
||||||
}
|
}
|
||||||
result, err := fetch()
|
result, err := fetch()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ func fetchCatalog(bookID string) (string, error) {
|
|||||||
delete(data, "catalog_data")
|
delete(data, "catalog_data")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新序列化
|
// 重新序列化(解包 JSON 字符串字段)
|
||||||
resultBytes, err := json.Marshal(respJSON)
|
resultBytes, err := json.Marshal(UnwrapJSONStrings(respJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildErrorResponse("序列化失败"), err
|
return BuildErrorResponse("序列化失败"), err
|
||||||
}
|
}
|
||||||
@@ -130,8 +130,7 @@ func offShelfCatalog(bookID string) (string, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
resultBytes, _ := json.Marshal(response)
|
return marshalNoEscape(UnwrapJSONStrings(response)), nil
|
||||||
return string(resultBytes), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemMultiDetail 批量获取章节详情
|
// itemMultiDetail 批量获取章节详情
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func fetchCategoryFront(tab interface{}) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if code, ok := respJSON["code"].(float64); ok && code == 0 {
|
if code, ok := respJSON["code"].(float64); ok && code == 0 {
|
||||||
return resp.Text, nil
|
return reencodeUnwrapped(resp.Text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildErrorResponse("获取失败"), nil
|
return BuildErrorResponse("获取失败"), nil
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func fetchCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy int
|
|||||||
}
|
}
|
||||||
|
|
||||||
if code, ok := respJSON["code"].(float64); ok && code == 0 {
|
if code, ok := respJSON["code"].(float64); ok && code == 0 {
|
||||||
return resp.Text, nil
|
return reencodeUnwrapped(resp.Text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildErrorResponse("获取失败"), nil
|
return BuildErrorResponse("获取失败"), nil
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, e
|
|||||||
return `{"code": -1, "msg": "需要参数 item_id"}`, nil
|
return `{"code": -1, "msg": "需要参数 item_id"}`, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 参数转换
|
// 参数转换(URL 参数优先,未传时回退配置)
|
||||||
textModeBool := parseBoolParam(textMode)
|
textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags)
|
||||||
imageModeBool := parseBoolParamDefault(imageMode, true)
|
imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages)
|
||||||
|
|
||||||
// contentLogger.Info(fmt.Sprintf("获取内容 item_id=%s text_mode=%v image_mode=%v", itemID, textModeBool, imageModeBool))
|
// contentLogger.Info(fmt.Sprintf("获取内容 item_id=%s text_mode=%v image_mode=%v", itemID, textModeBool, imageModeBool))
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func processCachedContent(cache string, textMode, imageMode bool) (string, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildJSONResponse(d), nil
|
return buildJSONResponse(UnwrapJSONStrings(d)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchContentFromAPI 从API获取内容(带设备切换重试)
|
// fetchContentFromAPI 从API获取内容(带设备切换重试)
|
||||||
@@ -193,7 +193,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i
|
|||||||
parser := NewContentParser()
|
parser := NewContentParser()
|
||||||
data["content"] = parser.Parse(contentStr, textMode, imageMode)
|
data["content"] = parser.Parse(contentStr, textMode, imageMode)
|
||||||
|
|
||||||
return buildJSONResponse(body), nil
|
return buildJSONResponse(UnwrapJSONStrings(body)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应code不为0,切换设备重试
|
// 响应code不为0,切换设备重试
|
||||||
@@ -270,7 +270,7 @@ func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) {
|
|||||||
parser := NewContentParser()
|
parser := NewContentParser()
|
||||||
data["content"] = parser.Parse(decryptedContent, textMode, imageMode)
|
data["content"] = parser.Parse(decryptedContent, textMode, imageMode)
|
||||||
|
|
||||||
return buildJSONResponse(body), nil
|
return buildJSONResponse(UnwrapJSONStrings(body)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
contentLogger.Warning("备用接口1获取正文出错!")
|
contentLogger.Warning("备用接口1获取正文出错!")
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ func (p *ContentParser) Parse(content string, textMode bool, imageMode bool) str
|
|||||||
content = p.removeImageDesc(content)
|
content = p.removeImageDesc(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 根据参数或配置移除图片
|
// 4. 根据参数移除图片
|
||||||
// URL参数 image_mode=0 或配置 remove_images=true 时移除图片
|
// imageMode=false 时移除(URL 参数与配置 remove_images 已在调用方合成:URL 优先)
|
||||||
if !imageMode || p.cfg.RemoveImages {
|
if !imageMode {
|
||||||
content = p.removeImages(content)
|
content = p.removeImages(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 根据参数或配置处理内容模式
|
// 5. 根据参数处理内容模式
|
||||||
// URL参数 text_mode=1 或配置 remove_html_tags=true 时使用文本模式
|
// textMode=true 时提取纯文本(URL 参数与配置 remove_html_tags 已在调用方合成:URL 优先)
|
||||||
if textMode || p.cfg.RemoveHTMLTags {
|
if textMode {
|
||||||
return p.extractText(content)
|
return p.extractText(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package fqapi
|
package fqapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fanqie/internal/config"
|
"fanqie/internal/config"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -66,11 +67,16 @@ func removeBlankLines(content string) string {
|
|||||||
|
|
||||||
// ============ 参数解析工具函数 ============
|
// ============ 参数解析工具函数 ============
|
||||||
|
|
||||||
// parseBoolParam 解析布尔参数
|
// ParseBoolParam 解析布尔参数,支持 1/0、true/false 及语义别名
|
||||||
func parseBoolParam(param interface{}) bool {
|
// text_mode: plain(纯文本)=true / html=false
|
||||||
|
// image_mode、review: keep(保留/启用)=true / remove(移除/禁用)=false
|
||||||
|
func ParseBoolParam(param interface{}) bool {
|
||||||
switch v := param.(type) {
|
switch v := param.(type) {
|
||||||
case string:
|
case string:
|
||||||
return v == "true" || v == "1" || strings.ToLower(v) == "true"
|
switch strings.ToLower(v) {
|
||||||
|
case "1", "true", "plain", "keep":
|
||||||
|
return true
|
||||||
|
}
|
||||||
case bool:
|
case bool:
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -82,7 +88,7 @@ func parseBoolParamDefault(param interface{}, defaultVal bool) bool {
|
|||||||
if param == nil || param == "" {
|
if param == nil || param == "" {
|
||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
return parseBoolParam(param)
|
return ParseBoolParam(param)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ JSON 处理工具函数 ============
|
// ============ JSON 处理工具函数 ============
|
||||||
@@ -100,6 +106,78 @@ func buildJSONResponse(data interface{}) string {
|
|||||||
return escapeJSON(string(resultBytes))
|
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(从设备池)
|
// getDeviceHeader 获取设备header(从设备池)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ func fetchDetail(bookID string) (string, error) {
|
|||||||
delete(data, "content")
|
delete(data, "content")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新序列化
|
// 重新序列化(解包 JSON 字符串字段)
|
||||||
resultBytes, err := json.Marshal(respJSON)
|
resultBytes, err := json.Marshal(UnwrapJSONStrings(respJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildErrorResponse("序列化失败"), err
|
return BuildErrorResponse("序列化失败"), err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func fetchItemSummary(itemIDs, bookID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if code, ok := respJSON["code"].(float64); ok && code == 0 {
|
if code, ok := respJSON["code"].(float64); ok && code == 0 {
|
||||||
return resp.Text, nil
|
return reencodeUnwrapped(resp.Text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildErrorResponse("获取失败"), nil
|
return BuildErrorResponse("获取失败"), nil
|
||||||
|
|||||||
+15
-5
@@ -234,8 +234,11 @@ func decryptAESContent(encrypted, secretKey string) (string, error) {
|
|||||||
plaintext := make([]byte, len(ciphertext))
|
plaintext := make([]byte, len(ciphertext))
|
||||||
mode.CryptBlocks(plaintext, ciphertext)
|
mode.CryptBlocks(plaintext, ciphertext)
|
||||||
|
|
||||||
// PKCS7 unpadding
|
// PKCS7 unpadding(无效填充说明密钥不匹配,返回错误触发上层重试)
|
||||||
plaintext = pkcs7Unpad(plaintext)
|
plaintext = pkcs7Unpad(plaintext)
|
||||||
|
if plaintext == nil {
|
||||||
|
return "", fmt.Errorf("解密失败(无效填充,密钥可能不匹配)")
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试gzip解压
|
// 尝试gzip解压
|
||||||
if len(plaintext) > 2 && plaintext[0] == 0x1f && plaintext[1] == 0x8b {
|
if len(plaintext) > 2 && plaintext[0] == 0x1f && plaintext[1] == 0x8b {
|
||||||
@@ -257,13 +260,20 @@ func decryptAESContent(encrypted, secretKey string) (string, error) {
|
|||||||
func pkcs7Unpad(data []byte) []byte {
|
func pkcs7Unpad(data []byte) []byte {
|
||||||
length := len(data)
|
length := len(data)
|
||||||
if length == 0 {
|
if length == 0 {
|
||||||
return data
|
return nil
|
||||||
}
|
}
|
||||||
unpadding := int(data[length-1])
|
unpadding := int(data[length-1])
|
||||||
if unpadding > length {
|
// 完整校验:值 1-16 且尾部 unpadding 个字节全等于填充值。
|
||||||
return data
|
// 密钥错误时解密结果是随机字节,弱校验会让乱码静默通过
|
||||||
|
if unpadding < 1 || unpadding > 16 || unpadding > length {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return data[:(length - unpadding)]
|
for _, b := range data[length-unpadding:] {
|
||||||
|
if int(b) != unpadding {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:length-unpadding]
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMangaResponse 构建漫画响应(统一HTML和JSON格式输出)
|
// buildMangaResponse 构建漫画响应(统一HTML和JSON格式输出)
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ func handleDecryptTask(itemID string, itemData map[string]interface{}, keyInfo *
|
|||||||
|
|
||||||
// MultiContent 批量获取内容
|
// MultiContent 批量获取内容
|
||||||
func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) {
|
func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) {
|
||||||
// 参数转换
|
// 参数转换(URL 参数优先,未传时回退配置)
|
||||||
textModeBool := parseBoolParam(textMode)
|
textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags)
|
||||||
imageModeBool := parseBoolParamDefault(imageMode, true)
|
imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages)
|
||||||
ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true)
|
ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true)
|
||||||
|
|
||||||
if depth > 3 {
|
if depth > 3 {
|
||||||
@@ -256,7 +256,7 @@ func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignor
|
|||||||
|
|
||||||
// 构建响应
|
// 构建响应
|
||||||
response := buildCacheData(results)
|
response := buildCacheData(results)
|
||||||
return buildJSONResponse(response), nil
|
return buildJSONResponse(UnwrapJSONStrings(response)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应code不为0,切换设备重试
|
// 响应code不为0,切换设备重试
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package fqapi
|
package fqapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fanqie/internal/common"
|
"fanqie/internal/common"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -215,6 +214,5 @@ func fetchMultiDetail(bookID interface{}) (string, error) {
|
|||||||
"data": res,
|
"data": res,
|
||||||
}
|
}
|
||||||
|
|
||||||
resultBytes, _ := json.Marshal(response)
|
return marshalNoEscape(UnwrapJSONStrings(response)), nil
|
||||||
return string(resultBytes), nil
|
|
||||||
}
|
}
|
||||||
@@ -70,11 +70,17 @@ func createSymmetricCrypto(algorithm string, key, iv []byte) (func([]byte) ([]by
|
|||||||
decrypted := make([]byte, len(data))
|
decrypted := make([]byte, len(data))
|
||||||
mode.CryptBlocks(decrypted, data)
|
mode.CryptBlocks(decrypted, data)
|
||||||
|
|
||||||
// 移除PKCS7填充
|
// 移除PKCS7填充(完整校验:密钥错误时尾部为随机字节,
|
||||||
|
// 弱校验会让乱码静默通过并返回 200)
|
||||||
padding := int(decrypted[len(decrypted)-1])
|
padding := int(decrypted[len(decrypted)-1])
|
||||||
if padding > len(decrypted) || padding > aes.BlockSize {
|
if padding < 1 || padding > len(decrypted) || padding > aes.BlockSize {
|
||||||
return nil, fmt.Errorf("无效的填充")
|
return nil, fmt.Errorf("无效的填充")
|
||||||
}
|
}
|
||||||
|
for _, b := range decrypted[len(decrypted)-padding:] {
|
||||||
|
if int(b) != padding {
|
||||||
|
return nil, fmt.Errorf("无效的填充")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return decrypted[:len(decrypted)-padding], nil
|
return decrypted[:len(decrypted)-padding], nil
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-95
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fanqie/internal/common"
|
"fanqie/internal/common"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,8 +64,11 @@ func postReviewAPI(url string, requestBody interface{}) (map[string]interface{},
|
|||||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 data 字段
|
// 提取 data 字段(并解包 JSON 字符串字段,如 post_data.content)
|
||||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
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 data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +174,11 @@ func GetParaReview(itemID, bookID, commentID string, para, sort, offset, count i
|
|||||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 data 字段
|
// 提取 data 字段(并解包 JSON 字符串字段,如 post_data.content)
|
||||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
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 data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,103 +243,33 @@ func GenerateParagraphsWithComments(itemID, bookID, content, baseURL string) (st
|
|||||||
strings.Contains(content, "<div") ||
|
strings.Contains(content, "<div") ||
|
||||||
strings.Contains(content, "<article")
|
strings.Contains(content, "<article")
|
||||||
|
|
||||||
// 关键:如果检测到有段评数据,强制使用HTML模式(包裹<p>标签)
|
// 纯文本模式下 <comment> 标签无法承载,保持纯文本原样返回(不注入段评);
|
||||||
// 因为<comment>标签需要XML兼容的格式
|
// 需要段评标记时配置 remove_html_tags: false 或 text_mode=html
|
||||||
hasAnyComments := len(commentData) > 0
|
if !isHTMLContent {
|
||||||
if hasAnyComments && !isHTMLContent {
|
return content, nil
|
||||||
isHTMLContent = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
// HTML 内容:在每个 </p> 前插入段评标记。
|
||||||
var result strings.Builder
|
// 兼容 <p idx="n">(HTML 模式结构)与无属性的 <p>;段号取 idx,无 idx 时按出现顺序。
|
||||||
paragraphIndex := 0
|
// header/article/div/img 等非段落标签不受影响。
|
||||||
|
pRe := regexp.MustCompile(`(?s)<p\b[^>]*>.*?</p>`)
|
||||||
for i, line := range lines {
|
idxRe := regexp.MustCompile(`idx="(\d+)"`)
|
||||||
isLastLine := i == len(lines)-1
|
order := 0
|
||||||
trimmedLine := strings.TrimSpace(line)
|
return pRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||||
|
defer func() { order++ }()
|
||||||
// 完全空行:直接输出换行符
|
para := order
|
||||||
if trimmedLine == "" {
|
if idx := idxRe.FindStringSubmatch(m); idx != nil {
|
||||||
if !isLastLine {
|
if n, err := strconv.Atoi(idx[1]); err == nil {
|
||||||
result.WriteString("\n")
|
para = 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if commentInfo, exists := commentData[fmt.Sprintf("%d", para)]; exists && commentInfo.Count > 0 {
|
||||||
paragraphIndex++
|
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 result.String(), nil
|
}
|
||||||
|
return m
|
||||||
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDeviceID 从 device header 中提取 device_id
|
// extractDeviceID 从 device header 中提取 device_id
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ func fetchBookSearch(key string, offset int, tabType int) (string, error) {
|
|||||||
return resp.Text, nil // 解析失败时退回原样转发
|
return resp.Text, nil // 解析失败时退回原样转发
|
||||||
}
|
}
|
||||||
filterSearchTabs(numJSON, tabType)
|
filterSearchTabs(numJSON, tabType)
|
||||||
|
unwrapSearchTab(numJSON)
|
||||||
|
UnwrapJSONStrings(numJSON)
|
||||||
return ToJSON(numJSON)
|
return ToJSON(numJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,3 +99,27 @@ func filterSearchTabs(respJSON map[string]interface{}, tabType int) {
|
|||||||
respJSON["search_tabs"] = filtered
|
respJSON["search_tabs"] = filtered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unwrapSearchTab 将过滤后的唯一 tab 展平:去掉 search_tabs 包装,
|
||||||
|
// tab 的 data 直接作为顶层 data,并提升分页字段 has_more/next_offset
|
||||||
|
func unwrapSearchTab(respJSON map[string]interface{}) {
|
||||||
|
tabs, ok := respJSON["search_tabs"].([]interface{})
|
||||||
|
if !ok || len(tabs) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tab, ok := tabs[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(respJSON, "search_tabs")
|
||||||
|
delete(respJSON, "selected_tab_idx") // tab 已展平,索引无意义
|
||||||
|
delete(respJSON, "server_ab_params") // 上游 AB 实验参数,透传无意义
|
||||||
|
if data, ok := tab["data"]; ok {
|
||||||
|
respJSON["data"] = data
|
||||||
|
}
|
||||||
|
for _, k := range []string{"has_more", "next_offset"} {
|
||||||
|
if v, ok := tab[k]; ok {
|
||||||
|
respJSON[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-4
@@ -95,8 +95,7 @@ func HandleVideo(itemIDs interface{}) (string, error) {
|
|||||||
"data": decodedModel,
|
"data": decodedModel,
|
||||||
}
|
}
|
||||||
|
|
||||||
resultBytes, _ := json.Marshal(response)
|
return marshalNoEscape(response), nil
|
||||||
return string(resultBytes), nil
|
|
||||||
} else {
|
} else {
|
||||||
// 批量接口:data 是一个map,key是ID,value包含video_model
|
// 批量接口:data 是一个map,key是ID,value包含video_model
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
@@ -119,8 +118,7 @@ func HandleVideo(itemIDs interface{}) (string, error) {
|
|||||||
"data": result,
|
"data": result,
|
||||||
}
|
}
|
||||||
|
|
||||||
resultBytes, _ := json.Marshal(response)
|
return marshalNoEscape(response), nil
|
||||||
return string(resultBytes), nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +142,10 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
|
|||||||
// 解析失败,返回空对象
|
// 解析失败,返回空对象
|
||||||
return map[string]interface{}{}
|
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{})
|
result := make(map[string]interface{})
|
||||||
@@ -158,6 +160,10 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
|
|||||||
if posterURL, ok := videoModel["poster_url"].(string); ok {
|
if posterURL, ok := videoModel["poster_url"].(string); ok {
|
||||||
result["poster_url"] = posterURL
|
result["poster_url"] = posterURL
|
||||||
}
|
}
|
||||||
|
// 加密信息:视频为 CENC 加密(encrypt=true),解密需要 key_seed/kid
|
||||||
|
if keySeed, ok := videoModel["key_seed"].(string); ok {
|
||||||
|
result["key_seed"] = keySeed
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 video_list
|
// 处理 video_list
|
||||||
if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok {
|
if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok {
|
||||||
@@ -189,6 +195,15 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
|
|||||||
if fps, ok := qualityMap["fps"].(float64); ok {
|
if fps, ok := qualityMap["fps"].(float64); ok {
|
||||||
qualityInfo["fps"] = int(fps)
|
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
|
// 解码 main_url
|
||||||
if mainURL, ok := qualityMap["main_url"].(string); ok {
|
if mainURL, ok := qualityMap["main_url"].(string); ok {
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ func StopStatisticsSaver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getQueryWithAlias 读 URL 参数,主名未传时回退到别名(兼容 README 历史参数名)
|
||||||
|
func getQueryWithAlias(r *http.Request, name, alias string) string {
|
||||||
|
if v := r.URL.Query().Get(name); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return r.URL.Query().Get(alias)
|
||||||
|
}
|
||||||
|
|
||||||
// respondError 统一错误响应(JSON 安全编码;isClientErr 为 true 时返回 400,否则 500)
|
// respondError 统一错误响应(JSON 安全编码;isClientErr 为 true 时返回 400,否则 500)
|
||||||
func respondError(w http.ResponseWriter, msg string, isClientErr bool) {
|
func respondError(w http.ResponseWriter, msg string, isClientErr bool) {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ func HandleContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
itemID := params["item_id"]
|
itemID := params["item_id"]
|
||||||
|
|
||||||
// 获取URL参数(支持动态覆盖配置)
|
// 获取URL参数(支持动态覆盖配置;image_mode/review 兼容 README 的 img_mode/review_mode 别名)
|
||||||
textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式
|
textMode := r.URL.Query().Get("text_mode") // plain/1=纯文本, html/0=HTML
|
||||||
imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片
|
imageMode := getQueryWithAlias(r, "image_mode", "img_mode")
|
||||||
reviewParam := r.URL.Query().Get("review") // 1=启用段评, 0=禁用段评
|
reviewParam := getQueryWithAlias(r, "review", "review_mode") // keep/1=启用段评, remove/0=禁用段评
|
||||||
|
|
||||||
result, err := fqapi.HandleGetContent(itemID, textMode, imageMode)
|
result, err := fqapi.HandleGetContent(itemID, textMode, imageMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,17 +32,15 @@ func HandleContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 判断是否启用段评功能
|
// 判断是否启用段评功能
|
||||||
// 优先使用URL参数,如果没有则使用配置文件
|
// 优先使用URL参数,如果没有则使用配置文件
|
||||||
enableReview := false
|
var enableReview bool
|
||||||
if reviewParam == "1" {
|
if reviewParam == "" {
|
||||||
enableReview = true
|
|
||||||
} else if reviewParam == "0" {
|
|
||||||
enableReview = false
|
|
||||||
} else {
|
|
||||||
// 没有URL参数时,使用配置文件
|
// 没有URL参数时,使用配置文件
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
enableReview = cfg.Fanqie.Content.EnableReview
|
enableReview = cfg.Fanqie.Content.EnableReview
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
enableReview = fqapi.ParseBoolParam(reviewParam)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果启用了段评功能,处理正文内容添加段评标记
|
// 如果启用了段评功能,处理正文内容添加段评标记
|
||||||
@@ -65,7 +63,7 @@ func HandleMultiContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
textMode := r.URL.Query().Get("text_mode")
|
textMode := r.URL.Query().Get("text_mode")
|
||||||
imageMode := r.URL.Query().Get("image_mode")
|
imageMode := getQueryWithAlias(r, "image_mode", "img_mode")
|
||||||
ignoreFailed := r.URL.Query().Get("ignore_failed")
|
ignoreFailed := r.URL.Query().Get("ignore_failed")
|
||||||
|
|
||||||
result, err := fqapi.HandleMultiContent(params["item_ids"], params["book_id"], textMode, imageMode, ignoreFailed)
|
result, err := fqapi.HandleMultiContent(params["item_ids"], params["book_id"], textMode, imageMode, ignoreFailed)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gzip 对 JSON/文本响应启用 gzip 压缩(仅客户端声明 Accept-Encoding: gzip 时)。
|
||||||
|
// 图片/音频等二进制响应不在白名单内,原样透传。
|
||||||
|
func Gzip(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gw := &gzipWriter{ResponseWriter: w}
|
||||||
|
defer gw.close()
|
||||||
|
next.ServeHTTP(gw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type gzipWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
zw *gzip.Writer
|
||||||
|
decided bool // 是否已根据 Content-Type 做过压缩决定
|
||||||
|
headerSent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gzipWriter) WriteHeader(status int) {
|
||||||
|
if g.headerSent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !g.decided {
|
||||||
|
g.decide()
|
||||||
|
}
|
||||||
|
g.ResponseWriter.WriteHeader(status)
|
||||||
|
g.headerSent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gzipWriter) Write(b []byte) (int, error) {
|
||||||
|
if !g.headerSent {
|
||||||
|
// handler 未显式 WriteHeader(fmt.Fprint 直接写)时的隐式 200
|
||||||
|
if !g.decided {
|
||||||
|
g.decide()
|
||||||
|
}
|
||||||
|
g.ResponseWriter.WriteHeader(http.StatusOK)
|
||||||
|
g.headerSent = true
|
||||||
|
}
|
||||||
|
if g.zw != nil {
|
||||||
|
return g.zw.Write(b)
|
||||||
|
}
|
||||||
|
return g.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decide 在首个响应头确定时判断是否压缩(此时 handler 已设置 Content-Type)
|
||||||
|
func (g *gzipWriter) decide() {
|
||||||
|
g.decided = true
|
||||||
|
ct := g.Header().Get("Content-Type")
|
||||||
|
for _, prefix := range []string{"application/json", "text/html", "text/plain"} {
|
||||||
|
if strings.HasPrefix(ct, prefix) {
|
||||||
|
g.Header().Set("Content-Encoding", "gzip")
|
||||||
|
g.Header().Del("Content-Length")
|
||||||
|
g.zw = gzip.NewWriter(g.ResponseWriter)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gzipWriter) close() {
|
||||||
|
if g.zw != nil {
|
||||||
|
g.zw.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
-31
@@ -98,7 +98,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
description: 搜索关键字(别名 `key`)
|
description: 搜索关键字(别名 `key`)
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
example: 斗罗大陆
|
example: 综漫
|
||||||
- name: key
|
- name: key
|
||||||
in: query
|
in: query
|
||||||
description: "`query` 的别名,`query` 为空时生效"
|
description: "`query` 的别名,`query` 为空时生效"
|
||||||
@@ -110,17 +110,38 @@ paths:
|
|||||||
example: 0
|
example: 0
|
||||||
- name: tab_type
|
- name: tab_type
|
||||||
in: query
|
in: query
|
||||||
description: 搜索类型,默认 3;响应 search_tabs 仅保留匹配该值的 tab
|
description: |
|
||||||
schema: { type: integer, default: 3 }
|
搜索类型,默认 3;仅返回匹配该值的 tab:
|
||||||
|
* `1` 综合(混合:书籍 + 运营位 cell_data + 猜你喜欢 guess_you_like_data)
|
||||||
|
* `2` 听书(book_data)
|
||||||
|
* `3` 书籍(book_data,默认)
|
||||||
|
* `4` 社区(topic_data)
|
||||||
|
* `5` 全文(book_data)
|
||||||
|
* `6` 用户(search_user_data)
|
||||||
|
* `8` 漫画(book_data)
|
||||||
|
* `11` 短剧(video_data)
|
||||||
|
schema: { type: integer, default: 3, enum: [1, 2, 3, 4, 5, 6, 8, 11] }
|
||||||
example: 3
|
example: 3
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 搜索结果(search_tabs 已按请求的 tab_type 过滤,仅保留匹配项)
|
description: 搜索结果(tab 已展平:结果块在顶层 data,并提升 has_more/next_offset 分页字段)
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/UpstreamResponse"
|
$ref: "#/components/schemas/SearchResponse"
|
||||||
example: { code: 0, msg: success, data: { search_book_data_list: [], has_more: 1 } }
|
example:
|
||||||
|
code: 0
|
||||||
|
message: SUCCESS
|
||||||
|
data:
|
||||||
|
- book_data:
|
||||||
|
- book_id: "7668727260244872254"
|
||||||
|
book_name: 综漫:今天的告白,我也很期待哦
|
||||||
|
author: 一念1浮生
|
||||||
|
abstract: (评分五天从7.5涨到9.0,真的不看看吗,前期有点青涩,往后会好一点)……
|
||||||
|
category: 动漫衍生
|
||||||
|
word_number: "357281"
|
||||||
|
has_more: 1
|
||||||
|
next_offset: 10
|
||||||
|
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
@@ -165,13 +186,18 @@ paths:
|
|||||||
/catalog:
|
/catalog:
|
||||||
get:
|
get:
|
||||||
tags: [书籍]
|
tags: [书籍]
|
||||||
summary: 获取章节目录
|
summary: 获取章节目录(book_id → 每章 item_id)
|
||||||
|
description: |
|
||||||
|
传入 book_id,返回全部章节列表 `data.item_data_list[]`,
|
||||||
|
每项含 `item_id`、`title`、`chapter_word_number`、`volume_name`。
|
||||||
|
item_id 用于调用 /content、/multi-content、/item-summary、/audio、/manga、/video 等
|
||||||
|
需要 item_id 的接口;漫画/短剧的每集(话)item_id 也从这里获取。
|
||||||
operationId: getBookCatalog
|
operationId: getBookCatalog
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/BookId"
|
- $ref: "#/components/parameters/BookId"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 章节目录(上游转发)
|
description: 章节目录(上游转发,含每章 item_id)
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: "#/components/schemas/UpstreamResponse" }
|
schema: { $ref: "#/components/schemas/UpstreamResponse" }
|
||||||
@@ -226,8 +252,10 @@ paths:
|
|||||||
tags: [内容]
|
tags: [内容]
|
||||||
summary: 获取章节正文
|
summary: 获取章节正文
|
||||||
description: |
|
description: |
|
||||||
返回章节正文。启用段评(`review=1` 或配置 `fanqie.content.enable_review: true`)时,
|
返回章节正文。启用段评(`review=keep` 或配置 `fanqie.content.enable_review: true`)时,
|
||||||
正文 HTML 中会注入段评标记。
|
正文 HTML 中会注入段评标记;纯文本输出(text_mode=plain 或配置 remove_html_tags: true)
|
||||||
|
时不注入段评标记(<comment> 为 XML 标签,纯文本无法承载)。
|
||||||
|
URL 参数优先于配置文件。
|
||||||
operationId: getItemContent
|
operationId: getItemContent
|
||||||
parameters:
|
parameters:
|
||||||
- name: item_id
|
- name: item_id
|
||||||
@@ -238,18 +266,18 @@ paths:
|
|||||||
example: "7271032589123412345"
|
example: "7271032589123412345"
|
||||||
- name: text_mode
|
- name: text_mode
|
||||||
in: query
|
in: query
|
||||||
description: 纯文本模式,1/true=纯文本,0/false=HTML
|
description: 文本模式,plain/1/true=纯文本,html/0/false=HTML;缺省用配置 remove_html_tags
|
||||||
schema: { type: string, enum: ["0", "1", "true", "false"] }
|
schema: { type: string, enum: ["plain", "html", "0", "1", "true", "false"] }
|
||||||
example: "0"
|
example: "plain"
|
||||||
- name: image_mode
|
- name: image_mode
|
||||||
in: query
|
in: query
|
||||||
description: 图片处理,1/true=保留图片,0/false=移除图片
|
description: 图片处理,keep/1/true=保留,remove/0/false=移除(别名 `img_mode`);缺省用配置 remove_images 取反
|
||||||
schema: { type: string, enum: ["0", "1", "true", "false"] }
|
schema: { type: string, enum: ["keep", "remove", "0", "1", "true", "false"] }
|
||||||
example: "1"
|
example: "keep"
|
||||||
- name: review
|
- name: review
|
||||||
in: query
|
in: query
|
||||||
description: 段评开关,1=启用,0=禁用;缺省时使用配置文件
|
description: 段评开关,keep/1=启用,remove/0=禁用(别名 `review_mode`);缺省用配置 enable_review
|
||||||
schema: { type: string, enum: ["0", "1"] }
|
schema: { type: string, enum: ["keep", "remove", "0", "1"] }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 章节正文
|
description: 章节正文
|
||||||
@@ -291,12 +319,12 @@ paths:
|
|||||||
example: "7271032589123412345,7271032589123412346"
|
example: "7271032589123412345,7271032589123412346"
|
||||||
- name: text_mode
|
- name: text_mode
|
||||||
in: query
|
in: query
|
||||||
description: 纯文本模式,1/true=纯文本
|
description: 文本模式,plain/1/true=纯文本,html/0/false=HTML;缺省用配置 remove_html_tags
|
||||||
schema: { type: string, enum: ["0", "1", "true", "false"] }
|
schema: { type: string, enum: ["plain", "html", "0", "1", "true", "false"] }
|
||||||
- name: image_mode
|
- name: image_mode
|
||||||
in: query
|
in: query
|
||||||
description: 图片处理,1/true=保留
|
description: 图片处理,keep/1/true=保留,remove/0/false=移除(别名 `img_mode`);缺省用配置 remove_images 取反
|
||||||
schema: { type: string, enum: ["0", "1", "true", "false"] }
|
schema: { type: string, enum: ["keep", "remove", "0", "1", "true", "false"] }
|
||||||
- name: ignore_failed
|
- name: ignore_failed
|
||||||
in: query
|
in: query
|
||||||
description: 忽略失败章节,1/true=在结果中跳过而非报错
|
description: 忽略失败章节,1/true=在结果中跳过而非报错
|
||||||
@@ -381,12 +409,24 @@ paths:
|
|||||||
example: 0
|
example: 0
|
||||||
- name: book_id
|
- name: book_id
|
||||||
in: query
|
in: query
|
||||||
description: 书籍 ID
|
description: 书籍 ID(实际必传,缺省时上游返回 101003 错误)
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
- name: comment_id
|
- name: comment_id
|
||||||
in: query
|
in: query
|
||||||
description: 评论 ID(获取回复时需要)
|
description: 评论 ID(获取回复时需要)
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
|
- name: sort
|
||||||
|
in: query
|
||||||
|
description: 排序方式,默认 1(热门)
|
||||||
|
schema: { type: integer, default: 1 }
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
description: 分页偏移量,默认 0
|
||||||
|
schema: { type: integer, default: 0, minimum: 0 }
|
||||||
|
- name: count
|
||||||
|
in: query
|
||||||
|
description: 每页数量,默认 20
|
||||||
|
schema: { type: integer, default: 20, minimum: 1 }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 评论/回复数据
|
description: 评论/回复数据
|
||||||
@@ -416,6 +456,22 @@ paths:
|
|||||||
description: 段落索引(从 0 开始)
|
description: 段落索引(从 0 开始)
|
||||||
schema: { type: integer, minimum: 0 }
|
schema: { type: integer, minimum: 0 }
|
||||||
example: 0
|
example: 0
|
||||||
|
- name: book_id
|
||||||
|
in: query
|
||||||
|
description: 书籍 ID
|
||||||
|
schema: { type: string }
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: 页码,默认 1
|
||||||
|
schema: { type: integer, default: 1, minimum: 1 }
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
description: 每页数量,默认 20
|
||||||
|
schema: { type: integer, default: 20, minimum: 1 }
|
||||||
|
- name: sort
|
||||||
|
in: query
|
||||||
|
description: 排序方式,默认 1(热门)
|
||||||
|
schema: { type: integer, default: 1 }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 评论 HTML 页面
|
description: 评论 HTML 页面
|
||||||
@@ -492,12 +548,17 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [多媒体]
|
tags: [多媒体]
|
||||||
summary: 获取视频信息
|
summary: 获取视频信息
|
||||||
|
description: |
|
||||||
|
短剧视频流为 CENC 加密(encrypt=true, cenc-aes-ctr, HEVC+AAC):
|
||||||
|
播放 URL 可直接下载,但文件本身加密,通用播放器无法解码。
|
||||||
|
响应透传加密状态字段:顶层 key_seed(Base64,每次请求变化)、
|
||||||
|
video_list.*.encrypt/encryption_method/kid,供需要对接官方播放器的调用方使用。
|
||||||
operationId: getVideo
|
operationId: getVideo
|
||||||
parameters:
|
parameters:
|
||||||
- name: item_ids
|
- name: item_ids
|
||||||
in: query
|
in: query
|
||||||
required: true
|
required: true
|
||||||
description: 章节 ID
|
description: 视频 ID,多个用逗号分隔;短剧每集的 item_id 从 /catalog 获取
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -517,9 +578,15 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
返回漫画章节内容,图片经解密处理。
|
返回漫画章节内容,图片经解密处理。
|
||||||
输出模式 `output` 优先于配置 `fanqie.manga.output_mode`:
|
输出模式 `output` 优先于配置 `fanqie.manga.output_mode`:
|
||||||
- `url`: 返回原始图片 URL 数组
|
- `url`: 返回原始图片 URL 数组(不可用:链接 404 且内容加密,仅作上游原始信息展示)
|
||||||
- `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index})
|
- `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index})——推荐,先调本接口再访问代理 URL(缓存 5 分钟)
|
||||||
- `data_uri`: 返回 Base64 编码(data:image/jpeg;base64,...)
|
- `data_uri`: 返回 Base64 编码(data:image/jpeg;base64,...)
|
||||||
|
|
||||||
|
CDN 链接说明(全接口通用):带 `x-signature` 签名的图片/音视频 URL 可直接 GET,
|
||||||
|
但不可修改 URL 任何部分(改尺寸模板/去参数即 403,签名覆盖整个 path)。
|
||||||
|
`x-expires` 为过期时间戳:图片约 30 天,音频约 2 小时,短剧视频约 6 小时,
|
||||||
|
过期后重新调用相应接口获取新 URL。无签名的 `audio_thumb_uri`/`horiz_thumb_url` 字段
|
||||||
|
为上游占位链接,不可用;封面请使用 `thumb_url`/`audio_thumb_url_hd`。
|
||||||
operationId: getManga
|
operationId: getManga
|
||||||
parameters:
|
parameters:
|
||||||
- name: item_ids
|
- name: item_ids
|
||||||
@@ -619,22 +686,30 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [分类]
|
tags: [分类]
|
||||||
summary: 获取分类详情页
|
summary: 获取分类详情页
|
||||||
description: 支持按分类筛选、排序与分页的书籍列表。
|
description: 支持按分类筛选、排序与分页的书籍列表(每页固定 20 条)。
|
||||||
operationId: getCategoryLanding
|
operationId: getCategoryLanding
|
||||||
parameters:
|
parameters:
|
||||||
- name: category_id
|
- name: category_id
|
||||||
in: query
|
in: query
|
||||||
required: true
|
required: true
|
||||||
description: 分类 ID
|
description: 分类 ID(可从 /category-front 或搜索结果 book_data.category_schema 中解析)
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
- name: offset
|
- name: offset
|
||||||
in: query
|
in: query
|
||||||
description: 偏移量,默认 0
|
description: 偏移量,默认 0
|
||||||
schema: { type: integer, default: 0, minimum: 0 }
|
schema: { type: integer, default: 0, minimum: 0 }
|
||||||
- name: limit
|
- name: word_number
|
||||||
in: query
|
in: query
|
||||||
description: 每页数量,默认 20
|
description: 字数筛选,透传上游(0=不限)
|
||||||
schema: { type: integer, default: 20, minimum: 1 }
|
schema: { type: integer, default: 0 }
|
||||||
|
- name: book_status
|
||||||
|
in: query
|
||||||
|
description: 连载状态筛选,透传上游(0=不限)
|
||||||
|
schema: { type: integer, default: 0 }
|
||||||
|
- name: sort_by
|
||||||
|
in: query
|
||||||
|
description: 排序方式,透传上游(0=默认)
|
||||||
|
schema: { type: integer, default: 0 }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 分类书籍列表(上游转发)
|
description: 分类书籍列表(上游转发)
|
||||||
@@ -736,6 +811,67 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: 错误信息
|
description: 错误信息
|
||||||
|
|
||||||
|
SearchResponse:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
搜索响应(已展平:原上游 `search_tabs[0]` 中的 `data` 提升为顶层 `data`,
|
||||||
|
并提升分页字段 `has_more`/`next_offset`;`search_tabs` 字段不再返回)。
|
||||||
|
required: [code, data]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
description: 0=成功
|
||||||
|
example: 0
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: SUCCESS
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
搜索结果块,单个元素的 *_data 字段由 tab_type 决定:
|
||||||
|
`book_data`(tab_type 1/2/3/5/8:书籍/听书/全文/漫画,含 book_id/book_name/author/abstract 等)、
|
||||||
|
`topic_data`(tab_type 4 社区,含 title/comment)、
|
||||||
|
`search_user_data`(tab_type 6 用户,含 encode_user_id/is_author)、
|
||||||
|
`video_data`(tab_type 11 短剧)、
|
||||||
|
`cell_data`/`guess_you_like_data`(tab_type 1 的运营位与猜你喜欢)。
|
||||||
|
所有元素均附带 book_hot_content_data 等上游字段,以实际返回为准。
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
book_data:
|
||||||
|
type: array
|
||||||
|
description: 书籍结果(tab_type 1/2/3/5)
|
||||||
|
items: { type: object }
|
||||||
|
topic_data:
|
||||||
|
type: array
|
||||||
|
description: 话题结果(tab_type 4)
|
||||||
|
items: { type: object }
|
||||||
|
search_user_data:
|
||||||
|
type: array
|
||||||
|
description: 用户结果(tab_type 6)
|
||||||
|
items: { type: object }
|
||||||
|
video_data:
|
||||||
|
type: array
|
||||||
|
description: 短剧结果(tab_type 11)
|
||||||
|
items: { type: object }
|
||||||
|
cell_data:
|
||||||
|
type: array
|
||||||
|
description: 运营位(tab_type 1 混合出现)
|
||||||
|
items: { type: object }
|
||||||
|
guess_you_like_data:
|
||||||
|
type: array
|
||||||
|
description: 猜你喜欢(tab_type 1 混合出现)
|
||||||
|
items: { type: object }
|
||||||
|
has_more:
|
||||||
|
type: integer
|
||||||
|
description: 是否还有更多结果(1=有)
|
||||||
|
example: 1
|
||||||
|
next_offset:
|
||||||
|
type: integer
|
||||||
|
description: 下一页 offset 值
|
||||||
|
example: 10
|
||||||
|
log_id:
|
||||||
|
type: string
|
||||||
UpstreamResponse:
|
UpstreamResponse:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
Reference in new issue
Block a user