✨ 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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 获取漫画内容,支持设备切换重试
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user