✨ feat(manga,device): 漫画免签名兜底与 phone 注册身份选项
- /manga 设备池签名路线全部失败时自动降级 wkbrowser 免签名路线(materials/skeleton 三段策略收集原图直链,无需设备/签名/解密),响应附 source: wkbrowser - 新增配置 fanqie.device_identity(默认 pad 保持现状;phone 对齐书源真机注册 payload:host_bit 64 + 补 hardware/device_device/fingerprint 字段) - README 与 openapi.yaml 同步文档,新增 manga_fallback 解析测试
This commit is contained in:
1 parent
84bd3238ab
commit
9aec09bb40
7 files changed
+301
-3
No files matched your search
@@ -278,6 +278,11 @@ fanqie:
|
|||||||
# - "sign4": X-Gorgon + X-Ladon + X-Argus + X-Khronos
|
# - "sign4": X-Gorgon + X-Ladon + X-Argus + X-Khronos
|
||||||
signature: "sign4"
|
signature: "sign4"
|
||||||
|
|
||||||
|
# 注册设备身份:
|
||||||
|
# - "pad": 平板身份(默认,历史行为)
|
||||||
|
# - "phone": 手机身份(对齐书源真机注册 payload,可对照测试注册率/存活率)
|
||||||
|
device_identity: "pad"
|
||||||
|
|
||||||
# 内容处理
|
# 内容处理
|
||||||
content:
|
content:
|
||||||
remove_images: false # 移除图片
|
remove_images: false # 移除图片
|
||||||
@@ -359,6 +364,7 @@ cp .env.example .env
|
|||||||
| `server.protect_all` | 业务接口是否也要求访问密钥 | `false` |
|
| `server.protect_all` | 业务接口是否也要求访问密钥 | `false` |
|
||||||
| `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` |
|
| `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` |
|
||||||
| `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` |
|
| `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` |
|
||||||
|
| `fanqie.device_identity` | 注册设备身份 `phone` / `pad` | `pad` |
|
||||||
| `fanqie.content.*` | 正文内容处理选项 | 见上文 |
|
| `fanqie.content.*` | 正文内容处理选项 | 见上文 |
|
||||||
| `fanqie.manga.output_mode` | 漫画输出模式 | `proxy` |
|
| `fanqie.manga.output_mode` | 漫画输出模式 | `proxy` |
|
||||||
| `cache.enabled` | 是否启用正文内容缓存 | `true` |
|
| `cache.enabled` | 是否启用正文内容缓存 | `true` |
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ type RequestLogConfig struct {
|
|||||||
type FanqieConfig struct {
|
type FanqieConfig struct {
|
||||||
Algorithm string `mapstructure:"algorithm"`
|
Algorithm string `mapstructure:"algorithm"`
|
||||||
Signature string `mapstructure:"signature"` // 签名方法: sign2 / sign4
|
Signature string `mapstructure:"signature"` // 签名方法: sign2 / sign4
|
||||||
|
// 注册设备身份: pad(默认,历史行为)/ phone(对齐手机 App 书源注册 payload)
|
||||||
|
DeviceIdentity string `mapstructure:"device_identity"`
|
||||||
Content ContentConfig `mapstructure:"content"`
|
Content ContentConfig `mapstructure:"content"`
|
||||||
Manga MangaConfig `mapstructure:"manga"`
|
Manga MangaConfig `mapstructure:"manga"`
|
||||||
Video VideoConfig `mapstructure:"video"`
|
Video VideoConfig `mapstructure:"video"`
|
||||||
@@ -192,6 +194,7 @@ func setDefaults() {
|
|||||||
// 番茄小说配置
|
// 番茄小说配置
|
||||||
viper.SetDefault("fanqie.algorithm", "gorgon")
|
viper.SetDefault("fanqie.algorithm", "gorgon")
|
||||||
viper.SetDefault("fanqie.signature", "sign2")
|
viper.SetDefault("fanqie.signature", "sign2")
|
||||||
|
viper.SetDefault("fanqie.device_identity", "pad")
|
||||||
|
|
||||||
// 内容处理配置
|
// 内容处理配置
|
||||||
viper.SetDefault("fanqie.content.remove_images", false)
|
viper.SetDefault("fanqie.content.remove_images", false)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fanqie/internal/common"
|
"fanqie/internal/common"
|
||||||
|
"fanqie/internal/config"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
@@ -186,6 +187,22 @@ func generateIPv6List() []map[string]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deviceIdentity 注册设备身份:pad(默认,历史行为)/ phone(对齐手机 App 书源注册 payload)
|
||||||
|
func deviceIdentity() string {
|
||||||
|
if cfg := config.GetConfig(); cfg != nil && cfg.Fanqie.DeviceIdentity == "phone" {
|
||||||
|
return "phone"
|
||||||
|
}
|
||||||
|
return "pad"
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCustomFields 注册身份对应的 custom 字段
|
||||||
|
func deviceCustomFields() map[string]interface{} {
|
||||||
|
if deviceIdentity() == "phone" {
|
||||||
|
return map[string]interface{}{"host_bit": 64, "dragon_device_type": "phone"}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"host_bit": 32, "dragon_device_type": "pad"}
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateFullRequestBody 生成完整的请求体数据
|
// GenerateFullRequestBody 生成完整的请求体数据
|
||||||
func GenerateFullRequestBody() map[string]interface{} {
|
func GenerateFullRequestBody() map[string]interface{} {
|
||||||
rawData := map[string]interface{}{
|
rawData := map[string]interface{}{
|
||||||
@@ -214,7 +231,7 @@ func GenerateFullRequestBody() map[string]interface{} {
|
|||||||
"sim_serial_number": []interface{}{},
|
"sim_serial_number": []interface{}{},
|
||||||
"oaid_may_support": false,
|
"oaid_may_support": false,
|
||||||
"device_platform": "android",
|
"device_platform": "android",
|
||||||
"custom": map[string]interface{}{"host_bit": 32, "dragon_device_type": "pad"},
|
"custom": deviceCustomFields(),
|
||||||
"pre_installed_channel": "",
|
"pre_installed_channel": "",
|
||||||
"is_system_app": 0,
|
"is_system_app": 0,
|
||||||
"sdk_flavor": "china",
|
"sdk_flavor": "china",
|
||||||
@@ -234,6 +251,16 @@ func GenerateFullRequestBody() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
header["ipv6_list"] = generateIPv6List()
|
header["ipv6_list"] = generateIPv6List()
|
||||||
|
|
||||||
|
// phone 身份补充真机指纹字段(对齐书源注册 payload,规避 pad 身份的差异化风控)
|
||||||
|
if deviceIdentity() == "phone" {
|
||||||
|
rom, _ := header["rom"].(string)
|
||||||
|
header["hardware"] = "qcom"
|
||||||
|
header["device_device"] = rom
|
||||||
|
header["fingerprint"] = fmt.Sprintf("%v/%v/%v:%v/%s/%s:user/release-keys",
|
||||||
|
header["device_brand"], header["device_model"], header["device_model"],
|
||||||
|
header["os_version"], rom, rom)
|
||||||
|
}
|
||||||
|
|
||||||
for k, v := range genTime {
|
for k, v := range genTime {
|
||||||
rawData[k] = v
|
rawData[k] = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,14 @@ type ImageResult struct {
|
|||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetManga 处理漫画请求
|
// HandleGetManga 处理漫画请求;设备池签名路线全部失败时降级 wkbrowser 免签名兜底
|
||||||
func HandleGetManga(itemID string, showHTML bool, urlOutputMode string) (interface{}, error) {
|
func HandleGetManga(itemID string, showHTML bool, urlOutputMode string) (interface{}, error) {
|
||||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, 0)
|
res, err := getMangaWithRetry(itemID, showHTML, urlOutputMode, 0)
|
||||||
|
if err == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
mangaLogger.Warning(fmt.Sprintf("签名路线失败,尝试 wkbrowser 免签名兜底: %v", err))
|
||||||
|
return fetchMangaFallback(itemID, showHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMangaWithRetry 获取漫画内容,支持设备切换重试
|
// getMangaWithRetry 获取漫画内容,支持设备切换重试
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package fqapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fanqie/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 漫画免签名兜底(对齐书源 20261012 的 wkbrowser 路线):
|
||||||
|
// 设备池签名路线全部失败时,直接从 m.wkbrowser.com 取章节图片,
|
||||||
|
// 无需注册设备/签名/解密,返回原图直链。
|
||||||
|
|
||||||
|
var (
|
||||||
|
mangaFallbackItemIDRe = regexp.MustCompile(`^\d+$`)
|
||||||
|
trailingNumberRe = regexp.MustCompile(`(\d+)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const mangaFallbackOrigin = "https://p6-novel.byteimg.com/origin/"
|
||||||
|
|
||||||
|
// fetchMangaFallback 免签名获取漫画章节图片(wkbrowser 原图直链)
|
||||||
|
func fetchMangaFallback(itemID string, showHTML bool) (interface{}, error) {
|
||||||
|
if !mangaFallbackItemIDRe.MatchString(itemID) {
|
||||||
|
return nil, fmt.Errorf("无效的 item_id: %s", itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := common.NewHTTPClient()
|
||||||
|
resp, err := client.Get("https://m.wkbrowser.com/i"+itemID+"/info/v2/", map[string]string{
|
||||||
|
"User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Mobile Safari/537.36",
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wkbrowser 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp.Content, &body); err != nil {
|
||||||
|
return nil, fmt.Errorf("wkbrowser 响应解析失败: %w", err)
|
||||||
|
}
|
||||||
|
data, _ := body["data"].(map[string]interface{})
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("wkbrowser 响应缺少 data(code=%v)", body["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// content 为 JSON 字符串或对象,内含 materials/skeleton 图片清单
|
||||||
|
payload := data["content"]
|
||||||
|
if s, ok := payload.(string); ok {
|
||||||
|
var p map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(s), &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("wkbrowser 图片内容解析失败: %w", err)
|
||||||
|
}
|
||||||
|
payload = p
|
||||||
|
}
|
||||||
|
|
||||||
|
uris := collectMangaFallbackURIs(payload)
|
||||||
|
if len(uris) == 0 {
|
||||||
|
return nil, fmt.Errorf("wkbrowser 未找到图片")
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]ImageResult, len(uris))
|
||||||
|
for i, u := range uris {
|
||||||
|
images[i] = ImageResult{Data: u, Format: "unknown", Index: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showHTML {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, img := range images {
|
||||||
|
fmt.Fprintf(&b, "<img src=\"%s\">\n", img.Data)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": b.String(), "source": "wkbrowser"}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"images": images, "source": "wkbrowser"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectMangaFallbackURIs 按书源三段策略收集图片链接(保证页序):
|
||||||
|
// 1) skeleton.data 顺序引用 materials 的 key
|
||||||
|
// 2) materials key 按末尾数字排序
|
||||||
|
// 3) 递归遍历全树兜底(顺序不保证,仅防白屏)
|
||||||
|
func collectMangaFallbackURIs(payload interface{}) []string {
|
||||||
|
p, _ := payload.(map[string]interface{})
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
images := make([]string, 0, 32)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
push := func(uri string) {
|
||||||
|
src := mangaFallbackImageURL(uri)
|
||||||
|
if src != "" && !seen[src] {
|
||||||
|
seen[src] = true
|
||||||
|
images = append(images, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
materials, _ := p["materials"].(map[string]interface{})
|
||||||
|
skeletonWrapper, _ := p["skeleton"].(map[string]interface{})
|
||||||
|
skeleton, _ := skeletonWrapper["data"].([]interface{})
|
||||||
|
|
||||||
|
if materials != nil && skeleton != nil {
|
||||||
|
for _, s := range skeleton {
|
||||||
|
node, _ := s.(map[string]interface{})
|
||||||
|
if node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := ""
|
||||||
|
for _, k := range []string{"elementInfo", "key", "id"} {
|
||||||
|
if v, _ := node[k].(string); v != "" {
|
||||||
|
key = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m, ok := materials[key].(map[string]interface{}); ok {
|
||||||
|
if uri := mangaFallbackNodeURI(m); uri != "" {
|
||||||
|
push(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(images) > 0 {
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if materials != nil {
|
||||||
|
keys := make([]string, 0, len(materials))
|
||||||
|
for k := range materials {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
return trailingNumber(keys[i]) < trailingNumber(keys[j])
|
||||||
|
})
|
||||||
|
for _, k := range keys {
|
||||||
|
if m, ok := materials[k].(map[string]interface{}); ok {
|
||||||
|
if uri := mangaFallbackNodeURI(m); uri != "" {
|
||||||
|
push(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(images) > 0 {
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(node interface{})
|
||||||
|
walk = func(node interface{}) {
|
||||||
|
switch t := node.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range t {
|
||||||
|
walk(item)
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
if uri := mangaFallbackNodeURI(t); uri != "" {
|
||||||
|
push(uri)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, v := range t {
|
||||||
|
switch v.(type) {
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
walk(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(p)
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
// mangaFallbackNodeURI 从 materials 节点取图片资源(web_uri 优先,其次 novel-pic/md5)
|
||||||
|
func mangaFallbackNodeURI(node map[string]interface{}) string {
|
||||||
|
if d, ok := node["data"].(map[string]interface{}); ok {
|
||||||
|
if u, _ := d["web_uri"].(string); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u, _ := node["web_uri"].(string); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
if d, ok := node["data"].(map[string]interface{}); ok {
|
||||||
|
if md5, _ := d["md5"].(string); md5 != "" {
|
||||||
|
return "novel-pic/" + md5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// mangaFallbackImageURL web_uri 转可访问的原图直链
|
||||||
|
func mangaFallbackImageURL(uri string) string {
|
||||||
|
u := strings.TrimPrefix(uri, "/")
|
||||||
|
if u == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u, "http://") {
|
||||||
|
return "https://" + strings.TrimPrefix(u, "http://")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u, "https://") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return mangaFallbackOrigin + u
|
||||||
|
}
|
||||||
|
|
||||||
|
// trailingNumber 取字符串末尾数字(materials key 排序用),无数字排最后
|
||||||
|
func trailingNumber(s string) int {
|
||||||
|
m := trailingNumberRe.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
|
return 1 << 30
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(m[1])
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package fqapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestCollectMangaFallbackURIs skeleton 顺序优先、web_uri/md5 取值与原图直链拼接
|
||||||
|
func TestCollectMangaFallbackURIs(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"materials": map[string]interface{}{
|
||||||
|
"img_1": map[string]interface{}{"data": map[string]interface{}{"web_uri": "novel-pic/aaa"}},
|
||||||
|
"img_2": map[string]interface{}{"data": map[string]interface{}{"web_uri": "/novel-pic/bbb"}},
|
||||||
|
"img_3": map[string]interface{}{"data": map[string]interface{}{"md5": "c0ffee"}},
|
||||||
|
},
|
||||||
|
"skeleton": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"elementInfo": "img_2"},
|
||||||
|
map[string]interface{}{"key": "img_1"},
|
||||||
|
map[string]interface{}{"id": "img_3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页序以 skeleton 引用顺序为准
|
||||||
|
want := []string{
|
||||||
|
"https://p6-novel.byteimg.com/origin/novel-pic/bbb",
|
||||||
|
"https://p6-novel.byteimg.com/origin/novel-pic/aaa",
|
||||||
|
"https://p6-novel.byteimg.com/origin/novel-pic/c0ffee",
|
||||||
|
}
|
||||||
|
got := collectMangaFallbackURIs(payload)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d 张 want %d 张: %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("[%d] got %s want %s", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 skeleton 时退化为 materials key 末尾数字排序
|
||||||
|
delete(payload, "skeleton")
|
||||||
|
got = collectMangaFallbackURIs(payload)
|
||||||
|
if got[0] != want[1] || got[1] != want[0] {
|
||||||
|
t.Errorf("key 排序错误: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -577,6 +577,7 @@ paths:
|
|||||||
summary: 获取漫画内容
|
summary: 获取漫画内容
|
||||||
description: |
|
description: |
|
||||||
返回漫画章节内容,图片经解密处理。
|
返回漫画章节内容,图片经解密处理。
|
||||||
|
设备池签名路线全部失败时自动降级 wkbrowser 免签名兜底(直接返回原图直链,响应附 `source: wkbrowser`)。
|
||||||
输出模式 `output` 优先于配置 `fanqie.manga.output_mode`:
|
输出模式 `output` 优先于配置 `fanqie.manga.output_mode`:
|
||||||
- `url`: 返回原始图片 URL 数组(不可用:链接 404 且内容加密,仅作上游原始信息展示)
|
- `url`: 返回原始图片 URL 数组(不可用:链接 404 且内容加密,仅作上游原始信息展示)
|
||||||
- `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index})——推荐,先调本接口再访问代理 URL(缓存 5 分钟)
|
- `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index})——推荐,先调本接口再访问代理 URL(缓存 5 分钟)
|
||||||
|
|||||||
Reference in new issue
Block a user