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搜索配置 + + + +
+
+

🔍 PanSou Gying搜索

+

配置你的专属搜索服务

+

+ 🔗 当前地址: HASH_PLACEHOLDER +

+
+ +
+
🔐 登录状态
+ + + + +
+ +
+
🔍 测试搜索(限制返回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 -}