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 }