Files
chenxin 9aec09bb40 ✨ 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 解析测试
2026-10-12 14:53:08 +08:00

213 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}