From 50d3e5466be150347e3617317f0c8e18272fc3d0 Mon Sep 17 00:00:00 2001 From: "www.xueximeng.com" Date: Tue, 28 Oct 2025 18:50:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6gying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- main.go | 1 + plugin/gying/README.md | 304 ++++++ plugin/gying/gying.go | 1977 ++++++++++++++++++++++++++++++++++ plugin/gying/html结构分析.md | 377 +++++++ 5 files changed, 2663 insertions(+), 1 deletion(-) create mode 100644 plugin/gying/README.md create mode 100644 plugin/gying/gying.go create mode 100644 plugin/gying/html结构分析.md diff --git a/README.md b/README.md index b39b0f3..5825f8e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ PanSou 还提供了一个基于 [Model Context Protocol (MCP)](https://modelcont [常见问题总结](https://github.com/fish2018/pansou/issues/46) [TG资源频道列表](https://github.com/fish2018/pansou/issues/4) + +[gying插件文档](https://github.com/fish2018/pansou/blob/main/plugin/gying/README.md) +
插件列表(请务必按需加载)
@@ -38,7 +41,7 @@ susu,thepiratebay,wanou,xuexizhinan,panyq,zhizhen,labi,muou,ouge,shandian,
 duoduo,huban,cyg,erxiao,miaoso,fox4k,pianku,clmao,wuji,cldi,xiaozhang,
 libvio,leijing,xb6v,xys,ddys,hdmoli,yuhuage,u3c3,javdb,clxiong,jutoushe,
 sdso,xiaoji,xdyh,haisou,bixin,djgou,nyaa,xinjuc,aikanzy,qupanshe,xdpan,
-discourse,yunsou,ahhhhfs,nsgame
+discourse,yunsou,ahhhhfs,nsgame,gying
 
diff --git a/main.go b/main.go index 88f114f..bb0676d 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( // 以下是插件的空导入,用于触发各插件的init函数,实现自动注册 // 添加新插件时,只需在此处添加对应的导入语句即可 _ "pansou/plugin/hdr4k" + _ "pansou/plugin/gying" _ "pansou/plugin/pan666" _ "pansou/plugin/hunhepan" _ "pansou/plugin/jikepan" diff --git a/plugin/gying/README.md b/plugin/gying/README.md new file mode 100644 index 0000000..355d1d7 --- /dev/null +++ b/plugin/gying/README.md @@ -0,0 +1,304 @@ +# Gying 搜索插件 + +## 📖 简介 + +Gying是PanSou的搜索插件,用于从 www.gying.net 网站搜索影视资源。支持多用户登录并配置账户,在搜索时自动聚合所有用户的搜索结果。 + +## ✨ 核心特性 + +- ✅ **多用户支持** - 每个用户独立配置,互不干扰 +- ✅ **用户名密码登录** - 支持使用用户名和密码登录 +- ✅ **智能去重** - 多用户搜索时自动去重 +- ✅ **负载均衡** - 任务均匀分配,避免单用户限流 +- ✅ **内存缓存** - 用户数据缓存到内存,搜索性能极高 +- ✅ **持久化存储** - Cookie和用户配置自动保存,重启不丢失 +- ✅ **Web管理界面** - 一站式配置,简单易用 +- ✅ **RESTful API** - 支持程序化调用 +- ✅ **默认账户自动登录** - 插件启动时自动使用默认账户登录 + +## 🚀 快速开始 + +### 步骤1: 启动服务 + +```bash +cd /Users/macbookpro/Desktop/fish2018/pansou +go run main.go + +# 或者编译后运行 +go build -o pansou main.go +./pansou +``` + +### 步骤2: 访问管理页面 + +如果需要添加更多账户或管理现有账户,可以访问管理页面: + +``` +http://localhost:8888/gying/你的用户名 +``` + +**示例**: +``` +http://localhost:8888/gying/myusername +``` + +系统会自动: +1. 根据用户名生成专属64位hash(不可逆) +2. 重定向到专属管理页面:`http://localhost:8888/gying/{hash}` +3. 显示登录表单供手动登录 + +**📌 提示**:请收藏hash后的URL(包含你的专属hash),方便下次访问。 + +### 步骤3: 手动登录 + +在"登录状态"区域输入: +- 用户名 +- 密码 + +点击"**登录**"按钮。 + +### 步骤4: 开始搜索 + +在PanSou主页搜索框输入关键词,系统会**自动聚合所有用户**的Gying搜索结果! + +```bash +# 通过API搜索 +curl "http://localhost:8888/api/search?kw=遮天" + +# 只搜索插件(包括gying) +curl "http://localhost:8888/api/search?kw=遮天&src=plugin" +``` + +## 📡 API文档 + +### 统一接口 + +所有操作通过统一的POST接口: + +``` +POST /gying/{hash} +Content-Type: application/json + +{ + "action": "操作类型", + ...其他参数 +} +``` + +### API列表 + +| Action | 说明 | 需要登录 | +|--------|------|---------| +| `get_status` | 获取状态 | ❌ | +| `login` | 登录 | ❌ | +| `logout` | 退出登录 | ✅ | +| `test_search` | 测试搜索 | ✅ | + +--- + +### 1️⃣ get_status - 获取用户状态 + +**请求**: +```bash +curl -X POST "http://localhost:8888/gying/{hash}" \ + -H "Content-Type: application/json" \ + -d '{"action": "get_status"}' +``` + +**成功响应(已登录)**: +```json +{ + "success": true, + "message": "获取成功", + "data": { + "hash": "abc123...", + "logged_in": true, + "status": "active", + "username_masked": "pa****ou", + "login_time": "2025-10-28 12:00:00", + "expire_time": "2026-02-26 12:00:00", + "expires_in_days": 121 + } +} +``` + +**成功响应(未登录)**: +```json +{ + "success": true, + "message": "获取成功", + "data": { + "hash": "abc123...", + "logged_in": false, + "status": "pending" + } +} +``` + +--- + +### 2️⃣ login - 登录 + +**请求**: +```bash +curl -X POST "http://localhost:8888/gying/{hash}" \ + -H "Content-Type: application/json" \ + -d '{"action": "login", "username": "xxx", "password": "xxx"}' +``` + +**成功响应**: +```json +{ + "success": true, + "message": "登录成功", + "data": { + "status": "active", + "username_masked": "pa****ou" + } +} +``` + +**失败响应**: +```json +{ + "success": false, + "message": "登录失败: 用户名或密码错误" +} +``` + +--- + +### 3️⃣ logout - 退出登录 + +**请求**: +```bash +curl -X POST "http://localhost:8888/gying/{hash}" \ + -H "Content-Type: application/json" \ + -d '{"action": "logout"}' +``` + +**成功响应**: +```json +{ + "success": true, + "message": "已退出登录", + "data": { + "status": "pending" + } +} +``` + +--- + +### 4️⃣ test_search - 测试搜索 + +**请求**: +```bash +curl -X POST "http://localhost:8888/gying/{hash}" \ + -H "Content-Type: application/json" \ + -d '{"action": "test_search", "keyword": "遮天"}' +``` + +**成功响应**: +```json +{ + "success": true, + "message": "找到 5 条结果", + "data": { + "keyword": "遮天", + "total_results": 5, + "results": [ + { + "title": "遮天:禁区", + "links": [ + { + "type": "quark", + "url": "https://pan.quark.cn/s/89f7aeef9681", + "password": "" + } + ] + } + ] + } +} +``` + +--- + +## 🔧 配置说明 + +### 环境变量(可选) + +```bash +# Hash Salt(推荐自定义,增强安全性) +export GYING_HASH_SALT="your-custom-salt-here" + +# Cookie加密密钥(32字节,推荐自定义) +export GYING_ENCRYPTION_KEY="your-32-byte-key-here!!!!!!!!!!" +``` + +### 代码内配置 + +在 `gying.go` 第20-24行修改: + +```go +const ( + MaxConcurrentUsers = 10 // 最多使用的用户数(搜索时) + MaxConcurrentDetails = 50 // 最大并发详情请求数 + DebugLog = false // 调试日志开关 +) +``` + +### 默认账户配置 + +在 `gying.go` 第27-32行修改默认账户: + +```go +var DefaultAccounts = []struct { + Username string + Password string +}{ + // 可以添加更多默认账户 + // {"user2", "password2"}, +} +``` + +**参数说明**: + +| 参数 | 默认值 | 说明 | 建议 | +|------|--------|------|------| +| `MaxConcurrentUsers` | 10 | 单次搜索最多使用的用户数 | 10-20足够 | +| `MaxConcurrentDetails` | 50 | 最大并发详情请求数 | 50-100 | +| `DebugLog` | false | 是否开启调试日志 | 生产环境false | + +## 📂 数据存储 + +### 存储位置 + +``` +cache/gying_users/{hash}.json +``` + +### 数据结构 + +```json +{ + "hash": "abc123...", + "username": "pansou", + "username_masked": "pa****ou", + "cookie": "BT_auth=xxx; BT_cookietime=xxx", + "status": "active", + "created_at": "2025-10-28T12:00:00+08:00", + "login_at": "2025-10-28T12:00:00+08:00", + "expire_at": "2026-02-26T12:00:00+08:00", + "last_access_at": "2025-10-28T13:00:00+08:00" +} +``` + +**字段说明**: +- `hash`: 用户唯一标识(SHA256,不可逆推用户名) +- `username`: 原始用户名(存储) +- `username_masked`: 脱敏用户名(如`pa****ou`) +- `cookie`: 登录Cookie(明文存储,建议配置加密) +- `status`: 用户状态(`pending`/`active`/`expired`) +- `expire_at`: Cookie过期时间(121天) \ No newline at end of file diff --git a/plugin/gying/gying.go b/plugin/gying/gying.go new file mode 100644 index 0000000..15be49c --- /dev/null +++ b/plugin/gying/gying.go @@ -0,0 +1,1977 @@ +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" + "time" + "unsafe" + + "github.com/gin-gonic/gin" + "pansou/model" + "pansou/plugin" + "pansou/util/json" + + cloudscraper "github.com/Advik-B/cloudscraper/lib" +) + +// 插件配置参数 +const ( + MaxConcurrentUsers = 10 // 最多使用的用户数 + MaxConcurrentDetails = 50 // 最大并发详情请求数 + DebugLog = false // 调试日志开关(排查问题时改为true) +) + +// 默认账户配置(可通过Web界面添加更多账户) +// 用户数据会保存到文件,重启后自动恢复 +var DefaultAccounts = []struct { + Username string + Password string +}{ + // 请使用 Web 接口添加用户: + // POST /gying/add_user?username=xxx&password=xxx +} + +// 存储目录 +var StorageDir string + +// 初始化存储目录 +func init() { + cachePath := os.Getenv("CACHE_PATH") + if cachePath == "" { + cachePath = "./cache" + } + + StorageDir = filepath.Join(cachePath, "gying_users") + + if err := os.MkdirAll(StorageDir, 0755); err != nil { + fmt.Printf("⚠️ 警告: 无法创建Gying存储目录 %s: %v\n", StorageDir, err) + } else { + fmt.Printf("✓ Gying存储目录: %s\n", StorageDir) + } +} + +// 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), + } + + // 初始化存储目录 + if err := os.MkdirAll(StorageDir, 0755); err != nil { + fmt.Printf("[Gying] 创建存储目录失败: %v\n", err) + return + } + + // 加载所有用户到内存 + p.loadAllUsers() + + // 异步初始化默认账户(不阻塞启动) + go func() { + // 延迟1秒,等待主程序完全启动 + time.Sleep(1 * time.Second) + p.initDefaultAccounts() + }() + + // 启动定期清理任务 + go p.startCleanupTask() + + 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) + + 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 + } + return result.Results, nil +} + +// SearchWithResult 执行搜索并返回包含IsFinal标记的结果 +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)) + } + + if len(users) == 0 { + if DebugLog { + fmt.Printf("[Gying] 没有有效用户,返回空结果\n") + } + return model.PluginSearchResult{Results: []model.SearchResult{}, IsFinal: true}, nil + } + + // 2. 限制用户数量 + if len(users) > MaxConcurrentUsers { + sort.Slice(users, func(i, j int) bool { + return users[i].LastAccessAt.After(users[j].LastAccessAt) + }) + 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 +} + +// ============ 用户管理 ============ + +// 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() { + // 步骤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 + }) + + 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") + } + } +} + +// 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) + } +} + +// 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 + } + + 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 + } + + // 保存用户 + 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(), + } + + 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 + } + + // 执行搜索 + results, err := p.searchWithScraper(keyword, scraper) + 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 { + 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 + } + + // 创建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") + } + } + } + + 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实例(每个用户独立的实例) + scraper, err := cloudscraper.New() + 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创建成功\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 +} + +// ============ 搜索逻辑 ============ + +// 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) + } + + // 为用户创建新的cloudscraper实例 + newScraper, err := cloudscraper.New() + 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实例\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.searchWithScraper(keyword, scraper) + 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) +} + +// searchWithCookie 使用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, 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) + } + } + + // 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. 并发请求详情接口 + results := p.fetchAllDetails(&searchData, scraper) + + if DebugLog { + fmt.Printf("[Gying] fetchAllDetails 返回 %d 条结果\n", len(results)) + fmt.Printf("[Gying] ---------- searchWithCookie 结束 ----------\n") + } + + return results, nil +} + +// fetchAllDetails 并发获取所有详情 +func (p *GyingPlugin) fetchAllDetails(searchData *SearchData, scraper *cloudscraper.Scraper) []model.SearchResult { + 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) + + successCount := 0 + failCount := 0 + + for i := 0; i < len(searchData.L.I); i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + + semaphore <- struct{}{} + defer func() { <-semaphore }() + + 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) + } + 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() + + if DebugLog { + fmt.Printf("[Gying] <<< fetchAllDetails 完成: 成功=%d, 失败=%d, 总计=%d\n", + successCount, failCount, len(searchData.L.I)) + } + + return results +} + +// 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) + } + + if resp.StatusCode != 200 { + 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)) + } + + 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 +} + +// ============ 定期清理 ============ + +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) + } + } +} + +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) + + 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 +} + diff --git a/plugin/gying/html结构分析.md b/plugin/gying/html结构分析.md new file mode 100644 index 0000000..6c6f266 --- /dev/null +++ b/plugin/gying/html结构分析.md @@ -0,0 +1,377 @@ +# Gying 网站结构分析 + +## 基本信息 +- **网站URL**: https://www.gying.net +- **数据源类型**: 混合型(HTML + JSON API) +- **特殊架构**: 需要登录 + 搜索结果在HTML内嵌JSON + 详情接口返回JSON +- **支持多账户**: 是(支持负载均衡搜索) + +## 登录认证 + +### 登录接口 +- **URL**: `https://www.gying.net/user/login` +- **方法**: POST +- **Content-Type**: `application/x-www-form-urlencoded` + +### 登录请求参数 +``` +code=&siteid=1&dosubmit=1&cookietime=10506240&username={用户名}&password={密码} +``` + +| 参数 | 说明 | 示例值 | +|------|------|--------| +| `code` | 验证码(可为空) | `` | +| `siteid` | 站点ID(固定) | `1` | +| `dosubmit` | 提交标识(固定) | `1` | +| `cookietime` | Cookie有效期(秒) | `10506240` (约121天) | +| `username` | 用户名 | `xxx` | +| `password` | 密码 | `xxx` | + +### 登录响应 +```json +{"code":200} +``` + +### 登录Cookie +- **BT_auth**: 认证Cookie(HttpOnly, Secure, 121天有效期) + ``` + BT_auth=433cnQGx2Obm5YAMWnGaG-ZCcuma9JvULO1CSvPz7JzBhj3-t4HhwhSXrxaEVO53lSVoFtT_0-Ilzglvh0vFvv7RLqFfPdE17Maen0B3sWPwnO5GSQszEW9ZyjOU4KLx8TuRvDj3mF7bVVX4rgtgOq9gP0ljq_X-APtIPf3tkliblls + ``` +- **BT_cookietime**: Cookie时间标识 + ``` + BT_cookietime=a9f5uPN9hZE-fXuzGhTxM8Vh6K5BUIVqeg4ESRHGbcU3jM7ZuuIB + ``` + +### 重要请求头 +``` +User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 +Accept: */* +Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 +Content-Type: application/x-www-form-urlencoded +Origin: https://www.gying.net +Referer: https://www.gying.net/user/login/ +``` + +## 搜索接口 + +### 搜索URL +- **格式**: `https://www.gying.net/s/1---1/{关键词}` +- **方法**: GET +- **关键词**: 需要URL编码(如:`遮天` -> `%E9%81%AE%E5%A4%A9`) + +### 搜索响应格式 +搜索结果返回HTML页面,但实际数据在JavaScript变量 `_obj.search` 中: + +```javascript +_obj.search = { + "q": "遮天", // 搜索关键词 + "wd": ["天","遮"], // 分词结果 + "n": "14", // 结果数量(字符串) + "ns": [14,6,4,4,35], // 各类型结果统计 + "ty": 0, // 类型标识 + "l": { // 详细信息列表 + "daoyan": [...], // 导演 + "bianju": [...], // 编剧 + "zhuyan": [...], // 主演 + "info": [...], // 信息(地区/类型等) + "pf": {...}, // 平台评分(豆瓣/IMDb) + "title": [...], // 标题 + "name": [...], // 名称(英文等) + "ename": [...], // 别名 + "year": [...], // 年份 + "d": [...], // 类型(mv=电影,ac=动画,tv=电视剧) + "i": [...] // 资源ID(用于详情页) + } +} +``` + +### 搜索结果字段映射 + +| 字段 | 说明 | 示例 | 用途 | +|------|------|------|------| +| `l.i` | 资源ID数组 | `["xJe3", "rzoj", ...]` | 用于构建详情接口URL | +| `l.title` | 标题数组 | `["遮天:禁区", ...]` | 显示标题 | +| `l.year` | 年份数组 | `[2023, 2023, ...]` | 年份标签 | +| `l.d` | 类型数组 | `["mv", "ac", "tv"]` | 资源类型 | +| `l.info` | 信息数组 | `["大陆 / 动作 / 冒险 / 奇幻", ...]` | 描述信息 | +| `l.daoyan` | 导演数组 | `["罗乐", ...]` | 导演信息 | +| `l.zhuyan` | 主演数组 | `["冯荔军 / 彭高唱 / ...", ...]` | 主演信息 | + +### 类型标识(d字段) +- `mv`: 电影 +- `ac`: 动画 +- `tv`: 电视剧 + +## 详情接口 + +### 详情URL +- **格式**: `https://www.gying.net/res/downurl/{类型}/{资源ID}` +- **示例**: `https://www.gying.net/res/downurl/mv/xJe3` +- **方法**: GET +- **认证**: 需要登录Cookie + +### 详情响应结构 +```json +{ + "code": 200, + "wp": false, // 是否需要网盘 + "downlist": { // 下载列表 + "imdb": "", // IMDb ID + "type": { + "a": ["1080P", "中字1080P", "中字4K"], // 清晰度类型数组 + "b": ["i3", "i7", "i4"] // 类型标识数组 + }, + "hex": "a0a74991cb03e4d43bb6564018c46c4034edff3cf4e32f356f735744258cbe5e", + "list": { // 下载文件列表 + "m": ["hash1", "hash2", ...], // 文件hash数组 + "t": ["文件名1.mkv", "文件名2.mkv", ...], // 文件名数组 + "s": ["999.46M", "4.87G", ...], // 文件大小数组 + "e": [3, 0, 2, ...], // 编码类型 + "p": ["i3", "i4", "i7", ...], // 类型标识 + "u": ["短链1", "短链2", ...], // 短链接数组 + "k": [0, 0, 0, ...], // 密码标识(0=无密码) + "n": ["1年前", "2年前", ...] // 上传时间 + } + }, + "playlist": [...], // 播放列表(在线播放) + "panlist": { // 网盘链接列表 + "id": ["lYPNk", "oJ858", ...], // 网盘分享ID + "name": ["标题1", "标题2", ...], // 分享标题 + "p": ["", "", "917d", ...], // 提取码数组 + "url": [ // 分享链接数组 + "https://pan.quark.cn/s/89f7aeef9681", + "https://cloud.189.cn/t\/3aQbiynAzEVn(访问码:7dsf)", + "https://pan.baidu.com/s/1B_BnI7IDtQexYiytiZXOwg?pwd=917d", + ... + ], + "type": [2, 3, 0, ...], // 网盘类型标识 + "user": ["沸羊羊爱分享", "大狗熊A", ...], // 分享用户 + "gid": [5, 4, 4, ...], // 用户组ID + "time": ["7天前", "12天前", ...], // 分享时间 + "e": [0, 0, 0, ...], // 过期标识 + "heart": [0, 0, 0, ...], // 点赞数 + "tname": ["百度网盘", "迅雷网盘", ...] // 网盘类型名称数组 + } +} +``` + +### 网盘类型标识(panlist.type) +| 标识 | 网盘类型 | 说明 | +|-----|---------|------| +| `0` | 百度网盘 | baidu | +| `1` | 迅雷网盘 | xunlei | +| `2` | 夸克网盘 | quark | +| `3` | 天翼网盘 | tianyi | +| `4` | UC网盘 | uc | +| `5` | 阿里网盘 | aliyun | + +### 提取码处理 +- 提取码在 `panlist.p` 数组中 +- 如果URL中包含 `?pwd=` 或 `访问码:`,优先从URL提取 +- 如果 `panlist.p` 为空字符串,则无提取码 + +## 插件所需字段映射 + +### SearchResult构建 +```go +result := model.SearchResult{ + UniqueID: fmt.Sprintf("gying-%s-%s", resourceType, resourceID), // 如 gying-mv-xJe3 + Title: title, // 从 l.title + Content: buildContent(info, director, actors), // 组合信息 + Links: extractPanLinks(panlist), // 从详情接口获取 + Tags: []string{year}, // 从 l.year + Channel: "", // 插件搜索结果Channel为空 + Datetime: time.Now(), // 当前时间 +} +``` + +### 链接提取逻辑 +从 `panlist` 中提取,需要处理: +1. 识别网盘类型(通过type标识或URL域名) +2. 提取提取码(优先从URL,其次从p数组) +3. 过滤无效链接(空URL或过期) +4. 去重(同一URL只保留一次) + +## 支持的网盘类型 + +### 主流网盘 +- **quark (夸克网盘)**: `https://pan.quark.cn/s/{分享码}` +- **baidu (百度网盘)**: `https://pan.baidu.com/s/{分享码}?pwd={密码}` +- **aliyun (阿里云盘)**: `https://www.alipan.com/s/{分享码}` +- **uc (UC网盘)**: `https://drive.uc.cn/s/{分享码}` +- **xunlei (迅雷网盘)**: `https://pan.xunlei.com/s/{分享码}` +- **tianyi (天翼云盘)**: `https://cloud.189.cn/t/{分享码}` + +## 插件开发指导 + +### 登录管理策略 +参考QQPD插件实现: +1. **初始化登录**: 插件启动时,从缓存加载已登录用户 +2. **Cookie持久化**: 将Cookie保存到 `cache/gying_users/{hash}.json` +3. **多账户支持**: 支持配置多个账户,进行负载均衡 +4. **Web管理界面**: 提供 `/gying/:param` 路由管理账户 + +### 用户数据结构 +```json +{ + "hash": "用户hash(SHA256)", + "username": "用户名(脱敏)", + "cookie": "BT_auth=xxx; BT_cookietime=xxx", + "status": "active/pending/expired", + "created_at": "2025-10-28T12:00:00+08:00", + "login_at": "2025-10-28T12:00:00+08:00", + "expire_at": "2026-02-26T12:00:00+08:00", // 121天后 + "last_access_at": "2025-10-28T13:00:00+08:00" +} +``` + +### 搜索流程 +``` +1. 用户搜索 "遮天" + ↓ +2. 获取所有有效用户(status=active) + ↓ +3. 负载均衡分配任务(每个用户处理部分搜索) + ↓ +4. 并发执行搜索: + a. 使用用户Cookie请求搜索页面 + b. 提取HTML中的 _obj.search JSON数据 + c. 遍历 l.i 数组,并发请求详情接口 + d. 解析网盘链接 + ↓ +5. 合并所有用户的结果 + ↓ +6. 去重并返回 +``` + +### 关键函数示例 + +#### 登录函数 +```go +func (p *GyingPlugin) login(username, password string) (string, error) { + data := url.Values{} + data.Set("code", "") + data.Set("siteid", "1") + data.Set("dosubmit", "1") + data.Set("cookietime", "10506240") // 121天 + data.Set("username", username) + data.Set("password", password) + + req, _ := http.NewRequest("POST", "https://www.gying.net/user/login", + strings.NewReader(data.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "Mozilla/5.0...") + + resp, err := client.Do(req) + // ... 处理响应 + + // 提取Cookie + cookies := resp.Cookies() + var btAuth, btCookietime string + for _, cookie := range cookies { + if cookie.Name == "BT_auth" { + btAuth = cookie.Value + } else if cookie.Name == "BT_cookietime" { + btCookietime = cookie.Value + } + } + + return fmt.Sprintf("BT_auth=%s; BT_cookietime=%s", btAuth, btCookietime), nil +} +``` + +#### 搜索函数 +```go +func (p *GyingPlugin) searchWithCookie(keyword, cookie string) ([]model.SearchResult, error) { + // 1. 请求搜索页面 + searchURL := fmt.Sprintf("https://www.gying.net/s/1---1/%s", url.QueryEscape(keyword)) + req, _ := http.NewRequest("GET", searchURL, nil) + req.Header.Set("Cookie", cookie) + req.Header.Set("User-Agent", "Mozilla/5.0...") + + resp, err := client.Do(req) + // ... 处理响应 + + // 2. 提取 _obj.search JSON + body, _ := ioutil.ReadAll(resp.Body) + re := regexp.MustCompile(`_obj\.search=(\{.*?\});`) + matches := re.FindSubmatch(body) + if len(matches) < 2 { + return nil, fmt.Errorf("未找到搜索结果") + } + + var searchData SearchData + json.Unmarshal(matches[1], &searchData) + + // 3. 并发请求详情接口 + var results []model.SearchResult + for i, resourceID := range searchData.L.I { + // 并发获取详情 + detail := p.fetchDetail(resourceID, searchData.L.D[i], cookie) + result := p.buildResult(detail, searchData, i) + results = append(results, result) + } + + return results, nil +} +``` + +#### 详情获取函数 +```go +func (p *GyingPlugin) fetchDetail(resourceID, resourceType, cookie string) (*DetailData, error) { + detailURL := fmt.Sprintf("https://www.gying.net/res/downurl/%s/%s", resourceType, resourceID) + req, _ := http.NewRequest("GET", detailURL, nil) + req.Header.Set("Cookie", cookie) + req.Header.Set("User-Agent", "Mozilla/5.0...") + + resp, err := client.Do(req) + // ... 处理响应 + + var detail DetailData + json.NewDecoder(resp.Body).Decode(&detail) + return &detail, nil +} +``` + +## 注意事项 + +1. **登录验证**: 每次请求前验证Cookie是否有效,失效则重新登录 +2. **并发控制**: 控制详情接口的并发数,避免触发反爬虫(建议50并发) +3. **错误处理**: 处理网络超时、JSON解析失败等异常情况 +4. **提取码处理**: 优先从URL中提取提取码,兼容多种格式 +5. **去重逻辑**: 同一资源可能有多个网盘链接,需要去重 +6. **Cookie刷新**: Cookie有效期121天,接近过期时提前刷新 +7. **多账户负载**: 当用户数大于1时,均匀分配搜索任务 + +## 与其他插件的差异 + +| 特性 | gying | qqpd | huban | +|------|-------|------|-------| +| **认证方式** | 用户名密码 | QQ扫码 | 无需登录 | +| **数据格式** | HTML内嵌JSON + 详情API | API | JSON API | +| **多账户** | 支持 | 支持 | 不支持 | +| **Cookie管理** | 需要 | 需要 | 不需要 | +| **负载均衡** | 支持 | 支持 | 不支持 | + +## 开发建议 + +1. **分步实现**: + - 先实现单账户登录和搜索 + - 再扩展多账户支持 + - 最后添加Web管理界面 + +2. **测试重点**: + - Cookie失效后的自动重新登录 + - 并发详情请求的稳定性 + - 多账户负载均衡的正确性 + +3. **性能优化**: + - 缓存搜索结果(5分钟) + - 批量并发请求详情接口 + - 复用HTTP连接 + +4. **容错机制**: + - 单个详情请求失败不影响整体 + - Cookie失效时自动降级到其他账户 + - 网络异常时自动重试3次 +