- /manga 设备池签名路线全部失败时自动降级 wkbrowser 免签名路线(materials/skeleton 三段策略收集原图直链,无需设备/签名/解密),响应附 source: wkbrowser - 新增配置 fanqie.device_identity(默认 pad 保持现状;phone 对齐书源真机注册 payload:host_bit 64 + 补 hardware/device_device/fingerprint 字段) - README 与 openapi.yaml 同步文档,新增 manga_fallback 解析测试
213 lines
5.5 KiB
Go
213 lines
5.5 KiB
Go
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
|
||
}
|