diff --git a/README.md b/README.md index 627391c..648e6ca 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,11 @@ fanqie: # - "sign4": X-Gorgon + X-Ladon + X-Argus + X-Khronos signature: "sign4" + # 注册设备身份: + # - "pad": 平板身份(默认,历史行为) + # - "phone": 手机身份(对齐书源真机注册 payload,可对照测试注册率/存活率) + device_identity: "pad" + # 内容处理 content: remove_images: false # 移除图片 @@ -359,6 +364,7 @@ cp .env.example .env | `server.protect_all` | 业务接口是否也要求访问密钥 | `false` | | `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` | | `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` | +| `fanqie.device_identity` | 注册设备身份 `phone` / `pad` | `pad` | | `fanqie.content.*` | 正文内容处理选项 | 见上文 | | `fanqie.manga.output_mode` | 漫画输出模式 | `proxy` | | `cache.enabled` | 是否启用正文内容缓存 | `true` | diff --git a/internal/config/config.go b/internal/config/config.go index 323117b..f1cc4c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/fqapi/device_register.go b/internal/fqapi/device_register.go index ac76383..ef3198d 100644 --- a/internal/fqapi/device_register.go +++ b/internal/fqapi/device_register.go @@ -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 } diff --git a/internal/fqapi/manga.go b/internal/fqapi/manga.go index d1c03c6..83e23db 100644 --- a/internal/fqapi/manga.go +++ b/internal/fqapi/manga.go @@ -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 获取漫画内容,支持设备切换重试 diff --git a/internal/fqapi/manga_fallback.go b/internal/fqapi/manga_fallback.go new file mode 100644 index 0000000..254bbaf --- /dev/null +++ b/internal/fqapi/manga_fallback.go @@ -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, "\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 +} diff --git a/internal/fqapi/manga_fallback_test.go b/internal/fqapi/manga_fallback_test.go new file mode 100644 index 0000000..b7c1342 --- /dev/null +++ b/internal/fqapi/manga_fallback_test.go @@ -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) + } +} diff --git a/openapi.yaml b/openapi.yaml index a99147c..2da8374 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -577,6 +577,7 @@ paths: summary: 获取漫画内容 description: | 返回漫画章节内容,图片经解密处理。 + 设备池签名路线全部失败时自动降级 wkbrowser 免签名兜底(直接返回原图直链,响应附 `source: wkbrowser`)。 输出模式 `output` 优先于配置 `fanqie.manga.output_mode`: - `url`: 返回原始图片 URL 数组(不可用:链接 404 且内容加密,仅作上游原始信息展示) - `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index})——推荐,先调本接口再访问代理 URL(缓存 5 分钟)