diff --git a/plugin/gying/gying.go b/plugin/gying/gying.go
index 1ef1139..1c92e13 100644
--- a/plugin/gying/gying.go
+++ b/plugin/gying/gying.go
@@ -1,1170 +1,2233 @@
-package plugin
+package gying
import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
"fmt"
+ "io"
+ "io/ioutil"
"net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "sort"
+ "strconv"
"strings"
"sync"
- "sync/atomic"
"time"
+ "unsafe"
"github.com/gin-gonic/gin"
- "pansou/config"
"pansou/model"
+ "pansou/plugin"
+ "pansou/util/json"
+
+ cloudscraper "github.com/Advik-B/cloudscraper/lib"
)
-// ============================================================
-// 第一部分:接口定义和类型
-// ============================================================
-
-// AsyncSearchPlugin 异步搜索插件接口
-type AsyncSearchPlugin interface {
- // Name 返回插件名称
- Name() string
-
- // Priority 返回插件优先级
- Priority() int
-
- // AsyncSearch 异步搜索方法
- AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)
-
- // SetMainCacheKey 设置主缓存键
- SetMainCacheKey(key string)
-
- // SetCurrentKeyword 设置当前搜索关键词(用于日志显示)
- SetCurrentKeyword(keyword string)
-
- // Search 兼容性方法(内部调用AsyncSearch)
- Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)
-
- // SkipServiceFilter 返回是否跳过Service层的关键词过滤
- // 对于磁力搜索等需要宽泛结果的插件,应返回true
- SkipServiceFilter() bool
-}
-
-// PluginWithWebHandler 支持Web路由的插件接口
-// 插件可以选择实现此接口来注册自定义的HTTP路由
-type PluginWithWebHandler interface {
- AsyncSearchPlugin // 继承搜索插件接口
-
- // RegisterWebRoutes 注册Web路由
- // router: gin的路由组,插件可以在此注册自己的路由
- RegisterWebRoutes(router *gin.RouterGroup)
-}
-
-// ============================================================
-// 第二部分:全局变量和注册表
-// ============================================================
-
-// 全局异步插件注册表
-var (
- globalRegistry = make(map[string]AsyncSearchPlugin)
- globalRegistryLock sync.RWMutex
+// 插件配置参数
+const (
+ MaxConcurrentUsers = 10 // 最多使用的用户数
+ MaxConcurrentDetails = 50 // 最大并发详情请求数
+ DebugLog = false // 调试日志开关(排查问题时改为true)
)
-// 工作池和统计相关变量
-var (
- // API响应缓存,键为关键词,值为缓存的响应(仅内存,不持久化)
- apiResponseCache = sync.Map{}
-
- // 工作池相关变量
- backgroundWorkerPool chan struct{}
- backgroundTasksCount int32 = 0
-
- // 统计数据 (仅用于内部监控)
- cacheHits int64 = 0
- cacheMisses int64 = 0
- asyncCompletions int64 = 0
-
- // 初始化标志
- initialized bool = false
- initLock sync.Mutex
-
- // 默认配置值
- defaultAsyncResponseTimeout = 4 * time.Second
- defaultPluginTimeout = 30 * time.Second
- defaultCacheTTL = 1 * time.Hour // 恢复但仅用于内存缓存
- defaultMaxBackgroundWorkers = 20
- defaultMaxBackgroundTasks = 100
-
- // 缓存访问频率记录
- cacheAccessCount = sync.Map{}
-
- // 缓存清理相关变量
- lastCleanupTime = time.Now()
- cleanupMutex sync.Mutex
-)
-
-// 全局序列化器引用(由主程序设置)
-var globalCacheSerializer interface {
- Serialize(interface{}) ([]byte, error)
- Deserialize([]byte, interface{}) error
+// 默认账户配置(可通过Web界面添加更多账户)
+// 用户数据会保存到文件,重启后自动恢复
+var DefaultAccounts = []struct {
+ Username string
+ Password string
+}{
+ // 请使用 Web 接口添加用户:
+ // POST /gying/add_user?username=xxx&password=xxx
}
-// 缓存响应结构(仅内存,不持久化到磁盘)
-type cachedResponse struct {
- Results []model.SearchResult `json:"results"`
- Timestamp time.Time `json:"timestamp"`
- Complete bool `json:"complete"`
- LastAccess time.Time `json:"last_access"`
- AccessCount int `json:"access_count"`
-}
+// 存储目录
+var StorageDir string
-// ============================================================
-// 第三部分:插件注册和管理
-// ============================================================
-
-// RegisterGlobalPlugin 注册异步插件到全局注册表
-func RegisterGlobalPlugin(plugin AsyncSearchPlugin) {
- if plugin == nil {
- return
+// 初始化存储目录
+func init() {
+ cachePath := os.Getenv("CACHE_PATH")
+ if cachePath == "" {
+ cachePath = "./cache"
}
- globalRegistryLock.Lock()
- defer globalRegistryLock.Unlock()
+ StorageDir = filepath.Join(cachePath, "gying_users")
- name := plugin.Name()
- if name == "" {
- return
- }
-
- globalRegistry[name] = plugin
-}
-
-// GetRegisteredPlugins 获取所有已注册的异步插件
-func GetRegisteredPlugins() []AsyncSearchPlugin {
- globalRegistryLock.RLock()
- defer globalRegistryLock.RUnlock()
-
- plugins := make([]AsyncSearchPlugin, 0, len(globalRegistry))
- for _, plugin := range globalRegistry {
- plugins = append(plugins, plugin)
- }
-
- return plugins
-}
-
-// GetPluginByName 根据名称获取已注册的插件
-func GetPluginByName(name string) (AsyncSearchPlugin, bool) {
- globalRegistryLock.RLock()
- defer globalRegistryLock.RUnlock()
-
- plugin, exists := globalRegistry[name]
- return plugin, exists
-}
-
-// PluginManager 异步插件管理器
-type PluginManager struct {
- plugins []AsyncSearchPlugin
-}
-
-// NewPluginManager 创建新的异步插件管理器
-func NewPluginManager() *PluginManager {
- return &PluginManager{
- plugins: make([]AsyncSearchPlugin, 0),
- }
-}
-
-// RegisterPlugin 注册异步插件
-func (pm *PluginManager) RegisterPlugin(plugin AsyncSearchPlugin) {
- pm.plugins = append(pm.plugins, plugin)
-}
-
-// RegisterAllGlobalPlugins 注册所有全局异步插件
-func (pm *PluginManager) RegisterAllGlobalPlugins() {
- allPlugins := GetRegisteredPlugins()
- for _, plugin := range allPlugins {
- pm.RegisterPlugin(plugin)
- }
-}
-
-// RegisterGlobalPluginsWithFilter 根据过滤器注册全局异步插件
-// enabledPlugins: nil表示未设置(不启用任何插件),空切片表示设置为空(不启用任何插件),具体列表表示启用指定插件
-func (pm *PluginManager) RegisterGlobalPluginsWithFilter(enabledPlugins []string) {
- allPlugins := GetRegisteredPlugins()
-
- // nil 表示未设置环境变量,不启用任何插件
- if enabledPlugins == nil {
- return
- }
-
- // 空切片表示设置为空字符串,也不启用任何插件
- if len(enabledPlugins) == 0 {
- return
- }
-
- // 创建启用插件名称的映射表,用于快速查找
- enabledMap := make(map[string]bool)
- for _, name := range enabledPlugins {
- enabledMap[name] = true
- }
-
- // 只注册在启用列表中的插件
- for _, plugin := range allPlugins {
- if enabledMap[plugin.Name()] {
- pm.RegisterPlugin(plugin)
- }
- }
-}
-
-// GetPlugins 获取所有注册的异步插件
-func (pm *PluginManager) GetPlugins() []AsyncSearchPlugin {
- return pm.plugins
-}
-
-// ============================================================
-// 第四部分:工具函数
-// ============================================================
-
-// FilterResultsByKeyword 根据关键词过滤搜索结果的全局辅助函数
-func FilterResultsByKeyword(results []model.SearchResult, keyword string) []model.SearchResult {
- if keyword == "" {
- return results
- }
-
- // 预估过滤后会保留80%的结果
- filteredResults := make([]model.SearchResult, 0, len(results)*8/10)
-
- // 将关键词转为小写,用于不区分大小写的比较
- lowerKeyword := strings.ToLower(keyword)
-
- // 将关键词按空格分割,用于支持多关键词搜索
- keywords := strings.Fields(lowerKeyword)
-
- for _, result := range results {
- // 将标题和内容转为小写
- lowerTitle := strings.ToLower(result.Title)
- lowerContent := strings.ToLower(result.Content)
-
- // 检查每个关键词是否在标题或内容中
- matched := true
- for _, kw := range keywords {
- // 对于所有关键词,检查是否在标题或内容中
- if !strings.Contains(lowerTitle, kw) && !strings.Contains(lowerContent, kw) {
- matched = false
- break
- }
- }
-
- if matched {
- filteredResults = append(filteredResults, result)
- }
- }
-
- return filteredResults
-}
-
-// ============================================================
-// 第五部分:异步插件基础设施(初始化、工作池、缓存)
-// ============================================================
-
-// cleanupExpiredApiCache 清理过期API缓存的函数
-func cleanupExpiredApiCache() {
- cleanupMutex.Lock()
- defer cleanupMutex.Unlock()
-
- now := time.Now()
- // 只有距离上次清理超过30分钟才执行
- if now.Sub(lastCleanupTime) < 30*time.Minute {
- return
- }
-
- cleanedCount := 0
- totalCount := 0
- deletedKeys := make([]string, 0)
-
- // 清理已过期的缓存(基于实际TTL + 合理的宽限期)
- apiResponseCache.Range(func(key, value interface{}) bool {
- totalCount++
- if cached, ok := value.(cachedResponse); ok {
- // 使用默认TTL + 30分钟宽限期,避免过于激进的清理
- expireThreshold := defaultCacheTTL + 30*time.Minute
- if now.Sub(cached.Timestamp) > expireThreshold {
- keyStr := key.(string)
- apiResponseCache.Delete(key)
- deletedKeys = append(deletedKeys, keyStr)
- cleanedCount++
- }
- }
- return true
- })
-
- // 清理访问计数缓存中对应的项
- for _, key := range deletedKeys {
- cacheAccessCount.Delete(key)
- }
-
- lastCleanupTime = now
-
- // 记录清理日志(仅在有清理时输出)
- if cleanedCount > 0 {
- fmt.Printf("[Cache] 清理过期缓存: 删除 %d/%d 项,释放内存\n", cleanedCount, totalCount)
- }
-}
-
-// initAsyncPlugin 初始化异步插件配置
-func initAsyncPlugin() {
- initLock.Lock()
- defer initLock.Unlock()
-
- if initialized {
- return
- }
-
- // 如果配置已加载,则从配置读取工作池大小
- maxWorkers := defaultMaxBackgroundWorkers
- if config.AppConfig != nil {
- maxWorkers = config.AppConfig.AsyncMaxBackgroundWorkers
- }
-
- backgroundWorkerPool = make(chan struct{}, maxWorkers)
-
- // 异步插件本地缓存系统已移除,现在只依赖主缓存系统
-
- initialized = true
-}
-
-// InitAsyncPluginSystem 导出的初始化函数,用于确保异步插件系统初始化
-func InitAsyncPluginSystem() {
- initAsyncPlugin()
-}
-
-// acquireWorkerSlot 尝试获取工作槽
-func acquireWorkerSlot() bool {
- // 获取最大任务数
- maxTasks := int32(defaultMaxBackgroundTasks)
- if config.AppConfig != nil {
- maxTasks = int32(config.AppConfig.AsyncMaxBackgroundTasks)
- }
-
- // 检查总任务数
- if atomic.LoadInt32(&backgroundTasksCount) >= maxTasks {
- return false
- }
-
- // 尝试获取工作槽
- select {
- case backgroundWorkerPool <- struct{}{}:
- atomic.AddInt32(&backgroundTasksCount, 1)
- return true
- default:
- return false
- }
-}
-
-// releaseWorkerSlot 释放工作槽
-func releaseWorkerSlot() {
- <-backgroundWorkerPool
- atomic.AddInt32(&backgroundTasksCount, -1)
-}
-
-// recordCacheHit 记录缓存命中 (内部使用)
-func recordCacheHit() {
- atomic.AddInt64(&cacheHits, 1)
-}
-
-// recordCacheMiss 记录缓存未命中 (内部使用)
-func recordCacheMiss() {
- atomic.AddInt64(&cacheMisses, 1)
-}
-
-// recordAsyncCompletion 记录异步完成 (内部使用)
-func recordAsyncCompletion() {
- atomic.AddInt64(&asyncCompletions, 1)
-}
-
-// recordCacheAccess 记录缓存访问次数,用于智能缓存策略(仅内存)
-func recordCacheAccess(key string) {
- // 更新缓存项的访问时间和计数
- if cached, ok := apiResponseCache.Load(key); ok {
- cachedItem := cached.(cachedResponse)
- cachedItem.LastAccess = time.Now()
- cachedItem.AccessCount++
- apiResponseCache.Store(key, cachedItem)
- }
-
- // 更新全局访问计数
- if count, ok := cacheAccessCount.Load(key); ok {
- cacheAccessCount.Store(key, count.(int) + 1)
+ if err := os.MkdirAll(StorageDir, 0755); err != nil {
+ fmt.Printf("⚠️ 警告: 无法创建Gying存储目录 %s: %v\n", StorageDir, err)
} else {
- cacheAccessCount.Store(key, 1)
- }
-
- // 触发定期清理(异步执行,不阻塞当前操作)
- go cleanupExpiredApiCache()
-}
-
-// ============================================================
-// 第六部分:BaseAsyncPlugin 结构和构造函数
-// ============================================================
-
-// BaseAsyncPlugin 基础异步插件结构
-type BaseAsyncPlugin struct {
- name string
- priority int
- client *http.Client // 用于短超时的客户端
- backgroundClient *http.Client // 用于长超时的客户端
- cacheTTL time.Duration // 内存缓存有效期
- mainCacheUpdater func(string, []model.SearchResult, time.Duration, bool, string) error // 主缓存更新函数(支持IsFinal参数,接收原始数据,最后参数为关键词)
- MainCacheKey string // 主缓存键,导出字段
- currentKeyword string // 当前搜索的关键词,用于日志显示
- finalUpdateTracker map[string]bool // 追踪已更新的最终结果缓存
- finalUpdateMutex sync.RWMutex // 保护finalUpdateTracker的并发访问
- skipServiceFilter bool // 是否跳过Service层的关键词过滤
-}
-
-// NewBaseAsyncPlugin 创建基础异步插件
-func NewBaseAsyncPlugin(name string, priority int) *BaseAsyncPlugin {
- // 确保异步插件已初始化
- if !initialized {
- initAsyncPlugin()
- }
-
- // 确定超时和缓存时间
- responseTimeout := defaultAsyncResponseTimeout
- processingTimeout := defaultPluginTimeout
- cacheTTL := defaultCacheTTL
-
- // 如果配置已初始化,则使用配置中的值
- if config.AppConfig != nil {
- responseTimeout = config.AppConfig.AsyncResponseTimeoutDur
- processingTimeout = config.AppConfig.PluginTimeout
- cacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour
- }
-
- return &BaseAsyncPlugin{
- name: name,
- priority: priority,
- client: &http.Client{
- Timeout: responseTimeout,
- },
- backgroundClient: &http.Client{
- Timeout: processingTimeout,
- },
- cacheTTL: cacheTTL,
- finalUpdateTracker: make(map[string]bool), // 初始化缓存更新追踪器
- skipServiceFilter: false, // 默认不跳过Service层过滤
+ fmt.Printf("✓ Gying存储目录: %s\n", StorageDir)
}
}
-// NewBaseAsyncPluginWithFilter 创建基础异步插件(支持设置Service层过滤参数)
-func NewBaseAsyncPluginWithFilter(name string, priority int, skipServiceFilter bool) *BaseAsyncPlugin {
- // 确保异步插件已初始化
- if !initialized {
- initAsyncPlugin()
+// HTML模板
+const HTMLTemplate = `
+
+
+
+
+ PanSou Gying搜索配置
+
+
+
+
+
+
+
+
🔐 登录状态
+
+
+
+
+ 状态
+ ✅ 已登录
+
+
+ 用户名
+ -
+
+
+ 登录时间
+ -
+
+
+ 有效期
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🔍 测试搜索(限制返回10条数据)
+
+
+
+
+
+
+
+
+
+
+
📖 API调用说明
+
+
你可以通过API程序化管理:
+
+
+ 登录
+ curl -X POST https://your-domain.com/gying/HASH_PLACEHOLDER \
+ -H "Content-Type: application/json" \
+ -d '{"action": "login", "username": "user", "password": "pass"}'
+
+
+
+
+
+
+`
+
+// GyingPlugin 插件结构
+type GyingPlugin struct {
+ *plugin.BaseAsyncPlugin
+ users sync.Map // 内存缓存:hash -> *User
+ scrapers sync.Map // cloudscraper实例缓存:hash -> *cloudscraper.Scraper
+ mu sync.RWMutex
+}
+
+// User 用户数据结构
+type User struct {
+ Hash string `json:"hash"`
+ Username string `json:"username"` // 原始用户名(存储)
+ UsernameMasked string `json:"username_masked"` // 脱敏用户名(显示)
+ EncryptedPassword string `json:"encrypted_password"` // 加密后的密码(用于重启恢复)
+ Cookie string `json:"cookie"` // 登录Cookie字符串(仅供参考)
+ Status string `json:"status"` // pending/active/expired
+ CreatedAt time.Time `json:"created_at"`
+ LoginAt time.Time `json:"login_at"`
+ ExpireAt time.Time `json:"expire_at"`
+ LastAccessAt time.Time `json:"last_access_at"`
+}
+
+// SearchData 搜索页面JSON数据结构
+type SearchData struct {
+ Q string `json:"q"` // 搜索关键词
+ WD []string `json:"wd"` // 分词
+ N string `json:"n"` // 结果数量
+ L struct {
+ Title []string `json:"title"` // 标题数组
+ Year []int `json:"year"` // 年份数组
+ D []string `json:"d"` // 类型数组(mv/ac/tv)
+ I []string `json:"i"` // 资源ID数组
+ Info []string `json:"info"` // 信息数组
+ Daoyan []string `json:"daoyan"` // 导演数组
+ Zhuyan []string `json:"zhuyan"` // 主演数组
+ } `json:"l"`
+}
+
+// DetailData 详情接口JSON数据结构
+type DetailData struct {
+ Code int `json:"code"`
+ WP bool `json:"wp"`
+ Panlist struct {
+ ID []string `json:"id"`
+ Name []string `json:"name"`
+ P []string `json:"p"` // 提取码数组
+ URL []string `json:"url"` // 链接数组
+ Type []int `json:"type"` // 类型标识
+ User []string `json:"user"` // 分享用户
+ Time []string `json:"time"` // 分享时间
+ TName []string `json:"tname"` // 网盘类型名称
+ } `json:"panlist"`
+}
+
+func init() {
+ p := &GyingPlugin{
+ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("gying", 3),
}
-
- // 确定超时和缓存时间
- responseTimeout := defaultAsyncResponseTimeout
- processingTimeout := defaultPluginTimeout
- cacheTTL := defaultCacheTTL
-
- // 如果配置已初始化,则使用配置中的值
- if config.AppConfig != nil {
- responseTimeout = config.AppConfig.AsyncResponseTimeoutDur
- processingTimeout = config.AppConfig.PluginTimeout
- cacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour
+
+ // 初始化存储目录
+ if err := os.MkdirAll(StorageDir, 0755); err != nil {
+ fmt.Printf("[Gying] 创建存储目录失败: %v\n", err)
+ return
}
-
- return &BaseAsyncPlugin{
- name: name,
- priority: priority,
- client: &http.Client{
- Timeout: responseTimeout,
- },
- backgroundClient: &http.Client{
- Timeout: processingTimeout,
- },
- cacheTTL: cacheTTL,
- finalUpdateTracker: make(map[string]bool), // 初始化缓存更新追踪器
- skipServiceFilter: skipServiceFilter, // 使用传入的过滤设置
- }
-}
-// ============================================================
-// 第七部分:BaseAsyncPlugin 接口实现方法
-// ============================================================
+ // 加载所有用户到内存
+ p.loadAllUsers()
-// SetMainCacheKey 设置主缓存键
-func (p *BaseAsyncPlugin) SetMainCacheKey(key string) {
- p.MainCacheKey = key
-}
-
-// SetCurrentKeyword 设置当前搜索关键词(用于日志显示)
-func (p *BaseAsyncPlugin) SetCurrentKeyword(keyword string) {
- p.currentKeyword = keyword
-}
-
-// SetMainCacheUpdater 设置主缓存更新函数(修复后的签名,增加关键词参数)
-func (p *BaseAsyncPlugin) SetMainCacheUpdater(updater func(string, []model.SearchResult, time.Duration, bool, string) error) {
- p.mainCacheUpdater = updater
-}
-
-// Name 返回插件名称
-func (p *BaseAsyncPlugin) Name() string {
- return p.name
-}
-
-// Priority 返回插件优先级
-func (p *BaseAsyncPlugin) Priority() int {
- return p.priority
-}
-
-// SkipServiceFilter 返回是否跳过Service层的关键词过滤
-func (p *BaseAsyncPlugin) SkipServiceFilter() bool {
- return p.skipServiceFilter
-}
-
-// GetClient 返回短超时客户端
-func (p *BaseAsyncPlugin) GetClient() *http.Client {
- return p.client
-}
-
-// ============================================================
-// 第八部分:异步搜索核心逻辑
-// ============================================================
-
-// AsyncSearch 异步搜索基础方法
-func (p *BaseAsyncPlugin) AsyncSearch(
- keyword string,
- searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),
- mainCacheKey string,
- ext map[string]interface{},
-) ([]model.SearchResult, error) {
- // 确保ext不为nil
- if ext == nil {
- ext = make(map[string]interface{})
- }
-
- now := time.Now()
-
- // 修改缓存键,确保包含插件名称
- pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, keyword)
-
- // 检查是否有refresh参数,如果为true则跳过缓存
- skipCache := false
- if refreshValue, ok := ext["refresh"]; ok {
- if refresh, ok := refreshValue.(bool); ok && refresh {
- skipCache = true
- }
- }
-
- // 检查缓存(除非refresh=true)
- if !skipCache {
- if cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {
- cachedResult := cachedItems.(cachedResponse)
-
- // 缓存完全有效(未过期且完整)
- if time.Since(cachedResult.Timestamp) < p.cacheTTL && cachedResult.Complete {
- recordCacheHit()
- recordCacheAccess(pluginSpecificCacheKey)
-
- // 如果缓存接近过期(已用时间超过TTL的80%),在后台刷新缓存
- if time.Since(cachedResult.Timestamp) > (p.cacheTTL * 4 / 5) {
- go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)
- }
-
- return cachedResult.Results, nil
- }
-
- // 缓存已过期但有结果,启动后台刷新,同时返回旧结果
- if len(cachedResult.Results) > 0 {
- recordCacheHit()
- recordCacheAccess(pluginSpecificCacheKey)
-
- // 标记为部分过期
- if time.Since(cachedResult.Timestamp) >= p.cacheTTL {
- // 在后台刷新缓存
- go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)
-
- // 日志记录
- fmt.Printf("[%s] 缓存已过期,后台刷新中: %s (已过期: %v)\n",
- p.name, pluginSpecificCacheKey, time.Since(cachedResult.Timestamp))
- }
-
- return cachedResult.Results, nil
- }
- }
- }
-
- recordCacheMiss()
-
- // 创建通道
- resultChan := make(chan []model.SearchResult, 1)
- errorChan := make(chan error, 1)
- doneChan := make(chan struct{})
-
- // 启动后台处理
+ // 异步初始化默认账户(不阻塞启动)
go func() {
- // 尝试获取工作槽
- if !acquireWorkerSlot() {
- // 工作池已满,使用快速响应客户端直接处理
- results, err := searchFunc(p.client, keyword, ext)
- if err != nil {
- select {
- case errorChan <- err:
- default:
- }
- return
- }
-
- select {
- case resultChan <- results:
- default:
- }
-
- // 缓存结果
- apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{
- Results: results,
- Timestamp: now,
- Complete: true,
- LastAccess: now,
- AccessCount: 1,
- })
-
- // 🔧 工作池满时短超时(默认4秒)内完成,这是完整结果
- p.updateMainCacheWithFinal(mainCacheKey, results, true)
-
- return
- }
- defer releaseWorkerSlot()
-
- // 执行搜索
- results, err := searchFunc(p.backgroundClient, keyword, ext)
-
- // 检查是否已经响应
- select {
- case <-doneChan:
- // 已经响应,只更新缓存
- if err == nil {
- // 检查是否存在旧缓存
- var accessCount int = 1
- var lastAccess time.Time = now
-
- if oldCache, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {
- oldCachedResult := oldCache.(cachedResponse)
- accessCount = oldCachedResult.AccessCount
- lastAccess = oldCachedResult.LastAccess
-
- // 合并结果(新结果优先)
- if len(oldCachedResult.Results) > 0 {
- // 创建合并结果集
- mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results))
-
- // 创建已有结果ID的映射
- existingIDs := make(map[string]bool)
- for _, r := range results {
- existingIDs[r.UniqueID] = true
- mergedResults = append(mergedResults, r)
- }
-
- // 添加旧结果中不存在的项
- for _, r := range oldCachedResult.Results {
- if !existingIDs[r.UniqueID] {
- mergedResults = append(mergedResults, r)
- }
- }
-
- // 使用合并结果
- results = mergedResults
- }
- }
-
- apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{
- Results: results,
- Timestamp: now,
- Complete: true,
- LastAccess: lastAccess,
- AccessCount: accessCount,
- })
- recordAsyncCompletion()
-
- // 异步插件后台完成时更新主缓存(标记为最终结果)
- p.updateMainCacheWithFinal(mainCacheKey, results, true)
-
- // 异步插件本地缓存系统已移除
- }
- default:
- // 尚未响应,发送结果
- if err != nil {
- select {
- case errorChan <- err:
- default:
- }
- } else {
- // 检查是否存在旧缓存用于合并
- if oldCache, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {
- oldCachedResult := oldCache.(cachedResponse)
- if len(oldCachedResult.Results) > 0 {
- // 创建合并结果集
- mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results))
-
- // 创建已有结果ID的映射
- existingIDs := make(map[string]bool)
- for _, r := range results {
- existingIDs[r.UniqueID] = true
- mergedResults = append(mergedResults, r)
- }
-
- // 添加旧结果中不存在的项
- for _, r := range oldCachedResult.Results {
- if !existingIDs[r.UniqueID] {
- mergedResults = append(mergedResults, r)
- }
- }
-
- // 使用合并结果
- results = mergedResults
- }
- }
-
- select {
- case resultChan <- results:
- default:
- }
-
- // 更新缓存
- apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{
- Results: results,
- Timestamp: now,
- Complete: true,
- LastAccess: now,
- AccessCount: 1,
- })
-
- // 🔧 短超时(默认4秒)内正常完成,这是完整的最终结果
- p.updateMainCacheWithFinal(mainCacheKey, results, true)
-
- // 异步插件本地缓存系统已移除
- }
- }
+ // 延迟1秒,等待主程序完全启动
+ time.Sleep(1 * time.Second)
+ p.initDefaultAccounts()
}()
+
+ // 启动定期清理任务
+ go p.startCleanupTask()
- // 获取响应超时时间
- responseTimeout := defaultAsyncResponseTimeout
- if config.AppConfig != nil {
- responseTimeout = config.AppConfig.AsyncResponseTimeoutDur
- }
+ // 启动session保活任务(防止session超时)
+ go p.startSessionKeepAlive()
+
+ plugin.RegisterGlobalPlugin(p)
+}
+
+// ============ 插件接口实现 ============
+
+// RegisterWebRoutes 注册Web路由
+func (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
+ gying := router.Group("/gying")
+ gying.GET("/:param", p.handleManagePage)
+ gying.POST("/:param", p.handleManagePagePOST)
- // 等待响应超时或结果
- select {
- case results := <-resultChan:
- close(doneChan)
- return results, nil
- case err := <-errorChan:
- close(doneChan)
+ fmt.Printf("[Gying] Web路由已注册: /gying/:param\n")
+}
+
+// Search 执行搜索并返回结果
+func (p *GyingPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
+ result, err := p.SearchWithResult(keyword, ext)
+ if err != nil {
return nil, err
- case <-time.After(responseTimeout):
- // 插件响应超时,后台继续处理(优化完成,日志简化)
-
- // 响应超时,返回空结果,后台继续处理
- go func() {
- defer close(doneChan)
- }()
-
- // 检查是否有部分缓存可用
- if cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {
- cachedResult := cachedItems.(cachedResponse)
- if len(cachedResult.Results) > 0 {
- // 有部分缓存可用,记录访问并返回
- recordCacheAccess(pluginSpecificCacheKey)
- fmt.Printf("[%s] 响应超时,返回部分缓存: %s (项目数: %d)\n",
- p.name, pluginSpecificCacheKey, len(cachedResult.Results))
- return cachedResult.Results, nil
- }
- }
-
- // 创建空的临时缓存,以便后台处理完成后可以更新
- apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{
- Results: []model.SearchResult{},
- Timestamp: now,
- Complete: false, // 标记为不完整
- LastAccess: now,
- AccessCount: 1,
- })
-
- // 🔧 修复:4秒超时时也要更新主缓存,标记为部分结果(空结果)
- p.updateMainCacheWithFinal(mainCacheKey, []model.SearchResult{}, false)
-
- // fmt.Printf("[%s] 响应超时,后台继续处理: %s\n", p.name, pluginSpecificCacheKey)
- return []model.SearchResult{}, nil
}
+ return result.Results, nil
}
-// AsyncSearchWithResult 异步搜索方法,返回PluginSearchResult
-func (p *BaseAsyncPlugin) AsyncSearchWithResult(
- keyword string,
- searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),
- mainCacheKey string,
- ext map[string]interface{},
-) (model.PluginSearchResult, error) {
- // 确保ext不为nil
- if ext == nil {
- ext = make(map[string]interface{})
+// SearchWithResult 执行搜索并返回包含IsFinal标记的结果
+// 注意:gying插件不使用AsyncSearchWithResult的缓存机制,因为:
+// 1. 使用自己的cloudscraper实例而不是传入的http.Client
+// 2. 有自己的用户会话管理
+// 3. Service层已经有缓存,无需插件层再次缓存
+func (p *GyingPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
+ if DebugLog {
+ fmt.Printf("[Gying] ========== 开始搜索: %s ==========\n", keyword)
+ }
+
+ // 1. 获取所有有效用户
+ users := p.getActiveUsers()
+ if DebugLog {
+ fmt.Printf("[Gying] 找到 %d 个有效用户\n", len(users))
}
- now := time.Now()
-
- // 修改缓存键,确保包含插件名称
- pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, keyword)
-
- // 检查是否有refresh参数,如果为true则跳过缓存
- skipCache := false
- if refreshValue, ok := ext["refresh"]; ok {
- if refresh, ok := refreshValue.(bool); ok && refresh {
- skipCache = true
+ if len(users) == 0 {
+ if DebugLog {
+ fmt.Printf("[Gying] 没有有效用户,返回空结果\n")
}
+ return model.PluginSearchResult{Results: []model.SearchResult{}, IsFinal: true}, nil
}
-
- // 检查缓存(除非refresh=true)
- if !skipCache {
- if cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {
- cachedResult := cachedItems.(cachedResponse)
-
- // 缓存完全有效(未过期且完整)
- if time.Since(cachedResult.Timestamp) < p.cacheTTL && cachedResult.Complete {
- recordCacheHit()
- recordCacheAccess(pluginSpecificCacheKey)
-
- // 如果缓存接近过期(已用时间超过TTL的80%),在后台刷新缓存
- if time.Since(cachedResult.Timestamp) > (p.cacheTTL * 4 / 5) {
- go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)
- }
-
- return model.PluginSearchResult{
- Results: cachedResult.Results,
- IsFinal: cachedResult.Complete,
- Timestamp: cachedResult.Timestamp,
- Source: p.name,
- Message: "从缓存获取",
- }, nil
- }
-
- // 缓存已过期但有结果,启动后台刷新,同时返回旧结果
- if len(cachedResult.Results) > 0 {
- recordCacheHit()
- recordCacheAccess(pluginSpecificCacheKey)
-
- // 标记为部分过期
- if time.Since(cachedResult.Timestamp) >= p.cacheTTL {
- // 在后台刷新缓存
- go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)
- }
-
- return model.PluginSearchResult{
- Results: cachedResult.Results,
- IsFinal: false, // 🔥 过期数据标记为非最终结果
- Timestamp: cachedResult.Timestamp,
- Source: p.name,
- Message: "缓存已过期,后台刷新中",
- }, nil
- }
- }
- }
-
- recordCacheMiss()
-
- // 创建通道
- resultChan := make(chan []model.SearchResult, 1)
- errorChan := make(chan error, 1)
- doneChan := make(chan struct{})
-
- // 启动后台处理
- go func() {
- defer func() {
- select {
- case <-doneChan:
- default:
- close(doneChan)
- }
- }()
-
- // 尝试获取工作槽
- if !acquireWorkerSlot() {
- // 工作池已满,使用快速响应客户端直接处理
- results, err := searchFunc(p.client, keyword, ext)
- if err != nil {
- select {
- case errorChan <- err:
- default:
- }
- return
- }
-
- select {
- case resultChan <- results:
- default:
- }
- return
- }
- defer releaseWorkerSlot()
-
- // 使用长超时客户端进行搜索
- results, err := searchFunc(p.backgroundClient, keyword, ext)
- if err != nil {
- select {
- case errorChan <- err:
- default:
- }
- } else {
- select {
- case resultChan <- results:
- default:
- }
- }
- }()
-
- // 等待结果或超时
- responseTimeout := defaultAsyncResponseTimeout
- if config.AppConfig != nil {
- responseTimeout = config.AppConfig.AsyncResponseTimeoutDur
- }
-
- select {
- case results := <-resultChan:
- // 不直接关闭,让defer处理
-
- // 缓存结果
- apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{
- Results: results,
- Timestamp: now,
- Complete: true, // 🔥 及时完成,标记为完整结果
- LastAccess: now,
- AccessCount: 1,
+
+ // 2. 限制用户数量
+ if len(users) > MaxConcurrentUsers {
+ sort.Slice(users, func(i, j int) bool {
+ return users[i].LastAccessAt.After(users[j].LastAccessAt)
})
-
- // 🔧 恢复主缓存更新:使用统一的GOB序列化
- // 传递原始数据,由主程序负责序列化
- if mainCacheKey != "" && p.mainCacheUpdater != nil {
- err := p.mainCacheUpdater(mainCacheKey, results, p.cacheTTL, true, p.currentKeyword)
- if err != nil {
- fmt.Printf("❌ [%s] 及时完成缓存更新失败: %s | 错误: %v\n", p.name, mainCacheKey, err)
- }
- }
-
- return model.PluginSearchResult{
- Results: results,
- IsFinal: true, // 🔥 及时完成,最终结果
- Timestamp: now,
- Source: p.name,
- Message: "搜索完成",
- }, nil
-
- case err := <-errorChan:
- // 不直接关闭,让defer处理
- return model.PluginSearchResult{}, err
-
- case <-time.After(responseTimeout):
- // 🔥 超时处理:返回空结果,后台继续处理
- go p.completeSearchInBackground(keyword, searchFunc, pluginSpecificCacheKey, mainCacheKey, doneChan, ext)
-
- // 存储临时缓存(标记为不完整)
- apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{
- Results: []model.SearchResult{},
- Timestamp: now,
- Complete: false, // 🔥 标记为不完整
- LastAccess: now,
- AccessCount: 1,
- })
-
- return model.PluginSearchResult{
- Results: []model.SearchResult{},
- IsFinal: false, // 🔥 超时返回,非最终结果
- Timestamp: now,
- Source: p.name,
- Message: "处理中,后台继续...",
- }, nil
+ users = users[:MaxConcurrentUsers]
}
+
+ // 3. 并发执行搜索
+ results := p.executeSearchTasks(users, keyword)
+ if DebugLog {
+ fmt.Printf("[Gying] 搜索完成,获得 %d 条结果\n", len(results))
+ }
+
+ return model.PluginSearchResult{
+ Results: results,
+ IsFinal: true,
+ }, nil
}
-// completeSearchInBackground 后台完成搜索
-func (p *BaseAsyncPlugin) completeSearchInBackground(
- keyword string,
- searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),
- pluginCacheKey string,
- mainCacheKey string,
- doneChan chan struct{},
- ext map[string]interface{},
-) {
- defer func() {
- select {
- case <-doneChan:
- default:
- close(doneChan)
- }
- }()
-
- // 执行完整搜索
- results, err := searchFunc(p.backgroundClient, keyword, ext)
+// ============ 用户管理 ============
+
+// loadAllUsers 加载所有用户到内存(包括用户名、加密密码等)
+// 注意:只加载用户数据,scraper实例将在initDefaultAccounts中使用密码重新登录获取
+func (p *GyingPlugin) loadAllUsers() {
+ files, err := ioutil.ReadDir(StorageDir)
if err != nil {
return
}
+
+ count := 0
+ for _, file := range files {
+ if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
+ continue
+ }
+
+ filePath := filepath.Join(StorageDir, file.Name())
+ data, err := ioutil.ReadFile(filePath)
+ if err != nil {
+ continue
+ }
+
+ var user User
+ if err := json.Unmarshal(data, &user); err != nil {
+ continue
+ }
+
+ // 只存储用户数据(包括用户名和加密密码)
+ // scraper实例将在initDefaultAccounts中通过重新登录获取
+ p.users.Store(user.Hash, &user)
+ count++
+
+ if DebugLog {
+ hasPassword := "无"
+ if user.EncryptedPassword != "" {
+ hasPassword = "有"
+ }
+ fmt.Printf("[Gying] 已加载用户 %s (密码:%s, 将在初始化时登录)\n", user.UsernameMasked, hasPassword)
+ }
+ }
+
+ fmt.Printf("[Gying] 已加载 %d 个用户到内存\n", count)
+}
+
+// initDefaultAccounts 初始化所有账户(异步执行,不阻塞启动)
+// 包括:1. DefaultAccounts(代码配置) 2. 从文件加载的用户(使用加密密码重新登录)
+func (p *GyingPlugin) initDefaultAccounts() {
+ fmt.Printf("[Gying] ========== 异步初始化所有账户 ==========\n")
- // 更新插件缓存
- now := time.Now()
- apiResponseCache.Store(pluginCacheKey, cachedResponse{
- Results: results,
- Timestamp: now,
- Complete: true, // 🔥 标记为完整结果
- LastAccess: now,
- AccessCount: 1,
+ // 步骤1:处理DefaultAccounts(代码中配置的默认账户)
+ for i, account := range DefaultAccounts {
+ if DebugLog {
+ fmt.Printf("[Gying] [默认账户 %d/%d] 处理: %s\n", i+1, len(DefaultAccounts), account.Username)
+ }
+
+ p.initOrRestoreUser(account.Username, account.Password, "default")
+ }
+
+ // 步骤2:遍历所有已加载的用户,恢复没有scraper的用户
+ var usersToRestore []*User
+ p.users.Range(func(key, value interface{}) bool {
+ user := value.(*User)
+ // 检查scraper是否存在
+ _, scraperExists := p.scrapers.Load(user.Hash)
+ if !scraperExists && user.EncryptedPassword != "" {
+ usersToRestore = append(usersToRestore, user)
+ }
+ return true
})
- // 🔧 恢复主缓存更新:使用统一的GOB序列化
- // 传递原始数据,由主程序负责序列化
- if mainCacheKey != "" && p.mainCacheUpdater != nil {
- err := p.mainCacheUpdater(mainCacheKey, results, p.cacheTTL, true, p.currentKeyword)
- if err != nil {
- fmt.Printf("❌ [%s] 后台完成缓存更新失败: %s | 错误: %v\n", p.name, mainCacheKey, err)
+ if len(usersToRestore) > 0 {
+ fmt.Printf("[Gying] 发现 %d 个需要恢复的用户(使用加密密码重新登录)\n", len(usersToRestore))
+ for i, user := range usersToRestore {
+ if DebugLog {
+ fmt.Printf("[Gying] [恢复用户 %d/%d] 处理: %s\n", i+1, len(usersToRestore), user.UsernameMasked)
+ }
+
+ // 解密密码
+ password, err := p.decryptPassword(user.EncryptedPassword)
+ if err != nil {
+ fmt.Printf("[Gying] ❌ 用户 %s 解密密码失败: %v\n", user.UsernameMasked, err)
+ continue
+ }
+
+ p.initOrRestoreUser(user.Username, password, "restore")
}
}
+
+ fmt.Printf("[Gying] ========== 所有账户初始化完成 ==========\n")
+}
+
+// initOrRestoreUser 初始化或恢复单个用户(登录并保存)
+func (p *GyingPlugin) initOrRestoreUser(username, password, source string) {
+ hash := p.generateHash(username)
+
+ // 检查scraper是否已存在
+ _, scraperExists := p.scrapers.Load(hash)
+ if scraperExists {
+ if DebugLog {
+ fmt.Printf("[Gying] 用户 %s scraper已存在,跳过\n", p.maskUsername(username))
+ }
+ return
+ }
+
+ // 登录
+ if DebugLog {
+ fmt.Printf("[Gying] 开始登录账户: %s\n", username)
+ }
+ scraper, cookie, err := p.doLogin(username, password)
+ if err != nil {
+ fmt.Printf("[Gying] ❌ 账户 %s 登录失败: %v\n", username, err)
+ return
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 登录成功,已获取cloudscraper实例\n")
+ }
+
+ // 加密密码
+ encryptedPassword, err := p.encryptPassword(password)
+ if err != nil {
+ fmt.Printf("[Gying] ❌ 加密密码失败: %v\n", err)
+ return
+ }
+
+ // 保存用户
+ user := &User{
+ Hash: hash,
+ Username: username,
+ UsernameMasked: p.maskUsername(username),
+ EncryptedPassword: encryptedPassword,
+ Cookie: cookie,
+ Status: "active",
+ CreatedAt: time.Now(),
+ LoginAt: time.Now(),
+ ExpireAt: time.Now().AddDate(0, 4, 0), // 121天有效期
+ LastAccessAt: time.Now(),
+ }
+
+ // 保存scraper实例到内存
+ p.scrapers.Store(hash, scraper)
+
+ if err := p.saveUser(user); err != nil {
+ fmt.Printf("[Gying] ❌ 保存账户失败: %v\n", err)
+ return
+ }
+
+ fmt.Printf("[Gying] ✅ 账户 %s 初始化成功 (来源:%s)\n", user.UsernameMasked, source)
+}
+
+// getUserByHash 获取用户
+func (p *GyingPlugin) getUserByHash(hash string) (*User, bool) {
+ value, ok := p.users.Load(hash)
+ if !ok {
+ return nil, false
+ }
+ return value.(*User), true
+}
+
+// saveUser 保存用户
+func (p *GyingPlugin) saveUser(user *User) error {
+ p.users.Store(user.Hash, user)
+ return p.persistUser(user)
+}
+
+// persistUser 持久化用户到文件
+func (p *GyingPlugin) persistUser(user *User) error {
+ filePath := filepath.Join(StorageDir, user.Hash+".json")
+ data, err := json.MarshalIndent(user, "", " ")
+ if err != nil {
+ return err
+ }
+ return ioutil.WriteFile(filePath, data, 0644)
+}
+
+// deleteUser 删除用户
+func (p *GyingPlugin) deleteUser(hash string) error {
+ p.users.Delete(hash)
+ filePath := filepath.Join(StorageDir, hash+".json")
+ return os.Remove(filePath)
+}
+
+// getActiveUsers 获取有效用户
+func (p *GyingPlugin) getActiveUsers() []*User {
+ var users []*User
+
+ p.users.Range(func(key, value interface{}) bool {
+ user := value.(*User)
+ if user.Status == "active" && user.Cookie != "" {
+ users = append(users, user)
+ }
+ return true
+ })
+
+ return users
+}
+
+// ============ HTTP路由处理 ============
+
+// handleManagePage GET路由处理
+func (p *GyingPlugin) handleManagePage(c *gin.Context) {
+ param := c.Param("param")
+
+ // 判断是用户名还是hash
+ if len(param) == 64 && p.isHexString(param) {
+ html := strings.ReplaceAll(HTMLTemplate, "HASH_PLACEHOLDER", param)
+ c.Data(200, "text/html; charset=utf-8", []byte(html))
+ } else {
+ hash := p.generateHash(param)
+ c.Redirect(302, "/gying/"+hash)
+ }
}
-// refreshCacheInBackground 在后台刷新缓存
-func (p *BaseAsyncPlugin) refreshCacheInBackground(
- keyword string,
- cacheKey string,
- searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),
- oldCache cachedResponse,
- originalCacheKey string,
- ext map[string]interface{},
-) {
- // 确保ext不为nil
- if ext == nil {
- ext = make(map[string]interface{})
- }
-
- // 注意:这里的cacheKey已经是插件特定的了,因为是从AsyncSearch传入的
-
- // 检查是否有足够的工作槽
- if !acquireWorkerSlot() {
+// handleManagePagePOST POST路由处理
+func (p *GyingPlugin) handleManagePagePOST(c *gin.Context) {
+ hash := c.Param("param")
+
+ var reqData map[string]interface{}
+ if err := c.ShouldBindJSON(&reqData); err != nil {
+ respondError(c, "无效的请求格式: "+err.Error())
return
}
- defer releaseWorkerSlot()
-
- // 记录刷新开始时间
- refreshStart := time.Now()
-
- // 执行搜索
- results, err := searchFunc(p.backgroundClient, keyword, ext)
- if err != nil || len(results) == 0 {
+
+ action, ok := reqData["action"].(string)
+ if !ok || action == "" {
+ respondError(c, "缺少action字段")
+ return
+ }
+
+ switch action {
+ case "get_status":
+ p.handleGetStatus(c, hash)
+ case "login":
+ p.handleLogin(c, hash, reqData)
+ case "logout":
+ p.handleLogout(c, hash)
+ case "test_search":
+ p.handleTestSearch(c, hash, reqData)
+ default:
+ respondError(c, "未知的操作类型: "+action)
+ }
+}
+
+// handleGetStatus 获取状态
+func (p *GyingPlugin) handleGetStatus(c *gin.Context, hash string) {
+ user, exists := p.getUserByHash(hash)
+
+ if !exists {
+ user = &User{
+ Hash: hash,
+ Status: "pending",
+ CreatedAt: time.Now(),
+ LastAccessAt: time.Now(),
+ }
+ p.saveUser(user)
+ } else {
+ user.LastAccessAt = time.Now()
+ p.saveUser(user)
+ }
+
+ loggedIn := false
+ if user.Status == "active" && user.Cookie != "" {
+ loggedIn = true
+ }
+
+ expiresInDays := 0
+ if !user.ExpireAt.IsZero() {
+ expiresInDays = int(time.Until(user.ExpireAt).Hours() / 24)
+ if expiresInDays < 0 {
+ expiresInDays = 0
+ }
+ }
+
+ respondSuccess(c, "获取成功", gin.H{
+ "hash": hash,
+ "logged_in": loggedIn,
+ "status": user.Status,
+ "username_masked": user.UsernameMasked,
+ "login_time": user.LoginAt.Format("2006-01-02 15:04:05"),
+ "expire_time": user.ExpireAt.Format("2006-01-02 15:04:05"),
+ "expires_in_days": expiresInDays,
+ })
+}
+
+// handleLogin 处理登录
+func (p *GyingPlugin) handleLogin(c *gin.Context, hash string, reqData map[string]interface{}) {
+ username, _ := reqData["username"].(string)
+ password, _ := reqData["password"].(string)
+
+ if username == "" || password == "" {
+ respondError(c, "缺少用户名或密码")
+ return
+ }
+
+ // 执行登录
+ scraper, cookie, err := p.doLogin(username, password)
+ if err != nil {
+ respondError(c, "登录失败: "+err.Error())
+ return
+ }
+
+ // 保存scraper实例到内存
+ p.scrapers.Store(hash, scraper)
+
+ // 加密密码
+ encryptedPassword, err := p.encryptPassword(password)
+ if err != nil {
+ respondError(c, "加密密码失败: "+err.Error())
return
}
- // 创建合并结果集
- mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCache.Results))
+ // 保存用户
+ user := &User{
+ Hash: hash,
+ Username: username,
+ UsernameMasked: p.maskUsername(username),
+ EncryptedPassword: encryptedPassword,
+ Cookie: cookie,
+ Status: "active",
+ LoginAt: time.Now(),
+ ExpireAt: time.Now().AddDate(0, 4, 0), // 121天
+ LastAccessAt: time.Now(),
+ }
- // 创建已有结果ID的映射
- existingIDs := make(map[string]bool)
+ if _, exists := p.getUserByHash(hash); !exists {
+ user.CreatedAt = time.Now()
+ }
+
+ if err := p.saveUser(user); err != nil {
+ respondError(c, "保存失败: "+err.Error())
+ return
+ }
+
+ respondSuccess(c, "登录成功", gin.H{
+ "status": "active",
+ "username_masked": user.UsernameMasked,
+ })
+}
+
+// handleLogout 退出登录
+func (p *GyingPlugin) handleLogout(c *gin.Context, hash string) {
+ user, exists := p.getUserByHash(hash)
+ if !exists {
+ respondError(c, "用户不存在")
+ return
+ }
+
+ user.Cookie = ""
+ user.Status = "pending"
+
+ if err := p.saveUser(user); err != nil {
+ respondError(c, "退出失败")
+ return
+ }
+
+ respondSuccess(c, "已退出登录", gin.H{
+ "status": "pending",
+ })
+}
+
+// handleTestSearch 测试搜索
+func (p *GyingPlugin) handleTestSearch(c *gin.Context, hash string, reqData map[string]interface{}) {
+ keyword, ok := reqData["keyword"].(string)
+ if !ok || keyword == "" {
+ respondError(c, "缺少keyword字段")
+ return
+ }
+
+ user, exists := p.getUserByHash(hash)
+ if !exists || user.Cookie == "" {
+ respondError(c, "请先登录")
+ return
+ }
+
+ // 获取scraper实例
+ scraperVal, exists := p.scrapers.Load(hash)
+ if !exists {
+ respondError(c, "用户scraper实例不存在,请重新登录")
+ return
+ }
+
+ scraper, ok := scraperVal.(*cloudscraper.Scraper)
+ if !ok || scraper == nil {
+ respondError(c, "scraper实例无效,请重新登录")
+ return
+ }
+
+ // 执行搜索(带403自动重新登录)
+ results, err := p.searchWithScraperWithRetry(keyword, scraper, user)
+ if err != nil {
+ respondError(c, "搜索失败: "+err.Error())
+ return
+ }
+
+ // 限制返回数量
+ maxResults := 10
+ if len(results) > maxResults {
+ results = results[:maxResults]
+ }
+
+ // 转换为前端格式
+ frontendResults := make([]gin.H, 0, len(results))
for _, r := range results {
- existingIDs[r.UniqueID] = true
- mergedResults = append(mergedResults, r)
+ links := make([]gin.H, 0, len(r.Links))
+ for _, link := range r.Links {
+ links = append(links, gin.H{
+ "type": link.Type,
+ "url": link.URL,
+ "password": link.Password,
+ })
+ }
+
+ frontendResults = append(frontendResults, gin.H{
+ "title": r.Title,
+ "links": links,
+ })
+ }
+
+ respondSuccess(c, fmt.Sprintf("找到 %d 条结果", len(frontendResults)), gin.H{
+ "keyword": keyword,
+ "total_results": len(frontendResults),
+ "results": frontendResults,
+ })
+}
+
+// ============ 密码加密/解密 ============
+
+// encryptPassword 使用AES加密密码
+func (p *GyingPlugin) encryptPassword(password string) (string, error) {
+ // 使用固定密钥(实际应用中可以使用配置或环境变量)
+ key := []byte("gying-secret-key-32bytes-long!!!") // 32字节密钥用于AES-256
+
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
}
- // 添加旧结果中不存在的项
- for _, r := range oldCache.Results {
- if !existingIDs[r.UniqueID] {
- mergedResults = append(mergedResults, r)
+ // 创建GCM模式
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ // 生成随机nonce
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+
+ // 加密
+ ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
+
+ // 返回base64编码的密文
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+// decryptPassword 解密密码
+func (p *GyingPlugin) decryptPassword(encrypted string) (string, error) {
+ // 使用与加密相同的密钥
+ key := []byte("gying-secret-key-32bytes-long!!!")
+
+ // base64解码
+ ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
+ if err != nil {
+ return "", err
+ }
+
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ nonceSize := gcm.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return "", fmt.Errorf("ciphertext too short")
+ }
+
+ nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return "", err
+ }
+
+ return string(plaintext), nil
+}
+
+// ============ Cookie管理 ============
+
+// createScraperWithCookies 创建一个带有指定cookies的cloudscraper实例
+// 使用反射访问内部的http.Client并设置cookies到cookiejar
+// 关键:禁用session refresh以防止cookies被清空
+func (p *GyingPlugin) createScraperWithCookies(cookieStr string) (*cloudscraper.Scraper, error) {
+ // 创建cloudscraper实例,配置以保护cookies不被刷新
+ scraper, err := cloudscraper.New(
+ cloudscraper.WithSessionConfig(
+ false, // refreshOn403 = false,禁用403时自动刷新
+ 365*24*time.Hour, // interval = 1年,基本不刷新
+ 0, // maxRetries = 0
+ ),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("创建cloudscraper失败: %w", err)
+ }
+
+ // 如果有保存的cookies,使用反射设置到scraper的内部http.Client
+ if cookieStr != "" {
+ cookies := parseCookieString(cookieStr)
+
+ if DebugLog {
+ fmt.Printf("[Gying] 正在恢复 %d 个cookie到scraper实例\n", len(cookies))
+ }
+
+ // 使用反射访问scraper的unexported client字段
+ scraperValue := reflect.ValueOf(scraper).Elem()
+ clientField := scraperValue.FieldByName("client")
+
+ if clientField.IsValid() && !clientField.IsNil() {
+ // 使用反射访问client (需要使用Elem()因为是指针)
+ clientValue := reflect.NewAt(clientField.Type(), unsafe.Pointer(clientField.UnsafeAddr())).Elem()
+ client, ok := clientValue.Interface().(*http.Client)
+
+ if ok && client != nil && client.Jar != nil {
+ // 将cookies设置到cookiejar
+ // 注意:必须使用正确的URL和cookie属性
+ gyingURL, _ := url.Parse("https://www.gying.net")
+ var httpCookies []*http.Cookie
+
+ for name, value := range cookies {
+ cookie := &http.Cookie{
+ Name: name,
+ Value: value,
+ // 不设置Domain和Path,让cookiejar根据URL自动推导
+ // cookiejar.SetCookies会根据提供的URL自动设置正确的Domain和Path
+ }
+ httpCookies = append(httpCookies, cookie)
+
+ if DebugLog {
+ fmt.Printf("[Gying] 准备恢复Cookie: %s=%s\n",
+ cookie.Name, cookie.Value[:min(10, len(cookie.Value))])
+ }
+ }
+
+ client.Jar.SetCookies(gyingURL, httpCookies)
+
+ // 验证cookies是否被正确设置
+ if DebugLog {
+ storedCookies := client.Jar.Cookies(gyingURL)
+ fmt.Printf("[Gying] ✅ 成功恢复 %d 个cookie到scraper的cookiejar\n", len(cookies))
+ fmt.Printf("[Gying] 验证: cookiejar中现有 %d 个cookie\n", len(storedCookies))
+
+ // 详细打印每个cookie以便调试
+ for i, c := range storedCookies {
+ fmt.Printf("[Gying] 设置后Cookie[%d]: %s=%s (Domain:%s, Path:%s)\n",
+ i, c.Name, c.Value[:min(10, len(c.Value))], c.Domain, c.Path)
+ }
+ }
+ } else {
+ if DebugLog {
+ fmt.Printf("[Gying] ⚠️ 无法获取http.Client或其Jar\n")
+ }
+ }
+ } else {
+ if DebugLog {
+ fmt.Printf("[Gying] ⚠️ 无法通过反射访问client字段\n")
+ }
}
}
- // 更新缓存
- apiResponseCache.Store(cacheKey, cachedResponse{
- Results: mergedResults,
- Timestamp: time.Now(),
- Complete: true,
- LastAccess: oldCache.LastAccess,
- AccessCount: oldCache.AccessCount,
+ return scraper, nil
+}
+
+// parseCookieString 解析cookie字符串为map
+func parseCookieString(cookieStr string) map[string]string {
+ cookies := make(map[string]string)
+ parts := strings.Split(cookieStr, ";")
+
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if idx := strings.Index(part, "="); idx > 0 {
+ name := part[:idx]
+ value := part[idx+1:]
+ cookies[name] = value
+ }
+ }
+
+ return cookies
+}
+
+// ============ 登录逻辑 ============
+
+// doLogin 执行登录,返回scraper实例和cookie字符串
+//
+// 登录流程(3步):
+// 1. GET登录页 (https://www.gying.net/user/login/) → 获取PHPSESSID
+// 2. POST登录 (https://www.gying.net/user/login) → 获取BT_auth、BT_cookietime等认证cookies
+// 3. GET详情页 (https://www.gying.net/mv/wkMn) → 触发防爬cookies (vrg_sc、vrg_go等)
+//
+// 返回: (*cloudscraper.Scraper, cookie字符串, error)
+func (p *GyingPlugin) doLogin(username, password string) (*cloudscraper.Scraper, string, error) {
+ if DebugLog {
+ fmt.Printf("[Gying] ========== 开始登录 ==========\n")
+ fmt.Printf("[Gying] 用户名: %s\n", username)
+ fmt.Printf("[Gying] 密码长度: %d\n", len(password))
+ }
+
+ // 创建cloudscraper实例(每个用户独立的实例)
+ // 关键配置:禁用403自动刷新,防止cookie被清空
+ scraper, err := cloudscraper.New(
+ cloudscraper.WithSessionConfig(
+ false, // refreshOn403 = false,禁用403时自动刷新(重要!)
+ 365*24*time.Hour, // interval = 1年,基本不刷新
+ 0, // maxRetries = 0
+ ),
+ )
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 创建cloudscraper失败: %v\n", err)
+ }
+ return nil, "", fmt.Errorf("创建cloudscraper失败: %w", err)
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] cloudscraper创建成功(已禁用403自动刷新)\n")
+ }
+
+ // 创建cookieMap用于收集所有cookies
+ cookieMap := make(map[string]string)
+
+ // ========== 步骤1: GET登录页 (获取初始PHPSESSID) ==========
+ loginPageURL := "https://www.gying.net/user/login/"
+ if DebugLog {
+ fmt.Printf("[Gying] 步骤1: 访问登录页面: %s\n", loginPageURL)
+ }
+
+ getResp, err := scraper.Get(loginPageURL)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 访问登录页面失败: %v\n", err)
+ }
+ return nil, "", fmt.Errorf("访问登录页面失败: %w", err)
+ }
+ defer getResp.Body.Close()
+ ioutil.ReadAll(getResp.Body) // 读取body
+
+ if DebugLog {
+ fmt.Printf("[Gying] 登录页面状态码: %d\n", getResp.StatusCode)
+ }
+
+ // 从登录页响应中收集cookies
+ for _, setCookie := range getResp.Header["Set-Cookie"] {
+ parts := strings.Split(setCookie, ";")
+ if len(parts) > 0 {
+ cookiePart := strings.TrimSpace(parts[0])
+ if idx := strings.Index(cookiePart, "="); idx > 0 {
+ name := cookiePart[:idx]
+ value := cookiePart[idx+1:]
+ cookieMap[name] = value
+ if DebugLog {
+ displayValue := value
+ if len(displayValue) > 20 {
+ displayValue = displayValue[:20] + "..."
+ }
+ fmt.Printf("[Gying] 登录页Cookie: %s=%s\n", name, displayValue)
+ }
+ }
+ }
+ }
+
+ // ========== 步骤2: POST登录 (获取认证cookies) ==========
+ loginURL := "https://www.gying.net/user/login"
+ postData := fmt.Sprintf("code=&siteid=1&dosubmit=1&cookietime=10506240&username=%s&password=%s",
+ url.QueryEscape(username),
+ url.QueryEscape(password))
+
+ if DebugLog {
+ fmt.Printf("[Gying] 步骤2: POST登录\n")
+ fmt.Printf("[Gying] 登录URL: %s\n", loginURL)
+ fmt.Printf("[Gying] POST数据: %s\n", postData)
+ }
+
+ resp, err := scraper.Post(loginURL, "application/x-www-form-urlencoded", strings.NewReader(postData))
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 登录POST请求失败: %v\n", err)
+ }
+ return nil, "", fmt.Errorf("登录POST请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if DebugLog {
+ fmt.Printf("[Gying] 响应状态码: %d\n", resp.StatusCode)
+ }
+
+ // 从POST登录响应中收集cookies
+ for _, setCookie := range resp.Header["Set-Cookie"] {
+ parts := strings.Split(setCookie, ";")
+ if len(parts) > 0 {
+ cookiePart := strings.TrimSpace(parts[0])
+ if idx := strings.Index(cookiePart, "="); idx > 0 {
+ name := cookiePart[:idx]
+ value := cookiePart[idx+1:]
+ cookieMap[name] = value
+ if DebugLog {
+ displayValue := value
+ if len(displayValue) > 20 {
+ displayValue = displayValue[:20] + "..."
+ }
+ fmt.Printf("[Gying] POST登录Cookie: %s=%s\n", name, displayValue)
+ }
+ }
+ }
+ }
+
+ // 读取响应
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 读取响应失败: %v\n", err)
+ }
+ return nil, "", fmt.Errorf("读取响应失败: %w", err)
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 响应内容: %s\n", string(body))
+ }
+
+ var loginResp map[string]interface{}
+ if err := json.Unmarshal(body, &loginResp); err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] JSON解析失败: %v\n", err)
+ }
+ return nil, "", fmt.Errorf("JSON解析失败: %w, 响应内容: %s", err, string(body))
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 解析后的响应: %+v\n", loginResp)
+ fmt.Printf("[Gying] code字段类型: %T, 值: %v\n", loginResp["code"], loginResp["code"])
+ }
+
+ // 检查登录结果(兼容多种类型:int、float64、json.Number、string)
+ var codeValue int
+ codeInterface := loginResp["code"]
+
+ switch v := codeInterface.(type) {
+ case int:
+ codeValue = v
+ case float64:
+ codeValue = int(v)
+ case int64:
+ codeValue = int(v)
+ default:
+ // 尝试转换为字符串再解析
+ codeStr := fmt.Sprintf("%v", codeInterface)
+ parsed, err := strconv.Atoi(codeStr)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 无法解析code字段: %T, 值: %v, 错误: %v\n", codeInterface, codeInterface, err)
+ }
+ return nil, "", fmt.Errorf("无法解析code字段,类型: %T, 值: %v", codeInterface, codeInterface)
+ }
+ codeValue = parsed
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 解析后的code值: %d\n", codeValue)
+ }
+
+ if codeValue != 200 {
+ if DebugLog {
+ fmt.Printf("[Gying] 登录失败: code=%d (期望200)\n", codeValue)
+ }
+ return nil, "", fmt.Errorf("登录失败: code=%d, 响应=%s", codeValue, string(body))
+ }
+
+ // ========== 步骤3: GET详情页 (触发防爬cookies如vrg_sc、vrg_go等) ==========
+ if DebugLog {
+ fmt.Printf("[Gying] 步骤3: GET详情页收集完整Cookie\n")
+ }
+
+ detailResp, err := scraper.Get("https://www.gying.net/mv/wkMn")
+ if err == nil {
+ defer detailResp.Body.Close()
+ ioutil.ReadAll(detailResp.Body)
+
+ if DebugLog {
+ fmt.Printf("[Gying] 详情页状态码: %d\n", detailResp.StatusCode)
+ }
+
+ // 从详情页响应中收集cookies
+ for _, setCookie := range detailResp.Header["Set-Cookie"] {
+ parts := strings.Split(setCookie, ";")
+ if len(parts) > 0 {
+ cookiePart := strings.TrimSpace(parts[0])
+ if idx := strings.Index(cookiePart, "="); idx > 0 {
+ name := cookiePart[:idx]
+ value := cookiePart[idx+1:]
+ cookieMap[name] = value
+ if DebugLog {
+ displayValue := value
+ if len(displayValue) > 30 {
+ displayValue = displayValue[:30] + "..."
+ }
+ fmt.Printf("[Gying] 详情页Cookie: %s=%s\n", name, displayValue)
+ }
+ }
+ }
+ }
+ }
+
+ // 构建cookie字符串
+ var cookieParts []string
+ for name, value := range cookieMap {
+ cookieParts = append(cookieParts, fmt.Sprintf("%s=%s", name, value))
+ }
+ cookieStr := strings.Join(cookieParts, "; ")
+
+ if DebugLog {
+ fmt.Printf("[Gying] ✅ 登录成功!提取到 %d 个Cookie\n", len(cookieMap))
+ fmt.Printf("[Gying] Cookie字符串长度: %d\n", len(cookieStr))
+ for name, value := range cookieMap {
+ displayValue := value
+ if len(displayValue) > 30 {
+ displayValue = displayValue[:30] + "..."
+ }
+ fmt.Printf("[Gying] %s=%s (len:%d)\n", name, displayValue, len(value))
+ }
+ fmt.Printf("[Gying] ========== 登录完成 ==========\n")
+ }
+
+ // 返回scraper实例和实际的cookie字符串
+ return scraper, cookieStr, nil
+}
+
+// min 辅助函数
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// ============ 重新登录逻辑 ============
+
+// reloginUser 重新登录指定用户
+func (p *GyingPlugin) reloginUser(user *User) error {
+ if DebugLog {
+ fmt.Printf("[Gying] 🔄 开始重新登录用户: %s\n", user.UsernameMasked)
+ }
+
+ // 解密密码
+ password, err := p.decryptPassword(user.EncryptedPassword)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 解密密码失败: %v\n", err)
+ }
+ return fmt.Errorf("解密密码失败: %w", err)
+ }
+
+ // 执行登录
+ scraper, cookie, err := p.doLogin(user.Username, password)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 重新登录失败: %v\n", err)
+ }
+ return fmt.Errorf("重新登录失败: %w", err)
+ }
+
+ // 更新scraper实例
+ p.scrapers.Store(user.Hash, scraper)
+
+ // 更新用户信息
+ user.Cookie = cookie
+ user.LoginAt = time.Now()
+ user.ExpireAt = time.Now().AddDate(0, 4, 0)
+ user.Status = "active"
+
+ if err := p.saveUser(user); err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] ⚠️ 保存用户失败: %v\n", err)
+ }
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] ✅ 用户 %s 重新登录成功\n", user.UsernameMasked)
+ }
+
+ return nil
+}
+
+// ============ 搜索逻辑 ============
+
+// executeSearchTasks 并发执行搜索任务
+func (p *GyingPlugin) executeSearchTasks(users []*User, keyword string) []model.SearchResult {
+ var allResults []model.SearchResult
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ for _, user := range users {
+ wg.Add(1)
+ go func(u *User) {
+ defer wg.Done()
+
+ // 获取用户的scraper实例
+ scraperVal, exists := p.scrapers.Load(u.Hash)
+ var scraper *cloudscraper.Scraper
+
+ if !exists {
+ if DebugLog {
+ fmt.Printf("[Gying] 用户 %s 没有scraper实例,尝试使用已保存的cookie创建\n", u.UsernameMasked)
+ }
+
+ // 使用已保存的cookie创建scraper实例(关键!)
+ newScraper, err := p.createScraperWithCookies(u.Cookie)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 为用户 %s 创建scraper失败: %v\n", u.UsernameMasked, err)
+ }
+ return
+ }
+
+ // 存储新创建的scraper实例
+ p.scrapers.Store(u.Hash, newScraper)
+ scraper = newScraper
+
+ if DebugLog {
+ fmt.Printf("[Gying] 已为用户 %s 恢复scraper实例(含cookie)\n", u.UsernameMasked)
+ }
+ } else {
+ var ok bool
+ scraper, ok = scraperVal.(*cloudscraper.Scraper)
+ if !ok || scraper == nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 用户 %s scraper实例无效,跳过\n", u.UsernameMasked)
+ }
+ return
+ }
+ }
+
+ results, err := p.searchWithScraperWithRetry(keyword, scraper, u)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 用户 %s 搜索失败(已重试): %v\n", u.UsernameMasked, err)
+ }
+ return
+ }
+
+ mu.Lock()
+ allResults = append(allResults, results...)
+ mu.Unlock()
+ }(user)
+ }
+
+ wg.Wait()
+
+ // 去重
+ return p.deduplicateResults(allResults)
+}
+
+// searchWithScraperWithRetry 使用scraper搜索(带403自动重新登录重试)
+func (p *GyingPlugin) searchWithScraperWithRetry(keyword string, scraper *cloudscraper.Scraper, user *User) ([]model.SearchResult, error) {
+ results, err := p.searchWithScraper(keyword, scraper)
+
+ // 检测是否为403错误
+ if err != nil && strings.Contains(err.Error(), "403") {
+ if DebugLog {
+ fmt.Printf("[Gying] ⚠️ 检测到403错误,尝试重新登录用户 %s\n", user.UsernameMasked)
+ }
+
+ // 尝试重新登录
+ if reloginErr := p.reloginUser(user); reloginErr != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 重新登录失败: %v\n", reloginErr)
+ }
+ return nil, fmt.Errorf("403错误且重新登录失败: %w", reloginErr)
+ }
+
+ // 获取新的scraper实例
+ scraperVal, exists := p.scrapers.Load(user.Hash)
+ if !exists {
+ return nil, fmt.Errorf("重新登录后未找到scraper实例")
+ }
+
+ newScraper, ok := scraperVal.(*cloudscraper.Scraper)
+ if !ok || newScraper == nil {
+ return nil, fmt.Errorf("重新登录后scraper实例无效")
+ }
+
+ // 使用新scraper重试搜索
+ if DebugLog {
+ fmt.Printf("[Gying] 🔄 使用新登录状态重试搜索\n")
+ }
+ results, err = p.searchWithScraper(keyword, newScraper)
+ if err != nil {
+ return nil, fmt.Errorf("重新登录后搜索仍然失败: %w", err)
+ }
+ }
+
+ return results, err
+}
+
+// searchWithScraper 使用scraper搜索
+func (p *GyingPlugin) searchWithScraper(keyword string, scraper *cloudscraper.Scraper) ([]model.SearchResult, error) {
+ if DebugLog {
+ fmt.Printf("[Gying] ---------- searchWithScraper 开始 ----------\n")
+ fmt.Printf("[Gying] 关键词: %s\n", keyword)
+ }
+
+ // 1. 使用cloudscraper请求搜索页面
+ searchURL := fmt.Sprintf("https://www.gying.net/s/1---1/%s", url.QueryEscape(keyword))
+
+ if DebugLog {
+ fmt.Printf("[Gying] 搜索URL: %s\n", searchURL)
+ fmt.Printf("[Gying] 使用cloudscraper发送请求\n")
+ }
+
+ resp, err := scraper.Get(searchURL)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 搜索请求失败: %v\n", err)
+ }
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if DebugLog {
+ fmt.Printf("[Gying] 搜索响应状态码: %d\n", resp.StatusCode)
+ }
+
+ // 读取响应body
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 读取响应失败: %v\n", err)
+ }
+ return nil, err
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 响应Body长度: %d 字节\n", len(body))
+ if len(body) > 0 {
+ // 打印前500字符
+ preview := string(body)
+ if len(preview) > 500 {
+ preview = preview[:500] + "..."
+ }
+ fmt.Printf("[Gying] 响应预览: %s\n", preview)
+ }
+ }
+
+ // 检查403错误
+ if resp.StatusCode == 403 {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 收到403 Forbidden - Cookie可能已过期或被网站拒绝\n")
+ if len(body) > 0 {
+ preview := string(body)
+ if len(preview) > 300 {
+ preview = preview[:300] + "..."
+ }
+ fmt.Printf("[Gying] 403响应内容: %s\n", preview)
+ }
+ }
+ return nil, fmt.Errorf("HTTP 403 Forbidden - 可能需要重新登录")
+ }
+
+ // 2. 提取 _obj.search JSON
+ re := regexp.MustCompile(`_obj\.search=(\{.*?\});`)
+ matches := re.FindSubmatch(body)
+
+ if DebugLog {
+ fmt.Printf("[Gying] 正则匹配结果: 找到 %d 个匹配\n", len(matches))
+ }
+
+ if len(matches) < 2 {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 未找到 _obj.search JSON数据\n")
+ // 尝试查找是否有其他模式
+ if strings.Contains(string(body), "_obj.search") {
+ fmt.Printf("[Gying] 但是Body中包含 '_obj.search' 字符串\n")
+ } else {
+ fmt.Printf("[Gying] Body中不包含 '_obj.search' 字符串\n")
+ }
+ }
+ return nil, fmt.Errorf("未找到搜索结果数据")
+ }
+
+ if DebugLog {
+ jsonStr := string(matches[1])
+ if len(jsonStr) > 200 {
+ jsonStr = jsonStr[:200] + "..."
+ }
+ fmt.Printf("[Gying] 提取的JSON数据: %s\n", jsonStr)
+ }
+
+ var searchData SearchData
+ if err := json.Unmarshal(matches[1], &searchData); err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] JSON解析失败: %v\n", err)
+ fmt.Printf("[Gying] 原始JSON: %s\n", string(matches[1]))
+ }
+ return nil, fmt.Errorf("解析搜索数据失败: %w", err)
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 搜索数据解析成功:\n")
+ fmt.Printf("[Gying] - 关键词: %s\n", searchData.Q)
+ fmt.Printf("[Gying] - 结果数量字符串: %s\n", searchData.N)
+ fmt.Printf("[Gying] - 资源ID数组长度: %d\n", len(searchData.L.I))
+ fmt.Printf("[Gying] - 标题数组长度: %d\n", len(searchData.L.Title))
+ if len(searchData.L.I) > 0 {
+ fmt.Printf("[Gying] - 前3个资源ID: %v\n", searchData.L.I[:min(3, len(searchData.L.I))])
+ fmt.Printf("[Gying] - 前3个标题: %v\n", searchData.L.Title[:min(3, len(searchData.L.Title))])
+ }
+ }
+
+ // 3. 刷新防爬cookies(关键!访问详情页触发vrg_sc、vrg_go等防爬cookies)
+ if DebugLog {
+ fmt.Printf("[Gying] 刷新防爬cookies...\n")
+ }
+ refreshResp, err := scraper.Get("https://www.gying.net/mv/wkMn")
+ if err == nil && refreshResp != nil {
+ refreshResp.Body.Close()
+ if DebugLog {
+ fmt.Printf("[Gying] 防爬cookies刷新成功 (状态码: %d)\n", refreshResp.StatusCode)
+ }
+ }
+
+ // 4. 并发请求详情接口
+ results, err := p.fetchAllDetails(&searchData, scraper)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] fetchAllDetails 失败: %v\n", err)
+ fmt.Printf("[Gying] ---------- searchWithScraper 结束 ----------\n")
+ }
+ return nil, err
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] fetchAllDetails 返回 %d 条结果\n", len(results))
+ fmt.Printf("[Gying] ---------- searchWithScraper 结束 ----------\n")
+ }
+
+ return results, nil
+}
+
+// fetchAllDetails 并发获取所有详情
+func (p *GyingPlugin) fetchAllDetails(searchData *SearchData, scraper *cloudscraper.Scraper) ([]model.SearchResult, error) {
+ if DebugLog {
+ fmt.Printf("[Gying] >>> fetchAllDetails 开始\n")
+ fmt.Printf("[Gying] 需要获取 %d 个详情\n", len(searchData.L.I))
+ }
+
+ var results []model.SearchResult
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ semaphore := make(chan struct{}, MaxConcurrentDetails)
+ errChan := make(chan error, 1) // 用于接收403错误
+
+ successCount := 0
+ failCount := 0
+ has403 := false
+
+ for i := 0; i < len(searchData.L.I); i++ {
+ wg.Add(1)
+ go func(index int) {
+ defer wg.Done()
+
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+
+ // 检查是否已经遇到403错误
+ mu.Lock()
+ if has403 {
+ mu.Unlock()
+ return
+ }
+ mu.Unlock()
+
+ if DebugLog {
+ fmt.Printf("[Gying] [%d/%d] 获取详情: ID=%s, Type=%s\n",
+ index+1, len(searchData.L.I), searchData.L.I[index], searchData.L.D[index])
+ }
+
+ detail, err := p.fetchDetail(searchData.L.I[index], searchData.L.D[index], scraper)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] [%d/%d] ❌ 获取详情失败: %v\n", index+1, len(searchData.L.I), err)
+ }
+
+ // 检查是否是403错误
+ if strings.Contains(err.Error(), "403") {
+ mu.Lock()
+ if !has403 {
+ has403 = true
+ select {
+ case errChan <- err:
+ default:
+ }
+ }
+ mu.Unlock()
+ }
+
+ mu.Lock()
+ failCount++
+ mu.Unlock()
+ return
+ }
+
+ result := p.buildResult(detail, searchData, index)
+ if result.Title != "" && len(result.Links) > 0 {
+ if DebugLog {
+ fmt.Printf("[Gying] [%d/%d] ✅ 成功: %s (%d个链接)\n",
+ index+1, len(searchData.L.I), result.Title, len(result.Links))
+ }
+ mu.Lock()
+ results = append(results, result)
+ successCount++
+ mu.Unlock()
+ } else {
+ if DebugLog {
+ fmt.Printf("[Gying] [%d/%d] ⚠️ 跳过: 标题或链接为空 (标题:%s, 链接数:%d)\n",
+ index+1, len(searchData.L.I), result.Title, len(result.Links))
+ }
+ }
+ }(i)
+ }
+
+ wg.Wait()
+
+ // 检查是否有403错误
+ select {
+ case err := <-errChan:
+ if DebugLog {
+ fmt.Printf("[Gying] <<< fetchAllDetails 检测到403错误,需要重新登录\n")
+ }
+ return nil, err
+ default:
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] <<< fetchAllDetails 完成: 成功=%d, 失败=%d, 总计=%d\n",
+ successCount, failCount, len(searchData.L.I))
+ }
+
+ return results, nil
+}
+
+// fetchDetail 获取详情
+func (p *GyingPlugin) fetchDetail(resourceID, resourceType string, scraper *cloudscraper.Scraper) (*DetailData, error) {
+ detailURL := fmt.Sprintf("https://www.gying.net/res/downurl/%s/%s", resourceType, resourceID)
+
+ if DebugLog {
+ fmt.Printf("[Gying] fetchDetail: %s\n", detailURL)
+ }
+
+ // 使用cloudscraper发送请求(自动管理Cookie和绕过反爬虫)
+ resp, err := scraper.Get(detailURL)
+
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 请求失败: %v\n", err)
+ }
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if DebugLog {
+ fmt.Printf("[Gying] 响应状态码: %d\n", resp.StatusCode)
+ }
+
+ // 检查403错误
+ if resp.StatusCode == 403 {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 详情接口返回403 - Cookie可能已过期\n")
+ }
+ return nil, fmt.Errorf("HTTP 403 Forbidden")
+ }
+
+ if resp.StatusCode != 200 {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ HTTP错误: %d\n", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] 读取响应失败: %v\n", err)
+ }
+ return nil, err
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 响应长度: %d 字节\n", len(body))
+ }
+
+ var detail DetailData
+ if err := json.Unmarshal(body, &detail); err != nil {
+ if DebugLog {
+ fmt.Printf("[Gying] JSON解析失败: %v\n", err)
+ // 打印前200字符
+ preview := string(body)
+ if len(preview) > 200 {
+ preview = preview[:200] + "..."
+ }
+ fmt.Printf("[Gying] 响应内容: %s\n", preview)
+ }
+ return nil, err
+ }
+
+ if DebugLog {
+ fmt.Printf("[Gying] 详情Code: %d, 网盘链接数: %d\n", detail.Code, len(detail.Panlist.URL))
+ }
+
+ // 检查JSON响应中的code字段(关键!)
+ if detail.Code == 403 {
+ if DebugLog {
+ fmt.Printf("[Gying] ❌ 详情接口返回Code=403 - 登录状态可能已失效\n")
+ }
+ return nil, fmt.Errorf("Detail API returned code 403 - authentication may have expired")
+ }
+
+ return &detail, nil
+}
+
+// buildResult 构建SearchResult
+func (p *GyingPlugin) buildResult(detail *DetailData, searchData *SearchData, index int) model.SearchResult {
+ if index >= len(searchData.L.Title) {
+ return model.SearchResult{}
+ }
+
+ title := searchData.L.Title[index]
+ resourceType := searchData.L.D[index]
+ resourceID := searchData.L.I[index]
+
+ // 构建描述
+ var contentParts []string
+ if index < len(searchData.L.Info) && searchData.L.Info[index] != "" {
+ contentParts = append(contentParts, searchData.L.Info[index])
+ }
+ if index < len(searchData.L.Daoyan) && searchData.L.Daoyan[index] != "" {
+ contentParts = append(contentParts, fmt.Sprintf("导演: %s", searchData.L.Daoyan[index]))
+ }
+ if index < len(searchData.L.Zhuyan) && searchData.L.Zhuyan[index] != "" {
+ contentParts = append(contentParts, fmt.Sprintf("主演: %s", searchData.L.Zhuyan[index]))
+ }
+
+ // 提取网盘链接
+ links := p.extractPanLinks(detail)
+
+ // 构建标签
+ var tags []string
+ if index < len(searchData.L.Year) && searchData.L.Year[index] > 0 {
+ tags = append(tags, fmt.Sprintf("%d", searchData.L.Year[index]))
+ }
+
+ return model.SearchResult{
+ UniqueID: fmt.Sprintf("gying-%s-%s", resourceType, resourceID),
+ Title: title,
+ Content: strings.Join(contentParts, " | "),
+ Links: links,
+ Tags: tags,
+ Channel: "", // 插件搜索结果Channel为空
+ Datetime: time.Now(),
+ }
+}
+
+// extractPanLinks 提取网盘链接
+func (p *GyingPlugin) extractPanLinks(detail *DetailData) []model.Link {
+ var links []model.Link
+ seen := make(map[string]bool)
+
+ for i := 0; i < len(detail.Panlist.URL); i++ {
+ linkURL := strings.TrimSpace(detail.Panlist.URL[i])
+
+ // 去除URL中的访问码标记
+ linkURL = regexp.MustCompile(`(访问码:.*?)`).ReplaceAllString(linkURL, "")
+ linkURL = regexp.MustCompile(`\(访问码:.*?\)`).ReplaceAllString(linkURL, "")
+ linkURL = strings.TrimSpace(linkURL)
+
+ if linkURL == "" || seen[linkURL] {
+ continue
+ }
+ seen[linkURL] = true
+
+ // 识别网盘类型
+ linkType := p.determineLinkType(linkURL)
+ if linkType == "others" {
+ continue
+ }
+
+ // 提取提取码
+ password := ""
+ if i < len(detail.Panlist.P) && detail.Panlist.P[i] != "" {
+ password = detail.Panlist.P[i]
+ }
+
+ // 从URL提取提取码(优先)
+ if urlPwd := p.extractPasswordFromURL(linkURL); urlPwd != "" {
+ password = urlPwd
+ }
+
+ links = append(links, model.Link{
+ Type: linkType,
+ URL: linkURL,
+ Password: password,
+ })
+ }
+
+ return links
+}
+
+// determineLinkType 识别网盘类型
+func (p *GyingPlugin) determineLinkType(linkURL string) string {
+ switch {
+ case strings.Contains(linkURL, "pan.quark.cn"):
+ return "quark"
+ case strings.Contains(linkURL, "drive.uc.cn"):
+ return "uc"
+ case strings.Contains(linkURL, "pan.baidu.com"):
+ return "baidu"
+ case strings.Contains(linkURL, "aliyundrive.com") || strings.Contains(linkURL, "alipan.com"):
+ return "aliyun"
+ case strings.Contains(linkURL, "pan.xunlei.com"):
+ return "xunlei"
+ case strings.Contains(linkURL, "cloud.189.cn"):
+ return "tianyi"
+ case strings.Contains(linkURL, "115.com"):
+ return "115"
+ case strings.Contains(linkURL, "123pan.com"):
+ return "123"
+ default:
+ return "others"
+ }
+}
+
+// extractPasswordFromURL 从URL提取提取码
+func (p *GyingPlugin) extractPasswordFromURL(linkURL string) string {
+ // 百度网盘: ?pwd=xxxx
+ if strings.Contains(linkURL, "?pwd=") {
+ re := regexp.MustCompile(`\?pwd=([a-zA-Z0-9]+)`)
+ if matches := re.FindStringSubmatch(linkURL); len(matches) > 1 {
+ return matches[1]
+ }
+ }
+
+ // 115网盘: ?password=xxxx
+ if strings.Contains(linkURL, "?password=") {
+ re := regexp.MustCompile(`\?password=([a-zA-Z0-9]+)`)
+ if matches := re.FindStringSubmatch(linkURL); len(matches) > 1 {
+ return matches[1]
+ }
+ }
+
+ return ""
+}
+
+// deduplicateResults 去重
+func (p *GyingPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {
+ seen := make(map[string]bool)
+ var deduplicated []model.SearchResult
+
+ for _, result := range results {
+ if !seen[result.UniqueID] {
+ seen[result.UniqueID] = true
+ deduplicated = append(deduplicated, result)
+ }
+ }
+
+ return deduplicated
+}
+
+// ============ 工具函数 ============
+
+// generateHash 生成hash
+func (p *GyingPlugin) generateHash(username string) string {
+ salt := os.Getenv("GYING_HASH_SALT")
+ if salt == "" {
+ salt = "pansou_gying_secret_2025"
+ }
+ data := username + salt
+ hash := sha256.Sum256([]byte(data))
+ return hex.EncodeToString(hash[:])
+}
+
+// maskUsername 脱敏用户名
+func (p *GyingPlugin) maskUsername(username string) string {
+ if len(username) <= 2 {
+ return username
+ }
+ if len(username) <= 4 {
+ return username[:1] + "**" + username[len(username)-1:]
+ }
+ return username[:2] + "****" + username[len(username)-2:]
+}
+
+// isHexString 判断是否为十六进制
+func (p *GyingPlugin) isHexString(s string) bool {
+ for _, c := range s {
+ if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
+ return false
+ }
+ }
+ return true
+}
+
+// respondSuccess 成功响应
+func respondSuccess(c *gin.Context, message string, data interface{}) {
+ c.JSON(200, gin.H{
+ "success": true,
+ "message": message,
+ "data": data,
+ })
+}
+
+// respondError 错误响应
+func respondError(c *gin.Context, message string) {
+ c.JSON(200, gin.H{
+ "success": false,
+ "message": message,
+ "data": nil,
+ })
+}
+
+// ============ Cookie加密(可选) ============
+
+func getEncryptionKey() []byte {
+ key := os.Getenv("GYING_ENCRYPTION_KEY")
+ if key == "" {
+ key = "default-32-byte-key-change-me!"
+ }
+ return []byte(key)[:32]
+}
+
+func encryptCookie(plaintext string) (string, error) {
+ key := getEncryptionKey()
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+
+ ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+func decryptCookie(encrypted string) (string, error) {
+ key := getEncryptionKey()
+ ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
+ if err != nil {
+ return "", err
+ }
+
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ nonceSize := gcm.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return "", fmt.Errorf("ciphertext too short")
+ }
+
+ nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return "", err
+ }
+
+ return string(plaintext), nil
+}
+
+// ============ Session保活 ============
+
+// startSessionKeepAlive 启动session保活任务
+func (p *GyingPlugin) startSessionKeepAlive() {
+ // 首次启动后延迟3分钟再开始(避免启动时过多请求)
+ time.Sleep(3 * time.Minute)
+
+ // 立即执行一次保活
+ p.keepAllSessionsAlive()
+
+ // 每3分钟执行一次保活
+ ticker := time.NewTicker(3 * time.Minute)
+ for range ticker.C {
+ p.keepAllSessionsAlive()
+ }
+}
+
+// keepAllSessionsAlive 保持所有用户的session活跃
+func (p *GyingPlugin) keepAllSessionsAlive() {
+ count := 0
+
+ p.users.Range(func(key, value interface{}) bool {
+ user := value.(*User)
+
+ // 只为active状态的用户保活
+ if user.Status != "active" {
+ return true
+ }
+
+ // 获取scraper实例
+ scraperVal, exists := p.scrapers.Load(user.Hash)
+ if !exists {
+ return true
+ }
+
+ scraper, ok := scraperVal.(*cloudscraper.Scraper)
+ if !ok || scraper == nil {
+ return true
+ }
+
+ // 访问首页保持session活跃
+ go func(s *cloudscraper.Scraper, username string) {
+ resp, err := s.Get("https://www.gying.net/")
+ if err == nil && resp != nil {
+ resp.Body.Close()
+ if DebugLog {
+ fmt.Printf("[Gying] 💓 Session保活成功: %s (状态码: %d)\n", username, resp.StatusCode)
+ }
+ }
+ }(scraper, user.UsernameMasked)
+
+ count++
+ return true
})
- // 🔥 异步插件后台刷新完成时更新主缓存(标记为最终结果)
- p.updateMainCacheWithFinal(originalCacheKey, mergedResults, true)
-
- // 记录刷新时间
- refreshTime := time.Since(refreshStart)
- fmt.Printf("[%s] 后台刷新完成: %s (耗时: %v, 新项目: %d, 合并项目: %d)\n",
- p.name, cacheKey, refreshTime, len(results), len(mergedResults))
-
- // 异步插件本地缓存系统已移除
-}
-
-// ============================================================
-// 第九部分:缓存管理
-// ============================================================
-
-// updateMainCache 更新主缓存系统(兼容性方法,默认IsFinal=true)
-func (p *BaseAsyncPlugin) updateMainCache(cacheKey string, results []model.SearchResult) {
- p.updateMainCacheWithFinal(cacheKey, results, true)
+ if DebugLog && count > 0 {
+ fmt.Printf("[Gying] 💓 已为 %d 个用户执行session保活\n", count)
+ }
}
-// updateMainCacheWithFinal 更新主缓存系统,支持IsFinal参数
-func (p *BaseAsyncPlugin) updateMainCacheWithFinal(cacheKey string, results []model.SearchResult, isFinal bool) {
- // 如果主缓存更新函数为空或缓存键为空,直接返回
- if p.mainCacheUpdater == nil || cacheKey == "" {
- return
- }
-
- // 🚀 优化:如果新结果为空,跳过缓存更新(避免无效操作)
- if len(results) == 0 {
- return
- }
-
- // 🔥 增强防重复更新机制 - 使用数据哈希确保真正的去重
- // 生成结果数据的简单哈希标识
- dataHash := fmt.Sprintf("%d_%d", len(results), results[0].UniqueID)
- if len(results) > 1 {
- dataHash += fmt.Sprintf("_%d", results[len(results)-1].UniqueID)
- }
- updateKey := fmt.Sprintf("final_%s_%s_%s_%t", p.name, cacheKey, dataHash, isFinal)
-
- // 检查是否已经处理过相同的数据
- if p.hasUpdatedFinalCache(updateKey) {
- return
- }
-
- // 标记已更新
- p.markFinalCacheUpdated(updateKey)
-
- // 🔧 恢复异步插件缓存更新,使用修复后的统一序列化
- // 传递原始数据,由主程序负责GOB序列化
- if p.mainCacheUpdater != nil {
- err := p.mainCacheUpdater(cacheKey, results, p.cacheTTL, isFinal, p.currentKeyword)
- if err != nil {
- fmt.Printf("❌ [%s] 主缓存更新失败: %s | 错误: %v\n", p.name, cacheKey, err)
+// ============ 定期清理 ============
+
+func (p *GyingPlugin) startCleanupTask() {
+ ticker := time.NewTicker(24 * time.Hour)
+ for range ticker.C {
+ deleted := p.cleanupExpiredUsers()
+ marked := p.markInactiveUsers()
+
+ if deleted > 0 || marked > 0 {
+ fmt.Printf("[Gying] 清理任务完成: 删除 %d 个过期用户, 标记 %d 个不活跃用户\n", deleted, marked)
}
}
-}
-
-// hasUpdatedFinalCache 检查是否已经更新过指定的最终结果缓存
-func (p *BaseAsyncPlugin) hasUpdatedFinalCache(updateKey string) bool {
- p.finalUpdateMutex.RLock()
- defer p.finalUpdateMutex.RUnlock()
- return p.finalUpdateTracker[updateKey]
}
-// markFinalCacheUpdated 标记已更新指定的最终结果缓存
-func (p *BaseAsyncPlugin) markFinalCacheUpdated(updateKey string) {
- p.finalUpdateMutex.Lock()
- defer p.finalUpdateMutex.Unlock()
- p.finalUpdateTracker[updateKey] = true
+func (p *GyingPlugin) cleanupExpiredUsers() int {
+ deletedCount := 0
+ now := time.Now()
+ expireThreshold := now.AddDate(0, 0, -30)
+
+ p.users.Range(func(key, value interface{}) bool {
+ user := value.(*User)
+ if user.Status == "expired" && user.LastAccessAt.Before(expireThreshold) {
+ if err := p.deleteUser(user.Hash); err == nil {
+ deletedCount++
+ }
+ }
+ return true
+ })
+
+ return deletedCount
}
-// ============================================================
-// 第十部分:序列化器
-// ============================================================
+func (p *GyingPlugin) markInactiveUsers() int {
+ markedCount := 0
+ now := time.Now()
+ inactiveThreshold := now.AddDate(0, 0, -90)
-// SetGlobalCacheSerializer 设置全局缓存序列化器(由主程序调用)
-func SetGlobalCacheSerializer(serializer interface {
- Serialize(interface{}) ([]byte, error)
- Deserialize([]byte, interface{}) error
-}) {
- globalCacheSerializer = serializer
+ p.users.Range(func(key, value interface{}) bool {
+ user := value.(*User)
+ if user.LastAccessAt.Before(inactiveThreshold) && user.Status != "expired" {
+ user.Status = "expired"
+ user.Cookie = ""
+
+ if err := p.saveUser(user); err == nil {
+ markedCount++
+ }
+ }
+ return true
+ })
+
+ return markedCount
}
-// getEnhancedCacheSerializer 获取增强缓存的序列化器
-func getEnhancedCacheSerializer() interface {
- Serialize(interface{}) ([]byte, error)
- Deserialize([]byte, interface{}) error
-} {
- return globalCacheSerializer
-}