✨ 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:
chenxin committed 2026-10-12 14:53:08 +08:00
1 parent 84bd3238ab
commit 9aec09bb40
7 files changed
+301 -3

No files matched your search

+3
View File
@@ -39,6 +39,8 @@ type RequestLogConfig struct {
type FanqieConfig struct {
Algorithm string `mapstructure:"algorithm"`
Signature string `mapstructure:"signature"` // 签名方法: sign2 / sign4
// 注册设备身份: pad(默认,历史行为)/ phone(对齐手机 App 书源注册 payload)
DeviceIdentity string `mapstructure:"device_identity"`
Content ContentConfig `mapstructure:"content"`
Manga MangaConfig `mapstructure:"manga"`
Video VideoConfig `mapstructure:"video"`
@@ -192,6 +194,7 @@ func setDefaults() {
// 番茄小说配置
viper.SetDefault("fanqie.algorithm", "gorgon")
viper.SetDefault("fanqie.signature", "sign2")
viper.SetDefault("fanqie.device_identity", "pad")
// 内容处理配置
viper.SetDefault("fanqie.content.remove_images", false)
+28 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fanqie/internal/common"
"fanqie/internal/config"
"fmt"
"math/big"
"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 生成完整的请求体数据
func GenerateFullRequestBody() map[string]interface{} {
rawData := map[string]interface{}{
@@ -214,7 +231,7 @@ func GenerateFullRequestBody() map[string]interface{} {
"sim_serial_number": []interface{}{},
"oaid_may_support": false,
"device_platform": "android",
"custom": map[string]interface{}{"host_bit": 32, "dragon_device_type": "pad"},
"custom": deviceCustomFields(),
"pre_installed_channel": "",
"is_system_app": 0,
"sdk_flavor": "china",
@@ -234,6 +251,16 @@ func GenerateFullRequestBody() map[string]interface{} {
}
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 {
rawData[k] = v
}
+7 -2
View File
@@ -58,9 +58,14 @@ type ImageResult struct {
Index int `json:"index"`
}
// HandleGetManga 处理漫画请求
// HandleGetManga 处理漫画请求;设备池签名路线全部失败时降级 wkbrowser 免签名兜底
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 获取漫画内容,支持设备切换重试
+212
View File
@@ -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
}
+44
View File
@@ -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)
}
}