🔧 chore: 初始化番茄小说 API 项目结构
新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块
This commit is contained in:
commit
6981148727
107 files changed
+12463
No files matched your search
@@ -0,0 +1,616 @@
|
||||
package fqapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fanqie/internal/common"
|
||||
"fanqie/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
mangaLogger = common.NewLogger("manga", "INFO", "")
|
||||
// 存储漫画图片信息的缓存(用于代理模式)
|
||||
mangaImageCache = make(map[string]*MangaImageInfo)
|
||||
mangaCacheMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// MangaImageInfo 漫画图片信息
|
||||
type MangaImageInfo struct {
|
||||
PicURL string
|
||||
EncryptKey string
|
||||
Width int
|
||||
Height int
|
||||
CachedAt time.Time
|
||||
}
|
||||
|
||||
// MangaData 漫画数据结构
|
||||
type MangaData struct {
|
||||
PicInfos []PicInfo `json:"picInfos"`
|
||||
EncryptKey string `json:"encrypt_key"`
|
||||
}
|
||||
|
||||
// PicInfo 图片信息
|
||||
type PicInfo struct {
|
||||
PicURL string `json:"picUrl"`
|
||||
Md5 string `json:"md5"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// ImageResult 图片结果
|
||||
type ImageResult struct {
|
||||
Data string `json:"data"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Format string `json:"format"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
// HandleGetManga 处理漫画请求
|
||||
func HandleGetManga(itemID string, showHTML bool, urlOutputMode string) (interface{}, error) {
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, 0)
|
||||
}
|
||||
|
||||
// getMangaWithRetry 获取漫画内容,支持设备切换重试
|
||||
func getMangaWithRetry(itemID string, showHTML bool, urlOutputMode string, retryCount int) (interface{}, error) {
|
||||
// 防止无限递归
|
||||
const maxRetries = 5
|
||||
if retryCount >= maxRetries {
|
||||
mangaLogger.Warning(fmt.Sprintf("已重试 %d 次,获取漫画失败", maxRetries))
|
||||
return nil, fmt.Errorf("已重试 %d 次,所有设备均失败", maxRetries)
|
||||
}
|
||||
|
||||
// 1. 从设备密钥池获取设备
|
||||
pool := GetDeviceKeyPool()
|
||||
item, err := pool.GetNext()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("从设备池获取设备失败: %w", err)
|
||||
}
|
||||
|
||||
header := item.DeviceHeader
|
||||
keyInfo := &KeyInfo{
|
||||
Key: item.Key,
|
||||
Ver: item.KeyVer,
|
||||
}
|
||||
mangaLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount))
|
||||
|
||||
// 2. 构建URL - 使用 BuildURL 添加设备参数
|
||||
headerWithoutVersion := strings.Replace(header, "&version_name=5.8.9.32", "", -1)
|
||||
|
||||
url := BuildURL("https://reading.snssdk.com/reading/reader/full/v/",
|
||||
"aid=1967",
|
||||
"app_name=novelapp",
|
||||
"channel=0",
|
||||
"device_platform=android",
|
||||
"version_code=66.9",
|
||||
"item_id="+itemID,
|
||||
"os_version=0",
|
||||
headerWithoutVersion,
|
||||
)
|
||||
|
||||
// 3. 调用API - 使用 GorgonRequest 添加签名
|
||||
response, err := GorgonRequest(url, map[string]interface{}{
|
||||
"method": "GET",
|
||||
"headers": map[string]string{
|
||||
"User-Agent": "com.dragon.read",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
mangaLogger.Warning(fmt.Sprintf("API请求失败,切换设备重试: %v", err))
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||||
}
|
||||
|
||||
// 4. 解析响应
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(response.Content, &body); err != nil {
|
||||
mangaLogger.Warning(fmt.Sprintf("JSON解析失败,切换设备重试: %v", err))
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||||
}
|
||||
|
||||
// 5. 获取加密的content
|
||||
data, ok := body["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
mangaLogger.Warning("响应data字段无效,切换设备重试")
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||||
}
|
||||
|
||||
encryptedContent, ok := data["content"].(string)
|
||||
if !ok || encryptedContent == "Invalid" {
|
||||
mangaLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount))
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||||
}
|
||||
|
||||
// 检查密钥版本
|
||||
if keyVersion, ok := data["key_version"].(float64); ok {
|
||||
if int(keyVersion) != keyInfo.Ver {
|
||||
mangaLogger.Warning("密钥版本不匹配,重新获取密钥")
|
||||
newKeyInfo, err := GetKey(header)
|
||||
if err == nil {
|
||||
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
|
||||
keyInfo = newKeyInfo
|
||||
} else {
|
||||
mangaLogger.Warning("获取密钥失败,切换设备重试")
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 解密content
|
||||
decrypted, err := decryptAESContent(encryptedContent, keyInfo.Key)
|
||||
if err != nil {
|
||||
mangaLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||||
}
|
||||
|
||||
// 7. 判断是否为漫画内容
|
||||
if strings.Contains(decrypted, "picInfos") {
|
||||
// 是漫画,决定输出模式
|
||||
// 优先级:URL参数 > 配置文件 > 默认值
|
||||
outputMode := "proxy" // 默认值
|
||||
|
||||
// 从配置文件读取
|
||||
cfg := config.GetConfig()
|
||||
if cfg != nil && cfg.Fanqie.Manga.OutputMode != "" {
|
||||
outputMode = cfg.Fanqie.Manga.OutputMode
|
||||
}
|
||||
|
||||
// URL参数优先级最高
|
||||
if urlOutputMode != "" {
|
||||
outputMode = urlOutputMode
|
||||
}
|
||||
|
||||
// 解析漫画图片(传入keyInfo用于解密模式)
|
||||
images, err := parseMangaImages(decrypted, showHTML, outputMode, itemID, keyInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析漫画失败: %w", err)
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// 不是漫画,返回普通文本
|
||||
return map[string]interface{}{
|
||||
"content": decrypted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decryptAESContent 使用AES-128-CBC解密content
|
||||
func decryptAESContent(encrypted, secretKey string) (string, error) {
|
||||
// Base64解码加密内容
|
||||
raw, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Base64解码失败: %w", err)
|
||||
}
|
||||
|
||||
if len(raw) < 16 {
|
||||
return "", fmt.Errorf("数据长度不足")
|
||||
}
|
||||
|
||||
// 提取IV和密文
|
||||
iv := raw[:16]
|
||||
ciphertext := raw[16:]
|
||||
|
||||
// Base64解码密钥
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("密钥解码失败: %w", err)
|
||||
}
|
||||
|
||||
if len(keyBytes) != 16 {
|
||||
return "", fmt.Errorf("密钥长度必须为16字节,实际: %d", len(keyBytes))
|
||||
}
|
||||
|
||||
// 创建AES cipher
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建cipher失败: %w", err)
|
||||
}
|
||||
|
||||
// CBC模式解密
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
|
||||
// PKCS7 unpadding
|
||||
plaintext = pkcs7Unpad(plaintext)
|
||||
|
||||
// 尝试gzip解压
|
||||
if len(plaintext) > 2 && plaintext[0] == 0x1f && plaintext[1] == 0x8b {
|
||||
// 是gzip压缩的数据
|
||||
reader, err := gzip.NewReader(bytes.NewReader(plaintext))
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err == nil {
|
||||
return string(decompressed), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// pkcs7Unpad PKCS7 unpadding
|
||||
func pkcs7Unpad(data []byte) []byte {
|
||||
length := len(data)
|
||||
if length == 0 {
|
||||
return data
|
||||
}
|
||||
unpadding := int(data[length-1])
|
||||
if unpadding > length {
|
||||
return data
|
||||
}
|
||||
return data[:(length - unpadding)]
|
||||
}
|
||||
|
||||
// buildMangaResponse 构建漫画响应(统一HTML和JSON格式输出)
|
||||
func buildMangaResponse(images []ImageResult, showHTML bool) interface{} {
|
||||
if showHTML {
|
||||
var htmlContent strings.Builder
|
||||
for _, img := range images {
|
||||
htmlContent.WriteString(fmt.Sprintf(`<img src="%s" width="%d" height="%d" alt="图片%d">`,
|
||||
img.Data, img.Width, img.Height, img.Index))
|
||||
htmlContent.WriteString("\n")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": htmlContent.String(),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"images": images,
|
||||
}
|
||||
}
|
||||
|
||||
// processURLMode 处理URL模式(MD5直链)
|
||||
func processURLMode(mangaData *MangaData) []ImageResult {
|
||||
baseDomain := "https://p6-novel.byteimg.com/origin/"
|
||||
validPath := detectMangaPath(baseDomain, mangaData.PicInfos[0].Md5)
|
||||
|
||||
images := make([]ImageResult, len(mangaData.PicInfos))
|
||||
for i, pic := range mangaData.PicInfos {
|
||||
imageURL := baseDomain + validPath + pic.Md5
|
||||
images[i] = ImageResult{
|
||||
Data: imageURL,
|
||||
Width: pic.Width,
|
||||
Height: pic.Height,
|
||||
Format: "unknown",
|
||||
Index: i,
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// processProxyMode 处理代理模式(生成短URL并缓存)
|
||||
func processProxyMode(mangaData *MangaData, itemID string) []ImageResult {
|
||||
images := make([]ImageResult, len(mangaData.PicInfos))
|
||||
for i, pic := range mangaData.PicInfos {
|
||||
cacheKey := fmt.Sprintf("%s_%d", itemID, i)
|
||||
|
||||
// 存储图片信息到缓存
|
||||
mangaCacheMutex.Lock()
|
||||
mangaImageCache[cacheKey] = &MangaImageInfo{
|
||||
PicURL: pic.PicURL,
|
||||
EncryptKey: mangaData.EncryptKey,
|
||||
Width: pic.Width,
|
||||
Height: pic.Height,
|
||||
CachedAt: time.Now(),
|
||||
}
|
||||
mangaCacheMutex.Unlock()
|
||||
|
||||
// 返回短的代理URL
|
||||
imageData := fmt.Sprintf("/manga/image/%s/%d", itemID, i)
|
||||
images[i] = ImageResult{
|
||||
Data: imageData,
|
||||
Width: pic.Width,
|
||||
Height: pic.Height,
|
||||
Format: "unknown",
|
||||
Index: i,
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// fillMangaCache 填充漫画图片缓存(供所有模式使用)
|
||||
func fillMangaCache(mangaData *MangaData, itemID string) {
|
||||
for i, pic := range mangaData.PicInfos {
|
||||
cacheKey := fmt.Sprintf("%s_%d", itemID, i)
|
||||
|
||||
// 检查是否已经存在,避免覆盖
|
||||
mangaCacheMutex.RLock()
|
||||
_, exists := mangaImageCache[cacheKey]
|
||||
mangaCacheMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// 存储图片信息到缓存
|
||||
mangaCacheMutex.Lock()
|
||||
mangaImageCache[cacheKey] = &MangaImageInfo{
|
||||
PicURL: pic.PicURL,
|
||||
EncryptKey: mangaData.EncryptKey,
|
||||
Width: pic.Width,
|
||||
Height: pic.Height,
|
||||
CachedAt: time.Now(),
|
||||
}
|
||||
mangaCacheMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// processDataURIMode 处理DataURI模式(下载、解密、Base64编码)
|
||||
func processDataURIMode(mangaData *MangaData) []ImageResult {
|
||||
keyBytes, err := hex.DecodeString(mangaData.EncryptKey)
|
||||
if err != nil {
|
||||
mangaLogger.Warning(fmt.Sprintf("密钥解码失败: %v", err))
|
||||
return []ImageResult{}
|
||||
}
|
||||
|
||||
images := make([]ImageResult, 0, len(mangaData.PicInfos))
|
||||
for i, pic := range mangaData.PicInfos {
|
||||
// 下载加密图片
|
||||
encryptedData, err := downloadImageWithRetry(pic.PicURL, 3)
|
||||
if err != nil {
|
||||
mangaLogger.Warning(fmt.Sprintf("下载图片失败 [%d]: %v", i, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密图片
|
||||
decryptedData, err := decryptImage(encryptedData, keyBytes)
|
||||
if err != nil {
|
||||
mangaLogger.Warning(fmt.Sprintf("解密图片失败 [%d]: %v", i, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 识别格式
|
||||
format := getImageFormat(decryptedData, pic.PicURL)
|
||||
|
||||
// Base64编码图片数据
|
||||
base64Data := base64.StdEncoding.EncodeToString(decryptedData)
|
||||
|
||||
// Data URI模式:返回完整的Base64编码
|
||||
mimeType := fmt.Sprintf("image/%s", format)
|
||||
if format == "jpg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
imageData := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
images = append(images, ImageResult{
|
||||
Data: imageData,
|
||||
Width: pic.Width,
|
||||
Height: pic.Height,
|
||||
Format: format,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// parseMangaImages 解析并处理漫画图片
|
||||
func parseMangaImages(content string, showHTML bool, outputMode string, itemID string, keyInfo *KeyInfo) (interface{}, error) {
|
||||
// 提取JSON部分
|
||||
jsonStart := strings.Index(content, "{")
|
||||
jsonEnd := strings.LastIndex(content, "}")
|
||||
if jsonStart == -1 || jsonEnd == -1 {
|
||||
return nil, fmt.Errorf("无法找到JSON数据")
|
||||
}
|
||||
|
||||
jsonStr := content[jsonStart : jsonEnd+1]
|
||||
var mangaData MangaData
|
||||
if err := json.Unmarshal([]byte(jsonStr), &mangaData); err != nil {
|
||||
return nil, fmt.Errorf("JSON解析失败: %w", err)
|
||||
}
|
||||
|
||||
if len(mangaData.PicInfos) == 0 {
|
||||
return nil, fmt.Errorf("未找到图片信息")
|
||||
}
|
||||
|
||||
if mangaData.EncryptKey == "" {
|
||||
return nil, fmt.Errorf("缺少加密密钥")
|
||||
}
|
||||
|
||||
// 根据不同模式处理图片
|
||||
var images []ImageResult
|
||||
switch outputMode {
|
||||
case "url":
|
||||
images = processURLMode(&mangaData)
|
||||
case "proxy":
|
||||
images = processProxyMode(&mangaData, itemID)
|
||||
default: // "data_uri"
|
||||
images = processDataURIMode(&mangaData)
|
||||
}
|
||||
|
||||
// 无论什么模式,都填充缓存以便后续可以通过 /manga/image 访问
|
||||
fillMangaCache(&mangaData, itemID)
|
||||
|
||||
return buildMangaResponse(images, showHTML), nil
|
||||
}
|
||||
|
||||
// downloadImageWithRetry 带重试的图片下载
|
||||
func downloadImageWithRetry(url string, maxRetries int) ([]byte, error) {
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i <= maxRetries; i++ {
|
||||
data, err := downloadImage(url)
|
||||
if err == nil && len(data) > 100 {
|
||||
return data, nil
|
||||
}
|
||||
lastErr = err
|
||||
if i < maxRetries {
|
||||
// 重试间隔递增(2秒、4秒、6秒)
|
||||
time.Sleep(time.Duration(i*2+2) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// downloadImage 下载图片
|
||||
func downloadImage(url string) ([]byte, error) {
|
||||
// 创建专用的图片下载客户端,超时时间更长
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 使用与API一致的请求头
|
||||
req.Header.Set("User-Agent", "okhttp/4.12.0")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
req.Header.Set("Referer", "https://fanqie.novel.com/")
|
||||
req.Header.Set("Origin", "https://fanqie.novel.com")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// decryptImage 解密图片数据(AES-GCM)
|
||||
func decryptImage(encryptedData, keyBytes []byte) ([]byte, error) {
|
||||
// 加密格式: IV(12字节) + Ciphertext + Tag(16字节)
|
||||
dataLen := len(encryptedData)
|
||||
if dataLen < 28 {
|
||||
return nil, fmt.Errorf("数据长度不足(至少需要28字节)")
|
||||
}
|
||||
|
||||
// 提取各部分
|
||||
iv := encryptedData[:12]
|
||||
tag := encryptedData[dataLen-16:]
|
||||
ciphertext := encryptedData[12 : dataLen-16]
|
||||
|
||||
// 创建AES cipher
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建cipher失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建GCM mode
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建GCM失败: %w", err)
|
||||
}
|
||||
|
||||
// 组合ciphertext和tag(Go的GCM要求tag附加在ciphertext后面)
|
||||
ciphertextWithTag := append(ciphertext, tag...)
|
||||
|
||||
// 解密
|
||||
plaintext, err := aesGCM.Open(nil, iv, ciphertextWithTag, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解密失败: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// getImageFormat 识别图片格式
|
||||
// detectMangaPath 探测正确的漫画图片路径(novel-images/ 或 novel-pic/)
|
||||
func detectMangaPath(baseDomain, firstMd5 string) string {
|
||||
// 优先尝试 novel-images/
|
||||
testURL := baseDomain + "novel-images/" + firstMd5
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(testURL)
|
||||
if err != nil {
|
||||
// 请求失败,使用novel-pic/
|
||||
return "novel-pic/"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应内容
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "novel-pic/"
|
||||
}
|
||||
|
||||
// 检查是否为JSON响应(错误响应通常是JSON)
|
||||
var jsonData interface{}
|
||||
if json.Unmarshal(body, &jsonData) == nil {
|
||||
// 是JSON,说明路径无效,切换到novel-pic/
|
||||
return "novel-pic/"
|
||||
}
|
||||
|
||||
// 不是JSON,说明返回的是图片,路径有效
|
||||
return "novel-images/"
|
||||
}
|
||||
|
||||
func getImageFormat(data []byte, url string) string {
|
||||
// 首先尝试从URL提取扩展名
|
||||
if idx := strings.LastIndex(url, "."); idx != -1 {
|
||||
ext := strings.ToLower(url[idx+1:])
|
||||
// 移除query参数
|
||||
if qIdx := strings.Index(ext, "?"); qIdx != -1 {
|
||||
ext = ext[:qIdx]
|
||||
}
|
||||
if ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "gif" || ext == "webp" {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
// 通过文件头识别
|
||||
if len(data) < 16 {
|
||||
return "png"
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}) {
|
||||
return "jpg"
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47
|
||||
if bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}) {
|
||||
return "png"
|
||||
}
|
||||
|
||||
// GIF: 47 49 46 38
|
||||
if bytes.HasPrefix(data, []byte{0x47, 0x49, 0x46, 0x38}) {
|
||||
return "gif"
|
||||
}
|
||||
|
||||
// WEBP: RIFF...WEBP
|
||||
if bytes.HasPrefix(data, []byte{0x52, 0x49, 0x46, 0x46}) &&
|
||||
len(data) >= 12 &&
|
||||
bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}) {
|
||||
return "webp"
|
||||
}
|
||||
|
||||
return "png"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user