🔧 chore: 初始化番茄小说 API 项目结构

新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块
This commit is contained in:
chenxin committed 2026-08-28 15:22:22 +08:00
commit 6981148727
107 files changed
+12463

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
package algos
import (
"crypto/md5"
"encoding/hex"
"fmt"
"math/rand"
)
// Algo8404 8404加密器
type Algo8404 struct {
unix int64
params string
xSSStub string
}
// NewAlgo8404 创建新的Algo8404实例
func NewAlgo8404(params string, unix int64, xSSStub string) *Algo8404 {
if xSSStub == "" {
xSSStub = "00000000000000000000000000000000"
}
return &Algo8404{
unix: unix,
params: params,
xSSStub: xSSStub,
}
}
// getXGorgon 生成X-Gorgon签名
func (a *Algo8404) getXGorgon() string {
timeHex := fmt.Sprintf("%x", a.unix)
timeBin, _ := hex.DecodeString(timeHex)
m := md5.Sum([]byte(a.params))
mBytes := m[:4]
var aaa []byte
if a.xSSStub != "" {
stubBytes, _ := hex.DecodeString(a.xSSStub)
if len(stubBytes) >= 4 {
aaa = stubBytes[:4]
} else {
aaa = make([]byte, 4)
}
} else {
aaa = make([]byte, 4)
}
// 生成随机数
a1 := rand.Intn(256)
a2 := rand.Intn(256)
// 构建数据
data := make([]byte, 0)
data = append(data, mBytes...)
data = append(data, aaa...)
data = append(data, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x07, 0x04}...)
data = append(data, timeBin...)
// 初始化表
table := make([]int, 256)
for i := 0; i < 256; i++ {
table[i] = i
}
key := []int{0x4A, 0x00, 0x16, a2, 0x47, 0x6C, 0x00, a1}
// 第一轮处理
w8 := 0
for i := 0; i < 256; i++ {
keyI := i & 7
w10 := key[keyI]
w9 := w8 + table[i]
w9 = (w9 + w10) & 0xFFFFFFFF
w10 = w9
w10 &= 0xFFFFFF00
w20 := (w9 - w10) & 0xFFFFFFFF
table[i] = table[w20]
w8 = w20
}
// 第二轮处理
dataArr := make([]int, len(data))
for i, b := range data {
dataArr[i] = int(b)
}
w26 := 0
for i := 0; i < len(dataArr); i++ {
w25 := i
w8_ := w25 ^ 1
w9_ := ((w25 & 1) << 1) & 2
w8_ = (w9_ + w8_) & 0xFFFFFFFF
x10 := w8_ & 0xFF
w9b := table[x10]
w11 := (w26 ^ w9b) & 0xFFFFFFFF
w9c := ((w26 | w9b) << 1) & 0xFFFFFFFF
w9c = (w9c - w11) & 0xFFFFFFFF
w26 = w9c
x11 := w26 & 0xFF
w12 := table[x11]
table[x10] = w12
table[x11] = w12
w10_ := w12
w11_ := dataArr[i]
w13_ := w10_ | w12
w10b := w10_ & w12
wX10 := (w13_ + w10b) & 0xFF
ks := table[wX10]
dataArr[i] = ks ^ w11_
}
// 第三轮处理
w2 := 0xFFFFFFAA
w3 := 0x55
w4 := 0x33
w8_2 := (^len(dataArr)) & 0xFFFFFFFF
for i := 0; i < len(dataArr); i++ {
val := dataArr[i]
w16 := ((val >> 4) & 0x0F) | ((val & 0x0F) << 4)
dataArr[i] = w16 & 0xFF
if i == len(dataArr)-1 {
dataArr[i] = dataArr[0] ^ w16
} else {
w7 := dataArr[i]
w16n := dataArr[i+1]
w20 := w7 | w16n
wI2 := w7 & w16n
dataArr[i] = (w20 - wI2) & 0xFF
}
w13_ := dataArr[i]
t1 := w2 & (w13_ << 1)
t2 := w3 & (w13_ >> 1)
w13_ = t1 | t2
t3 := w13_ << 2
t4 := w4 & (w13_ >> 2)
mix := (t3 & 0xFFFFFFCF) | t4
high := (mix >> 4) & 0x0F
low := mix & 0x0FFFFFFF
mix = ((low << 4) | high) & 0xFFFFFFFF
w13_ = mix ^ w8_2
dataArr[i] = w13_ & 0xFF
}
// 构建输出
output := make([]byte, 0)
output = append(output, 0x84, 0x04)
output = append(output, byte(a1), byte(a2))
output = append(output, 0x00, 0x00)
for _, val := range dataArr {
output = append(output, byte(val))
}
return hex.EncodeToString(output)
}
// GetValue 获取加密值
func (a *Algo8404) GetValue() map[string]string {
return map[string]string{
"X-Gorgon": a.getXGorgon(),
"X-Khronos": fmt.Sprintf("%d", a.unix),
"X-SS-REQ-Ticket": fmt.Sprintf("%d", a.unix*1000),
}
}
+177
View File
@@ -0,0 +1,177 @@
package algos
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"math/rand"
"net/url"
)
// Argus Argus加密器
type Argus struct{}
// encryptEncPb 加密ProtoBuf
func encryptEncPb(data []byte, l int) []byte {
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
xorArray := dataCopy[:8]
for i := 8; i < l; i++ {
dataCopy[i] ^= xorArray[i%8]
}
// 反转数组
for i := 0; i < l/2; i++ {
dataCopy[i], dataCopy[l-1-i] = dataCopy[l-1-i], dataCopy[i]
}
return dataCopy[:l]
}
// GetBodyHash 获取Body哈希
func GetBodyHash(stub string) []byte {
sm3 := NewSM3()
if stub == "" || len(stub) == 0 {
return sm3.Hash(make([]byte, 16))[0:6]
}
stubBytes, _ := hex.DecodeString(stub)
return sm3.Hash(stubBytes)[0:6]
}
// GetQueryHash 获取Query哈希
func GetQueryHash(query string) []byte {
sm3 := NewSM3()
if query == "" || len(query) == 0 {
return sm3.Hash(make([]byte, 16))[0:6]
}
return sm3.Hash([]byte(query))[0:6]
}
// Encrypt Argus加密
func (a *Argus) Encrypt(xargusBean map[int]interface{}) string {
// 创建ProtoBuf
protobuf := NewProtoBuf(xargusBean).ToBuf()
// PKCS7填充
protobuf = AddPKCS7Padding(protobuf, 16)
newLen := len(protobuf)
signKey := []byte{
0xAC, 0x1A, 0xDA, 0xAE, 0x95, 0xA7, 0xAF, 0x94,
0xA5, 0x11, 0x4A, 0xB3, 0xB3, 0xA9, 0x7D, 0xD8,
0x00, 0x50, 0xAA, 0x0A, 0x39, 0x31, 0x4C, 0x40,
0x52, 0x8C, 0xAE, 0xC9, 0x52, 0x56, 0xC2, 0x8C,
}
sm3Output := []byte{
0xFC, 0x78, 0xE0, 0xA9, 0x65, 0x7A, 0x0C, 0x74,
0x8C, 0xE5, 0x15, 0x59, 0x90, 0x3C, 0xCF, 0x03,
0x51, 0x0E, 0x51, 0xD3, 0xCF, 0xF2, 0x32, 0xD7,
0x13, 0x43, 0xE8, 0x8A, 0x32, 0x1C, 0x53, 0x04,
}
key := sm3Output[:32]
keyList := make([]uint64, 0)
encPb := make([]byte, newLen)
for i := 0; i < 2; i++ {
val1 := binary.LittleEndian.Uint64(key[i*16 : i*16+8])
val2 := binary.LittleEndian.Uint64(key[i*16+8 : i*16+16])
keyList = append(keyList, val1, val2)
}
for i := 0; i < newLen/16; i++ {
pt := []uint64{
binary.LittleEndian.Uint64(protobuf[i*16 : i*16+8]),
binary.LittleEndian.Uint64(protobuf[i*16+8 : i*16+16]),
}
ct := SimonEnc(pt, keyList, 0)
binary.LittleEndian.PutUint64(encPb[i*16:i*16+8], ct[0])
binary.LittleEndian.PutUint64(encPb[i*16+8:i*16+16], ct[1])
}
header := []byte{0xF2, 0xF7, 0xFC, 0xFF, 0xF2, 0xF7, 0xFC, 0xFF}
bBuffer := encryptEncPb(append(header, encPb...), newLen+8)
bBuffer = append([]byte{0xA6, 0x6E, 0xAD, 0x9F, 0x77, 0x01, 0xD0, 0x0C, 0x18}, bBuffer...)
bBuffer = append(bBuffer, []byte("ao")...)
// AES加密
keyHash := md5.Sum(signKey[:16])
ivHash := md5.Sum(signKey[16:])
block, _ := aes.NewCipher(keyHash[:])
paddedBuffer := AddPKCS7Padding(bBuffer, aes.BlockSize)
ciphertext := make([]byte, len(paddedBuffer))
mode := cipher.NewCBCEncrypter(block, ivHash[:])
mode.CryptBlocks(ciphertext, paddedBuffer)
result := append([]byte{0xF2, 0x81}, ciphertext...)
return base64.StdEncoding.EncodeToString(result)
}
// GetSign 获取签名
func (a *Argus) GetSign(
queryhash string,
data string,
timestamp int64,
aid int,
licenseID int,
platform int,
secDeviceID string,
sdkVersion string,
sdkVersionInt int,
) string {
paramsDict, _ := url.ParseQuery(queryhash)
deviceID := ""
if val, ok := paramsDict["device_id"]; ok && len(val) > 0 {
deviceID = val[0]
}
versionName := ""
if val, ok := paramsDict["version_name"]; ok && len(val) > 0 {
versionName = val[0]
}
xargusBean := map[int]interface{}{
1: 0x20200929 << 1,
2: 2,
3: rand.Int63n(0x7FFFFFFF),
4: fmt.Sprintf("%d", aid),
5: deviceID,
6: fmt.Sprintf("%d", licenseID),
7: versionName,
8: sdkVersion,
9: sdkVersionInt,
10: make([]byte, 8),
11: platform,
12: timestamp << 1,
13: GetBodyHash(data),
14: GetQueryHash(queryhash),
15: map[int]interface{}{
1: 1,
2: 1,
3: 1,
7: 3348294860,
},
16: secDeviceID,
20: "none",
21: 738,
23: map[int]interface{}{
1: "NX551J",
2: 8196,
4: 2162219008,
},
25: 2,
}
return a.Encrypt(xargusBean)
}
+53
View File
@@ -0,0 +1,53 @@
package algos
// ByteBuf 字节缓冲区
type ByteBuf struct {
mem []byte
dataSize int
pos int
}
// NewByteBuf 创建新的字节缓冲区
func NewByteBuf(data []byte, size *int) *ByteBuf {
buf := &ByteBuf{
pos: 0,
}
if data != nil {
buf.mem = data
}
if size != nil {
buf.dataSize = *size
} else if data != nil {
buf.dataSize = len(data)
} else {
panic("Either size or data must be provided")
}
return buf
}
// Data 获取数据
func (b *ByteBuf) Data() []byte {
return b.mem
}
// Size 获取大小
func (b *ByteBuf) Size() int {
return b.dataSize
}
// RemovePadding 移除填充
func (b *ByteBuf) RemovePadding() []byte {
paddingSize := PKCS7DataLength(b.mem, b.dataSize, 16)
if paddingSize == 0 {
return b.mem[:b.dataSize]
}
b.dataSize = paddingSize
dst := make([]byte, b.dataSize)
copy(dst, b.mem[:b.dataSize])
b.mem = dst
return b.mem
}
+137
View File
@@ -0,0 +1,137 @@
package algos
import (
"crypto/md5"
"encoding/hex"
"fmt"
"strconv"
)
// Gorgon Gorgon加密器
type Gorgon struct {
unix int64
params string
xSSStub string
cookies string
}
// NewGorgon 创建新的Gorgon实例
func NewGorgon(params string, unix int64, xSSStub string, cookies string) *Gorgon {
if xSSStub == "" {
xSSStub = "00000000000000000000000000000000"
}
return &Gorgon{
unix: unix,
params: params,
xSSStub: xSSStub,
cookies: cookies,
}
}
// hash MD5哈希
func (g *Gorgon) hash(data string) string {
hasher := md5.New()
hasher.Write([]byte(data))
return hex.EncodeToString(hasher.Sum(nil))
}
// getBaseString 获取基础字符串
func (g *Gorgon) getBaseString() string {
baseStr := g.hash(g.params)
baseStr = baseStr + g.xSSStub
if g.cookies != "" {
baseStr = baseStr + g.hash(g.cookies)
} else {
baseStr = baseStr + "00000000000000000000000000000000"
}
return baseStr
}
// rbitAlgorithm 位反转算法
func (g *Gorgon) rbitAlgorithm(num int) int {
result := ""
tmpString := fmt.Sprintf("%08b", num)
for i := 0; i < 8; i++ {
result = result + string(tmpString[7-i])
}
val, _ := strconv.ParseInt(result, 2, 64)
return int(val)
}
// hexString 转十六进制字符串
func (g *Gorgon) hexString(num int) string {
tmpString := fmt.Sprintf("%x", num)
if len(tmpString) < 2 {
tmpString = "0" + tmpString
}
return tmpString
}
// reverse 反转
func (g *Gorgon) reverse(num int) int {
tmpString := g.hexString(num)
val, _ := strconv.ParseInt(tmpString[1:]+tmpString[:1], 16, 64)
return int(val)
}
// encrypt 加密
func (g *Gorgon) encrypt(data string) map[string]string {
length := 0x14
key := []int{
0xDF, 0x77, 0xB9, 0x40, 0xB9, 0x9B, 0x84, 0x83,
0xD1, 0xB9, 0xCB, 0xD1, 0xF7, 0xC2, 0xB9, 0x85,
0xC3, 0xD0, 0xFB, 0xC3,
}
paramList := make([]int, 0)
for i := 0; i < 12; i += 4 {
temp := data[8*i : 8*(i+1)]
for j := 0; j < 4; j++ {
val, _ := strconv.ParseInt(temp[j*2:(j+1)*2], 16, 64)
paramList = append(paramList, int(val))
}
}
paramList = append(paramList, 0x0, 0x6, 0xB, 0x1C)
H := int(g.unix)
paramList = append(paramList, (H&0xFF000000)>>24)
paramList = append(paramList, (H&0x00FF0000)>>16)
paramList = append(paramList, (H&0x0000FF00)>>8)
paramList = append(paramList, (H&0x000000FF)>>0)
eorResultList := make([]int, len(paramList))
for i, val := range paramList {
eorResultList[i] = val ^ key[i]
}
for i := 0; i < length; i++ {
C := g.reverse(eorResultList[i])
D := eorResultList[(i+1)%length]
E := C ^ D
F := g.rbitAlgorithm(E)
H := ((F ^ 0xFFFFFFFF) ^ length) & 0xFF
eorResultList[i] = H
}
result := ""
for _, param := range eorResultList {
result += g.hexString(param)
}
return map[string]string{
"x-ss-req-ticket": strconv.FormatInt(g.unix*1000, 10),
"x-khronos": strconv.FormatInt(g.unix, 10),
"x-gorgon": fmt.Sprintf("0404b0d30000%s", result),
}
}
// GetValue 获取加密值
func (g *Gorgon) GetValue() map[string]string {
return g.encrypt(g.getBaseString())
}
+121
View File
@@ -0,0 +1,121 @@
package algos
import (
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
)
// validate 验证并限制在64位范围内
func validate(num uint64) uint64 {
return num & 0xFFFFFFFFFFFFFFFF
}
// ror 右循环移位
func ror(value uint64, count int) uint64 {
nbits := 64
count %= nbits
low := (value << (nbits - count))
value = (value >> count)
value = value | low
return value
}
// encryptLadonInput 加密Ladon输入
func encryptLadonInput(hashTable []byte, inputData []byte) []byte {
data0 := binary.LittleEndian.Uint64(inputData[:8])
data1 := binary.LittleEndian.Uint64(inputData[8:])
for i := 0; i < 0x22; i++ {
hash := binary.LittleEndian.Uint64(hashTable[i*8 : (i+1)*8])
data1 = validate(hash ^ (data0 + ((data1 >> 8) | (data1 << (64 - 8)))))
data0 = validate(data1 ^ ((data0 >> 0x3D) | (data0 << (64 - 0x3D))))
}
outputData := make([]byte, 26)
binary.LittleEndian.PutUint64(outputData[:8], data0)
binary.LittleEndian.PutUint64(outputData[8:], data1)
return outputData
}
// encryptLadon 加密Ladon
func encryptLadon(md5Hex []byte, data []byte, size int) []byte {
hashTable := make([]byte, 272+16)
copy(hashTable[:32], md5Hex)
temp := make([]uint64, 0)
for i := 0; i < 4; i++ {
temp = append(temp, binary.LittleEndian.Uint64(hashTable[i*8:(i+1)*8]))
}
bufferB0 := temp[0]
bufferB8 := temp[1]
temp = temp[2:]
for i := 0; i < 0x22; i++ {
x9 := bufferB0
x8 := bufferB8
x8 = validate(ror(x8, 8))
x8 = validate(x8 + x9)
x8 = validate(x8 ^ uint64(i))
temp = append(temp, x8)
x8 = validate(x8 ^ ror(x9, 61))
binary.LittleEndian.PutUint64(hashTable[(i+1)*8:], x8)
bufferB0 = x8
bufferB8 = temp[0]
temp = temp[1:]
}
newSize := PaddingSize(size)
input := make([]byte, newSize)
copy(input[:size], data)
PKCS7PadBuffer(input, size, newSize, 16)
output := make([]byte, newSize)
for i := 0; i < newSize/16; i++ {
result := encryptLadonInput(hashTable, input[i*16:(i+1)*16])
copy(output[i*16:(i+1)*16], result)
}
return output
}
// LadonEncrypt Ladon加密主函数
func LadonEncrypt(khronos int64, lcID int, aid int, randomBytes []byte) string {
data := fmt.Sprintf("%d-%d-%d", khronos, lcID, aid)
if randomBytes == nil {
randomBytes = make([]byte, 4)
rand.Read(randomBytes)
}
keygen := append(randomBytes, []byte(fmt.Sprintf("%d", aid))...)
hasher := md5.New()
hasher.Write(keygen)
md5Hex := hex.EncodeToString(hasher.Sum(nil))
size := len(data)
newSize := PaddingSize(size)
output := make([]byte, newSize+4)
copy(output[:4], randomBytes)
encrypted := encryptLadon([]byte(md5Hex), []byte(data), size)
copy(output[4:], encrypted)
return base64.StdEncoding.EncodeToString(output)
}
// Ladon Ladon加密器
type Ladon struct{}
// Encrypt Ladon加密
func (l *Ladon) Encrypt(xKhronos int64, lcID int, aid int) string {
return LadonEncrypt(xKhronos, lcID, aid, nil)
}
+78
View File
@@ -0,0 +1,78 @@
package algos
// PKCS7DataLength 获取PKCS7填充数据的实际长度
func PKCS7DataLength(buffer []byte, bufferSize, modulus int) int {
if bufferSize%modulus != 0 || bufferSize < modulus {
return 0
}
paddingValue := buffer[bufferSize-1]
if paddingValue < 1 || int(paddingValue) > modulus {
return 0
}
if bufferSize < int(paddingValue)+1 {
return 0
}
count := 1
bufferSize--
for i := count; i < int(paddingValue); i++ {
bufferSize--
if buffer[bufferSize] != paddingValue {
return 0
}
}
return bufferSize
}
// PKCS7PadBuffer 对缓冲区进行PKCS7填充
func PKCS7PadBuffer(buffer []byte, dataLength, bufferSize, modulus int) int {
padByte := modulus - (dataLength % modulus)
if dataLength+padByte > bufferSize {
return -padByte
}
for i := 0; i < padByte; i++ {
buffer[dataLength+i] = byte(padByte)
}
return padByte
}
// PaddingSize 计算填充后的大小
func PaddingSize(size int) int {
mod := size % 16
if mod > 0 {
return size + (16 - mod)
}
return size
}
// RemovePKCS7Padding 移除PKCS7填充
func RemovePKCS7Padding(data []byte) []byte {
length := len(data)
if length == 0 {
return data
}
paddingLen := int(data[length-1])
if paddingLen > length || paddingLen > 16 {
return data
}
return data[:length-paddingLen]
}
// AddPKCS7Padding 添加PKCS7填充
func AddPKCS7Padding(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
return append(data, padText...)
}
+404
View File
@@ -0,0 +1,404 @@
package algos
import (
"fmt"
)
// ProtoFieldType 协议字段类型
type ProtoFieldType int
const (
ProtoFieldTypeVARINT ProtoFieldType = 0
ProtoFieldTypeINT64 ProtoFieldType = 1
ProtoFieldTypeSTRING ProtoFieldType = 2
ProtoFieldTypeGROUPSTART ProtoFieldType = 3
ProtoFieldTypeGROUPEND ProtoFieldType = 4
ProtoFieldTypeINT32 ProtoFieldType = 5
ProtoFieldTypeERROR1 ProtoFieldType = 6
ProtoFieldTypeERROR2 ProtoFieldType = 7
)
// ProtoField 协议字段
type ProtoField struct {
Idx int
Type ProtoFieldType
Val interface{}
}
// NewProtoField 创建新的协议字段
func NewProtoField(idx int, fieldType ProtoFieldType, val interface{}) *ProtoField {
return &ProtoField{
Idx: idx,
Type: fieldType,
Val: val,
}
}
// IsAsciiStr 检查是否为ASCII字符串
func (pf *ProtoField) IsAsciiStr() bool {
if data, ok := pf.Val.([]byte); ok {
for _, b := range data {
if b < 0x20 || b > 0x7E {
return false
}
}
return true
}
return false
}
// ProtoReader 协议读取器
type ProtoReader struct {
data []byte
pos int
}
// NewProtoReader 创建新的协议读取器
func NewProtoReader(data []byte) *ProtoReader {
return &ProtoReader{
data: data,
pos: 0,
}
}
// Seek 设置位置
func (pr *ProtoReader) Seek(pos int) {
pr.pos = pos
}
// IsRemain 检查是否还有剩余数据
func (pr *ProtoReader) IsRemain(length int) bool {
return pr.pos+length <= len(pr.data)
}
// Read0 读取一个字节
func (pr *ProtoReader) Read0() byte {
if !pr.IsRemain(1) {
panic("buffer overflow")
}
ret := pr.data[pr.pos]
pr.pos++
return ret & 0xFF
}
// Read 读取指定长度的字节
func (pr *ProtoReader) Read(length int) []byte {
if !pr.IsRemain(length) {
panic("buffer overflow")
}
ret := pr.data[pr.pos : pr.pos+length]
pr.pos += length
return ret
}
// ReadInt32 读取32位整数
func (pr *ProtoReader) ReadInt32() uint32 {
bytes := pr.Read(4)
return uint32(bytes[0]) | uint32(bytes[1])<<8 | uint32(bytes[2])<<16 | uint32(bytes[3])<<24
}
// ReadInt64 读取64位整数
func (pr *ProtoReader) ReadInt64() uint64 {
bytes := pr.Read(8)
return uint64(bytes[0]) | uint64(bytes[1])<<8 | uint64(bytes[2])<<16 | uint64(bytes[3])<<24 |
uint64(bytes[4])<<32 | uint64(bytes[5])<<40 | uint64(bytes[6])<<48 | uint64(bytes[7])<<56
}
// ReadVarint 读取可变长度整数
func (pr *ProtoReader) ReadVarint() uint64 {
vint := uint64(0)
n := 0
for {
b := pr.Read0()
vint |= uint64(b&0x7F) << (7 * n)
if b < 0x80 {
break
}
n++
}
return vint
}
// ReadString 读取字符串
func (pr *ProtoReader) ReadString() []byte {
length := pr.ReadVarint()
return pr.Read(int(length))
}
// ProtoWriter 协议写入器
type ProtoWriter struct {
data []byte
}
// NewProtoWriter 创建新的协议写入器
func NewProtoWriter() *ProtoWriter {
return &ProtoWriter{
data: make([]byte, 0),
}
}
// Write0 写入一个字节
func (pw *ProtoWriter) Write0(b byte) {
pw.data = append(pw.data, b&0xFF)
}
// Write 写入字节数组
func (pw *ProtoWriter) Write(bytes []byte) {
pw.data = append(pw.data, bytes...)
}
// WriteInt32 写入32位整数
func (pw *ProtoWriter) WriteInt32(val uint32) {
bs := []byte{
byte(val & 0xFF),
byte((val >> 8) & 0xFF),
byte((val >> 16) & 0xFF),
byte((val >> 24) & 0xFF),
}
pw.Write(bs)
}
// WriteInt64 写入64位整数
func (pw *ProtoWriter) WriteInt64(val uint64) {
bs := []byte{
byte(val & 0xFF),
byte((val >> 8) & 0xFF),
byte((val >> 16) & 0xFF),
byte((val >> 24) & 0xFF),
byte((val >> 32) & 0xFF),
byte((val >> 40) & 0xFF),
byte((val >> 48) & 0xFF),
byte((val >> 56) & 0xFF),
}
pw.Write(bs)
}
// WriteVarint 写入可变长度整数
func (pw *ProtoWriter) WriteVarint(vint uint64) {
vint = vint & 0xFFFFFFFF
for vint > 0x80 {
pw.Write0(byte((vint & 0x7F) | 0x80))
vint >>= 7
}
pw.Write0(byte(vint & 0x7F))
}
// WriteString 写入字符串
func (pw *ProtoWriter) WriteString(bytes []byte) {
pw.WriteVarint(uint64(len(bytes)))
pw.Write(bytes)
}
// ToBytes 转换为字节数组
func (pw *ProtoWriter) ToBytes() []byte {
return pw.data
}
// ProtoBuf 协议缓冲区
type ProtoBuf struct {
fields []*ProtoField
}
// NewProtoBuf 创建新的协议缓冲区
func NewProtoBuf(data interface{}) *ProtoBuf {
pb := &ProtoBuf{
fields: make([]*ProtoField, 0),
}
if data != nil {
switch v := data.(type) {
case []byte:
if len(v) > 0 {
pb.parseBuf(v)
}
case map[int]interface{}:
if len(v) > 0 {
pb.parseDict(v)
}
default:
panic(fmt.Sprintf("unsupported type(%T) to protobuf", data))
}
}
return pb
}
// parseBuf 解析字节数组
func (pb *ProtoBuf) parseBuf(bytes []byte) {
reader := NewProtoReader(bytes)
for reader.IsRemain(1) {
key := reader.ReadVarint()
fieldType := ProtoFieldType(key & 0x7)
fieldIdx := int(key >> 3)
if fieldIdx == 0 {
break
}
switch fieldType {
case ProtoFieldTypeINT32:
pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadInt32()))
case ProtoFieldTypeINT64:
pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadInt64()))
case ProtoFieldTypeVARINT:
pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadVarint()))
case ProtoFieldTypeSTRING:
pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadString()))
default:
panic(fmt.Sprintf("parse protobuf error, unexpected field type: %d", fieldType))
}
}
}
// ToBuf 转换为字节数组
func (pb *ProtoBuf) ToBuf() []byte {
writer := NewProtoWriter()
for _, field := range pb.fields {
key := uint64((field.Idx << 3) | int(field.Type&7))
writer.WriteVarint(key)
switch field.Type {
case ProtoFieldTypeINT32:
writer.WriteInt32(field.Val.(uint32))
case ProtoFieldTypeINT64:
writer.WriteInt64(field.Val.(uint64))
case ProtoFieldTypeVARINT:
writer.WriteVarint(field.Val.(uint64))
case ProtoFieldTypeSTRING:
writer.WriteString(field.Val.([]byte))
default:
panic(fmt.Sprintf("encode to protobuf error, unexpected field type: %d", field.Type))
}
}
return writer.ToBytes()
}
// Get 获取字段
func (pb *ProtoBuf) Get(idx int) *ProtoField {
for _, field := range pb.fields {
if field.Idx == idx {
return field
}
}
return nil
}
// GetList 获取字段列表
func (pb *ProtoBuf) GetList(idx int) []*ProtoField {
result := make([]*ProtoField, 0)
for _, field := range pb.fields {
if field.Idx == idx {
result = append(result, field)
}
}
return result
}
// GetInt 获取整数
func (pb *ProtoBuf) GetInt(idx int) uint64 {
pf := pb.Get(idx)
if pf == nil {
return 0
}
switch pf.Type {
case ProtoFieldTypeINT32:
return uint64(pf.Val.(uint32))
case ProtoFieldTypeINT64:
return pf.Val.(uint64)
case ProtoFieldTypeVARINT:
return pf.Val.(uint64)
default:
panic(fmt.Sprintf("getInt(%d) -> %d", idx, pf.Type))
}
}
// GetBytes 获取字节数组
func (pb *ProtoBuf) GetBytes(idx int) []byte {
pf := pb.Get(idx)
if pf == nil {
return nil
}
if pf.Type == ProtoFieldTypeSTRING {
return pf.Val.([]byte)
}
panic(fmt.Sprintf("getBytes(%d) -> %d", idx, pf.Type))
}
// GetUTF8 获取UTF8字符串
func (pb *ProtoBuf) GetUTF8(idx int) string {
bs := pb.GetBytes(idx)
if bs == nil {
return ""
}
return string(bs)
}
// GetProtoBuf 获取嵌套的ProtoBuf
func (pb *ProtoBuf) GetProtoBuf(idx int) *ProtoBuf {
bs := pb.GetBytes(idx)
if bs == nil {
return nil
}
return NewProtoBuf(bs)
}
// Put 添加字段
func (pb *ProtoBuf) Put(field *ProtoField) {
pb.fields = append(pb.fields, field)
}
// PutInt32 添加32位整数
func (pb *ProtoBuf) PutInt32(idx int, val uint32) {
pb.Put(NewProtoField(idx, ProtoFieldTypeINT32, val))
}
// PutInt64 添加64位整数
func (pb *ProtoBuf) PutInt64(idx int, val uint64) {
pb.Put(NewProtoField(idx, ProtoFieldTypeINT64, val))
}
// PutVarint 添加可变长度整数
func (pb *ProtoBuf) PutVarint(idx int, val uint64) {
pb.Put(NewProtoField(idx, ProtoFieldTypeVARINT, val))
}
// PutBytes 添加字节数组
func (pb *ProtoBuf) PutBytes(idx int, data []byte) {
pb.Put(NewProtoField(idx, ProtoFieldTypeSTRING, data))
}
// PutUTF8 添加UTF8字符串
func (pb *ProtoBuf) PutUTF8(idx int, data string) {
pb.Put(NewProtoField(idx, ProtoFieldTypeSTRING, []byte(data)))
}
// PutProtoBuf 添加嵌套的ProtoBuf
func (pb *ProtoBuf) PutProtoBuf(idx int, data *ProtoBuf) {
pb.Put(NewProtoField(idx, ProtoFieldTypeSTRING, data.ToBuf()))
}
// parseDict 解析字典
func (pb *ProtoBuf) parseDict(data map[int]interface{}) {
for k, v := range data {
switch val := v.(type) {
case int:
pb.PutVarint(k, uint64(val))
case uint64:
pb.PutVarint(k, val)
case int64:
pb.PutVarint(k, uint64(val))
case string:
pb.PutUTF8(k, val)
case []byte:
pb.PutBytes(k, val)
case map[int]interface{}:
pb.PutProtoBuf(k, NewProtoBuf(val))
default:
panic(fmt.Sprintf("unsupported type(%T) to protobuf", v))
}
}
}
+219
View File
@@ -0,0 +1,219 @@
package algos
import (
"fmt"
"net/url"
"regexp"
"strconv"
"sync"
"time"
)
// getTimestamp 获取时间戳(辅助函数)
func getTimestamp(unix *int64) int64 {
if unix == nil {
return time.Now().Unix()
}
return *unix
}
// Sign2 签名方法2(Gorgon 或 8404)
func Sign2(
params string,
xSSStub string,
secDeviceID string,
cookie string,
aid int,
licenseID int,
sdkVersionStr string,
sdkVersion int,
platform int,
unix *int64,
) map[string]string {
timestamp := getTimestamp(unix)
// 根据当前算法选择生成签名
currentAlgo := GlobalAlgorithmSwitcher.GetCurrentAlgorithm()
if currentAlgo == "8404" {
algo := NewAlgo8404(params, timestamp, xSSStub)
return algo.GetValue()
}
// 默认使用 Gorgon
gorgon := NewGorgon(params, timestamp, xSSStub, cookie)
return gorgon.GetValue()
}
// Sign4 签名方法4(Gorgon + Ladon + Argus)
func Sign4(
params string,
xSSStub string,
secDeviceID string,
cookie string,
aid int,
licenseID int,
sdkVersionStr string,
sdkVersion int,
platform int,
unix *int64,
) map[string]string {
timestamp := getTimestamp(unix)
// 根据当前算法选择生成基础签名
var result map[string]string
currentAlgo := GlobalAlgorithmSwitcher.GetCurrentAlgorithm()
if currentAlgo == "8404" {
algo := NewAlgo8404(params, timestamp, xSSStub)
result = algo.GetValue()
} else {
// 默认使用 Gorgon
gorgon := NewGorgon(params, timestamp, xSSStub, cookie)
result = gorgon.GetValue()
}
// 添加 Ladon 和 Argus 签名
ladon := &Ladon{}
result["x-ladon"] = ladon.Encrypt(timestamp, licenseID, aid)
argus := &Argus{}
result["x-argus"] = argus.GetSign(
params,
xSSStub,
timestamp,
aid,
licenseID,
platform,
secDeviceID,
sdkVersionStr,
sdkVersion,
)
return result
}
// SignatureInterface 签名接口
type SignatureInterface interface {
Sign(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string
}
// SignatureSwitcher 签名方法切换器
type SignatureSwitcher struct {
mu sync.RWMutex
currentMethod string
methods map[string]SignatureInterface
}
// NewSignatureSwitcher 创建签名切换器
func NewSignatureSwitcher() *SignatureSwitcher {
switcher := &SignatureSwitcher{
methods: make(map[string]SignatureInterface),
}
// 注册所有签名方法
switcher.methods["sign2"] = &Sign2Method{}
switcher.methods["sign4"] = &Sign4Method{}
// 设置默认签名方法为sign2
switcher.currentMethod = "sign2"
return switcher
}
// SwitchSignature 切换签名方法
func (s *SignatureSwitcher) SwitchSignature(method string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.methods[method]; !exists {
return fmt.Errorf("不支持的签名方法: %s", method)
}
s.currentMethod = method
return nil
}
// GetCurrentMethod 获取当前签名方法
func (s *SignatureSwitcher) GetCurrentMethod() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentMethod
}
// Sign 使用当前签名方法进行签名
func (s *SignatureSwitcher) Sign(urlStr string, headers map[string]string) map[string]string {
return SignFromURL(s.GetCurrentMethod(), urlStr, headers)
}
// SignFromParams 使用当前签名方法进行签名
func (s *SignatureSwitcher) SignFromParams(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string {
s.mu.RLock()
method := s.methods[s.currentMethod]
s.mu.RUnlock()
return method.Sign(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, unix)
}
// Sign2Method Sign2签名方法实现
type Sign2Method struct{}
func (s *Sign2Method) Sign(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string {
return Sign2(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, unix)
}
// Sign4Method Sign4签名方法实现
type Sign4Method struct{}
func (s *Sign4Method) Sign(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string {
return Sign4(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, unix)
}
// GlobalSignatureSwitcher 全局签名切换器实例
var GlobalSignatureSwitcher = NewSignatureSwitcher()
// SignFromURL 从 URL 和 headers 中提取参数并生成签名
func SignFromURL(method, urlStr string, headers map[string]string) map[string]string {
// 解析 URL
u, err := url.Parse(urlStr)
if err != nil {
return make(map[string]string)
}
// 提取查询参数
params := u.RawQuery
xSSStub := headers["x-ss-stub"]
cookie := headers["Cookie"]
// 从 URL 中提取 device_id 和 aid
deviceIDRegex := regexp.MustCompile(`device_id=(\d+)`)
aidRegex := regexp.MustCompile(`aid=(\d+)`)
deviceIDMatch := deviceIDRegex.FindStringSubmatch(urlStr)
aidMatch := aidRegex.FindStringSubmatch(urlStr)
secDeviceID := ""
aid := 1967
if len(deviceIDMatch) > 1 {
secDeviceID = deviceIDMatch[1]
}
if len(aidMatch) > 1 {
aid, _ = strconv.Atoi(aidMatch[1])
}
// 使用固定参数
licenseID := 1611921764
sdkVersionStr := "v04.06.01-ml-android"
sdkVersion := 135004672
platform := 0
// 根据方法调用对应的签名函数
if method == "sign4" {
return Sign4(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, nil)
}
return Sign2(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, nil)
}
// Sign2Wrapper 硬编码使用 Sign2(从 URL 和 headers 生成签名)
func Sign2Wrapper(urlStr string, headers map[string]string) map[string]string {
return SignFromURL("sign2", urlStr, headers)
}
+85
View File
@@ -0,0 +1,85 @@
package algos
// getBit 获取指定位的值
func getBit(val uint64, pos int) uint64 {
if val&(1<<pos) != 0 {
return 1
}
return 0
}
// rotateLeft64 64位左循环移位
func rotateLeft64(v uint64, n int) uint64 {
r := (v << n) | (v >> (64 - n))
return r & 0xFFFFFFFFFFFFFFFF
}
// rotateRight64 64位右循环移位
func rotateRight64(v uint64, n int) uint64 {
r := (v << (64 - n)) | (v >> n)
return r & 0xFFFFFFFFFFFFFFFF
}
// keyExpansion 密钥扩展
func keyExpansion(key []uint64) []uint64 {
expandedKey := make([]uint64, 72)
copy(expandedKey, key[:4])
var tmp uint64
for i := 4; i < 72; i++ {
tmp = rotateRight64(expandedKey[i-1], 3)
tmp = tmp ^ expandedKey[i-3]
tmp = tmp ^ rotateRight64(tmp, 1)
expandedKey[i] = (^expandedKey[i-4]) ^ tmp ^ getBit(0x3DC94C3A046D678B, (i-4)%62) ^ 3
}
return expandedKey
}
// SimonEnc Simon加密
func SimonEnc(pt []uint64, k []uint64, c int) []uint64 {
key := make([]uint64, 72)
copy(key[:4], k[:4])
key = keyExpansion(key)
xi := pt[0]
xi1 := pt[1]
var tmp, f uint64
for i := 0; i < 72; i++ {
tmp = xi1
if c == 1 {
f = rotateLeft64(xi1, 1)
} else {
f = rotateLeft64(xi1, 1) & rotateLeft64(xi1, 8)
}
xi1 = xi ^ f ^ rotateLeft64(xi1, 2) ^ key[i]
xi = tmp
}
return []uint64{xi, xi1}
}
// SimonDec Simon解密
func SimonDec(ct []uint64, k []uint64, c int) []uint64 {
key := make([]uint64, 72)
copy(key[:4], k[:4])
key = keyExpansion(key)
xi := ct[0]
xi1 := ct[1]
var tmp, f uint64
for i := 71; i >= 0; i-- {
tmp = xi
if c == 1 {
f = rotateLeft64(xi, 1)
} else {
f = rotateLeft64(xi, 1) & rotateLeft64(xi, 8)
}
xi = xi1 ^ f ^ rotateLeft64(xi, 2) ^ key[i]
xi1 = tmp
}
return []uint64{xi, xi1}
}
+154
View File
@@ -0,0 +1,154 @@
package algos
// SM3 SM3哈希算法实现
type SM3 struct {
IV []uint32
TJ []uint32
}
// NewSM3 创建SM3实例
func NewSM3() *SM3 {
return &SM3{
IV: []uint32{1937774191, 1226093241, 388252375, 3666478592, 2842636476, 372324522, 3817729613, 2969243214},
TJ: []uint32{
2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169,
2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169,
2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042,
2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042,
2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042,
2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042,
2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042,
2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042,
},
}
}
// rotateLeft 循环左移
func (sm3 *SM3) rotateLeft(a uint32, k int) uint32 {
k = k % 32
return ((a << uint(k)) & 0xFFFFFFFF) | ((a & 0xFFFFFFFF) >> uint(32-k))
}
// FFJ FF函数
func (sm3 *SM3) FFJ(X, Y, Z uint32, j int) uint32 {
if j >= 0 && j < 16 {
return X ^ Y ^ Z
} else if j >= 16 && j < 64 {
return (X & Y) | (X & Z) | (Y & Z)
}
return 0
}
// GGJ GG函数
func (sm3 *SM3) GGJ(X, Y, Z uint32, j int) uint32 {
if j >= 0 && j < 16 {
return X ^ Y ^ Z
} else if j >= 16 && j < 64 {
return (X & Y) | ((^X) & Z)
}
return 0
}
// P0 压缩函数P0
func (sm3 *SM3) P0(X uint32) uint32 {
return X ^ sm3.rotateLeft(X, 9) ^ sm3.rotateLeft(X, 17)
}
// P1 压缩函数P1
func (sm3 *SM3) P1(X uint32) uint32 {
return X ^ sm3.rotateLeft(X, 15) ^ sm3.rotateLeft(X, 23)
}
// CF 压缩函数
func (sm3 *SM3) CF(Vi []uint32, Bi []byte) []uint32 {
W := make([]uint32, 68)
for i := 0; i < 16; i++ {
weight := uint32(0x1000000)
data := uint32(0)
for k := i * 4; k < (i+1)*4; k++ {
data = data + uint32(Bi[k])*weight
weight = weight / 0x100
}
W[i] = data
}
for j := 16; j < 68; j++ {
W[j] = sm3.P1(W[j-16]^W[j-9]^sm3.rotateLeft(W[j-3], 15)) ^ sm3.rotateLeft(W[j-13], 7) ^ W[j-6]
}
W1 := make([]uint32, 64)
for j := 0; j < 64; j++ {
W1[j] = W[j] ^ W[j+4]
}
A, B, C, D, E, F, G, H := Vi[0], Vi[1], Vi[2], Vi[3], Vi[4], Vi[5], Vi[6], Vi[7]
for j := 0; j < 64; j++ {
SS1 := sm3.rotateLeft((sm3.rotateLeft(A, 12)+E+sm3.rotateLeft(sm3.TJ[j], j%32))&0xFFFFFFFF, 7)
SS2 := SS1 ^ sm3.rotateLeft(A, 12)
TT1 := (sm3.FFJ(A, B, C, j) + D + SS2 + W1[j]) & 0xFFFFFFFF
TT2 := (sm3.GGJ(E, F, G, j) + H + SS1 + W[j]) & 0xFFFFFFFF
D = C
C = sm3.rotateLeft(B, 9)
B = A
A = TT1
H = G
G = sm3.rotateLeft(F, 19)
F = E
E = sm3.P0(TT2)
}
V := make([]uint32, 8)
V[0] = A ^ Vi[0]
V[1] = B ^ Vi[1]
V[2] = C ^ Vi[2]
V[3] = D ^ Vi[3]
V[4] = E ^ Vi[4]
V[5] = F ^ Vi[5]
V[6] = G ^ Vi[6]
V[7] = H ^ Vi[7]
return V
}
// Hash 计算SM3哈希
func (sm3 *SM3) Hash(msg []byte) []byte {
msgLen := len(msg)
reserve1 := msgLen % 64
paddingLen := 0
if reserve1 > 55 {
paddingLen = 64 - reserve1 + 64
} else {
paddingLen = 64 - reserve1
}
msgPadding := make([]byte, msgLen+paddingLen)
copy(msgPadding, msg)
msgPadding[msgLen] = 0x80
lengthBits := uint64(msgLen) * 8
for i := 0; i < 8; i++ {
msgPadding[len(msgPadding)-1-i] = byte(lengthBits >> uint(8*i))
}
groupCount := len(msgPadding) / 64
V := make([]uint32, len(sm3.IV))
copy(V, sm3.IV)
for i := 0; i < groupCount; i++ {
B := msgPadding[i*64 : (i+1)*64]
V = sm3.CF(V, B)
}
result := make([]byte, 32)
for i := 0; i < 8; i++ {
result[i*4] = byte((V[i] >> 24) & 0xFF)
result[i*4+1] = byte((V[i] >> 16) & 0xFF)
result[i*4+2] = byte((V[i] >> 8) & 0xFF)
result[i*4+3] = byte(V[i] & 0xFF)
}
return result
}
+159
View File
@@ -0,0 +1,159 @@
package algos
import (
"fmt"
"strings"
"sync"
"time"
)
// AlgorithmInterface 算法接口
type AlgorithmInterface interface {
GetAlgorithmName() string
GenerateHeaders(url string, headers map[string]string) (map[string]string, error)
}
// AlgorithmSwitcher 算法切换器
type AlgorithmSwitcher struct {
mu sync.RWMutex
currentAlgorithm string
algorithms map[string]AlgorithmInterface
}
// NewAlgorithmSwitcher 创建算法切换器
func NewAlgorithmSwitcher() *AlgorithmSwitcher {
switcher := &AlgorithmSwitcher{
algorithms: make(map[string]AlgorithmInterface),
}
// 注册所有算法
switcher.algorithms["gorgon"] = &GorgonAlgorithmAdapter{}
switcher.algorithms["8404"] = &Algo8404Adapter{}
// 设置默认算法为gorgon
switcher.currentAlgorithm = "gorgon"
return switcher
}
// SwitchAlgorithm 切换算法
func (s *AlgorithmSwitcher) SwitchAlgorithm(algorithmType string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.algorithms[algorithmType]; !exists {
return fmt.Errorf("不支持的算法类型: %s", algorithmType)
}
s.currentAlgorithm = algorithmType
return nil
}
// GetCurrentAlgorithm 获取当前算法名称
func (s *AlgorithmSwitcher) GetCurrentAlgorithm() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentAlgorithm
}
// GetAvailableAlgorithms 获取可用算法列表
func (s *AlgorithmSwitcher) GetAvailableAlgorithms() []string {
s.mu.RLock()
defer s.mu.RUnlock()
algorithms := make([]string, 0, len(s.algorithms))
for name := range s.algorithms {
algorithms = append(algorithms, name)
}
return algorithms
}
// GetCurrentAlgorithmInstance 获取当前算法实例
func (s *AlgorithmSwitcher) GetCurrentAlgorithmInstance() AlgorithmInterface {
s.mu.RLock()
defer s.mu.RUnlock()
return s.algorithms[s.currentAlgorithm]
}
// GetAlgorithmInstance 获取指定算法实例
func (s *AlgorithmSwitcher) GetAlgorithmInstance(algorithmType string) (AlgorithmInterface, error) {
s.mu.RLock()
defer s.mu.RUnlock()
algorithm, exists := s.algorithms[algorithmType]
if !exists {
return nil, fmt.Errorf("不支持的算法类型: %s", algorithmType)
}
return algorithm, nil
}
// GorgonAlgorithmAdapter Gorgon算法适配器(兼容旧版)
type GorgonAlgorithmAdapter struct{}
func (g *GorgonAlgorithmAdapter) GetAlgorithmName() string {
return "Gorgon算法"
}
func (g *GorgonAlgorithmAdapter) GenerateHeaders(url string, headers map[string]string) (map[string]string, error) {
// 使用Gorgon算法
parts := strings.Split(url, "?")
if len(parts) < 2 {
return headers, fmt.Errorf("URL格式错误")
}
xSSStub := headers["x-ss-stub"]
cookie := headers["Cookie"]
if cookie == "" {
cookie = headers["cookie"]
}
gorgon := NewGorgon(parts[1], time.Now().Unix(), xSSStub, cookie)
result := gorgon.GetValue()
finalHeaders := make(map[string]string)
for k, v := range headers {
finalHeaders[k] = v
}
for k, v := range result {
finalHeaders[k] = v
}
return finalHeaders, nil
}
// Algo8404Adapter 8404算法适配器
type Algo8404Adapter struct{}
func (a *Algo8404Adapter) GetAlgorithmName() string {
return "8404算法"
}
func (a *Algo8404Adapter) GenerateHeaders(url string, headers map[string]string) (map[string]string, error) {
// 使用8404算法
parts := strings.Split(url, "?")
if len(parts) < 2 {
return headers, fmt.Errorf("URL格式错误")
}
xSSStub := headers["x-ss-stub"]
algo := NewAlgo8404(parts[1], time.Now().Unix(), xSSStub)
result := algo.GetValue()
finalHeaders := make(map[string]string)
for k, v := range headers {
finalHeaders[k] = v
}
for k, v := range result {
finalHeaders[k] = v
}
return finalHeaders, nil
}
// GlobalAlgorithmSwitcher 全局算法切换器实例
var GlobalAlgorithmSwitcher = NewAlgorithmSwitcher()
+192
View File
@@ -0,0 +1,192 @@
package common
import (
"bytes"
"compress/zlib"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// HTTPClient HTTP客户端
type HTTPClient struct {
client *http.Client
logger *Logger
}
// NewHTTPClient 创建新的HTTP客户端
func NewHTTPClient() *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
logger: NewLogger("http_utils", "INFO", ""),
}
}
// ClientResponse 客户端响应
type ClientResponse struct {
Status int
Content []byte
Headers http.Header
Cookies []*http.Cookie
Text string
}
// GetHeader 获取响应头(兼容方法)
func (r *ClientResponse) GetHeader(key string) string {
return r.Headers.Get(key)
}
// JSON 解析JSON响应
func (r *ClientResponse) JSON() (map[string]interface{}, error) {
var result map[string]interface{}
err := json.Unmarshal(r.Content, &result)
return result, err
}
// Request HTTP请求
func (h *HTTPClient) Request(url string, options map[string]interface{}) (*ClientResponse, error) {
// 获取请求方法
method := "GET"
if m, ok := options["method"].(string); ok {
method = strings.ToUpper(m)
}
// 获取请求头
headers := make(map[string]string)
if h, ok := options["headers"].(map[string]string); ok {
headers = h
}
// 设置默认User-Agent
if _, ok := headers["User-Agent"]; !ok {
headers["User-Agent"] = "okhttp/4.12.0"
}
// 获取请求体
var body io.Reader
if method == "POST" || method == "PUT" {
if b, ok := options["body"].(string); ok {
body = strings.NewReader(b)
} else if b, ok := options["body"].([]byte); ok {
body = bytes.NewReader(b)
} else if data, ok := options["data"].(string); ok {
body = strings.NewReader(data)
} else if data, ok := options["data"].([]byte); ok {
body = bytes.NewReader(data)
}
}
// 创建请求
req, err := http.NewRequest(method, url, body)
if err != nil {
h.logger.Error(fmt.Sprintf("Failed to create request: %v", err))
return nil, err
}
// 设置请求头
for k, v := range headers {
req.Header.Set(k, v)
}
// 打印请求信息
// h.logger.Info(fmt.Sprintf("=== HTTP 请求 ==="))
// h.logger.Info(fmt.Sprintf("方法: %s", method))
// h.logger.Info(fmt.Sprintf("URL: %s", url))
// h.logger.Info(fmt.Sprintf("请求头:"))
// for k, v := range req.Header {
// h.logger.Info(fmt.Sprintf(" %s: %s", k, strings.Join(v, ", ")))
// }
// 检查是否跳过SSL验证
skipSSL := os.Getenv("FQWEB_SKIP_SSL_VERIFY")
if skipSSL != "" && h.client.Transport == nil {
// 配置跳过SSL验证
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
h.client.Transport = transport
h.logger.Warning("已启用SSL验证跳过模式")
}
// 发送请求
resp, err := h.client.Do(req)
if err != nil {
h.logger.Error(fmt.Sprintf("HTTP Request error: %v", err))
return nil, err
}
defer resp.Body.Close()
// 读取响应体
content, err := io.ReadAll(resp.Body)
if err != nil {
h.logger.Error(fmt.Sprintf("Failed to read response: %v", err))
return nil, err
}
// 处理zlib压缩
if len(content) > 2 && (content[0] == 0x78 && (content[1] == 0x9C || content[1] == 0x01)) {
reader, err := zlib.NewReader(bytes.NewReader(content))
if err == nil {
decompressed, err := io.ReadAll(reader)
reader.Close()
if err == nil {
content = decompressed
}
}
}
response := &ClientResponse{
Status: resp.StatusCode,
Content: content,
Headers: resp.Header,
Cookies: resp.Cookies(),
Text: string(content),
}
return response, nil
}
// Get GET请求
func (h *HTTPClient) Get(url string, headers map[string]string) (*ClientResponse, error) {
options := map[string]interface{}{
"method": "GET",
"headers": headers,
}
return h.Request(url, options)
}
// Post POST请求
func (h *HTTPClient) Post(url string, data interface{}, headers map[string]string) (*ClientResponse, error) {
options := map[string]interface{}{
"method": "POST",
"headers": headers,
"body": data,
}
return h.Request(url, options)
}
// ConvertDictToFormString 将字典转换为表单字符串
func ConvertDictToFormString(dict map[string]string) string {
parts := make([]string, 0, len(dict))
for k, v := range dict {
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(parts, "&")
}
// DefaultHTTPClient 默认HTTP客户端
var DefaultHTTPClient = NewHTTPClient()
// Request 全局请求函数
func Request(url string, options map[string]interface{}) (*ClientResponse, error) {
return DefaultHTTPClient.Request(url, options)
}
+123
View File
@@ -0,0 +1,123 @@
package common
import (
"fmt"
"log"
"strings"
"time"
)
// LogLevel 日志级别
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARNING
ERROR
CRITICAL
)
// Logger 日志器
type Logger struct {
moduleName string
outputLevel LogLevel
}
// NewLogger 创建新的日志器
func NewLogger(moduleName string, outputLevel string, filename string) *Logger {
logger := &Logger{
moduleName: moduleName,
}
// 设置日志级别
switch strings.ToUpper(outputLevel) {
case "DEBUG":
logger.outputLevel = DEBUG
case "INFO":
logger.outputLevel = INFO
case "WARNING":
logger.outputLevel = WARNING
case "ERROR":
logger.outputLevel = ERROR
case "CRITICAL":
logger.outputLevel = CRITICAL
default:
logger.outputLevel = INFO
}
return logger
}
// Debug 调试日志
func (l *Logger) Debug(message string) {
if l.outputLevel <= DEBUG {
l.log("DEBUG", message)
}
}
// Info 信息日志
func (l *Logger) Info(message string) {
if l.outputLevel <= INFO {
l.log("INFO", message)
}
}
// Warning 警告日志
func (l *Logger) Warning(message string) {
if l.outputLevel <= WARNING {
l.log("WARNING", message)
}
}
// Error 错误日志
func (l *Logger) Error(message string) {
if l.outputLevel <= ERROR {
l.log("ERROR", message)
}
}
// Critical 严重错误日志
func (l *Logger) Critical(message string) {
if l.outputLevel <= CRITICAL {
l.log("CRITICAL", message)
}
}
// log 内部日志方法
func (l *Logger) log(level string, message string) {
timestamp := time.Now().Format("2006-01-02 15:04:05")
logMessage := fmt.Sprintf("%s|[%s/%s]|%s",
timestamp, l.moduleName, level, message)
// 控制台输出
fmt.Println(logMessage)
}
// Log 全局日志函数
func Log(moduleName string) *Logger {
return NewLogger(moduleName, "INFO", "")
}
// HighlightError 高亮错误
func HighlightError(err interface{}) string {
return fmt.Sprintf("%v", err)
}
// 全局日志实例
var (
defaultLogger = NewLogger("fanqie", "INFO", "")
)
// LogPrint 打印日志
func LogPrint(args ...interface{}) {
message := fmt.Sprint(args...)
defaultLogger.Info(message)
}
func init() {
// 初始化日志系统
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
+32
View File
@@ -0,0 +1,32 @@
package common
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"fmt"
)
// Bytes2DataURI 将字节转换为 Data URI
func Bytes2DataURI(dataBytes []byte, mimeType string) string {
encoded := base64.StdEncoding.EncodeToString(dataBytes)
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
}
// CreateMD5 创建MD5哈希
func CreateMD5(s interface{}) string {
var data []byte
switch v := s.(type) {
case string:
data = []byte(v)
case []byte:
data = v
default:
data = []byte(fmt.Sprintf("%v", v))
}
hasher := md5.New()
hasher.Write(data)
return hex.EncodeToString(hasher.Sum(nil))
}
+234
View File
@@ -0,0 +1,234 @@
package config
import (
"fmt"
"log"
"sync"
"github.com/spf13/viper"
)
// Config 应用配置结构
type Config struct {
Server ServerConfig `mapstructure:"server"`
Fanqie FanqieConfig `mapstructure:"fanqie"`
Cache CacheConfig `mapstructure:"cache"`
DeviceKeyPool DeviceKeyPoolConfig `mapstructure:"device_key_pool"`
}
// ServerConfig 服务器配置
type ServerConfig struct {
Port string `mapstructure:"port"`
Host string `mapstructure:"host"`
AccessKey string `mapstructure:"access_key"`
}
// FanqieConfig 番茄小说配置
type FanqieConfig struct {
Algorithm string `mapstructure:"algorithm"`
Signature string `mapstructure:"signature"` // 签名方法: sign2 / sign4
Content ContentConfig `mapstructure:"content"`
Manga MangaConfig `mapstructure:"manga"`
}
// ContentConfig 内容处理配置
type ContentConfig struct {
RemoveImages bool `mapstructure:"remove_images"`
RemoveImageDesc bool `mapstructure:"remove_image_desc"`
RemoveBlankLines bool `mapstructure:"remove_blank_lines"`
RemoveHTMLTags bool `mapstructure:"remove_html_tags"`
RemoveChapterTitle bool `mapstructure:"remove_chapter_title"`
AddParagraphIndent bool `mapstructure:"add_paragraph_indent"`
DoubleNewline bool `mapstructure:"double_newline"` // 段落间使用双换行
EnableReview bool `mapstructure:"enable_review"` // 是否启用段评功能
}
// MangaConfig 漫画处理配置
type MangaConfig struct {
OutputMode string `mapstructure:"output_mode"` // 输出模式: data_uri / base64 / url
}
// CacheConfig 缓存配置
type CacheConfig struct {
Enabled bool `mapstructure:"enabled"`
SQLitePath string `mapstructure:"sqlite_path"`
CleanupEnabled bool `mapstructure:"cleanup_enabled"` // 是否启用缓存清理
CleanupSeconds int `mapstructure:"cleanup_seconds"` // 缓存清理时间(秒)
}
// DeviceKeyPoolConfig 设备密钥池配置
type DeviceKeyPoolConfig struct {
Enabled bool `mapstructure:"enabled"` // 是否启用设备密钥池
InitialSize int `mapstructure:"initial_size"` // 初始化设备数量
MinSize int `mapstructure:"min_size"` // 最小设备数量
MaxSize int `mapstructure:"max_size"` // 最大设备数量
}
// ConfigManager 配置管理器
type ConfigManager struct {
mu sync.RWMutex
config *Config
configPath string
callbacks []func(*Config)
}
var (
// AppConfig 全局配置实例
AppConfig *Config
manager *ConfigManager
)
// NewConfigManager 创建配置管理器
func NewConfigManager() *ConfigManager {
return &ConfigManager{
callbacks: make([]func(*Config), 0),
}
}
// LoadConfig 加载配置文件
func LoadConfig(configPath string) error {
// 初始化管理器
if manager == nil {
manager = NewConfigManager()
manager.configPath = configPath
}
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// 设置默认值
setDefaults()
if err := viper.ReadInConfig(); err != nil {
return fmt.Errorf("读取配置文件失败: %w", err)
}
newConfig := &Config{}
if err := viper.Unmarshal(newConfig); err != nil {
return fmt.Errorf("解析配置文件失败: %w", err)
}
// 更新配置
manager.mu.Lock()
manager.config = newConfig
AppConfig = newConfig
manager.mu.Unlock()
return nil
}
// setDefaults 设置默认值
func setDefaults() {
// 服务器配置
viper.SetDefault("server.port", "40180")
viper.SetDefault("server.host", "0.0.0.0")
// 番茄小说配置
viper.SetDefault("fanqie.algorithm", "gorgon")
viper.SetDefault("fanqie.signature", "sign2")
// 内容处理配置
viper.SetDefault("fanqie.content.remove_images", false)
viper.SetDefault("fanqie.content.remove_image_desc", false)
viper.SetDefault("fanqie.content.remove_blank_lines", true)
viper.SetDefault("fanqie.content.remove_html_tags", false)
viper.SetDefault("fanqie.content.remove_chapter_title", false)
viper.SetDefault("fanqie.content.add_paragraph_indent", false)
viper.SetDefault("fanqie.content.double_newline", true)
viper.SetDefault("fanqie.content.enable_review", true)
// 漫画处理配置
viper.SetDefault("fanqie.manga.output_mode", "url")
// 缓存配置
viper.SetDefault("cache.enabled", true)
viper.SetDefault("cache.sqlite_path", "./cache.db")
viper.SetDefault("cache.cleanup_enabled", false)
viper.SetDefault("cache.cleanup_seconds", 86400)
// 设备密钥池配置
viper.SetDefault("device_key_pool.enabled", true)
viper.SetDefault("device_key_pool.initial_size", 3)
viper.SetDefault("device_key_pool.min_size", 2)
viper.SetDefault("device_key_pool.max_size", 10)
}
// GetConfig 获取配置实例
func GetConfig() *Config {
if manager != nil {
manager.mu.RLock()
defer manager.mu.RUnlock()
if manager.config != nil {
return manager.config
}
}
if AppConfig == nil {
log.Fatal("配置未初始化,请先调用 LoadConfig")
}
return AppConfig
}
// ReloadConfig 重新加载配置文件
func ReloadConfig() error {
if manager == nil {
return fmt.Errorf("配置管理器未初始化")
}
manager.mu.RLock()
configPath := manager.configPath
manager.mu.RUnlock()
if configPath == "" {
return fmt.Errorf("配置文件路径为空")
}
// 重新读取配置文件
viper.SetConfigFile(configPath)
if err := viper.ReadInConfig(); err != nil {
return fmt.Errorf("重新读取配置文件失败: %w", err)
}
newConfig := &Config{}
if err := viper.Unmarshal(newConfig); err != nil {
return fmt.Errorf("重新解析配置文件失败: %w", err)
}
// 更新配置
manager.mu.Lock()
oldAlgorithm := manager.config.Fanqie.Algorithm
manager.config = newConfig
AppConfig = newConfig
callbacks := make([]func(*Config), len(manager.callbacks))
copy(callbacks, manager.callbacks)
manager.mu.Unlock()
// 通知配置更新
for _, callback := range callbacks {
callback(newConfig)
}
log.Printf("配置重新加载成功: %s", configPath)
if oldAlgorithm != newConfig.Fanqie.Algorithm {
log.Printf("算法已切换: %s -> %s", oldAlgorithm, newConfig.Fanqie.Algorithm)
}
return nil
}
// RegisterConfigCallback 注册配置更新回调
func RegisterConfigCallback(callback func(*Config)) {
if manager == nil {
log.Printf("警告: 配置管理器未初始化,无法注册回调")
return
}
manager.mu.Lock()
defer manager.mu.Unlock()
manager.callbacks = append(manager.callbacks, callback)
}
// GetConfigManager 获取配置管理器实例
func GetConfigManager() *ConfigManager {
return manager
}
+120
View File
@@ -0,0 +1,120 @@
package config
import (
"log"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
// ConfigWatcher 配置文件监控器
type ConfigWatcher struct {
watcher *fsnotify.Watcher
configPath string
stopCh chan struct{}
}
// NewConfigWatcher 创建配置文件监控器
func NewConfigWatcher(configPath string) (*ConfigWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
// 转换为绝对路径
absPath, err := filepath.Abs(configPath)
if err != nil {
return nil, err
}
return &ConfigWatcher{
watcher: watcher,
configPath: absPath,
stopCh: make(chan struct{}),
}, nil
}
// Start 启动配置文件监控
func (w *ConfigWatcher) Start() error {
// 监控配置文件所在目录
configDir := filepath.Dir(w.configPath)
if err := w.watcher.Add(configDir); err != nil {
return err
}
go w.watchLoop()
return nil
}
// Stop 停止配置文件监控
func (w *ConfigWatcher) Stop() {
close(w.stopCh)
if w.watcher != nil {
w.watcher.Close()
}
log.Println("配置文件监控已停止")
}
// watchLoop 监控循环
func (w *ConfigWatcher) watchLoop() {
// 防抖动:在短时间内多次文件变化只处理一次
var debounceTimer *time.Timer
const debounceDelay = 500 * time.Millisecond
for {
select {
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// 只处理配置文件的写入或创建事件
if filepath.Base(event.Name) == filepath.Base(w.configPath) {
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
// log.Printf("检测到配置文件变化: %s", event.Name)
// 重置防抖动定时器
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(debounceDelay, func() {
if err := ReloadConfig(); err != nil {
log.Printf("重新加载配置失败: %v", err)
} else {
log.Println("✅ 配置热更新成功!")
}
})
}
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("配置文件监控错误: %v", err)
case <-w.stopCh:
if debounceTimer != nil {
debounceTimer.Stop()
}
return
}
}
}
// StartConfigWatcher 启动配置文件监控(全局函数)
func StartConfigWatcher(configPath string) (*ConfigWatcher, error) {
watcher, err := NewConfigWatcher(configPath)
if err != nil {
return nil, err
}
if err := watcher.Start(); err != nil {
return nil, err
}
return watcher, nil
}
+135
View File
@@ -0,0 +1,135 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
)
var audioLogger = common.NewLogger("audio", "INFO", "")
// HandleAudio 处理获取音频
func HandleAudio(itemIDs interface{}, toneID interface{}, output interface{}) (string, error) {
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 处理itemIDs - 使用ParseStringParam
itemIDStr := ParseStringParam(itemIDs, "")
// 处理toneID - 使用ParseIntParam
toneIDInt := ParseIntParam(toneID, 1)
// 处理output参数
outputStr := ParseStringParam(output, "url")
// 使用 BuildURL 添加设备参数
url := BuildURL("https://reading.snssdk.com/reading/reader/audio/playinfo/",
fmt.Sprintf("item_ids=%s", itemIDStr),
fmt.Sprintf("tone_id=%d", toneIDInt),
"pv_player=-1",
)
// 使用 GorgonRequest 添加签名和设备信息
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
audioLogger.Error(fmt.Sprintf("获取音频失败: %v", err))
return BuildErrorResponse("获取音频失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
audioLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponse("解析响应失败"), err
}
// 检查响应码
var respCode float64
var okCode bool
if code, ok := respJSON["code"].(float64); ok {
respCode, okCode = code, code == 0
} else if code, ok := respJSON["Code"].(float64); ok {
respCode, okCode = code, code == 0
}
if okCode {
// 转换响应格式:只修改 code 和 message,保留原始数据结构
respJSON["code"] = 200
respJSON["message"] = "SUCCESS"
// 处理output参数,替换音频URL
if outputStr != "url" {
// 获取当前使用的设备信息
currentDevice := GetCurrentDevice()
deviceID := ""
if currentDevice != nil {
deviceID = currentDevice.DeviceHeader
}
// 准备请求头
headers := map[string]string{
"User-Agent": "com.dragon.read",
}
// 处理音频URL,支持多种可能的结构
if dataArray, ok := respJSON["data"].([]interface{}); ok {
// 情况1:data是数组格式(实际返回的格式)
for _, item := range dataArray {
if audioItem, ok := item.(map[string]interface{}); ok {
// 处理main_url
if mainURL, ok := audioItem["main_url"].(string); ok && mainURL != "" {
processedURL := processMediaURL(mainURL, deviceID, headers, outputStr, "audio")
audioItem["main_url"] = processedURL
}
// 处理backup_url
if backupURL, ok := audioItem["backup_url"].(string); ok && backupURL != "" {
processedURL := processMediaURL(backupURL, deviceID, headers, outputStr, "audio")
audioItem["backup_url"] = processedURL
}
}
}
} else if data, ok := respJSON["data"].(map[string]interface{}); ok {
// 情况2:单个音频在audio_info下
if audioInfo, ok := data["audio_info"].(map[string]interface{}); ok {
if playURL, ok := audioInfo["play_url"].(string); ok && playURL != "" {
processedURL := processMediaURL(playURL, deviceID, headers, outputStr, "audio")
audioInfo["play_url"] = processedURL
}
}
// 情况3:音频列表在audio_list下
if audioList, ok := data["audio_list"].([]interface{}); ok {
for _, item := range audioList {
if audioItem, ok := item.(map[string]interface{}); ok {
if playURL, ok := audioItem["play_url"].(string); ok && playURL != "" {
processedURL := processMediaURL(playURL, deviceID, headers, outputStr, "audio")
audioItem["play_url"] = processedURL
}
}
}
}
}
}
resultBytes, _ := json.Marshal(respJSON)
return string(resultBytes), nil
}
// 返回错误信息
errMsg := "获取音频失败"
if msg, ok := respJSON["message"].(string); ok {
errMsg = msg
} else if msg, ok := respJSON["Message"].(string); ok {
errMsg = msg
}
audioLogger.Error(fmt.Sprintf("获取音频失败: code=%v, msg=%s", respCode, errMsg))
return BuildErrorResponse(errMsg), nil
}
+113
View File
@@ -0,0 +1,113 @@
package fqapi
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// AudioCacheItem 音频缓存项
type AudioCacheItem struct {
OriginalURL string `json:"original_url"` // 原始CDN URL
DeviceID string `json:"device_id"` // 关联的设备ID
Headers map[string]string `json:"headers"` // 请求头信息
CachedAt time.Time `json:"cached_at"` // 缓存时间
}
// GenerateAudioProxyURL 生成音频代理URL
func GenerateAudioProxyURL(originalURL, deviceID string, headers map[string]string) string {
// 生成缓存key
key := generateRandomKey(16)
// 将headers序列化为JSON字符串
headersJSON, _ := json.Marshal(headers)
// 存储到数据库
if cacheDB != nil {
cacheMutex.Lock()
defer cacheMutex.Unlock()
_, err := cacheDB.Exec(`
INSERT INTO audio_cache (key, original_url, device_id, headers, created_at)
VALUES (?, ?, ?, ?, ?)
`, key, originalURL, deviceID, string(headersJSON), time.Now().Unix())
if err != nil {
audioLogger.Error("保存音频缓存失败: " + err.Error())
}
}
// 返回代理URL
return fmt.Sprintf("/audio/proxy/%s", key)
}
// HandleAudioProxy 处理音频代理请求
func HandleAudioProxy(key string) ([]byte, string, error) {
if cacheDB == nil {
return nil, "", fmt.Errorf("数据库未初始化")
}
// 从数据库获取音频信息
var originalURL, deviceID, headersJSON string
var createdAt int64
cacheMutex.Lock()
err := cacheDB.QueryRow(`
SELECT original_url, device_id, headers, created_at
FROM audio_cache WHERE key = ?
`, key).Scan(&originalURL, &deviceID, &headersJSON, &createdAt)
cacheMutex.Unlock()
if err == sql.ErrNoRows {
return nil, "", fmt.Errorf("音频信息不存在,请先调用 /audio 接口")
}
if err != nil {
return nil, "", fmt.Errorf("查询音频缓存失败: %w", err)
}
// 检查缓存是否过期(2小时,匹配CDN链接有效期)
if time.Now().Unix()-createdAt > 7200 { // 2小时 = 7200秒
// 清理过期缓存
cacheMutex.Lock()
cacheDB.Exec("DELETE FROM audio_cache WHERE key = ?", key)
cacheMutex.Unlock()
return nil, "", fmt.Errorf("音频信息已过期,请重新调用 /audio 接口")
}
// 解析headers
var headers map[string]string
json.Unmarshal([]byte(headersJSON), &headers)
if headers == nil {
headers = make(map[string]string)
}
// 下载音频内容,使用关联的设备信息
data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3)
if err != nil {
return nil, "", fmt.Errorf("下载音频失败: %w", err)
}
return data, contentType, nil
}
// CleanExpiredAudioCache 清理过期的音频缓存
func CleanExpiredAudioCache() {
if cacheDB == nil {
return
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 删除2小时前的缓存
result, err := cacheDB.Exec("DELETE FROM audio_cache WHERE created_at < ?", time.Now().Unix()-7200)
if err != nil {
audioLogger.Error("清理过期音频缓存失败: " + err.Error())
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
audioLogger.Info(fmt.Sprintf("已清理 %d 条过期音频缓存", rowsAffected))
}
}
+528
View File
@@ -0,0 +1,528 @@
package fqapi
import (
"database/sql"
"encoding/json"
"fanqie/internal/common"
"fanqie/internal/config"
"fmt"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
cacheDB *sql.DB
cacheMutex sync.Mutex
cacheLogger = common.NewLogger("cache_manager", "INFO", "")
contentRequestCount int // content接口访问计数
countMutex sync.Mutex
cacheCleanupTicker *time.Ticker // 缓存清理定时器
)
// CacheData 缓存数据结构
type CacheData struct {
Data string `json:"data"`
Expi float64 `json:"expi"`
}
// InitDB 初始化数据库
func InitDB() error {
if cacheDB != nil {
return nil // 已初始化
}
// 使用配置的路径
dbPath := "./cache.db"
cfg := config.GetConfig()
if cfg != nil && cfg.Cache.SQLitePath != "" {
dbPath = cfg.Cache.SQLitePath
}
var err error
cacheDB, err = sql.Open("sqlite3", dbPath)
if err != nil {
cacheLogger.Error("打开数据库失败: " + err.Error())
return err
}
// 创建缓存表(添加created_at字段用于清理)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`)
if err != nil {
cacheLogger.Error("创建缓存表失败: " + err.Error())
return err
}
// 创建statistics表(保存请求统计,始终使用)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS statistics (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_requests INTEGER DEFAULT 0,
search_requests INTEGER DEFAULT 0,
detail_requests INTEGER DEFAULT 0,
catalog_requests INTEGER DEFAULT 0,
content_requests INTEGER DEFAULT 0,
manga_requests INTEGER DEFAULT 0,
audio_requests INTEGER DEFAULT 0,
video_requests INTEGER DEFAULT 0,
category_requests INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
)`)
if err != nil {
cacheLogger.Error("创建statistics表失败: " + err.Error())
return err
}
// 初始化statistics表(如果不存在记录)
_, err = cacheDB.Exec(`INSERT OR IGNORE INTO statistics (id) VALUES (1)`)
if err != nil {
cacheLogger.Error("初始化statistics表失败: " + err.Error())
}
// 创建索引以提高清理效率
_, err = cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_created_at ON cache(created_at)`)
if err != nil {
cacheLogger.Error("创建索引失败: " + err.Error())
}
// 创建device_pool表(设备密钥池)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS device_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_header TEXT NOT NULL,
key TEXT NOT NULL,
key_ver INTEGER NOT NULL,
is_valid INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
)`)
if err != nil {
cacheLogger.Error("创建device_pool表失败: " + err.Error())
return err
}
// 创建device_pool索引
_, err = cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_device_pool_valid ON device_pool(is_valid)`)
if err != nil {
cacheLogger.Warning("创建device_pool索引失败: " + err.Error())
}
// 创建audio_cache表(音频代理缓存)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS audio_cache (
key TEXT PRIMARY KEY,
original_url TEXT NOT NULL,
device_id TEXT NOT NULL,
headers TEXT,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`)
if err != nil {
cacheLogger.Error("创建audio_cache表失败: " + err.Error())
return err
}
return nil
}
// isCacheEnabled 检查正文内容缓存是否启用
func isCacheEnabled() bool {
cfg := config.GetConfig()
if cfg == nil {
return false
}
return cfg.Cache.Enabled
}
// GetCache 获取正文内容缓存
func GetCache(key string) string {
if !isCacheEnabled() {
return ""
}
if cacheDB == nil {
return ""
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
var value string
err := cacheDB.QueryRow("SELECT value FROM cache WHERE key = ?", key).Scan(&value)
if err != nil {
if err != sql.ErrNoRows {
cacheLogger.Error("查询缓存失败: " + err.Error())
}
return ""
}
var cacheData CacheData
err = json.Unmarshal([]byte(value), &cacheData)
if err != nil {
cacheLogger.Error("解析缓存数据失败: " + err.Error())
return ""
}
cacheLogger.Debug("缓存命中: " + key)
// 检查是否过期
if time.Now().Unix() < int64(cacheData.Expi) {
cacheLogger.Debug("缓存有效!")
return cacheData.Data
}
cacheLogger.Debug("缓存已过期!")
return ""
}
// UpdateCache 更新缓存
// UpdateCache 更新或插入正文内容缓存
func UpdateCache(key string, value string, expi int) error {
if !isCacheEnabled() {
return nil
}
if cacheDB == nil {
return nil
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
cacheData := CacheData{
Data: value,
Expi: float64(time.Now().Unix() + int64(expi)),
}
jsonData, err := json.Marshal(cacheData)
if err != nil {
return err
}
// 检查是否存在
var exists bool
err = cacheDB.QueryRow("SELECT 1 FROM cache WHERE key = ?", key).Scan(&exists)
if err == sql.ErrNoRows {
// 不存在,插入
_, err = cacheDB.Exec("INSERT INTO cache (key, value) VALUES (?, ?)", key, string(jsonData))
} else {
// 存在,更新
_, err = cacheDB.Exec("UPDATE cache SET value = ? WHERE key = ?", string(jsonData), key)
}
if err != nil {
cacheLogger.Error("更新缓存失败: " + err.Error())
return err
}
cacheLogger.Debug("缓存已更新: " + key)
return nil
}
// CloseCache 关闭缓存数据库
func CloseCache() {
if cacheDB != nil {
cacheDB.Close()
}
}
// CleanupAllCache 立即清理所有正文缓存
func CleanupAllCache() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 删除所有缓存数据
_, err := cacheDB.Exec("DELETE FROM cache")
if err != nil {
return err
}
// 智能压缩:仅在空闲空间超过阈值时执行
var freelistCount int64
err = cacheDB.QueryRow("PRAGMA freelist_count").Scan(&freelistCount)
if err == nil && freelistCount > 0 {
var pageSize int64
err = cacheDB.QueryRow("PRAGMA page_size").Scan(&pageSize)
if err == nil {
freeBytes := freelistCount * pageSize
// 阈值:空闲空间超过 5MB 时才执行压缩
const vacuumThreshold = 5 * 1024 * 1024 // 5MB
if freeBytes >= vacuumThreshold {
cacheLogger.Info(fmt.Sprintf("空闲空间 %.2f MB,开始执行压缩...", float64(freeBytes)/(1024*1024)))
_, err = cacheDB.Exec("VACUUM")
if err != nil {
cacheLogger.Warning("数据库压缩失败: " + err.Error())
} else {
cacheLogger.Info(fmt.Sprintf("数据库压缩完成,已释放 %.2f MB 磁盘空间", float64(freeBytes)/(1024*1024)))
}
} else {
cacheLogger.Debug(fmt.Sprintf("空闲空间仅 %.2f MB,跳过压缩", float64(freeBytes)/(1024*1024)))
}
}
}
return nil
}
// CleanupExpiredCache 清理过期的正文缓存
func CleanupExpiredCache() error {
cfg := config.GetConfig()
if cfg == nil {
return fmt.Errorf("配置未加载")
}
// 检查是否启用清理
if !cfg.Cache.CleanupEnabled {
return nil
}
// 检查清理时间配置
if cfg.Cache.CleanupSeconds <= 0 {
return nil
}
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 计算过期时间点(当前时间 - cleanup_seconds)
expiredTime := time.Now().Unix() - int64(cfg.Cache.CleanupSeconds)
// 删除过期的缓存(created_at < expiredTime)
result, err := cacheDB.Exec("DELETE FROM cache WHERE created_at < ?", expiredTime)
if err != nil {
cacheLogger.Error("清理过期缓存失败: " + err.Error())
return err
}
// 获取删除的行数
rowsAffected, err := result.RowsAffected()
if err != nil {
cacheLogger.Warning("获取删除行数失败: " + err.Error())
} else if rowsAffected > 0 {
cacheLogger.Info(fmt.Sprintf("已清理 %d 条过期缓存(创建时间早于 %d 秒前)", rowsAffected, cfg.Cache.CleanupSeconds))
}
return nil
}
// StartCacheCleanup 启动缓存自动清理任务
func StartCacheCleanup() {
cfg := config.GetConfig()
if cfg == nil || !cfg.Cache.CleanupEnabled || cfg.Cache.CleanupSeconds <= 0 {
cacheLogger.Info("缓存自动清理未启用或配置无效,跳过启动")
return
}
// 每小时执行一次清理(可以根据需要调整间隔)
cleanupInterval := 1 * time.Hour
cacheCleanupTicker = time.NewTicker(cleanupInterval)
cacheLogger.Info(fmt.Sprintf("缓存自动清理已启动,清理间隔: %v,过期时间: %d 秒", cleanupInterval, cfg.Cache.CleanupSeconds))
// 立即执行一次清理
go func() {
if err := CleanupExpiredCache(); err != nil {
cacheLogger.Error("初始清理失败: " + err.Error())
}
}()
// 定时执行清理
go func() {
for range cacheCleanupTicker.C {
if err := CleanupExpiredCache(); err != nil {
cacheLogger.Error("定时清理失败: " + err.Error())
}
}
}()
}
// StopCacheCleanup 停止缓存自动清理任务
func StopCacheCleanup() {
if cacheCleanupTicker != nil {
cacheCleanupTicker.Stop()
cacheLogger.Info("缓存自动清理已停止")
// 最后执行一次清理
if err := CleanupExpiredCache(); err != nil {
cacheLogger.Error("最后清理失败: " + err.Error())
}
}
}
// IncrementContentRequestCount 增加content接口访问计数
func IncrementContentRequestCount() int {
countMutex.Lock()
defer countMutex.Unlock()
contentRequestCount++
return contentRequestCount
}
// ResetContentRequestCount 重置访问计数
func ResetContentRequestCount() {
countMutex.Lock()
defer countMutex.Unlock()
contentRequestCount = 0
}
// GetContentRequestCount 获取当前访问计数
func GetContentRequestCount() int {
countMutex.Lock()
defer countMutex.Unlock()
return contentRequestCount
}
// VacuumDatabase 压缩数据库,释放磁盘空间
func VacuumDatabase() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
cacheLogger.Info("开始执行数据库压缩...")
_, err := cacheDB.Exec("VACUUM")
if err != nil {
cacheLogger.Error("数据库压缩失败: " + err.Error())
return err
}
cacheLogger.Info("数据库压缩完成,磁盘空间已释放")
return nil
}
// GetDatabaseStats 获取数据库统计信息
func GetDatabaseStats() (map[string]interface{}, error) {
if cacheDB == nil {
return nil, fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
stats := make(map[string]interface{})
// 获取cache表记录数
var cacheCount int
err := cacheDB.QueryRow("SELECT COUNT(*) FROM cache").Scan(&cacheCount)
if err != nil {
cacheLogger.Warning("获取cache表记录数失败: " + err.Error())
cacheCount = 0
}
stats["cache_count"] = cacheCount
// 获取数据库页面统计
var pageCount, pageSize, freelistCount int64
err = cacheDB.QueryRow("PRAGMA page_count").Scan(&pageCount)
if err == nil {
stats["page_count"] = pageCount
}
err = cacheDB.QueryRow("PRAGMA page_size").Scan(&pageSize)
if err == nil {
stats["page_size"] = pageSize
}
err = cacheDB.QueryRow("PRAGMA freelist_count").Scan(&freelistCount)
if err == nil {
stats["freelist_count"] = freelistCount
stats["total_size_bytes"] = pageCount * pageSize
stats["free_size_bytes"] = freelistCount * pageSize
stats["used_size_bytes"] = (pageCount - freelistCount) * pageSize
}
return stats, nil
}
// SaveStatistics 保存请求统计到数据库
func SaveStatistics(stats map[string]int64) error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
_, err := cacheDB.Exec(`
UPDATE statistics
SET total_requests = ?,
search_requests = ?,
detail_requests = ?,
catalog_requests = ?,
content_requests = ?,
manga_requests = ?,
audio_requests = ?,
video_requests = ?,
category_requests = ?,
updated_at = strftime('%s', 'now')
WHERE id = 1
`, stats["total"], stats["search"], stats["detail"], stats["catalog"],
stats["content"], stats["manga"], stats["audio"], stats["video"], stats["category"])
if err != nil {
cacheLogger.Error("保存统计数据失败: " + err.Error())
return err
}
return nil
}
// LoadStatistics 从数据库加载请求统计
func LoadStatistics() (map[string]int64, error) {
if cacheDB == nil {
return nil, fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
var total, search, detail, catalog, content, manga, audio, video, category int64
err := cacheDB.QueryRow(`
SELECT total_requests, search_requests, detail_requests, catalog_requests,
content_requests, manga_requests, audio_requests, video_requests, category_requests
FROM statistics WHERE id = 1
`).Scan(&total, &search, &detail, &catalog, &content, &manga, &audio, &video, &category)
if err == sql.ErrNoRows {
// 没有数据,返回空统计
return map[string]int64{
"total": 0, "search": 0, "detail": 0, "catalog": 0,
"content": 0, "manga": 0, "audio": 0, "video": 0, "category": 0,
}, nil
}
if err != nil {
cacheLogger.Error("加载统计数据失败: " + err.Error())
return nil, err
}
stats := map[string]int64{
"total": total,
"search": search,
"detail": detail,
"catalog": catalog,
"content": content,
"manga": manga,
"audio": audio,
"video": video,
"category": category,
}
return stats, nil
}
+207
View File
@@ -0,0 +1,207 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
"strconv"
"strings"
)
var catalogLogger = common.NewLogger("catalog", "INFO", "")
// HandleGetCatalog 处理获取书籍目录
func HandleGetCatalog(bookID string) (string, error) {
if bookID == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
// 构建URL
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/directory/all_items/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
catalogLogger.Error(fmt.Sprintf("获取目录请求失败: %v", err))
// 尝试备用接口
return offShelfCatalog(bookID)
}
respJSON, err := resp.JSON()
if err != nil {
catalogLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return offShelfCatalog(bookID)
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
// 删除不需要的字段
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "item_list")
delete(data, "book_info")
delete(data, "catalog_data")
}
// 重新序列化
resultBytes, err := json.Marshal(respJSON)
if err != nil {
return BuildErrorResponse("序列化失败"), err
}
return string(resultBytes), nil
}
// 如果主接口失败,尝试备用接口
return offShelfCatalog(bookID)
}
// offShelfCatalog 下架书籍目录获取(备用接口)
func offShelfCatalog(bookID string) (string, error) {
// 第一步:获取所有item_id列表
url := "https://fanqienovel.com/api/reader/directory/detail?bookId=" + bookID
client := common.NewHTTPClient()
resp, err := client.Get(url, map[string]string{
"Referer": "https://fanqienovel.com/page/" + bookID,
"Origin": "https://fanqienovel.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
})
if err != nil {
catalogLogger.Error(fmt.Sprintf("备用接口请求失败: %v", err))
return BuildErrorResponse("FAILED"), err
}
var itemListResponse map[string]interface{}
err = json.Unmarshal(resp.Content, &itemListResponse)
if err != nil {
return BuildErrorResponse("FAILED"), err
}
data, ok := itemListResponse["data"].(map[string]interface{})
if !ok {
return BuildErrorResponse("响应格式错误"), fmt.Errorf("响应格式错误")
}
allItemIDs, ok := data["allItemIds"].([]interface{})
if !ok {
return BuildErrorResponse("无法获取item列表"), fmt.Errorf("无法获取item列表")
}
// 转换为字符串数组
itemList := make([]string, len(allItemIDs))
for i, id := range allItemIDs {
itemList[i] = fmt.Sprintf("%v", id)
}
// 分批处理(每批100个)
chunkSize := 100
var result []map[string]interface{}
for i := 0; i < len(itemList); i += chunkSize {
end := i + chunkSize
if end > len(itemList) {
end = len(itemList)
}
chunk := itemList[i:end]
details, err := itemMultiDetail(chunk)
if err == nil {
result = append(result, details...)
}
}
// 构建响应
response := map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": map[string]interface{}{
"item_data_list": result,
},
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
}
// itemMultiDetail 批量获取章节详情
func itemMultiDetail(itemList []string) ([]map[string]interface{}, error) {
url := "https://novel.snssdk.com/api/novel/book/directory/detail/v/?aid=1319&item_ids=" + strings.Join(itemList, ",")
client := common.NewHTTPClient()
resp, err := client.Get(url, map[string]string{
"User-Agent": "okhttp/4.10.0",
})
if err != nil {
return nil, err
}
var dataResp map[string]interface{}
err = json.Unmarshal(resp.Content, &dataResp)
if err != nil {
return nil, err
}
if code, ok := dataResp["code"].(float64); ok && code == 0 {
chapters, ok := dataResp["data"].([]interface{})
if !ok {
return nil, fmt.Errorf("响应格式错误")
}
var result []map[string]interface{}
for _, ch := range chapters {
chapter, ok := ch.(map[string]interface{})
if !ok {
continue
}
versionStr := ""
if version, ok := chapter["version"].(string); ok && len(version) >= 32 {
versionStr = version[:32]
}
// 安全解析 first_pass_time
firstPassTime := 0
if fpt, ok := chapter["first_pass_time"].(float64); ok {
firstPassTime = int(fpt)
} else if fptStr, ok := chapter["first_pass_time"].(string); ok && fptStr != "" {
// 尝试解析字符串形式的数字
if fptVal, err := strconv.ParseFloat(fptStr, 64); err == nil {
firstPassTime = int(fptVal)
}
}
// 安全解析 chapter_word_number
chapterWordNumber := 0
if cwn, ok := chapter["chapter_word_number"].(float64); ok {
chapterWordNumber = int(cwn)
} else if cwnStr, ok := chapter["chapter_word_number"].(string); ok && cwnStr != "" {
if cwnVal, err := strconv.ParseFloat(cwnStr, 64); err == nil {
chapterWordNumber = int(cwnVal)
}
}
result = append(result, map[string]interface{}{
"item_id": chapter["item_id"],
"version": nil,
"content_md5": versionStr,
"first_pass_time": firstPassTime,
"title": chapter["title"],
"volume_name": chapter["volume_name"],
"chapter_type": chapter["chapter_type"],
"chapter_word_number": chapterWordNumber,
"is_review": nil,
})
}
return result, nil
}
return nil, fmt.Errorf("请求失败")
}
+44
View File
@@ -0,0 +1,44 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
)
var categoryFrontLogger = common.NewLogger("category_front", "INFO", "")
// HandleCategoryFront 处理获取分类列表
func HandleCategoryFront(tab interface{}) (string, error) {
tabInt := ParseIntParam(tab, 0)
reqURL := BuildURL(
"https://reading.snssdk.com/reading/bookapi/new_category/front/v/",
fmt.Sprintf("new_category_tab=%d", tabInt),
fmt.Sprintf("update_version_code=%s", "58932"),
"distinct_style=1",
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
categoryFrontLogger.Error(fmt.Sprintf("获取分类列表失败: %v", err))
return BuildErrorResponse("请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponse("获取失败"), nil
}
+56
View File
@@ -0,0 +1,56 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
)
var categoryLandingLogger = common.NewLogger("category_landing", "INFO", "")
// HandleCategoryLanding 处理获取分类详情
func HandleCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy interface{}) (string, error) {
// 参数转换
categoryIDInt := ParseIntParam(categoryID, 0)
offsetInt := ParseIntParam(offset, 0)
wordNumberInt := ParseIntParam(wordNumber, 0)
bookStatusInt := ParseIntParam(bookStatus, 0)
sortByInt := ParseIntParam(sortBy, 0)
if categoryIDInt == 0 {
return BuildErrorResponseWithCode(100144, "参数错误"), nil
}
reqURL := BuildURL(
"https://reading.snssdk.com/reading/bookapi/new_category/landing/v/",
fmt.Sprintf("category_id=%d", categoryIDInt),
fmt.Sprintf("limit=%d", 20),
fmt.Sprintf("offset=%d", offsetInt),
fmt.Sprintf("word_number=%d", wordNumberInt),
fmt.Sprintf("book_status=%d", bookStatusInt),
fmt.Sprintf("sort_by=%d", sortByInt),
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
categoryLandingLogger.Error(fmt.Sprintf("获取分类详情失败: %v", err))
return BuildErrorResponse("请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponse("获取失败"), nil
}
+279
View File
@@ -0,0 +1,279 @@
package fqapi
import (
"encoding/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"sort"
"strings"
)
var (
contentLogger = common.NewLogger("content", "INFO", "")
)
// ContentFetcher 内容获取器
type ContentFetcher struct{}
// HandleGetContent 处理获取内容
func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, error) {
if itemID == "" {
return `{"code": -1, "msg": "需要参数 item_id"}`, nil
}
// 参数转换
textModeBool := parseBoolParam(textMode)
imageModeBool := parseBoolParamDefault(imageMode, true)
// contentLogger.Info(fmt.Sprintf("获取内容 item_id=%s text_mode=%v image_mode=%v", itemID, textModeBool, imageModeBool))
// 检查缓存
cache := GetCache("content_" + itemID)
if cache != "" {
return processCachedContent(cache, textModeBool, imageModeBool)
}
// 缓存未命中,从API获取
return fetchContentFromAPI(itemID, textModeBool, imageModeBool)
}
// processCachedContent 处理缓存内容
func processCachedContent(cache string, textMode, imageMode bool) (string, error) {
var d map[string]interface{}
json.Unmarshal([]byte(cache), &d)
if data, ok := d["data"].(map[string]interface{}); ok {
if content, ok := data["content"].(string); ok {
parser := NewContentParser()
data["content"] = parser.Parse(content, textMode, imageMode)
}
}
return buildJSONResponse(d), nil
}
// fetchContentFromAPI 从API获取内容(带设备切换重试)
func fetchContentFromAPI(itemID string, textMode, imageMode bool) (string, error) {
return fetchContentWithRetry(itemID, textMode, imageMode, 0)
}
// fetchContentWithRetry 从API获取内容,支持设备切换重试
func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount int) (string, error) {
// 防止无限递归
const maxRetries = 5
if retryCount >= maxRetries {
contentLogger.Warning(fmt.Sprintf("已重试 %d 次,尝试备用接口", maxRetries))
return tryBackupAPI(itemID, textMode, imageMode)
}
// 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
contentLogger.Error("从设备池获取设备失败: " + err.Error())
return `{"code": -1, "msg": "设备池暂无可用设备"}`, err
}
header := item.DeviceHeader
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
contentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount))
// 移除version_name参数
headerWithoutVersion := removeVersionFromHeader(header)
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"version_code=66.9",
"item_id=" + itemID,
"os_version=0",
headerWithoutVersion,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
url := "https://reading.snssdk.com/reading/reader/full/v/?" + sortedParams
resp, err := ContentRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
contentLogger.Warning(fmt.Sprintf("主接口请求出错,标记设备无效并切换设备: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
var body map[string]interface{}
json.Unmarshal(resp.Content, &body)
if code, ok := body["code"].(float64); ok && code == 0 {
data, ok := body["data"].(map[string]interface{})
if !ok {
contentLogger.Warning("响应data字段无效,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
contentEncoded, ok := data["content"].(string)
if !ok || contentEncoded == "Invalid" {
// 内容无效,标记当前设备为无效并切换设备
contentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
// 检查密钥版本
keyVersion, _ := data["key_version"].(float64)
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
contentLogger.Warning("密钥版本不匹配,重新获取密钥")
// 更新池中的密钥
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
currentKeyInfo = newKeyInfo
} else {
// 获取密钥失败,切换设备
contentLogger.Warning("获取密钥失败,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
}
// 解密内容
contentBytes, _ := base64.StdEncoding.DecodeString(contentEncoded)
if len(contentBytes) < 16 {
contentLogger.Warning("内容长度不足,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
iv := contentBytes[0:16]
encryptedData := contentBytes[16:]
keyBytes, _ := base64.StdEncoding.DecodeString(currentKeyInfo.Key)
decrypted, err := De(encryptedData, keyBytes, iv)
if err != nil {
contentLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
// 处理内容
contentStr := string(decrypted)
contentStr = strings.Replace(contentStr, `<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>`, "", -1)
contentStr = strings.TrimSpace(contentStr)
data["content"] = contentStr
// 更新缓存
cacheData := buildCacheData(data)
saveCacheJSON("content_"+itemID, cacheData, 86400*2)
// 解析内容
parser := NewContentParser()
data["content"] = parser.Parse(contentStr, textMode, imageMode)
return buildJSONResponse(body), nil
}
// 响应code不为0,切换设备重试
contentLogger.Warning(fmt.Sprintf("响应code异常: %.0f,切换设备重试", body["code"]))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
// tryBackupAPI 尝试备用API(所有设备都失败后的最后手段)
func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) {
contentLogger.Warning("尝试备用接口1(所有设备均失败)")
// 从设备池获取任意一个有效设备用于备用接口
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
contentLogger.Error("备用接口:无法获取设备")
return `{"code": -1, "msg": "获取失败"}`, err
}
header := item.DeviceHeader
// 备用接口1:使用DH加密
backupAPI := fmt.Sprintf("https://novel.snssdk.com/api/novel/book/reader/content/v1?support_image=1&item_id=%s&aid=1319&app_name=novelapp&channel=0&device_platform=android&os_version=2&version_code=58932&%s",
itemID, header)
cm := NewCM()
resp, err := GorgonRequest(backupAPI, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"y": cm.ClientHandshake(),
},
})
if err != nil {
contentLogger.Warning("备用接口1获取正文出错!")
return `{"code": -1, "msg": "获取失败"}`, err
}
var body map[string]interface{}
json.Unmarshal(resp.Content, &body)
if code, ok := body["code"].(float64); ok && code == 0 {
delete(body, "log_id")
data, ok := body["data"].(map[string]interface{})
if !ok {
return `{"code": -1, "msg": "获取失败"}`, err
}
contentEncrypted, ok := data["content"].(string)
if !ok || contentEncrypted == "Bad Content" {
return `{"code": -1, "msg": "获取失败"}`, err
}
// 解密内容
cHeader := resp.GetHeader("c")
yHeader := resp.GetHeader("y")
decryptedContent, err := cm.Decrypt(cHeader, yHeader, contentEncrypted)
if err != nil {
contentLogger.Error("解密失败: " + err.Error())
return `{"code": -1, "msg": "解密失败"}`, err
}
data["content"] = decryptedContent
// 更新缓存
bodyJSONStr := buildJSONResponse(body)
UpdateCache("content_"+itemID, bodyJSONStr, 86400)
// 解析内容
parser := NewContentParser()
data["content"] = parser.Parse(decryptedContent, textMode, imageMode)
return buildJSONResponse(body), nil
}
contentLogger.Warning("备用接口1获取正文出错!")
return `{"code": -1, "msg": "获取失败"}`, fmt.Errorf("所有接口都失败")
}
+187
View File
@@ -0,0 +1,187 @@
package fqapi
import (
"fanqie/internal/common"
"fanqie/internal/config"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
)
var (
parserLogger = common.NewLogger("content_parser", "INFO", "")
)
// ContentParser 内容解析器
type ContentParser struct {
cfg config.ContentConfig
}
// NewContentParser 创建内容解析器
func NewContentParser() *ContentParser {
return &ContentParser{
cfg: getContentConfig(),
}
}
// Parse 解析内容(URL参数优先于配置)
func (p *ContentParser) Parse(content string, textMode bool, imageMode bool) string {
// 1. 清理XML声明和DOCTYPE
content = p.cleanXMLDeclaration(content)
// 2. 根据配置移除章节标题
if p.cfg.RemoveChapterTitle {
content = p.removeChapterTitle(content)
}
// 3. 根据配置处理图片描述
if p.cfg.RemoveImageDesc {
content = p.removeImageDesc(content)
}
// 4. 根据参数或配置移除图片
// URL参数 image_mode=0 或配置 remove_images=true 时移除图片
if !imageMode || p.cfg.RemoveImages {
content = p.removeImages(content)
}
// 5. 根据参数或配置处理内容模式
// URL参数 text_mode=1 或配置 remove_html_tags=true 时使用文本模式
if textMode || p.cfg.RemoveHTMLTags {
return p.extractText(content)
}
// 6. HTML模式处理
return p.processHTMLMode(content)
}
// cleanXMLDeclaration 清理XML声明
func (p *ContentParser) cleanXMLDeclaration(content string) string {
content = regexp.MustCompile(`<\?xml[^>]*\?>`).ReplaceAllString(content, "")
content = regexp.MustCompile(`<!DOCTYPE[^>]*>`).ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
// removeChapterTitle 移除章节标题
func (p *ContentParser) removeChapterTitle(content string) string {
// 移除 h1 和 h2 标签中的章节标题
content = regexp.MustCompile(`(?si)<h[12][^>]*class="[^"]*chapter[Tt]itle[^"]*"[^>]*>.*?</h[12]>`).ReplaceAllString(content, "")
// 同时移除 <title> 标签
content = regexp.MustCompile(`(?si)<title>.*?</title>`).ReplaceAllString(content, "")
return content
}
// removeImageDesc 移除图片描述
func (p *ContentParser) removeImageDesc(content string) string {
return regexp.MustCompile(`(?s)<p[^>]*class="pictureDesc"[^>]*>.*?</p>`).ReplaceAllString(content, "")
}
// removeImages 移除图片
func (p *ContentParser) removeImages(content string) string {
return regexp.MustCompile(`(?s)<div[^>]*data-fanqie-type="image"[^>]*>.*?</div>`).ReplaceAllString(content, "")
}
// extractText 提取文本
func (p *ContentParser) extractText(content string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
if err != nil {
parserLogger.Error("解析HTML失败: " + err.Error())
return content
}
finalResult := make([]string, 0)
// 查找body或article
body := doc.Find("body")
article := body.Find("article")
var elements *goquery.Selection
if article.Length() > 0 {
elements = article
} else {
elements = body
}
// 遍历所有直接子元素
elements.Children().Each(func(i int, item *goquery.Selection) {
// 检查是否是图片div
dataType, _ := item.Attr("data-fanqie-type")
isImageDiv := dataType == "image"
if isImageDiv {
// 这是图片div
if !p.cfg.RemoveImages {
// 保留完整的图片div结构
divHTML, _ := goquery.OuterHtml(item)
// 解码HTML实体
divHTML = decodeHTMLEntities(divHTML)
// 标准化img属性
divHTML = normalizeImgAttributes(divHTML)
finalResult = append(finalResult, divHTML)
}
return
}
// 普通文本元素
if item.Children().Length() == 0 {
text := strings.TrimSpace(item.Text())
if text != "" {
finalResult = append(finalResult, text)
}
} else {
// 递归提取文本
item.Find("p").Each(func(j int, p *goquery.Selection) {
text := strings.TrimSpace(p.Text())
if text != "" {
finalResult = append(finalResult, text)
}
})
}
})
// 拼接结果
var result string
// 根据配置决定段落间的换行符
separator := "\n"
if p.cfg.DoubleNewline {
separator = "\n\n"
}
if p.cfg.AddParagraphIndent && len(finalResult) > 0 {
// 添加段落缩进(两个全角空格)
result = "\u3000\u3000" + strings.Join(finalResult, separator+"\u3000\u3000")
} else {
// 不添加缩进
result = strings.Join(finalResult, separator)
}
// 根据配置决定是否移除空行
if p.cfg.RemoveBlankLines {
result = removeBlankLines(result)
}
return result
}
// processHTMLMode 处理HTML模式
func (p *ContentParser) processHTMLMode(content string) string {
// 标准化img属性
content = normalizeImgAttributes(content)
// 解码HTML实体
content = decodeHTMLEntities(content)
// 根据配置决定是否移除空行
if p.cfg.RemoveBlankLines {
// 对HTML内容移除空的<p>标签
content = regexp.MustCompile(`(?s)<p[^>]*>\s*</p>`).ReplaceAllString(content, "")
// 移除多余的空白
content = regexp.MustCompile(`\n\s*\n\s*\n`).ReplaceAllString(content, "\n\n")
}
return content
}
+265
View File
@@ -0,0 +1,265 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/config"
"sort"
"strings"
)
// getContentConfig 获取内容配置(带默认值)
func getContentConfig() config.ContentConfig {
if config.AppConfig != nil {
return config.AppConfig.Fanqie.Content
}
// 返回默认配置
return config.ContentConfig{
RemoveImages: false,
RemoveImageDesc: false,
RemoveBlankLines: true,
RemoveHTMLTags: false,
RemoveChapterTitle: false,
AddParagraphIndent: false,
}
}
// decodeHTMLEntities 解码HTML实体
func decodeHTMLEntities(content string) string {
// 解码常见HTML实体
content = strings.ReplaceAll(content, "&amp;", "&")
content = strings.ReplaceAll(content, "&lt;", "<")
content = strings.ReplaceAll(content, "&gt;", ">")
content = strings.ReplaceAll(content, "&quot;", "\"")
content = strings.ReplaceAll(content, "&#34;", "\"")
content = strings.ReplaceAll(content, "&#39;", "'")
content = strings.ReplaceAll(content, "&apos;", "'")
content = strings.ReplaceAll(content, "&#x27;", "'")
content = strings.ReplaceAll(content, "&#x2F;", "/")
// 解码转义的反斜杠
content = strings.ReplaceAll(content, "\\\"", "\"")
return content
}
// normalizeImgAttributes 将img标签中的img-width和img-height属性替换为标准的width和height属性
func normalizeImgAttributes(content string) string {
// 替换 img-width 为 width
content = strings.ReplaceAll(content, "img-width=", "width=")
// 替换 img-height 为 height
content = strings.ReplaceAll(content, "img-height=", "height=")
return content
}
// removeBlankLines 移除多余空行,但保留段落间的双换行
func removeBlankLines(content string) string {
// 先将多个连续换行(3个及以上)规范化为双换行
for strings.Contains(content, "\n\n\n") {
content = strings.ReplaceAll(content, "\n\n\n", "\n\n")
}
// 移除首尾的空白
content = strings.TrimSpace(content)
return content
}
// ============ 参数解析工具函数 ============
// parseBoolParam 解析布尔参数
func parseBoolParam(param interface{}) bool {
switch v := param.(type) {
case string:
return v == "true" || v == "1" || strings.ToLower(v) == "true"
case bool:
return v
}
return false
}
// parseBoolParamDefault 解析布尔参数(带默认值)
func parseBoolParamDefault(param interface{}, defaultVal bool) bool {
if param == nil || param == "" {
return defaultVal
}
return parseBoolParam(param)
}
// ============ JSON 处理工具函数 ============
// escapeJSON 转义JSON中的特殊字符
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "<", "\\u003c")
s = strings.ReplaceAll(s, ">", "\\u003e")
return s
}
// buildJSONResponse 构建JSON响应并转义
func buildJSONResponse(data interface{}) string {
resultBytes, _ := json.Marshal(data)
return escapeJSON(string(resultBytes))
}
// ============ 设备和密钥管理 ============
// getDeviceHeader 获取设备header(从设备池)
func getDeviceHeader() string {
// 从设备池获取任意有效设备header
return GetAnyValidDeviceHeader()
}
// removeVersionFromHeader 移除header中的version_name参数
func removeVersionFromHeader(header string) string {
return strings.Replace(header, "&version_name=5.8.9.32", "", -1)
}
// ============ API参数构建工具 ============
// BuildURL 构建API请求URL
// 用法: BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id=123", "tab_type=3")
func BuildURL(fullURL string, customParams ...string) string {
// 基础参数
allParams := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=58932",
}
// 添加设备header
header := getDeviceHeader()
if header != "" {
allParams = append(allParams, header)
}
// 添加自定义参数
allParams = append(allParams, customParams...)
// 合并、拆分、排序
joined := strings.Join(allParams, "&")
paramList := strings.Split(joined, "&")
sort.Strings(paramList)
params := strings.Join(paramList, "&")
return fullURL + "?" + params
}
// ============ 缓存处理工具函数 ============
// buildCacheData 构建缓存数据结构
func buildCacheData(data interface{}) map[string]interface{} {
return map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": data,
}
}
// saveCacheJSON 保存JSON到缓存
func saveCacheJSON(key string, data interface{}, ttl int) {
cacheJSON, _ := json.Marshal(data)
cacheStr := escapeJSON(string(cacheJSON))
UpdateCache(key, cacheStr, ttl)
}
// ============ 错误响应工具函数 ============
// BuildErrorResponse 构建统一的错误响应
func BuildErrorResponse(msg string) string {
return buildJSONResponse(map[string]interface{}{
"code": -1,
"msg": msg,
})
}
// BuildErrorResponseWithCode 构建带自定义错误码的响应
func BuildErrorResponseWithCode(code int, msg string) string {
return buildJSONResponse(map[string]interface{}{
"code": code,
"msg": msg,
})
}
// BuildSuccessResponse 构建成功响应
func BuildSuccessResponse(data interface{}) string {
return buildJSONResponse(map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": data,
})
}
// ============ 参数转换工具函数 ============
// ParseIntParam 解析整数参数(支持多种类型)
func ParseIntParam(param interface{}, defaultVal int) int {
switch v := param.(type) {
case string:
if v != "" {
// 使用strconv.Atoi
if result, err := parseStringToInt(v); err == nil {
return result
}
}
case int:
return v
case float64:
return int(v)
case int64:
return int(v)
}
return defaultVal
}
// parseStringToInt 辅助函数:将字符串转换为整数
func parseStringToInt(s string) (int, error) {
// 简单实现,避免导入strconv
result := 0
negative := false
if len(s) == 0 {
return 0, nil
}
start := 0
if s[0] == '-' {
negative = true
start = 1
} else if s[0] == '+' {
start = 1
}
for i := start; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
break
}
result = result*10 + int(s[i]-'0')
}
if negative {
result = -result
}
return result, nil
}
// ParseStringParam 解析字符串参数(支持数组类型自动Join)
func ParseStringParam(param interface{}, defaultVal string) string {
if param == nil || param == "" {
return defaultVal
}
switch v := param.(type) {
case string:
if v != "" {
return v
}
case []string:
// 数组类型,用逗号连接
return strings.Join(v, ",")
}
return defaultVal
}
+55
View File
@@ -0,0 +1,55 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
)
var detailLogger = common.NewLogger("detail", "INFO", "")
// HandleGetDetail 处理获取书籍详情
func HandleGetDetail(bookID string) (string, error) {
if bookID == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
// 构建URL
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
detailLogger.Error(fmt.Sprintf("获取详情请求失败: %v", err))
return BuildErrorResponse("获取详情请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
detailLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
// 删除content字段
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "content")
}
// 重新序列化
resultBytes, err := json.Marshal(respJSON)
if err != nil {
return BuildErrorResponse("序列化失败"), err
}
return string(resultBytes), nil
}
return BuildErrorResponse("获取失败"), nil
}
+102
View File
@@ -0,0 +1,102 @@
package fqapi
// DeviceConfig 设备配置
var DeviceConfig = struct {
AndroidVersions [][2]interface{}
BrandModels map[string][]string
DeviceBrands []string
}{
// Android 版本列表 [版本号, API Level]
AndroidVersions: [][2]interface{}{
{"9", 29}, {"10", 30}, {"11", 31},
{"12", 32}, {"13", 33}, {"14", 34},
{"15", 35}, {"16", 36},
},
// 品牌和型号对应关系
BrandModels: map[string][]string{
"Xiaomi": {
"MI8", "MI9", "MI10", "MI11", "MI12", "MI13", "MI14", "MI15",
"MIX2S", "MIX3", "MIX4", "MIXFold3", "MIXFold4",
"2201122C", "2206123SC", "2211133C", "2304FPN6DC", "23013RK75C",
"24031PN0DC", "24116PN88C", "2409ARN8BC", "25013PN75C",
},
"Redmi": {
"RedmiK20", "RedmiK30", "RedmiK40", "RedmiK50", "RedmiK60", "RedmiK70", "RedmiK80",
"RedmiNote8", "RedmiNote9", "RedmiNote10", "RedmiNote11", "RedmiNote12", "RedmiNote13", "RedmiNote14",
"RedmiTurbo3", "RedmiTurbo4",
},
"OPPO": {
"PCCM00", "PDEM10", "PDEM30", "PDYM20", "PDPM00", "PDSM00", "PDST00",
"CPH2015", "CPH2023", "CPH2145", "CPH2173", "CPH2179", "CPH2207", "CPH2211",
"CPH2247", "CPH2271", "CPH2333", "CPH2365", "CPH2437", "CPH2451", "CPH2473",
"CPH2477", "CPH2551", "CPH2591", "CPH2613", "CPH2647", "CPH2719", "CPH2759",
"PGBM10", "PGJM10", "PGKM10", "PCLM50", "PECM00", "PEXM00",
"PJCM10", "PJEM00", "PKBM00", "PKCM00",
},
"vivo": {
"V1921A", "V1923A", "V1924A", "V1928A", "V1930A", "V1932A", "V1936A",
"V2001A", "V2011A", "V2012A", "V2020A", "V2021A", "V2022A", "V2023A",
"V2024A", "V2025A", "V2031A", "V2032A", "V2046A", "V2047A", "V2048A",
"V2049A", "V2050A", "V2056A", "V2057A", "V2059A", "V2061A", "V2065A",
"V2070A", "V2072A", "V2073A", "V2080A", "V2099A", "V2111A", "V2115A",
"V2121A", "V2134A", "V2141A", "V2145A", "V2156A", "V2162A", "V2171A",
"V2199A", "V2207A", "V2218A", "V2227A", "V2231A", "V2247A", "V2266A",
"V2280A", "V2296A", "V2318A", "V2327A", "V2338A", "V2352A", "V2366A",
"V2401A", "V2428A", "V2451A", "V2473A",
},
"iQOO": {
"I2007", "I2012", "I2018", "I2019", "V2130A", "V2141A", "V2156A",
"I2207", "I2214", "I2301", "I2302", "V2307A", "V2352A",
"I2401", "I2409",
},
"Huawei": {
"P30", "P40", "P50", "P60", "P70",
"Mate20", "Mate30", "Mate40", "Mate50", "Mate60", "MateX5", "MateXT",
"Nova5", "Nova6", "Nova7", "Nova8", "Nova9", "Nova10", "Nova11", "Nova12", "Nova13",
"ELE-AL00", "ANA-AN00", "TAS-AN00", "VOG-AL10", "LYA-AL00", "MAR-AL00",
"NOH-AN00", "ABR-AL00", "BRQ-AN00", "ELS-AN00", "FRL-AN00A", "JAD-AL50",
"ALN-AL00", "DBY-AN00", "CET-AN00", "OXP-AN00", "PGT-AN00",
},
"Honor": {
"Honor10", "Honor20", "Honor30", "Honor50", "Honor60", "Honor70", "Honor80", "Honor90", "Honor100", "Honor200",
"HonorMagic5", "HonorMagic6", "HonorMagic7",
"HonorX40", "HonorX50", "HonorX60",
},
"realme": {
"RMX1931", "RMX2001", "RMX2021", "RMX2061", "RMX2071", "RMX2085", "RMX2111",
"RMX2121", "RMX2141", "RMX2155", "RMX2161", "RMX2170", "RMX2173", "RMX2176",
"RMX2202", "RMX2205", "RMX3031", "RMX3033", "RMX3061", "RMX3063", "RMX3081",
"RMX3085", "RMX3092", "RMX3141", "RMX3142", "RMX3151", "RMX3161", "RMX3171",
"RMX3195", "RMX3197", "RMX3201", "RMX3231", "RMX3241", "RMX3261", "RMX3263",
"RMX3300", "RMX3310", "RMX3350", "RMX3357", "RMX3363", "RMX3371", "RMX3381",
"RMX3461", "RMX3478", "RMX3506", "RMX3511", "RMX3516", "RMX3551", "RMX3562",
"RMX3630", "RMX3660", "RMX3686", "RMX3687", "RMX3700", "RMX3710", "RMX3785",
"RMX3800", "RMX3890", "RMX3933", "RMX3999", "RMX4001", "RMX4100", "RMX4200",
"RMX4311", "RMX4400", "RMX4506", "RMX4600", "RMX4710",
},
"Samsung": {
"SM-G9600", "SM-G9650", "SM-G9730", "SM-G9750", "SM-G9810", "SM-G9880",
"SM-G9900", "SM-G9910", "SM-G9960", "SM-G9980", "SM-N9600", "SM-N9700",
"SM-N9750", "SM-N9760", "SM-N9810", "SM-N9860",
"SM-A5160", "SM-A6060", "SM-A7070", "SM-A8050", "SM-A9080", "SM-A9200",
"SM-F7070", "SM-F9000", "SM-F9160", "SM-W2021", "SM-W2022", "SM-W2023",
"SM-S9180", "SM-S9280", "SM-S9360", "SM-S9480",
"SM-F9360", "SM-F9460", "SM-F9560", "SM-F9660",
"SM-A5560", "SM-A7260", "SM-A8860",
},
"OnePlus": {
"GM1900", "GM1910", "HD1900", "HD1910", "IN2010", "IN2020", "LE2100", "LE2110",
"MT2110", "MT2111", "NE2210", "NE2211", "CPH2413", "CPH2423", "CPH2449",
"PGP110", "PHB110", "PHK110", "CPH2525", "CPH2573", "CPH2609", "PHT110",
"CPH2617", "CPH2667", "CPH2729", "CPH2747", "CPH2805",
"PJA110", "PJB110", "PJC110", "PJD110",
},
},
// 所有支持的品牌列表
DeviceBrands: []string{
"Xiaomi", "Redmi", "OPPO", "vivo", "iQOO",
"Huawei", "Honor", "realme", "Samsung", "OnePlus",
},
}
+582
View File
@@ -0,0 +1,582 @@
package fqapi
import (
"fanqie/internal/common"
"fanqie/internal/config"
"fmt"
"sync"
"time"
)
var (
poolLogger = common.NewLogger("device_key_pool", "INFO", "")
globalPool *DeviceKeyPool
poolOnce sync.Once
)
// DeviceKeyItem 设备密钥项
type DeviceKeyItem struct {
ID int `json:"id"`
DeviceHeader string `json:"device_header"`
Key string `json:"key"`
KeyVer int `json:"key_ver"`
IsValid bool `json:"is_valid"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt time.Time `json:"last_used_at"`
}
// DeviceKeyPool 设备密钥池
type DeviceKeyPool struct {
mu sync.RWMutex
currentIndex int
items []*DeviceKeyItem
enabled bool
}
// GetDeviceKeyPool 获取全局设备密钥池实例
func GetDeviceKeyPool() *DeviceKeyPool {
poolOnce.Do(func() {
globalPool = &DeviceKeyPool{
items: make([]*DeviceKeyItem, 0),
enabled: false,
}
})
return globalPool
}
// Initialize 初始化设备密钥池
func (p *DeviceKeyPool) Initialize() error {
cfg := config.GetConfig()
if cfg == nil || !cfg.DeviceKeyPool.Enabled {
poolLogger.Info("设备密钥池未启用")
p.enabled = false
return nil
}
p.enabled = true
poolLogger.Info("开始初始化设备密钥池...")
// 从数据库加载已有的设备
if err := p.loadFromDB(); err != nil {
poolLogger.Warning("从数据库加载设备失败: " + err.Error())
}
// 检查并补充设备到初始化数量
validCount := p.getValidCount()
initialSize := cfg.DeviceKeyPool.InitialSize
if validCount < initialSize {
needCount := initialSize - validCount
poolLogger.Info(fmt.Sprintf("当前有效设备数: %d, 需要补充: %d", validCount, needCount))
for i := 0; i < needCount; i++ {
if err := p.addNewDevice(); err != nil {
poolLogger.Error(fmt.Sprintf("添加设备失败: %v", err))
}
}
}
poolLogger.Info(fmt.Sprintf("设备密钥池初始化完成,当前有效设备数: %d", p.getValidCount()))
return nil
}
// IsEnabled 检查设备密钥池是否启用
func (p *DeviceKeyPool) IsEnabled() bool {
return p.enabled
}
// GetNext 获取下一个可用的设备密钥(轮询方式)
func (p *DeviceKeyPool) GetNext() (*DeviceKeyItem, error) {
if !p.enabled {
return nil, fmt.Errorf("设备密钥池未启用")
}
p.mu.Lock()
defer p.mu.Unlock()
// 过滤有效设备
validItems := make([]*DeviceKeyItem, 0)
for _, item := range p.items {
if item.IsValid {
validItems = append(validItems, item)
}
}
if len(validItems) == 0 {
return nil, fmt.Errorf("没有可用的设备")
}
// 轮询获取下一个设备
item := validItems[p.currentIndex%len(validItems)]
p.currentIndex++
// 更新最后使用时间
item.LastUsedAt = time.Now()
p.updateDeviceInDB(item)
// poolLogger.Debug(fmt.Sprintf("使用设备 ID=%d", item.ID))
// 异步检查是否需要补充
go p.checkAndRefill()
return item, nil
}
// MarkInvalid 标记设备为无效
func (p *DeviceKeyPool) MarkInvalid(deviceHeader string) error {
if !p.enabled {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
for _, item := range p.items {
if item.DeviceHeader == deviceHeader {
item.IsValid = false
// poolLogger.Warning(fmt.Sprintf("设备 ID=%d 已标记为无效", item.ID))
// 更新数据库
if err := p.updateDeviceInDB(item); err != nil {
return err
}
// 异步补充新设备
go p.checkAndRefill()
return nil
}
}
return fmt.Errorf("未找到设备")
}
// RemoveInvalid 移除所有无效设备并自动补充
func (p *DeviceKeyPool) RemoveInvalid() error {
if !p.enabled {
return nil
}
p.mu.Lock()
// 从数据库中删除无效设备
if err := p.removeInvalidFromDB(); err != nil {
p.mu.Unlock()
return err
}
// 从内存中移除无效设备
validItems := make([]*DeviceKeyItem, 0)
for _, item := range p.items {
if item.IsValid {
validItems = append(validItems, item)
}
}
removedCount := len(p.items) - len(validItems)
p.items = validItems
if removedCount > 0 {
poolLogger.Info(fmt.Sprintf("已移除 %d 个无效设备", removedCount))
// 立即检查并补充设备(使用已持有锁的版本)
p.checkAndRefillLocked()
}
p.mu.Unlock()
return nil
}
// GetStats 获取池统计信息
func (p *DeviceKeyPool) GetStats() map[string]interface{} {
p.mu.RLock()
defer p.mu.RUnlock()
validCount := 0
invalidCount := 0
for _, item := range p.items {
if item.IsValid {
validCount++
} else {
invalidCount++
}
}
return map[string]interface{}{
"enabled": p.enabled,
"total": len(p.items),
"valid": validCount,
"invalid": invalidCount,
"current_index": p.currentIndex,
}
}
// checkAndRefill 检查并补充设备(私有方法,会自动加锁)
func (p *DeviceKeyPool) checkAndRefill() {
if !p.enabled {
return
}
cfg := config.GetConfig()
if cfg == nil {
return
}
p.mu.Lock()
validCount := p.getValidCount()
p.mu.Unlock()
minSize := cfg.DeviceKeyPool.MinSize
maxSize := cfg.DeviceKeyPool.MaxSize
if validCount < minSize {
needCount := minSize - validCount
// 限制不超过最大值
if validCount+needCount > maxSize {
needCount = maxSize - validCount
}
if needCount > 0 {
poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount))
for i := 0; i < needCount; i++ {
if err := p.addNewDevice(); err != nil {
poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err))
}
}
}
}
}
// checkAndRefillLocked 检查并补充设备(已持有锁的内部版本)
func (p *DeviceKeyPool) checkAndRefillLocked() {
if !p.enabled {
return
}
cfg := config.GetConfig()
if cfg == nil {
return
}
validCount := p.getValidCount()
minSize := cfg.DeviceKeyPool.MinSize
maxSize := cfg.DeviceKeyPool.MaxSize
if validCount < minSize {
needCount := minSize - validCount
// 限制不超过最大值
if validCount+needCount > maxSize {
needCount = maxSize - validCount
}
if needCount > 0 {
poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount))
// 临时释放锁,因为addNewDevice需要加锁
p.mu.Unlock()
for i := 0; i < needCount; i++ {
if err := p.addNewDevice(); err != nil {
poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err))
}
}
p.mu.Lock()
}
}
}
// getValidCount 获取有效设备数量(调用前需已加锁或在RLock内)
func (p *DeviceKeyPool) getValidCount() int {
count := 0
for _, item := range p.items {
if item.IsValid {
count++
}
}
return count
}
// addNewDevice 添加新设备(会自动加锁)
func (p *DeviceKeyPool) addNewDevice() error {
// 注册新设备
deviceHeader, err := Register()
if err != nil {
return fmt.Errorf("注册设备失败: %w", err)
}
// 获取密钥
keyInfo, err := GetKey(deviceHeader)
if err != nil {
return fmt.Errorf("获取密钥失败: %w", err)
}
// 创建设备项
item := &DeviceKeyItem{
DeviceHeader: deviceHeader,
Key: keyInfo.Key,
KeyVer: keyInfo.Ver,
IsValid: true,
CreatedAt: time.Now(),
LastUsedAt: time.Now(),
}
// 保存到数据库
if err := p.saveDeviceToDB(item); err != nil {
return fmt.Errorf("保存设备到数据库失败: %w", err)
}
// 添加到内存
p.mu.Lock()
p.items = append(p.items, item)
p.mu.Unlock()
// poolLogger.Info(fmt.Sprintf("已添加新设备 ID=%d", item.ID))
return nil
}
// ===== 数据库操作方法 =====
// loadFromDB 从数据库加载设备
func (p *DeviceKeyPool) loadFromDB() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
rows, err := cacheDB.Query(`
SELECT id, device_header, key, key_ver, is_valid, created_at, last_used_at
FROM device_pool
ORDER BY id
`)
if err != nil {
return err
}
defer rows.Close()
items := make([]*DeviceKeyItem, 0)
for rows.Next() {
var item DeviceKeyItem
var createdAt, lastUsedAt int64
err := rows.Scan(
&item.ID,
&item.DeviceHeader,
&item.Key,
&item.KeyVer,
&item.IsValid,
&createdAt,
&lastUsedAt,
)
if err != nil {
poolLogger.Error("扫描行失败: " + err.Error())
continue
}
item.CreatedAt = time.Unix(createdAt, 0)
item.LastUsedAt = time.Unix(lastUsedAt, 0)
items = append(items, &item)
}
p.mu.Lock()
p.items = items
p.mu.Unlock()
poolLogger.Info(fmt.Sprintf("从数据库加载了 %d 个设备", len(items)))
return nil
}
// saveDeviceToDB 保存设备到数据库
func (p *DeviceKeyPool) saveDeviceToDB(item *DeviceKeyItem) error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
result, err := cacheDB.Exec(`
INSERT INTO device_pool (device_header, key, key_ver, is_valid, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?)
`, item.DeviceHeader, item.Key, item.KeyVer, item.IsValid,
item.CreatedAt.Unix(), item.LastUsedAt.Unix())
if err != nil {
return err
}
// 获取插入的ID
id, err := result.LastInsertId()
if err == nil {
item.ID = int(id)
}
return nil
}
// updateDeviceInDB 更新设备信息
func (p *DeviceKeyPool) updateDeviceInDB(item *DeviceKeyItem) error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
_, err := cacheDB.Exec(`
UPDATE device_pool
SET device_header = ?, key = ?, key_ver = ?, is_valid = ?, last_used_at = ?
WHERE id = ?
`, item.DeviceHeader, item.Key, item.KeyVer, item.IsValid,
item.LastUsedAt.Unix(), item.ID)
return err
}
// removeInvalidFromDB 从数据库移除无效设备
func (p *DeviceKeyPool) removeInvalidFromDB() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
result, err := cacheDB.Exec(`DELETE FROM device_pool WHERE is_valid = 0`)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected > 0 {
poolLogger.Info(fmt.Sprintf("从数据库删除了 %d 个无效设备", affected))
}
return nil
}
// InitDevicePoolTable 初始化设备池表
func InitDevicePoolTable() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
_, err := cacheDB.Exec(`CREATE TABLE IF NOT EXISTS device_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_header TEXT NOT NULL,
key TEXT NOT NULL,
key_ver INTEGER NOT NULL,
is_valid INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
)`)
if err != nil {
return err
}
// 创建索引
_, err = cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_device_pool_valid ON device_pool(is_valid)`)
if err != nil {
poolLogger.Warning("创建索引失败: " + err.Error())
}
return nil
}
// GetDeviceHeaderFromPool 从池中获取设备header(兼容旧接口)
func GetDeviceHeaderFromPool() string {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return ""
}
item, err := pool.GetNext()
if err != nil {
poolLogger.Error("从池中获取设备失败: " + err.Error())
return ""
}
return item.DeviceHeader
}
// GetKeyInfoFromPool 从池中获取密钥信息(兼容旧接口)
func GetKeyInfoFromPool(deviceHeader string) *KeyInfo {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil
}
pool.mu.RLock()
defer pool.mu.RUnlock()
for _, item := range pool.items {
if item.DeviceHeader == deviceHeader && item.IsValid {
return &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
}
}
return nil
}
// UpdateKeyInPool 更新池中设备的密钥
func UpdateKeyInPool(deviceHeader string, key string, ver int) error {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil
}
pool.mu.Lock()
defer pool.mu.Unlock()
for _, item := range pool.items {
if item.DeviceHeader == deviceHeader {
item.Key = key
item.KeyVer = ver
return pool.updateDeviceInDB(item)
}
}
return fmt.Errorf("未找到设备")
}
// GetAnyValidDeviceHeader 获取任意一个有效设备的header(用于辅助功能)
func GetAnyValidDeviceHeader() string {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return ""
}
pool.mu.RLock()
defer pool.mu.RUnlock()
for _, item := range pool.items {
if item.IsValid {
return item.DeviceHeader
}
}
return ""
}
// GetValidDevice 获取一个有效的设备
func GetValidDevice() (*DeviceKeyItem, error) {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil, fmt.Errorf("设备池未启用")
}
return pool.GetNext()
}
// GetCurrentDevice 获取当前使用的设备(和GetValidDevice一样,返回下一个可用设备)
func GetCurrentDevice() *DeviceKeyItem {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil
}
item, _ := pool.GetNext()
return item
}
+384
View File
@@ -0,0 +1,384 @@
package fqapi
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fanqie/internal/common"
"fmt"
"math/big"
"net"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// 使用共享的设备配置
var (
androidVersions = DeviceConfig.AndroidVersions
brandModels = DeviceConfig.BrandModels
deviceBrands = DeviceConfig.DeviceBrands
)
// generateUUID 生成UUID4字符串
func generateUUID() string {
return uuid.New().String()
}
// currentMillis 返回当前毫秒级时间戳
func currentMillis() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
// getCustomBT 获取设备开机时间戳
// 模拟 Android 中的 m25453o() 方法,计算设备开机时间戳:
// custom_bt = current_millis - elapsed_ms_since_boot
func getCustomBT() string {
var uptimeSeconds float64
if runtime.GOOS == "linux" {
// 在Linux上读取/proc/uptime
data, err := os.ReadFile("/proc/uptime")
if err == nil {
// /proc/uptime格式: "系统运行时间(秒) 空闲时间(秒)"
parts := strings.Split(string(data), " ")
if len(parts) > 0 {
uptime, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
if err == nil {
uptimeSeconds = uptime
}
}
}
// 如果读取失败,使用time.monotonic作为备选
if uptimeSeconds == 0 {
// Go没有直接的monotonic API,使用启动时间估算
uptimeSeconds = float64(time.Now().Unix() % 86400)
}
} else {
// 非Linux系统,使用估算值
// 这里可以使用进程启动时间作为近似
uptimeSeconds = float64(time.Now().Unix() % 86400)
}
return fmt.Sprintf("%d", currentMillis()-int64(uptimeSeconds*1000))
}
// generateDeviceInfo 随机生成设备及操作系统信息
func generateDeviceInfo() map[string]interface{} {
osVersion := androidVersions[randInt(len(androidVersions))]
// 随机选择品牌
deviceBrand := deviceBrands[randInt(len(deviceBrands))]
// 根据品牌选择对应的型号
models := brandModels[deviceBrand]
deviceModel := models[randInt(len(models))]
releaseBytes := make([]byte, 7)
rand.Read(releaseBytes)
releaseBuild := hex.EncodeToString(releaseBytes)
openUDIDBytes := make([]byte, 10)
rand.Read(openUDIDBytes)
return map[string]interface{}{
"os": "Android",
"os_version": osVersion[0],
"os_api": osVersion[1],
"device_model": deviceModel,
"device_brand": deviceBrand,
"device_manufacturer": deviceBrand,
"cpu_abi": "arm64-v8a",
"release_build": releaseBuild,
"cdid": generateUUID(),
"sig_hash": "a4a27c2633195374c15651ffc3c4a497",
"openudid": hex.EncodeToString(openUDIDBytes),
"clientudid": generateUUID(),
"req_id": generateUUID(),
}
}
// randInt 生成随机整数
func randInt(max int) int {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max)))
return int(n.Int64())
}
// randomString 生成随机字符串
func randomString(length int) string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
b := make([]byte, length)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
b[i] = charset[n.Int64()]
}
return string(b)
}
// generateOptionalFields 生成ROM信息等
func generateOptionalFields() (map[string]interface{}, map[string]int64) {
rom := randomString(14)
part1 := randomString(4)
part2 := randomString(6)
part3 := randomString(8)
genTime := currentMillis()
return map[string]interface{}{
"rom": rom,
"rom_version": fmt.Sprintf("coloros__%s.%s.%s release-keys", part1, part2, part3),
"apk_first_install_time": genTime - int64(randInt(365))*86400000,
}, map[string]int64{
"_gen_time": genTime,
}
}
// generateIPv6AddressLinkLocal 生成链路本地IPv6地址
func generateIPv6AddressLinkLocal() string {
ifaces, err := net.Interfaces()
if err != nil {
return "::1"
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
if ip.To16() != nil && strings.HasPrefix(ip.String(), "fe80::") {
return strings.Split(ip.String(), "%")[0]
}
}
}
return "::1"
}
// generateIPv6AddressUniqueLocal 生成唯一本地IPv6地址
func generateIPv6AddressUniqueLocal() string {
b := make([]byte, 1)
rand.Read(b)
parts := []string{fmt.Sprintf("fd%02x", b[0])}
for i := 0; i < 7; i++ {
b := make([]byte, 2)
rand.Read(b)
parts = append(parts, fmt.Sprintf("%04x", uint16(b[0])<<8|uint16(b[1])))
}
return strings.Join(parts, ":")
}
// generateIPv6List 生成IPv6列表
func generateIPv6List() []map[string]string {
return []map[string]string{
{"type": "client_anpi", "value": generateIPv6AddressLinkLocal()},
{"type": "client_anpi", "value": generateIPv6AddressUniqueLocal()},
{"type": "client_anpi", "value": generateIPv6AddressUniqueLocal()},
}
}
// GenerateFullRequestBody 生成完整的请求体数据
func GenerateFullRequestBody() map[string]interface{} {
rawData := map[string]interface{}{
"magic_tag": "ss_app_log",
"header": map[string]interface{}{
"display_name": "番茄免费小说",
"aid": 1967,
"channel": "43536163a",
"package": "com.dragon.read",
"sdk_version": "3.7.0-rc.25-fanqie-xiaoshuo",
"sdk_target_version": 29,
"git_hash": "711d1a7",
"density_dpi": 240,
"display_density": "hdpi",
"resolution": "720x1280",
"language": "zh",
"timezone": 8,
"access": "wifi",
"not_request_sender": 0,
"carrier": "CHINA MOBILE",
"mcc_mnc": "46000",
"region": "CN",
"tz_name": "Asia/Shanghai",
"tz_offset": 28800,
"sim_region": "cn",
"sim_serial_number": []interface{}{},
"oaid_may_support": false,
"device_platform": "android",
"custom": map[string]interface{}{"host_bit": 32, "dragon_device_type": "pad"},
"pre_installed_channel": "",
"is_system_app": 0,
"sdk_flavor": "china",
"guest_mode": 0,
},
}
deviceInfo := generateDeviceInfo()
optionalFields, genTime := generateOptionalFields()
header := rawData["header"].(map[string]interface{})
for k, v := range deviceInfo {
header[k] = v
}
for k, v := range optionalFields {
header[k] = v
}
header["ipv6_list"] = generateIPv6List()
for k, v := range genTime {
rawData[k] = v
}
return rawData
}
// tryActivatePremium 尝试激活设备特权
func tryActivatePremium(header string) {
logger := common.NewLogger("device_register", "INFO", "")
// 构建查询参数
baseParams := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=58932",
"manifest_version_code=58932",
"update_version_code=58932",
header,
}
// 合并并排序参数
allParams := strings.Join(baseParams, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
requestBody := map[string]interface{}{
"add_count_daily": 0,
"amount": 2592000,
"privilege_id": 7210376203117531962,
"from": 8,
"unique_key": fmt.Sprintf("%d", time.Now().UnixNano()/1e6),
}
bodyJSON, _ := json.Marshal(requestBody)
url := "https://reading.snssdk.com/reading/user/privilege/add/v/?" + sortedParams
resp, err := GorgonRequestSync(url, map[string]interface{}{
"method": "post",
"headers": map[string]string{
"content-type": "application/json",
},
"body": bodyJSON,
})
if err != nil {
logger.Error("激活特权失败: " + err.Error())
return
}
var result map[string]interface{}
json.Unmarshal(resp.Content, &result)
if code, ok := result["code"].(float64); ok && code == 0 {
logger.Info("已成功激活设备特权")
} else {
logger.Warning("设备特权激活失败...")
}
}
// Register 设备注册
func Register() (string, error) {
logger := common.NewLogger("device_register", "INFO", "")
logger.Info("正在注册设备...")
// 生成完整请求体数据
data := GenerateFullRequestBody()
// 只记录关键设备信息
header := data["header"].(map[string]interface{})
logger.Info(fmt.Sprintf("生成设备信息 - 品牌: %s, 型号: %s, 系统: Android %v",
header["device_brand"],
header["device_model"],
header["os_version"]))
jsonData, err := json.Marshal(data)
if err != nil {
logger.Error("JSON序列化失败: " + err.Error())
return "", err
}
// 使用TTEncrypt加密
encrypted, err := TTEncrypt(jsonData)
if err != nil {
logger.Error("加密失败: " + err.Error())
return "", err
}
url := "https://i.snssdk.com/service/2/device_register/?tt_data=a"
headers := map[string]string{
"user-agent": "okhttp/4.10.0",
"content-type": "application/octet-stream; tt-data=a",
}
client := common.NewHTTPClient()
resp, err := client.Post(url, encrypted, headers)
if err != nil {
logger.Warning("设备注册失败!")
logger.Warning(err.Error())
return "", fmt.Errorf("设备注册错误")
}
var respData map[string]interface{}
err = json.Unmarshal(resp.Content, &respData)
if err != nil {
logger.Error("响应解析失败: " + err.Error())
return "", err
}
if resp.Status == 200 {
// 检查device_id
deviceID, ok := respData["device_id"]
if !ok {
logger.Warning("设备注册失败!响应中没有device_id")
return "", fmt.Errorf("设备注册失败")
}
// 转换device_id为float64并检查
deviceIDFloat, ok := deviceID.(float64)
if !ok || deviceIDFloat == 0 {
logger.Warning("设备注册失败!device_id无效")
return "", fmt.Errorf("设备注册失败")
}
deviceModel := data["header"].(map[string]interface{})["device_model"].(string)
header := fmt.Sprintf("device_id=%v&device_type=%s&iid=%v&version_name=5.8.9.32",
respData["device_id_str"],
deviceModel,
respData["install_id_str"])
logger.Info("设备注册成功!" )
// 尝试激活特权
tryActivatePremium(header)
return header, nil
}
logger.Warning("设备注册失败!")
return "", fmt.Errorf("设备注册失败")
}
+185
View File
@@ -0,0 +1,185 @@
package fqapi
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
)
// CM 加密管理器
type CM struct {
bigC *big.Int // 大素数C
bigB int64 // 底数B
arryD []byte // 固定密钥
thisD [2]*big.Int // 密钥对(私钥, 公钥)
thisC []byte // 随机IV
}
// NewCM 创建新的CM实例
func NewCM() *CM {
// 大素数C
bigCStr := "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919"
bigC := new(big.Int)
bigC.SetString(bigCStr, 10)
// 固定密钥(base64解码)
arryD, _ := base64.StdEncoding.DecodeString("rCXGfd2POMGzeiNIgo4iLg==")
return &CM{
bigC: bigC,
bigB: 2,
arryD: arryD,
}
}
// generateKeyPair 生成密钥对
func (cm *CM) generateKeyPair() [2]*big.Int {
// 生成随机私钥
randomBytes := make([]byte, 32)
rand.Read(randomBytes)
x := new(big.Int).SetBytes(randomBytes)
x = x.Mod(x, new(big.Int).Sub(cm.bigC, big.NewInt(1)))
// 计算公钥: y = b^x mod c
y := new(big.Int).Exp(big.NewInt(cm.bigB), x, cm.bigC)
cm.thisD = [2]*big.Int{x, y}
return cm.thisD
}
// pairToBytes 将公钥转换为字节数组
func (cm *CM) pairToBytes(keyPair [2]*big.Int) []byte {
return keyPair[1].Bytes()
}
// randIV 生成随机IV
func (cm *CM) randIV() []byte {
iv := make([]byte, 16)
rand.Read(iv)
return iv
}
// encodeAES AES-CBC加密
func (cm *CM) encodeAES(key, iv, data []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7填充
padding := aes.BlockSize - len(data)%aes.BlockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
paddedData := append(data, padText...)
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, paddedData)
return ciphertext, nil
}
// decodeAES AES-CBC解密
func (cm *CM) decodeAES(key, iv, data []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
// 移除PKCS7填充
padding := int(decrypted[len(decrypted)-1])
if padding > len(decrypted) || padding > aes.BlockSize {
return nil, fmt.Errorf("无效的填充")
}
return decrypted[:len(decrypted)-padding], nil
}
// ClientHandshake 客户端握手,生成请求头y
func (cm *CM) ClientHandshake() string {
keyPair := cm.generateKeyPair()
cm.thisC = cm.randIV()
publicKeyBytes := cm.pairToBytes(keyPair)
encrypted, _ := cm.encodeAES(cm.arryD, cm.thisC, publicKeyBytes)
combined := append(cm.thisC, encrypted...)
return base64.StdEncoding.EncodeToString(combined)
}
// decryptF 解密函数
func (cm *CM) decryptF(peerPublicKey []byte, keyPair [2]*big.Int, iv, data []byte) ([]byte, error) {
x := keyPair[0]
// 计算共享密钥: shared_secret = peer_public_key^x mod c
peerPubInt := new(big.Int).SetBytes(peerPublicKey)
sharedSecret := new(big.Int).Exp(peerPubInt, x, cm.bigC)
key := sharedSecret.Bytes()
// 确保key长度为32字节
if len(key) < 32 {
// 前面补0
paddedKey := make([]byte, 32)
copy(paddedKey[32-len(key):], key)
key = paddedKey
} else if len(key) > 32 {
key = key[:32]
}
return cm.decodeAES(key, iv, data)
}
// Decrypt 解密响应内容
func (cm *CM) Decrypt(c, y, content string) (string, error) {
// c 应该为 "1" 表示启用加密
// y 是服务器返回的公钥
// content 是加密的内容
if c != "1" || y == "" {
return content, nil
}
// 解码content
contentDecoded, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", err
}
if len(contentDecoded) < 16 {
return "", fmt.Errorf("内容太短")
}
// 提取IV和数据
iv := contentDecoded[:16]
data := contentDecoded[16:]
// 解码服务器公钥
peerPublicKey, err := base64.StdEncoding.DecodeString(y)
if err != nil {
return "", err
}
// 检查密钥对是否已初始化
if cm.thisD[0] == nil {
return "", fmt.Errorf("密钥对未初始化")
}
// 解密
decrypted, err := cm.decryptF(peerPublicKey, cm.thisD, iv, data)
if err != nil {
return "", err
}
return string(decrypted), nil
}
+230
View File
@@ -0,0 +1,230 @@
package fqapi
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// EmojiManager 表情管理器
type EmojiManager struct {
emojiMap map[string]string // 表情文本 -> 图片URL的映射
mutex sync.RWMutex
lastUpdate time.Time
cacheExpiry time.Duration
}
// DouyinEmojiResponse 抖音表情API响应结构
type DouyinEmojiResponse struct {
EmojiList []DouyinEmoji `json:"emoji_list"`
}
// DouyinEmoji 抖音单个表情结构
type DouyinEmoji struct {
OriginURI string `json:"origin_uri"`
DisplayName string `json:"display_name"`
Hide int `json:"hide"`
EmojiURL struct {
URI string `json:"uri"`
URLList []string `json:"url_list"`
} `json:"emoji_url"`
}
// LocalEmojiData 本地表情数据结构
type LocalEmojiData struct {
Version int `json:"version"`
Size int `json:"size"`
Datas map[string]string `json:"datas"`
}
var (
globalEmojiManager *EmojiManager
emojiOnce sync.Once
)
// GetEmojiManager 获取全局表情管理器实例
func GetEmojiManager() *EmojiManager {
emojiOnce.Do(func() {
globalEmojiManager = &EmojiManager{
emojiMap: make(map[string]string),
cacheExpiry: 24 * time.Hour, // 缓存24小时
}
// 初始化时加载表情
globalEmojiManager.LoadEmojis()
})
return globalEmojiManager
}
// LoadEmojis 加载表情映射(优先API,其次本地)
func (em *EmojiManager) LoadEmojis() error {
em.mutex.Lock()
defer em.mutex.Unlock()
// 检查缓存是否过期
if time.Since(em.lastUpdate) < em.cacheExpiry && len(em.emojiMap) > 0 {
return nil
}
newMap := make(map[string]string)
// 1. 先尝试从抖音API获取表情
apiEmojis, err := em.fetchDouyinEmojis()
if err == nil && len(apiEmojis) > 0 {
for displayName, url := range apiEmojis {
newMap[displayName] = url
}
}
// 2. 加载本地表情作为补充
localEmojis, err := em.loadLocalEmojis()
if err == nil {
for displayName, url := range localEmojis {
// 如果API中没有,则使用本地的
if _, exists := newMap[displayName]; !exists {
newMap[displayName] = url
}
}
}
// 3. 更新缓存
if len(newMap) > 0 {
em.emojiMap = newMap
em.lastUpdate = time.Now()
}
return nil
}
// fetchDouyinEmojis 从抖音API获取表情映射
func (em *EmojiManager) fetchDouyinEmojis() (map[string]string, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://www.douyin.com/aweme/v1/web/emoji/list")
if err != nil {
return nil, fmt.Errorf("获取抖音表情API失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("抖音表情API返回状态码: %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var apiResp DouyinEmojiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析JSON失败: %w", err)
}
emojiMap := make(map[string]string)
for _, emoji := range apiResp.EmojiList {
if len(emoji.EmojiURL.URLList) > 0 {
// 使用第一个URL
emojiMap[emoji.DisplayName] = emoji.EmojiURL.URLList[0]
}
}
return emojiMap, nil
}
// loadLocalEmojis 从本地文件加载表情映射
func (em *EmojiManager) loadLocalEmojis() (map[string]string, error) {
// 读取 data.json
dataPath := filepath.Join("emoji", "data.json")
data, err := ioutil.ReadFile(dataPath)
if err != nil {
return nil, fmt.Errorf("读取emoji/data.json失败: %w", err)
}
var localData LocalEmojiData
if err := json.Unmarshal(data, &localData); err != nil {
return nil, fmt.Errorf("解析emoji/data.json失败: %w", err)
}
emojiMap := make(map[string]string)
for id, displayName := range localData.Datas {
// 生成本地图片路径:emoji/images/emoji_dr_{id}.png
// 转换为相对URL路径
localPath := fmt.Sprintf("/emoji/images/emoji_dr_%s.png", id)
emojiMap[displayName] = localPath
}
return emojiMap, nil
}
// GetEmojiURL 获取表情图片URL
func (em *EmojiManager) GetEmojiURL(displayName string) (string, bool) {
em.mutex.RLock()
defer em.mutex.RUnlock()
url, exists := em.emojiMap[displayName]
return url, exists
}
// GetAllEmojis 获取所有表情映射(用于生成JavaScript代码)
func (em *EmojiManager) GetAllEmojis() map[string]string {
em.mutex.RLock()
defer em.mutex.RUnlock()
// 返回副本
result := make(map[string]string, len(em.emojiMap))
for k, v := range em.emojiMap {
result[k] = v
}
return result
}
// RefreshEmojis 手动刷新表情缓存
func (em *EmojiManager) RefreshEmojis() error {
em.mutex.Lock()
em.lastUpdate = time.Time{} // 重置更新时间
em.mutex.Unlock()
return em.LoadEmojis()
}
// CheckLocalEmojiFiles 检查本地表情文件是否存在
func CheckLocalEmojiFiles() error {
dataPath := filepath.Join("emoji", "data.json")
if _, err := os.Stat(dataPath); os.IsNotExist(err) {
return fmt.Errorf("emoji/data.json 文件不存在")
}
imagesDir := filepath.Join("emoji", "images")
if _, err := os.Stat(imagesDir); os.IsNotExist(err) {
return fmt.Errorf("emoji/images 目录不存在")
}
return nil
}
// ReplaceEmojisInText 替换文本中的表情为HTML img标签
func ReplaceEmojisInText(text string) string {
em := GetEmojiManager()
// 匹配 [表情] 格式
result := text
for displayName, url := range em.GetAllEmojis() {
// 转义特殊字符
escapedName := strings.ReplaceAll(displayName, "[", "\\[")
escapedName = strings.ReplaceAll(escapedName, "]", "\\]")
// 替换为 img 标签
imgTag := fmt.Sprintf(`<img src="%s" class="emoji-img" alt="%s" />`, url, displayName)
result = strings.ReplaceAll(result, displayName, imgTag)
}
return result
}
+117
View File
@@ -0,0 +1,117 @@
package fqapi
import (
"crypto/md5"
"encoding/hex"
"fanqie/internal/algos"
"fanqie/internal/common"
)
// CreateMD5 创建MD5哈希
func CreateMD5(s string) string {
hasher := md5.New()
hasher.Write([]byte(s))
return hex.EncodeToString(hasher.Sum(nil))
}
// GorgonRequest Gorgon请求(使用签名切换器)
func GorgonRequest(urlStr string, options map[string]interface{}) (*common.ClientResponse, error) {
headers, ok := options["headers"].(map[string]string)
if !ok {
headers = make(map[string]string)
options["headers"] = headers
}
dataMD5 := ""
if body, ok := options["body"].(string); ok && body != "" {
dataMD5 = CreateMD5(body)
} else if body, ok := options["body"].([]byte); ok && len(body) > 0 {
dataMD5 = CreateMD5(string(body))
}
if dataMD5 != "" {
headers["x-ss-stub"] = dataMD5
}
// 使用签名切换器(sign2 或 sign4)生成签名
signedHeaders := algos.GlobalSignatureSwitcher.Sign(urlStr, headers)
// 更新headers
for k, v := range signedHeaders {
headers[k] = v
}
// 调用实际的HTTP请求
client := common.NewHTTPClient()
return client.Request(urlStr, options)
}
// GorgonRequestSync Gorgon同步请求(使用签名切换器)
func GorgonRequestSync(urlStr string, options map[string]interface{}) (*common.ClientResponse, error) {
headers, ok := options["headers"].(map[string]string)
if !ok {
headers = make(map[string]string)
options["headers"] = headers
}
dataMD5 := ""
if body, ok := options["body"].(string); ok && body != "" {
dataMD5 = CreateMD5(body)
} else if body, ok := options["body"].([]byte); ok && len(body) > 0 {
dataMD5 = CreateMD5(string(body))
}
if dataMD5 != "" {
headers["x-ss-stub"] = dataMD5
}
// 使用签名切换器(sign2 或 sign4)生成签名
signedHeaders := algos.GlobalSignatureSwitcher.Sign(urlStr, headers)
// 删除x-ss-stub(如果存在)
if _, exists := signedHeaders["x-ss-stub"]; exists {
delete(signedHeaders, "x-ss-stub")
}
// 更新headers
for k, v := range signedHeaders {
headers[k] = v
}
// 调用实际的HTTP请求
client := common.NewHTTPClient()
return client.Request(urlStr, options)
}
// ContentRequest Content请求(硬编码使用Sign2,不受配置影响)
func ContentRequest(urlStr string, options map[string]interface{}) (*common.ClientResponse, error) {
headers, ok := options["headers"].(map[string]string)
if !ok {
headers = make(map[string]string)
options["headers"] = headers
}
dataMD5 := ""
if body, ok := options["body"].(string); ok && body != "" {
dataMD5 = CreateMD5(body)
} else if body, ok := options["body"].([]byte); ok && len(body) > 0 {
dataMD5 = CreateMD5(string(body))
}
if dataMD5 != "" {
headers["x-ss-stub"] = dataMD5
}
// 硬编码使用 Sign2(不受配置影响)
signedHeaders := algos.Sign2Wrapper(urlStr, headers)
// 更新headers
for k, v := range signedHeaders {
headers[k] = v
}
// 调用实际的HTTP请求
client := common.NewHTTPClient()
return client.Request(urlStr, options)
}
+45
View File
@@ -0,0 +1,45 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
)
var itemSummaryLogger = common.NewLogger("item_summary", "INFO", "")
// HandleItemSummary 处理获取章节摘要
func HandleItemSummary(itemIDs, bookID string) (string, error) {
if itemIDs == "" || bookID == "" {
return BuildErrorResponseWithCode(100144, "参数错误"), nil
}
reqURL := BuildURL(
"https://reading.snssdk.com/reading/reader/item_summary/mget/v",
"book_id="+bookID,
"item_ids="+itemIDs,
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
itemSummaryLogger.Error(fmt.Sprintf("获取章节摘要失败: %v", err))
return BuildErrorResponse("请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponse("获取失败"), nil
}
+616
View File
@@ -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"
}
+72
View File
@@ -0,0 +1,72 @@
package fqapi
import (
"encoding/hex"
"fmt"
"time"
)
// HandleMangaImageProxy 处理漫画图片代理请求
func HandleMangaImageProxy(itemID string, index int) ([]byte, string, error) {
// 从缓存获取图片信息
cacheKey := fmt.Sprintf("%s_%d", itemID, index)
mangaCacheMutex.RLock()
imageInfo, exists := mangaImageCache[cacheKey]
mangaCacheMutex.RUnlock()
if !exists {
return nil, "", fmt.Errorf("图片信息不存在,请先调用 /manga 接口")
}
// 检查缓存是否过期(5分钟)
if time.Since(imageInfo.CachedAt) > 5*time.Minute {
// 清理过期缓存
mangaCacheMutex.Lock()
delete(mangaImageCache, cacheKey)
mangaCacheMutex.Unlock()
return nil, "", fmt.Errorf("图片信息已过期,请重新调用 /manga 接口")
}
// 下载加密图片
encryptedData, err := downloadImageWithRetry(imageInfo.PicURL, 2)
if err != nil {
return nil, "", fmt.Errorf("下载图片失败: %w", err)
}
// 解密图片
keyBytes, err := hex.DecodeString(imageInfo.EncryptKey)
if err != nil {
return nil, "", fmt.Errorf("密钥解码失败: %w", err)
}
decryptedData, err := decryptImage(encryptedData, keyBytes)
if err != nil {
return nil, "", fmt.Errorf("解密失败: %w", err)
}
// 识别格式
format := getImageFormat(decryptedData, imageInfo.PicURL)
// 返回图片数据和MIME类型
mimeType := fmt.Sprintf("image/%s", format)
if format == "jpg" {
mimeType = "image/jpeg"
}
return decryptedData, mimeType, nil
}
// CleanExpiredMangaCache 清理过期的漫画图片缓存
func CleanExpiredMangaCache() {
mangaCacheMutex.Lock()
defer mangaCacheMutex.Unlock()
now := time.Now()
for key, info := range mangaImageCache {
if now.Sub(info.CachedAt) > 5*time.Minute {
delete(mangaImageCache, key)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package fqapi
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
)
// generateRandomKey 生成随机key
func generateRandomKey(length int) string {
b := make([]byte, length/2)
rand.Read(b)
return hex.EncodeToString(b)
}
// extractDeviceParam 从deviceHeader字符串中提取指定参数
func extractDeviceParam(deviceHeader, paramName string) string {
// deviceHeader 格式: device_id=xxx&device_type=xxx&iid=xxx&version_name=xxx
parts := strings.Split(deviceHeader, "&")
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) == 2 && kv[0] == paramName {
return kv[1]
}
}
return ""
}
// downloadMediaWithRetry 带重试的媒体下载,自动切换设备
func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) ([]byte, string, error) {
var lastErr error
currentDeviceID := deviceID
for i := 0; i <= maxRetries; i++ {
data, contentType, err := downloadMedia(url, currentDeviceID, headers)
if err == nil && len(data) > 0 {
return data, contentType, nil
}
lastErr = err
if i < maxRetries {
// 重试时切换到新的有效设备
newDevice, err := GetValidDevice()
if err == nil {
currentDeviceID = newDevice.DeviceHeader
}
// 重试间隔递增(2秒、4秒、6秒)
time.Sleep(time.Duration(i*2+2) * time.Second)
}
}
return nil, "", fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
}
// downloadMedia 下载媒体内容
func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, string, error) {
// 创建专用的媒体下载客户端,超时时间更长
client := &http.Client{
Timeout: 300 * time.Second, // 5分钟超时,适合大文件
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, "", err
}
// 设置CDN请求头,模拟安卓客户端请求
req.Header.Set("User-Agent", "com.dragon.read/7.9.9.32 (Linux; U; Android 13; zh_CN; M2102K1AC; Build/TKQ1.220807.001; Cronet/113.0.5672.62)")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Host", req.URL.Host)
// 添加设备信息(如果提供了deviceID)
if deviceID != "" {
req.Header.Set("X-Device-ID", extractDeviceParam(deviceID, "device_id"))
req.Header.Set("X-Device-Type", extractDeviceParam(deviceID, "device_type"))
req.Header.Set("X-IID", extractDeviceParam(deviceID, "iid"))
req.Header.Set("X-Version-Name", extractDeviceParam(deviceID, "version_name"))
}
// 添加自定义请求头
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("下载失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
}
// 读取内容
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("读取内容失败: %w", err)
}
// 获取Content-Type
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
// 根据文件后缀推断
contentType = getContentTypeFromURL(url)
}
return data, contentType, nil
}
// getContentTypeFromURL 根据URL推断Content-Type
func getContentTypeFromURL(url string) string {
ext := strings.ToLower(filepath.Ext(url))
switch ext {
case ".mp3", ".m4a", ".aac":
return "audio/mpeg"
case ".mp4", ".m4v", ".mov":
return "video/mp4"
case ".flv":
return "video/x-flv"
case ".ts":
return "video/MP2T"
case ".webm":
return "video/webm"
case ".ogg":
return "audio/ogg"
default:
return "application/octet-stream"
}
}
// DetectMediaType 检测媒体类型
func DetectMediaType(data []byte) string {
if len(data) < 4 {
return "unknown"
}
// 检测音频格式
if data[0] == 0xFF && (data[1] == 0xFB || data[1] == 0xF3 || data[1] == 0xF2) {
return "audio/mpeg" // MP3
}
if string(data[:4]) == "ftyp" || string(data[:4]) == "moov" || string(data[:4]) == "mdat" {
return "video/mp4" // MP4/M4A
}
if string(data[:4]) == "OggS" {
return "audio/ogg" // OGG
}
if string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
return "audio/wav" // WAV
}
// 检测视频格式
if string(data[:3]) == "FLV" {
return "video/x-flv" // FLV
}
if string(data[:4]) == "webm" {
return "video/webm" // WebM
}
return "unknown"
}
// processMediaURL 处理媒体URL,根据output模式返回不同的URL(仅支持音频)
func processMediaURL(originalURL, deviceID string, headers map[string]string, outputMode string, mediaType string) interface{} {
switch outputMode {
case "proxy":
// 生成代理URL
if mediaType == "audio" {
return GenerateAudioProxyURL(originalURL, deviceID, headers)
}
// 其他类型返回原始URL
return originalURL
case "data_uri":
// 直接下载并返回base64
data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2)
if err != nil {
return originalURL
}
return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data))
default:
// 直接返回原始URL
return originalURL
}
}
+273
View File
@@ -0,0 +1,273 @@
package fqapi
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"io"
"sort"
"strings"
"sync"
)
var (
multiContentLogger = common.NewLogger("multi_content", "INFO", "")
)
// handleDecryptTask 处理解密任务
func handleDecryptTask(itemID string, itemData map[string]interface{}, keyInfo *KeyInfo, textMode, imageMode bool) (string, map[string]interface{}) {
if code, ok := itemData["code"].(float64); !ok || code != 0 {
return itemID, itemData
}
contentEncoded, ok := itemData["content"].(string)
if !ok {
return itemID, itemData
}
// 解码content
contentBytes, err := base64.StdEncoding.DecodeString(contentEncoded)
if err != nil || len(contentBytes) < 16 {
return itemID, itemData
}
iv := contentBytes[0:16]
encryptedData := contentBytes[16:]
// 解密
if keyInfo == nil {
return itemID, itemData
}
keyBytes, _ := base64.StdEncoding.DecodeString(keyInfo.Key)
decrypted, err := De(encryptedData, keyBytes, iv)
if err != nil {
return itemID, itemData
}
// 检查是否需要解压
compressStatus, _ := itemData["compress_status"].(float64)
if compressStatus != 0 {
// zlib解压
reader, err := zlib.NewReader(bytes.NewReader(decrypted))
if err == nil {
decompressed, err := io.ReadAll(reader)
reader.Close()
if err == nil {
decrypted = decompressed
}
}
}
// 更新内容
bodyCopy := make(map[string]interface{})
for k, v := range itemData {
bodyCopy[k] = v
}
bodyCopy["content"] = string(decrypted)
// 缓存
cacheData := buildCacheData(bodyCopy)
saveCacheJSON("content_"+itemID, cacheData, 86400)
// 解析内容
parser := NewContentParser()
bodyCopy["content"] = parser.Parse(string(decrypted), textMode, imageMode)
return itemID, bodyCopy
}
// MultiContent 批量获取内容
func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) {
// 参数转换
textModeBool := parseBoolParam(textMode)
imageModeBool := parseBoolParamDefault(imageMode, true)
ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true)
if depth > 3 {
return BuildErrorResponse("获取失败,请重试!"), nil
}
if bookID == "" {
return BuildErrorResponse("需要book_id!"), nil
}
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 转换itemIDs
var itemIDStr string
switch v := itemIDs.(type) {
case string:
itemIDStr = v
case []string:
itemIDStr = strings.Join(v, ",")
default:
itemIDStr = fmt.Sprintf("%v", v)
}
// 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
return `{"code": -1, "msg": "从设备池获取设备失败"}`, err
}
header := item.DeviceHeader
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
multiContentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (depth: %d)", item.ID, depth))
// 构建参数
headerWithoutVersion := removeVersionFromHeader(header)
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=66.9",
"book_id=" + bookID,
"item_ids=" + itemIDStr,
"novel_text_type=1",
"req_type=1",
headerWithoutVersion,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
url := "https://reading.snssdk.com/reading/reader/batch_full/v/?" + sortedParams
resp, err := ContentRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
multiContentLogger.Warning(fmt.Sprintf("批量请求失败,标记设备无效并切换设备 (depth: %d): %v", depth, err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
var data map[string]interface{}
json.Unmarshal(resp.Content, &data)
if code, ok := data["code"].(float64); ok && code == 0 {
dataMap, ok := data["data"].(map[string]interface{})
if !ok {
multiContentLogger.Warning(fmt.Sprintf("响应data字段无效,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 检查是否有Invalid内容或密钥版本不匹配
needRetry := false
for _, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
itemCode, _ := itemMap["code"].(float64)
if itemCode != 0 && !ignoreFailedBool {
multiContentLogger.Warning(fmt.Sprintf("item code异常: %.0f,切换设备重试 (depth: %d)", itemCode, depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
if content, ok := itemMap["content"].(string); ok && content == "Invalid" {
// 标记设备无效并移除
multiContentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
needRetry = true
break
}
if keyVersion, ok := itemMap["key_version"].(float64); ok {
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
// 更新密钥版本
multiContentLogger.Warning("密钥版本不匹配,重新获取密钥")
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
} else {
// 获取密钥失败,切换设备
multiContentLogger.Warning(fmt.Sprintf("获取密钥失败,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
}
needRetry = true
break
}
}
}
if needRetry {
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 并发解密
results := make(map[string]map[string]interface{})
var mutex sync.Mutex
var wg sync.WaitGroup
for itemID, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
wg.Add(1)
go func(id string, data map[string]interface{}) {
defer wg.Done()
_, decrypted := handleDecryptTask(id, data, currentKeyInfo, textModeBool, imageModeBool)
mutex.Lock()
results[id] = decrypted
mutex.Unlock()
}(itemID, itemMap)
}
wg.Wait()
// 检查结果数量
itemCount := len(strings.Split(itemIDStr, ","))
if len(results) == 1 && itemCount != 1 {
multiContentLogger.Warning(fmt.Sprintf("结果数量异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 构建响应
response := buildCacheData(results)
return buildJSONResponse(response), nil
}
// 响应code不为0,切换设备重试
multiContentLogger.Warning(fmt.Sprintf("响应code异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// HandleMultiContent 处理批量获取内容
func HandleMultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}) (string, error) {
return MultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed, 0)
}
+215
View File
@@ -0,0 +1,215 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
"strings"
"sync"
)
var multiDetailLogger = common.NewLogger("multi_detail", "INFO", "")
// singleDetail 获取单个书籍详情
func singleDetail(bookID string) (map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
return map[string]interface{}{}, err
}
respJSON, err := resp.JSON()
if err != nil {
return map[string]interface{}{}, err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "content")
return data, nil
}
}
return map[string]interface{}{}, nil
}
// multiDetail 批量获取书籍详情
func multiDetail(bookIDs string) ([]map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/multi-detail/v/", "book_id="+bookIDs)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
// 返回空列表
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, err
}
respJSON, err := resp.JSON()
if err != nil {
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
if data, ok := respJSON["data"].([]interface{}); ok && len(data) > 0 {
result := make([]map[string]interface{}, len(data))
for i, item := range data {
if itemMap, ok := item.(map[string]interface{}); ok {
result[i] = itemMap
} else {
result[i] = map[string]interface{}{}
}
}
return result, nil
}
}
// 返回空列表
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, nil
}
// chunkArray 分块数组
func chunkArray(arr []string, size int) [][]string {
chunks := make([][]string, 0)
for i := 0; i < len(arr); i += size {
end := i + size
if end > len(arr) {
end = len(arr)
}
chunks = append(chunks, arr[i:end])
}
return chunks
}
// HandleGetMultiDetail 处理批量获取书籍详情
func HandleGetMultiDetail(bookID interface{}) (string, error) {
if bookID == nil {
return BuildErrorResponse("需要参数 book_id"), nil
}
var bookIDList []string
switch v := bookID.(type) {
case string:
if v == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
bookIDList = strings.Split(v, ",")
case []string:
bookIDList = v
default:
return BuildErrorResponse("book_id 参数格式错误"), nil
}
// 分块处理,每批100个
chunks := chunkArray(bookIDList, 100)
// 并发请求
type result struct {
data []map[string]interface{}
err error
}
results := make([]result, len(chunks))
var wg sync.WaitGroup
for i, chunk := range chunks {
wg.Add(1)
go func(idx int, ch []string) {
defer wg.Done()
data, err := multiDetail(strings.Join(ch, ","))
results[idx] = result{data: data, err: err}
}(i, chunk)
}
wg.Wait()
// 合并结果
res := make([]map[string]interface{}, 0)
for _, r := range results {
if r.err == nil {
res = append(res, r.data...)
} else {
multiDetailLogger.Error(fmt.Sprintf("批量获取详情失败: %v", r.err))
}
}
// 处理下架书籍
offShelfBookIDs := make([]string, 0)
offShelfIndexes := make([]int, 0)
for i, item := range res {
if status, ok := item["tomato_book_status"].(string); ok && status == "3" {
if bid, ok := item["book_id"].(string); ok {
offShelfBookIDs = append(offShelfBookIDs, bid)
offShelfIndexes = append(offShelfIndexes, i)
}
}
}
// 单独获取下架书籍信息
if len(offShelfBookIDs) > 0 {
type offshelfResult struct {
data map[string]interface{}
idx int
}
offshelfResults := make([]offshelfResult, len(offShelfBookIDs))
var wg2 sync.WaitGroup
for i, bid := range offShelfBookIDs {
wg2.Add(1)
go func(idx int, bookID string, originalIdx int) {
defer wg2.Done()
data, _ := singleDetail(bookID)
offshelfResults[idx] = offshelfResult{data: data, idx: originalIdx}
}(i, bid, offShelfIndexes[i])
}
wg2.Wait()
// 更新结果
for _, r := range offshelfResults {
if r.idx < len(res) {
res[r.idx] = r.data
}
}
}
// 序列化结果
response := map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": res,
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
}
+249
View File
@@ -0,0 +1,249 @@
package fqapi
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fanqie/internal/common"
"fmt"
"math/rand"
"regexp"
"sort"
"strings"
"time"
)
// KeyInfo 密钥信息
type KeyInfo struct {
Key string `json:"key"`
Ver int `json:"ver"`
}
var (
keyInfo *KeyInfo
keyLogger = common.NewLogger("register_key", "INFO", "")
)
// rHex 将数字转换为反转的16进制字符串
func rHex(num int64) string {
hexStr := fmt.Sprintf("%032x", num)
result := ""
for i := len(hexStr); i > 0; i -= 2 {
result += hexStr[i-2 : i]
}
return result
}
// createSymmetricCrypto 创建对称加密器
func createSymmetricCrypto(algorithm string, key, iv []byte) (func([]byte) ([]byte, error), func([]byte) ([]byte, error), error) {
if algorithm != "AES/CBC/PKCS5Padding" {
return nil, nil, fmt.Errorf("不支持的加密算法")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, err
}
encrypt := func(data []byte) ([]byte, error) {
// PKCS7填充
padding := aes.BlockSize - len(data)%aes.BlockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
paddedData := append(data, padText...)
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, paddedData)
return ciphertext, nil
}
decrypt := func(data []byte) ([]byte, error) {
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
// 移除PKCS7填充
padding := int(decrypted[len(decrypted)-1])
if padding > len(decrypted) || padding > aes.BlockSize {
return nil, fmt.Errorf("无效的填充")
}
return decrypted[:len(decrypted)-padding], nil
}
return encrypt, decrypt, nil
}
// En AES加密
func En(data, key, iv []byte) ([]byte, error) {
encrypt, _, err := createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv)
if err != nil {
return nil, err
}
return encrypt(data)
}
// De AES解密
func De(data, key, iv []byte) ([]byte, error) {
_, decrypt, err := createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv)
if err != nil {
return nil, err
}
return decrypt(data)
}
// randomStringKey 生成随机字符串(密钥用)
func randomStringKey(length int) string {
const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
// GetKey 获取密钥
func GetKey(header string) (*KeyInfo, error) {
if header == "" {
keyLogger.Error("请先注册设备")
return nil, fmt.Errorf("请先注册设备")
}
key, _ := base64.StdEncoding.DecodeString("rCXGfd2POMGzeiNIgo4iLg==")
iv := []byte(randomStringKey(16))
// 提取device_id
re := regexp.MustCompile(`device_id=(\d+)`)
matches := re.FindStringSubmatch(header)
if len(matches) < 2 {
return nil, fmt.Errorf("无法从header中提取device_id")
}
var deviceIDInt int64
fmt.Sscanf(matches[1], "%d", &deviceIDInt)
// 转换device_id
hexStr := rHex(deviceIDInt)
data, _ := hex.DecodeString(hexStr)
keyLogger.Debug(fmt.Sprintf("rKey: %v", iv))
// 加密
encrypted, err := En(data, key, iv)
if err != nil {
return nil, err
}
// 组合iv和加密数据
newArr := append(iv, encrypted...)
content := base64.StdEncoding.EncodeToString(newArr)
keyLogger.Info("正在获取密钥...")
// 构建参数
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=5.8.9.32",
header,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
// 准备请求体
requestBody := map[string]string{
"content": content,
}
bodyJSON, _ := json.Marshal(requestBody)
// gzip压缩
var gzipBuf bytes.Buffer
gzipWriter := gzip.NewWriter(&gzipBuf)
gzipWriter.Write(bodyJSON)
gzipWriter.Close()
reqURL := "https://reading.snssdk.com/reading/crypt/registerkey?" + sortedParams
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "POST",
"body": gzipBuf.Bytes(),
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Encoding": "gzip",
},
})
if err != nil {
keyLogger.Error("获取密钥失败: " + err.Error())
return nil, err
}
if resp.Status == 200 {
var respData map[string]interface{}
json.Unmarshal(resp.Content, &respData)
dataObj, ok := respData["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("响应格式错误")
}
keyStr, ok := dataObj["key"].(string)
if !ok {
return nil, fmt.Errorf("密钥字段不存在")
}
// 解码返回的密钥
res, _ := base64.StdEncoding.DecodeString(keyStr)
if len(res) < 32 {
return nil, fmt.Errorf("密钥长度不足")
}
data := res[16:]
ivResp := res[0:16]
// 解密密钥
decryptedKey, err := De(data, key, ivResp)
if err != nil {
return nil, err
}
finalKey := base64.StdEncoding.EncodeToString(decryptedKey)
keyVer, _ := dataObj["keyver"].(float64)
keyInfo = &KeyInfo{
Key: finalKey,
Ver: int(keyVer),
}
keyLogger.Info(fmt.Sprintf("密钥获取成功!"))
return keyInfo, nil
}
return nil, fmt.Errorf("获取密钥失败")
}
// GetKeyInfo 获取当前密钥信息
func GetKeyInfo() *KeyInfo {
return keyInfo
}
func init() {
rand.Seed(time.Now().UnixNano())
}
+371
View File
@@ -0,0 +1,371 @@
package fqapi
import (
"bytes"
"compress/gzip"
"encoding/json"
"fanqie/internal/common"
"fmt"
"strings"
)
var (
reviewLogger = common.NewLogger("review", "INFO", "")
)
// compressJSON 压缩 JSON 数据为 Gzip 格式
func compressJSON(data interface{}) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("JSON序列化失败: %w", err)
}
var compressedData bytes.Buffer
gzw := gzip.NewWriter(&compressedData)
if _, err := gzw.Write(jsonData); err != nil {
gzw.Close()
return nil, fmt.Errorf("Gzip压缩失败: %w", err)
}
if err := gzw.Close(); err != nil {
return nil, fmt.Errorf("Gzip关闭失败: %w", err)
}
return compressedData.Bytes(), nil
}
// postReviewAPI 发送段评相关的 POST 请求
func postReviewAPI(url string, requestBody interface{}) (map[string]interface{}, error) {
// 压缩 JSON
compressedData, err := compressJSON(requestBody)
if err != nil {
return nil, err
}
// 使用 GorgonRequest 添加签名
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "POST",
"body": compressedData,
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
})
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
// 解析响应
var result map[string]interface{}
if err := json.Unmarshal(resp.Content, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 提取 data 字段
if data, ok := result["data"].(map[string]interface{}); ok {
return data, nil
}
return result, nil
}
// CommentData 评论数据结构
type CommentData struct {
Data map[string]struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
} `json:"data"`
ItemVersion string `json:"item_version"`
}
// GetChapterReview 获取章节的段落评论统计(想法评论)
func GetChapterReview(itemID string) (map[string]interface{}, error) {
// 使用 BuildURL 添加设备参数
url := BuildURL(fmt.Sprintf("https://reading.snssdk.com/novel/commentapi/idea/list/%s/v1", itemID))
requestBody := map[string]interface{}{
"comment_source": 3,
"item_id": itemID,
"item_version": "cdd11695939bcc41d1ff8c3f10c67802_1_e5c2474f6821deb",
}
return postReviewAPI(url, requestBody)
}
// GetChapterMixData 获取章节混合数据(评论)
func GetChapterMixData(bookID, itemID string) (map[string]interface{}, error) {
// 使用 BuildURL 添加设备参数
url := BuildURL("https://api5-normal-sinfonlinec.fqnovel.com/reading/ugc/item/mix_data/get/v")
requestBody := map[string]interface{}{
"count": 5000,
"item_id": itemID,
"source_type": 51,
"book_id": bookID,
}
return postReviewAPI(url, requestBody)
}
// GetParaReview 获取段落评论或评论回复
// sort: 1=热门, 3=最新
// offset: 分页偏移量
// count: 每页数量
func GetParaReview(itemID, bookID, commentID string, para, sort, offset, count int) (map[string]interface{}, error) {
// 默认排序值
if sort != 1 && sort != 3 {
sort = 1 // 默认热门
}
// 默认数量
if count <= 0 {
count = 20
}
var url string
if commentID != "" {
// 获取评论的回复列表 - 使用 BuildURL 添加设备参数
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/reading/ugc/reply/topic/v/",
fmt.Sprintf("offset=%d", offset),
fmt.Sprintf("group_id=%s", itemID),
"service_id=1",
fmt.Sprintf("count=%d", count),
"source_type=63",
fmt.Sprintf("book_id=%s", bookID),
fmt.Sprintf("comment_id=%s", commentID),
)
} else {
// 获取段落的评论列表 - 使用 BuildURL 添加设备参数
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/reading/ugc/idea/comment_list/v/",
"item_version=c63f0e6195259adec926ae3209291854_1_1099f2b8324de5f",
fmt.Sprintf("offset=%d", offset),
"author_user_id=104957777655",
fmt.Sprintf("item_id=%s", itemID),
fmt.Sprintf("count=%d", count),
fmt.Sprintf("para_index=%d", para),
fmt.Sprintf("book_id=%s", bookID),
fmt.Sprintf("sort=%d", sort),
)
}
// 使用 GorgonRequest 添加签名
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
// 解析响应
var result map[string]interface{}
if err := json.Unmarshal(resp.Content, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 提取 data 字段
if data, ok := result["data"].(map[string]interface{}); ok {
return data, nil
}
return result, nil
}
// GenerateParagraphsWithComments 生成带评论标记的内容
func GenerateParagraphsWithComments(itemID, bookID, content, baseURL string) (string, error) {
// 获取段评数据
data, err := GetChapterReview(itemID)
if err != nil {
reviewLogger.Warning("获取段评数据失败: " + err.Error())
// 如果获取失败,返回原始内容
return content, nil
}
// 解析评论数据
commentData := make(map[string]struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
})
if dataMap, ok := data["data"].(map[string]interface{}); ok {
for key, val := range dataMap {
if valMap, ok := val.(map[string]interface{}); ok {
count := 0
if c, ok := valMap["count"].(float64); ok {
count = int(c)
}
hot := ""
if h, ok := valMap["hot"].(string); ok {
hot = h
}
isAuthor := false
if ia, ok := valMap["is_author_comment"].(bool); ok {
isAuthor = ia
}
userCount := 0
if uc, ok := valMap["user_count"].(float64); ok {
userCount = int(uc)
}
commentData[key] = struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
}{
Count: count,
Hot: hot,
IsAuthorComment: isAuthor,
UserCount: userCount,
}
}
}
}
// 检测内容格式:是否包含HTML标签(检测实际内容,而非配置)
isHTMLContent := strings.Contains(content, "<p>") ||
strings.Contains(content, "<div") ||
strings.Contains(content, "<article")
// 关键:如果检测到有段评数据,强制使用HTML模式(包裹<p>标签)
// 因为<comment>标签需要XML兼容的格式
hasAnyComments := len(commentData) > 0
if hasAnyComments && !isHTMLContent {
isHTMLContent = true
}
lines := strings.Split(content, "\n")
var result strings.Builder
paragraphIndex := 0
for i, line := range lines {
isLastLine := i == len(lines)-1
trimmedLine := strings.TrimSpace(line)
// 完全空行:直接输出换行符
if trimmedLine == "" {
if !isLastLine {
result.WriteString("\n")
}
continue
}
// 跳过图片标签和div标签
if strings.HasPrefix(trimmedLine, "<img") ||
strings.HasPrefix(trimmedLine, "<div") ||
strings.HasPrefix(trimmedLine, "</div>") ||
strings.HasPrefix(trimmedLine, "<article") ||
strings.HasPrefix(trimmedLine, "</article") {
result.WriteString(line)
if !isLastLine {
result.WriteString("\n")
}
continue
}
// 如果原内容已经有<p>标签,在</p>之前插入段评标记
if strings.HasPrefix(trimmedLine, "<p>") && strings.HasSuffix(trimmedLine, "</p>") {
// 提取<p>和</p>之间的内容
innerContent := trimmedLine[3 : len(trimmedLine)-4]
result.WriteString("<p>")
result.WriteString(innerContent)
// 添加评论标记
key := fmt.Sprintf("%d", paragraphIndex)
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
result.WriteString(commentTag)
}
result.WriteString("</p>")
if !isLastLine {
result.WriteString("\n")
}
paragraphIndex++
continue
}
// 如果是HTML模式,包裹<p>标签
if isHTMLContent {
result.WriteString("<p>")
result.WriteString(line)
// 添加评论标记
key := fmt.Sprintf("%d", paragraphIndex)
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
result.WriteString(commentTag)
}
result.WriteString("</p>")
if !isLastLine {
result.WriteString("\n")
}
} else {
// 纯文本模式:只在行尾添加comment标记
result.WriteString(line)
// 添加评论标记(纯文本格式)
key := fmt.Sprintf("%d", paragraphIndex)
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
result.WriteString(commentTag)
}
if !isLastLine {
result.WriteString("\n")
}
}
paragraphIndex++
}
return result.String(), nil
}
// extractDeviceID 从 device header 中提取 device_id
func extractDeviceID(header string) string {
if header == "" {
return "0"
}
// 从 header 中提取 device_id
parts := strings.Split(header, "&")
for _, part := range parts {
if strings.HasPrefix(part, "device_id=") {
return strings.TrimPrefix(part, "device_id=")
}
}
return "0"
}
// ParseJSON 解析 JSON 字符串
func ParseJSON(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// ToJSON 将对象序列化为 JSON 字符串
func ToJSON(v interface{}) (string, error) {
buffer := &strings.Builder{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false) // 禁用HTML转义,避免 " 被转义为 \"
err := encoder.Encode(v)
if err != nil {
return "", err
}
// Encode 会在末尾添加换行符,需要去掉
result := buffer.String()
return strings.TrimSuffix(result, "\n"), nil
}
+59
View File
@@ -0,0 +1,59 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
"net/url"
)
var searchLogger = common.NewLogger("search", "INFO", "")
// GetDeviceHeader 获取设备header(需要从外部注入)
var GetDeviceHeader func() string
// HandleBookSearch 处理书籍搜索
func HandleBookSearch(key string, offset int, tabType int) (string, error) {
if key == "" {
return BuildErrorResponseWithCode(1, "需要参数 key"), nil
}
if tabType == 0 {
tabType = 3
}
// 编码查询关键字
encodedKey := url.QueryEscape(key)
// 使用新的参数构建函数
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/search/tab/v/",
fmt.Sprintf("tab_type=%d", tabType),
"update_version_code=58932",
"query="+encodedKey,
fmt.Sprintf("passback=%d", offset),
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
searchLogger.Error(fmt.Sprintf("搜索请求失败: %v", err))
return BuildErrorResponseWithCode(1, "搜索请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
searchLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponseWithCode(1, "解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponseWithCode(1, "搜索失败"), nil
}
+196
View File
@@ -0,0 +1,196 @@
package fqapi
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"io"
)
// FIXED_STRING 固定字符串(base64解码后的值)
var FIXED_STRING = []byte{
0x4D, 0xD4, 0xC2, 0xE6, 0xB8, 0x31, 0x62, 0x09,
0x0E, 0x52, 0xB3, 0xC7, 0xA6, 0x73, 0x3B, 0xA4,
0x1C, 0xB2, 0x46, 0x2B, 0x82, 0x9A, 0xB5, 0x8A,
0x19, 0x6B, 0x39, 0xDB, 0x57, 0x17, 0x75, 0x24,
0xF4, 0x9B, 0xAF, 0x7F, 0x08, 0xE8, 0xD6, 0x8D,
0x26, 0xA7, 0x2E, 0x37, 0xC1, 0xA9, 0x5A, 0x2F,
0x1F, 0x05, 0xA5, 0x18, 0x92, 0xAE, 0xF2, 0x94,
0x97, 0x32, 0xB6, 0x2A, 0x38, 0xAA, 0xDD, 0x58,
}
// sha512Hash 计算SHA512哈希值
func sha512Hash(data []byte) []byte {
hash := sha512.Sum512(data)
return hash[:]
}
// generateRandomBytes 生成指定长度的随机字节
func generateRandomBytes(size int) []byte {
bytes := make([]byte, size)
rand.Read(bytes)
return bytes
}
// gzipCompress 使用gzip压缩数据
func gzipCompress(data []byte) ([]byte, error) {
buf := new(bytes.Buffer)
writer := gzip.NewWriter(buf)
_, err := writer.Write(data)
if err != nil {
return nil, err
}
err = writer.Close()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// gzipDecompress 使用gzip解压缩数据
func gzipDecompress(data []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer reader.Close()
result, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
return result, nil
}
// encryptData 使用AES-CBC模式加密数据
func encryptData(data []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7填充
padding := aes.BlockSize - len(data)%aes.BlockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
paddedData := append(data, padText...)
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, paddedData)
return ciphertext, nil
}
// decryptData 使用AES-CBC模式解密数据
func decryptData(data []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
// 移除PKCS7填充
padding := int(decrypted[len(decrypted)-1])
return decrypted[:len(decrypted)-padding], nil
}
// TTEncrypt 加密函数
func TTEncrypt(data []byte) ([]byte, error) {
// 生成随机值
randomValue := generateRandomBytes(32)
// 计算SHA512哈希值
hashValue := sha512Hash(append(sha512Hash(randomValue), FIXED_STRING...))
// 提取key和iv
key := hashValue[:16]
iv := hashValue[16:32]
// 压缩数据并计算哈希值
compressedData, err := gzipCompress(data)
if err != nil {
return nil, err
}
hashedData := append(sha512Hash(compressedData), compressedData...)
// 加密数据
encryptedData, err := encryptData(hashedData, key, iv)
if err != nil {
return nil, err
}
// 返回结果
header := []byte{116, 99, 5, 16, 0, 0}
result := append(header, randomValue...)
result = append(result, encryptedData...)
return result, nil
}
// TTDecrypt 解密函数
func TTDecrypt(encryptedData []byte) ([]byte, error) {
// 提取固定头、随机值和加密数据
// header := encryptedData[:6]
randomValue := encryptedData[6:38]
encryptedPayload := encryptedData[38:]
// 计算SHA512哈希值
hashValue := sha512Hash(append(sha512Hash(randomValue), FIXED_STRING...))
// 提取key和iv
key := hashValue[:16]
iv := hashValue[16:32]
// 解密数据
decryptedData, err := decryptData(encryptedPayload, key, iv)
if err != nil {
return nil, err
}
// 分离哈希值和压缩数据
hashLength := 64
hashedValue := decryptedData[:hashLength]
compressedData := decryptedData[hashLength:]
// 验证哈希值
expectedHash := sha512Hash(compressedData)
for i := 0; i < hashLength; i++ {
if hashedValue[i] != expectedHash[i] {
return nil, fmt.Errorf("hash verification failed")
}
}
// 解压缩数据
originalData, err := gzipDecompress(compressedData)
if err != nil {
return nil, err
}
return originalData, nil
}
// Encrypt 导出的加密函数
func Encrypt(data []byte) string {
result, _ := TTEncrypt(data)
return base64.StdEncoding.EncodeToString(result)
}
// Decrypt 导出的解密函数
func Decrypt(encryptedStr string) ([]byte, error) {
encryptedData, err := base64.StdEncoding.DecodeString(encryptedStr)
if err != nil {
return nil, err
}
return TTDecrypt(encryptedData)
}
+266
View File
@@ -0,0 +1,266 @@
package fqapi
import (
"encoding/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"math"
)
var videoLogger = common.NewLogger("video", "INFO", "")
// HandleVideo 处理获取视频(支持批量和单个接口)
func HandleVideo(itemIDs interface{}) (string, error) {
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 处理itemIDs - 使用ParseStringParam
itemIDStr := ParseStringParam(itemIDs, "")
// 判断是单个ID还是多个ID(用逗号分隔)
isSingle := true
if str, ok := itemIDs.(string); ok {
for _, ch := range str {
if ch == ',' {
isSingle = false
break
}
}
}
// 根据ID数量选择不同的接口
var url string
if isSingle {
// 单个视频接口 - 使用带签名的URL
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/novel/player/video_model/v1")
} else {
// 批量视频接口
url = BuildURL("https://reading.snssdk.com/novel/player/multi_video_model/v1/")
}
// 构建请求体,添加更多设备参数,请求所有清晰度
requestBody := fmt.Sprintf(`{"biz_param":{"device_level":3,"need_all_video_definition":true,"need_mp4_align":false,"use_os_player":false,"video_platform":1024},"video_id":"%s"}`,
itemIDStr)
// 使用 GorgonRequest 添加签名和设备信息
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "POST",
"body": requestBody,
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Type": "application/json",
},
})
if err != nil {
videoLogger.Error(fmt.Sprintf("获取视频失败: %v", err))
return BuildErrorResponse("FAILED"), err
}
respJSON, err := resp.JSON()
if err != nil {
videoLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponse("FAILED"), err
}
// 检查响应码(可能返回 Code 或 code)
var respCode float64
var okCode bool
if code, ok := respJSON["code"].(float64); ok {
respCode, okCode = code, code == 0
} else if code, ok := respJSON["Code"].(float64); ok {
respCode, okCode = code, code == 0
}
if okCode {
// 尝试获取 data(可能在不同位置)
var data map[string]interface{}
if d, ok := respJSON["data"].(map[string]interface{}); ok {
data = d
} else if d, ok := respJSON["Data"].(map[string]interface{}); ok {
data = d
}
if data != nil {
if videoModelStr, ok := data["video_model"].(string); ok && isSingle {
decodedModel := decodeVideoModelAsJSON(videoModelStr)
// 单个接口直接返回 video_model
response := map[string]interface{}{
"code": 200,
"message": "SUCCESS",
"data": decodedModel,
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
} else {
// 批量接口:data 是一个map,key是ID,value包含video_model
result := make(map[string]interface{})
for itemID, itemData := range data {
if itemMap, ok := itemData.(map[string]interface{}); ok {
if videoModelStr, ok := itemMap["video_model"].(string); ok {
decodedModel := decodeVideoModelAsJSON(videoModelStr)
result[itemID] = map[string]interface{}{
"video_model": decodedModel,
}
}
}
}
// 构建返回结果
response := map[string]interface{}{
"code": 200,
"message": "SUCCESS",
"data": result,
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
}
}
}
// 返回错误信息
errMsg := "获取视频失败"
if msg, ok := respJSON["message"].(string); ok {
errMsg = msg
} else if msg, ok := respJSON["Message"].(string); ok {
errMsg = msg
}
videoLogger.Error(fmt.Sprintf("获取视频失败: code=%v, msg=%s", respCode, errMsg))
return BuildErrorResponse(errMsg), nil
}
// decodeVideoModelAsJSON 解析并解码 video_model,返回关键字段的 JSON 对象
func decodeVideoModelAsJSON(videoModelStr string) interface{} {
// 解析 video_model JSON 字符串
var videoModel map[string]interface{}
if err := json.Unmarshal([]byte(videoModelStr), &videoModel); err != nil {
// 解析失败,返回空对象
return map[string]interface{}{}
}
// 构建精简的返回结果,只包含关键字段
result := make(map[string]interface{})
// 提取关键字段
if videoID, ok := videoModel["video_id"].(string); ok {
result["video_id"] = videoID
}
if duration, ok := videoModel["video_duration"].(float64); ok {
result["video_duration"] = formatDuration(duration)
}
if posterURL, ok := videoModel["poster_url"].(string); ok {
result["poster_url"] = posterURL
}
// 处理 video_list
if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok {
processedVideoList := make(map[string]interface{})
for quality, qualityData := range videoList {
if qualityMap, ok := qualityData.(map[string]interface{}); ok {
// 只保留关键字段
qualityInfo := make(map[string]interface{})
if definition, ok := qualityMap["definition"].(string); ok {
qualityInfo["definition"] = definition
}
if vtype, ok := qualityMap["vtype"].(string); ok {
qualityInfo["vtype"] = vtype
}
if vwidth, ok := qualityMap["vwidth"].(float64); ok {
qualityInfo["vwidth"] = int(vwidth)
}
if vheight, ok := qualityMap["vheight"].(float64); ok {
qualityInfo["vheight"] = int(vheight)
}
if bitrate, ok := qualityMap["bitrate"].(float64); ok {
qualityInfo["bitrate"] = formatBitrate(bitrate)
}
if size, ok := qualityMap["size"].(float64); ok {
qualityInfo["size"] = formatFileSize(size)
}
if fps, ok := qualityMap["fps"].(float64); ok {
qualityInfo["fps"] = int(fps)
}
// 解码 main_url
if mainURL, ok := qualityMap["main_url"].(string); ok {
if decoded, err := base64.StdEncoding.DecodeString(mainURL); err == nil {
qualityInfo["main_url"] = string(decoded)
}
}
// 解码 backup_url_1
if backupURL, ok := qualityMap["backup_url_1"].(string); ok {
if decoded, err := base64.StdEncoding.DecodeString(backupURL); err == nil {
qualityInfo["backup_url_1"] = string(decoded)
}
}
processedVideoList[quality] = qualityInfo
}
}
result["video_list"] = processedVideoList
}
return result
}
// formatDuration 将秒数转换为可读的时间格式
func formatDuration(seconds float64) string {
totalSeconds := int(math.Round(seconds))
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
secs := totalSeconds % 60
if hours > 0 {
return fmt.Sprintf("%d小时%d分%d秒", hours, minutes, secs)
} else if minutes > 0 {
return fmt.Sprintf("%d分%d秒", minutes, secs)
}
return fmt.Sprintf("%d秒", secs)
}
// formatFileSize 将字节数转换为可读的文件大小格式
func formatFileSize(bytes float64) string {
const unit = 1024.0
if bytes < unit {
return fmt.Sprintf("%.0f B", bytes)
}
kb := bytes / unit
if kb < unit {
return fmt.Sprintf("%.2f KB", kb)
}
mb := kb / unit
if mb < unit {
return fmt.Sprintf("%.2f MB", mb)
}
gb := mb / unit
return fmt.Sprintf("%.2f GB", gb)
}
// formatBitrate 将比特率转换为可读的格式
func formatBitrate(bitrate float64) string {
const unit = 1000.0
kbps := bitrate / unit
if kbps < unit {
return fmt.Sprintf("%.0f Kbps", kbps)
}
mbps := kbps / unit
return fmt.Sprintf("%.2f Mbps", mbps)
}
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"fanqie/internal/fqapi"
"fmt"
"net/http"
"strconv"
)
// HandleSearch 搜索处理
func HandleSearch(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementSearch()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
query := r.URL.Query().Get("query")
if query == "" {
query = r.URL.Query().Get("key")
}
offsetStr := r.URL.Query().Get("offset")
offset := 0
if offsetStr != "" {
offset, _ = strconv.Atoi(offsetStr)
}
tabTypeStr := r.URL.Query().Get("tab_type")
tabType := 3
if tabTypeStr != "" {
tabType, _ = strconv.Atoi(tabTypeStr)
}
result, err := fqapi.HandleBookSearch(query, offset, tabType)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "搜索失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleDetail 详情处理
func HandleDetail(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementDetail()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
bookID := r.URL.Query().Get("book_id")
result, err := fqapi.HandleGetDetail(bookID)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取详情失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleCatalog 目录处理
func HandleCatalog(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementCatalog()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
bookID := r.URL.Query().Get("book_id")
result, err := fqapi.HandleGetCatalog(bookID)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取目录失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleMultiDetail 批量详情处理
func HandleMultiDetail(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
bookID := r.URL.Query().Get("book_id")
result, err := fqapi.HandleGetMultiDetail(bookID)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "批量获取详情失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
+46
View File
@@ -0,0 +1,46 @@
package handlers
import (
"fanqie/internal/fqapi"
"fmt"
"net/http"
)
// HandleCategoryFront 分类列表处理
func HandleCategoryFront(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementCategory()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
tab := r.URL.Query().Get("tab")
result, err := fqapi.HandleCategoryFront(tab)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取分类列表失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleCategoryLanding 分类详情处理
func HandleCategoryLanding(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementCategory()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
categoryID := r.URL.Query().Get("category_id")
offset := r.URL.Query().Get("offset")
wordNumber := r.URL.Query().Get("word_number")
bookStatus := r.URL.Query().Get("book_status")
sortBy := r.URL.Query().Get("sort_by")
result, err := fqapi.HandleCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取分类详情失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
+174
View File
@@ -0,0 +1,174 @@
package handlers
import (
"encoding/json"
"fanqie/internal/config"
"fanqie/internal/fqapi"
"fmt"
"net/http"
"sync/atomic"
"time"
)
var (
startTime = time.Now()
deviceHeader string
// 请求统计
totalRequests int64
searchRequests int64
detailRequests int64
catalogRequests int64
contentRequests int64
mangaRequests int64
audioRequests int64
videoRequests int64
categoryRequests int64
// 统计保存定时器
statsSaveTicker *time.Ticker
)
// 统计函数
func incrementTotal() { atomic.AddInt64(&totalRequests, 1) }
func incrementSearch() { atomic.AddInt64(&searchRequests, 1) }
func incrementDetail() { atomic.AddInt64(&detailRequests, 1) }
func incrementCatalog() { atomic.AddInt64(&catalogRequests, 1) }
func incrementContent() { atomic.AddInt64(&contentRequests, 1) }
func incrementManga() { atomic.AddInt64(&mangaRequests, 1) }
func incrementAudio() { atomic.AddInt64(&audioRequests, 1) }
func incrementVideo() { atomic.AddInt64(&videoRequests, 1) }
func incrementCategory() { atomic.AddInt64(&categoryRequests, 1) }
func getTotal() int64 { return atomic.LoadInt64(&totalRequests) }
func getSearch() int64 { return atomic.LoadInt64(&searchRequests) }
func getDetail() int64 { return atomic.LoadInt64(&detailRequests) }
func getCatalog() int64 { return atomic.LoadInt64(&catalogRequests) }
func getContent() int64 { return atomic.LoadInt64(&contentRequests) }
func getManga() int64 { return atomic.LoadInt64(&mangaRequests) }
func getAudio() int64 { return atomic.LoadInt64(&audioRequests) }
func getVideo() int64 { return atomic.LoadInt64(&videoRequests) }
func getCategory() int64 { return atomic.LoadInt64(&categoryRequests) }
// SetDeviceHeader 设置设备header
func SetDeviceHeader(header string) {
deviceHeader = header
}
// LoadStatistics 从数据库加载统计数据
func LoadStatistics() {
stats, err := fqapi.LoadStatistics()
if err != nil {
fmt.Printf("加载统计数据失败: %v\n", err)
return
}
atomic.StoreInt64(&totalRequests, stats["total"])
atomic.StoreInt64(&searchRequests, stats["search"])
atomic.StoreInt64(&detailRequests, stats["detail"])
atomic.StoreInt64(&catalogRequests, stats["catalog"])
atomic.StoreInt64(&contentRequests, stats["content"])
atomic.StoreInt64(&mangaRequests, stats["manga"])
atomic.StoreInt64(&audioRequests, stats["audio"])
atomic.StoreInt64(&videoRequests, stats["video"])
atomic.StoreInt64(&categoryRequests, stats["category"])
}
// SaveStatistics 保存统计数据到数据库
func SaveStatistics() {
stats := map[string]int64{
"total": atomic.LoadInt64(&totalRequests),
"search": atomic.LoadInt64(&searchRequests),
"detail": atomic.LoadInt64(&detailRequests),
"catalog": atomic.LoadInt64(&catalogRequests),
"content": atomic.LoadInt64(&contentRequests),
"manga": atomic.LoadInt64(&mangaRequests),
"audio": atomic.LoadInt64(&audioRequests),
"video": atomic.LoadInt64(&videoRequests),
"category": atomic.LoadInt64(&categoryRequests),
}
if err := fqapi.SaveStatistics(stats); err != nil {
fmt.Printf("保存统计数据失败: %v\n", err)
}
}
// StartStatisticsSaver 启动统计数据定期保存
func StartStatisticsSaver() {
// 每30秒保存一次统计数据
statsSaveTicker = time.NewTicker(30 * time.Second)
go func() {
for range statsSaveTicker.C {
SaveStatistics()
}
}()
}
// StopStatisticsSaver 停止统计数据定期保存
func StopStatisticsSaver() {
if statsSaveTicker != nil {
statsSaveTicker.Stop()
// 最后保存一次
SaveStatistics()
}
}
// RequireAccessKey 验证访问密钥中间件
func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg := config.GetConfig()
// 如果未配置 access_key 或为空,则不启用验证
if cfg == nil || cfg.Server.AccessKey == "" {
handler(w, r)
return
}
// 从查询参数或 Header 中获取 key
key := r.URL.Query().Get("key")
if key == "" {
key = r.Header.Get("X-Access-Key")
}
// 验证密钥
if key != cfg.Server.AccessKey {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"code": -1, "msg": "访问被拒绝:无效的访问密钥"}`)
return
}
// 验证通过,继续处理请求
handler(w, r)
}
}
// HandleMain 主页处理
func HandleMain(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
response := map[string]interface{}{
"code": 0,
"message": "success",
"data": nil,
}
json.NewEncoder(w).Encode(response)
}
// HandleSelfInfo 设备信息处理
func HandleSelfInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 获取设备池统计信息
pool := fqapi.GetDeviceKeyPool()
poolStats := pool.GetStats()
response := map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": map[string]interface{}{
"device_pool_enabled": pool.IsEnabled(),
"device_pool_stats": poolStats,
},
}
json.NewEncoder(w).Encode(response)
}
+156
View File
@@ -0,0 +1,156 @@
package handlers
import (
"fanqie/internal/config"
"fanqie/internal/fqapi"
"fmt"
"net/http"
)
// HandleContent 内容处理
func HandleContent(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementContent()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 从查询参数获取item_id
itemID := r.URL.Query().Get("item_id")
// 获取URL参数(支持动态覆盖配置)
textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式
imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片
reviewParam := r.URL.Query().Get("review") // 1=启用段评, 0=禁用段评
result, err := fqapi.HandleGetContent(itemID, textMode, imageMode)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取内容失败: %s"}`, err.Error())
return
}
// 判断是否启用段评功能
// 优先使用URL参数,如果没有则使用配置文件
enableReview := false
if reviewParam == "1" {
enableReview = true
} else if reviewParam == "0" {
enableReview = false
} else {
// 没有URL参数时,使用配置文件
cfg := config.GetConfig()
if cfg != nil {
enableReview = cfg.Fanqie.Content.EnableReview
}
}
// 如果启用了段评功能,处理正文内容添加段评标记
if enableReview {
result = addReviewToContent(result, itemID, r)
}
fmt.Fprint(w, result)
}
// HandleMultiContent 批量内容处理
func HandleMultiContent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
bookID := r.URL.Query().Get("book_id")
itemIDs := r.URL.Query().Get("item_ids")
textMode := r.URL.Query().Get("text_mode")
imageMode := r.URL.Query().Get("image_mode")
ignoreFailed := r.URL.Query().Get("ignore_failed")
result, err := fqapi.HandleMultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "批量获取内容失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleItemSummary 章节摘要处理
func HandleItemSummary(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
bookID := r.URL.Query().Get("book_id")
itemIDs := r.URL.Query().Get("item_ids")
result, err := fqapi.HandleItemSummary(itemIDs, bookID)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取章节摘要失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// addReviewToContent 为正文内容添加段评标记
func addReviewToContent(jsonResult, itemID string, r *http.Request) string {
// 解析 JSON 结果
var resultData map[string]interface{}
if err := fqapi.ParseJSON([]byte(jsonResult), &resultData); err != nil {
return jsonResult
}
// 获取 data 字段
data, ok := resultData["data"].(map[string]interface{})
if !ok {
return jsonResult
}
// 获取 content 字段
content, ok := data["content"].(string)
if !ok || content == "" {
return jsonResult
}
// 从响应数据中提取 book_id (data.novel_data.book_id)
bookID := ""
if novelData, ok := data["novel_data"].(map[string]interface{}); ok {
if bookIDVal, ok := novelData["book_id"].(string); ok {
bookID = bookIDVal
} else if bookIDVal, ok := novelData["book_id"].(float64); ok {
bookID = fmt.Sprintf("%.0f", bookIDVal)
}
}
// 获取基础 URL
cfg := config.GetConfig()
baseURL := ""
if cfg != nil {
host := cfg.Server.Host
if host == "0.0.0.0" {
host = "localhost"
}
port := cfg.Server.Port
scheme := "http"
// 检查是否来自外部请求
if r.Host != "" {
baseURL = scheme + "://" + r.Host
} else {
baseURL = scheme + "://" + host + ":" + port
}
}
// 生成带段评标记的内容
newContent, err := fqapi.GenerateParagraphsWithComments(itemID, bookID, content, baseURL)
if err != nil {
// 如果失败,返回原始内容
return jsonResult
}
// 替换 content 字段
data["content"] = newContent
resultData["data"] = data
// 重新序列化为 JSON
newJSON, err := fqapi.ToJSON(resultData)
if err != nil {
return jsonResult
}
return newJSON
}
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"fanqie/internal/fqapi"
"fmt"
"net/http"
"strconv"
"strings"
)
// HandleMangaImageProxy 处理漫画图片代理请求
func HandleMangaImageProxy(w http.ResponseWriter, r *http.Request) {
// 从URL路径提取参数: /manga/image/{itemID}/{index}
path := strings.TrimPrefix(r.URL.Path, "/manga/image/")
parts := strings.Split(path, "/")
if len(parts) != 2 {
http.Error(w, "Invalid URL format. Expected: /manga/image/{itemID}/{index}", http.StatusBadRequest)
return
}
itemID := parts[0]
index, err := strconv.Atoi(parts[1])
if err != nil {
http.Error(w, "Invalid index parameter", http.StatusBadRequest)
return
}
// 获取解密后的图片数据
imageData, mimeType, err := fqapi.HandleMangaImageProxy(itemID, index)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get image: %s", err.Error()), http.StatusInternalServerError)
return
}
// 设置响应头
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Cache-Control", "public, max-age=3600") // 缓存1小时
w.Header().Set("Content-Length", strconv.Itoa(len(imageData)))
// 返回图片数据
w.Write(imageData)
}
+132
View File
@@ -0,0 +1,132 @@
package handlers
import (
"encoding/json"
"fanqie/internal/fqapi"
"fmt"
"net/http"
"strings"
)
// HandleAudio 音频处理
func HandleAudio(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementAudio()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
itemIDs := r.URL.Query().Get("item_ids")
toneID := r.URL.Query().Get("tone_id")
output := r.URL.Query().Get("output")
result, err := fqapi.HandleAudio(itemIDs, toneID, output)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取音频失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleAudioProxy 音频代理处理
func HandleAudioProxy(w http.ResponseWriter, r *http.Request) {
incrementTotal()
// 从URL路径提取key: /audio/proxy/{key}
path := r.URL.Path
key := strings.TrimPrefix(path, "/audio/proxy/")
if key == "" || key == path {
http.Error(w, "缺少音频key", http.StatusBadRequest)
return
}
// 调用代理处理函数
data, contentType, err := fqapi.HandleAudioProxy(key)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 设置正确的Content-Type
if contentType != "" {
w.Header().Set("Content-Type", contentType)
} else {
w.Header().Set("Content-Type", "audio/mpeg")
}
// 直接播放,不强制下载
// 浏览器会根据Content-Type决定是播放还是下载
w.Write(data)
}
// HandleVideo 视频处理
func HandleVideo(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementVideo()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
itemIDs := r.URL.Query().Get("item_ids")
result, err := fqapi.HandleVideo(itemIDs)
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "获取视频失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, result)
}
// HandleManga 漫画处理
func HandleManga(w http.ResponseWriter, r *http.Request) {
incrementTotal()
incrementManga()
itemID := r.URL.Query().Get("item_ids")
if itemID == "" {
itemID = r.URL.Query().Get("item_id")
}
if itemID == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, `{"code": -1, "msg": "缺少item_ids参数"}`)
return
}
// 检查是否要返回HTML格式
showHTML := r.URL.Query().Get("show_html") == "1"
// 检查输出模式参数(优先级高于配置文件)
// 支持:output=url / output=proxy / output=data_uri
outputMode := r.URL.Query().Get("output")
result, err := fqapi.HandleGetManga(itemID, showHTML, outputMode)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprintf(w, `{"code": -1, "msg": "获取漫画失败: %s"}`, err.Error())
return
}
if showHTML {
// 返回HTML格式
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if contentMap, ok := result.(map[string]interface{}); ok {
if content, ok := contentMap["content"].(string); ok {
fmt.Fprint(w, content)
return
}
}
fmt.Fprint(w, `<p>未找到漫画内容</p>`)
} else {
// 返回JSON格式
w.Header().Set("Content-Type", "application/json; charset=utf-8")
jsonData, err := json.Marshal(map[string]interface{}{
"code": 0,
"msg": "success",
"data": result,
})
if err != nil {
fmt.Fprintf(w, `{"code": -1, "msg": "JSON编码失败: %s"}`, err.Error())
return
}
fmt.Fprint(w, string(jsonData))
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff