新增插件nsgame

This commit is contained in:
www.xueximeng.com
2025-10-27 18:28:57 +08:00
parent 9beb4c6c0c
commit b514c5635b
4 changed files with 636 additions and 1 deletions

View File

@@ -38,7 +38,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
discourse,yunsou,ahhhhfs,nsgame
</pre>
</details>

View File

@@ -79,6 +79,7 @@ import (
_ "pansou/plugin/discourse"
_ "pansou/plugin/yunsou"
_ "pansou/plugin/ahhhhfs"
_ "pansou/plugin/nsgame"
)
// 全局缓存写入管理器

View File

@@ -0,0 +1,335 @@
# NSGame API JSON 结构分析
## 概述
NSGame (NS游戏网) 是一个专门提供 Nintendo Switch 游戏资源的搜索平台,提供 RESTful API 接口进行游戏资源搜索。本文档详细说明 NSGame API 的请求格式和响应结构。
## API 接口信息
### 请求地址
- **URL**: `https://nsthwj.com/thwj/game/query`
- **方法**: GET
- **参数**:
- `pageNum`: 页码从1开始
- `pageSize`: 每页大小建议100
- `type`: 游戏类型(可选,空字符串表示全部)
- `queryName`: 搜索关键词URL编码
### 请求示例
```
GET https://nsthwj.com/thwj/game/query?pageNum=1&pageSize=100&type=&queryName=%E9%A9%AC%E9%87%8C%E5%A5%A5
```
### 请求头设置
```http
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Referer: https://nsthwj.com/
```
## 响应数据结构
### 根级响应结构
```json
{
"success": true,
"data": {
"pageData": {
"totalCount": 27,
"pageNum": 0,
"data": []
},
"pageView": null
},
"code": "200",
"message": null
}
```
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `success` | boolean | 请求是否成功 |
| `data` | object | 响应数据对象 |
| `code` | string | 状态码,"200"表示成功 |
| `message` | string/null | 错误消息成功时为null |
### data 对象结构
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `pageData` | object | 分页数据对象 |
| `pageView` | null | 页面视图信息通常为null |
### pageData 对象结构
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `totalCount` | number | 搜索结果总数 |
| `pageNum` | number | 当前页码 |
| `data` | array | 游戏资源列表 |
### data 数组中的游戏资源项结构
每个游戏资源项包含以下字段:
```json
{
"name": "马里奥奥德赛|Super Mario Odyssey中文",
"url": "https://pan.baidu.com/s/1ZNTxWN-Vn7TUb6vq0QoIVA?pwd=thwj\n[夸克网盘]https://pan.quark.cn/s/2dab74360187\n[UC网盘]https://drive.uc.cn/s/843e8385fbb34",
"password": "最新版本1.4.1\n含1.4.1金手指"
}
```
| 字段名 | 类型 | 必填 | 示例值 | 说明 |
|--------|------|------|--------|------|
| `name` | string | ✓ | `"马里奥奥德赛\|Super Mario Odyssey中文"` | 游戏名称(中文\|英文) |
| `url` | string | ✓ | 多行链接文本 | 网盘链接(换行符分隔) |
| `password` | string | ✓ | `"最新版本1.4.1\n含1.4.1金手指"` | 版本信息和金手指说明 |
## 特殊数据格式说明
### 1. url 字段格式 ⭐ 重要
`url` 字段包含多个网盘链接,使用换行符 `\n` 分隔:
```
https://pan.baidu.com/s/1ZNTxWN-Vn7TUb6vq0QoIVA?pwd=thwj
[夸克网盘]https://pan.quark.cn/s/2dab74360187
[UC网盘]https://drive.uc.cn/s/843e8385fbb34
```
**格式规则**:
- **百度网盘**: 直接链接密码在URL参数中 `?pwd=xxxx`
- **夸克网盘**: 格式 `[夸克网盘]{链接}`,无密码
- **UC网盘**: 格式 `[UC网盘]{链接}`,无密码
**提取方法**:
1.`\n` 分割字符串
2. 逐行解析链接
3. 识别链接类型并提取
### 2. password 字段格式
`password` 字段实际上不是网盘提取码,而是游戏版本信息:
```
最新版本1.4.1
含1.4.1金手指
```
**内容说明**:
- 第一行:游戏的最新版本号
- 第二行:金手指信息(如果有)
### 3. name 字段格式
游戏名称使用竖线 `|` 分隔中英文:
```
马里奥奥德赛|Super Mario Odyssey中文
```
**格式规则**:
- 中文名称 `|` 英文名称
- 可能包含语言标识(中文、汉化等)
## 支持的网盘平台
| 平台标识 | 平台名称 | 域名特征 | 密码位置 |
|----------|----------|----------|----------|
| `baidu` | 百度网盘 | `pan.baidu.com` | URL参数 `?pwd=` |
| `quark` | 夸克网盘 | `pan.quark.cn` | 无密码 |
| `uc` | UC网盘 | `drive.uc.cn` | 无密码 |
## 插件实现要点
### 1. 插件配置
- **插件名称**: `nsgame`
- **优先级**: 建议设置为 2高质量游戏资源
- **Service层过滤**: 启用(标准资源搜索插件)
- **特点**: Nintendo Switch 游戏专属
### 2. 数据转换映射
| NSGame字段 | PanSou SearchResult字段 | 转换说明 |
|------------|-------------------------|----------|
| `name` | `UniqueID` | 格式:`nsgame-{游戏名hash}` |
| `name` | `Title` | 游戏名称(中英文) |
| `password` | `Content` | 版本信息和金手指说明 |
| - | `Datetime` | 使用当前时间 |
| `url` | `Links` | 解析多行链接文本 |
| - | `Tags` | 添加"NS游戏"、"Switch"标签 |
| - | `Channel` | 设置为空字符串(插件搜索结果) |
### 3. 链接解析逻辑
```go
// 解析 url 字段中的多个网盘链接
func parseMultipleLinks(urlText string) []model.Link {
var links []model.Link
// 按换行符分割
lines := strings.Split(urlText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 提取链接和类型
var url, cloudType, password string
if strings.Contains(line, "[夸克网盘]") {
// 夸克网盘格式
url = extractURL(line)
cloudType = "quark"
} else if strings.Contains(line, "[UC网盘]") {
// UC网盘格式
url = extractURL(line)
cloudType = "uc"
} else if strings.Contains(line, "pan.baidu.com") {
// 百度网盘格式
url, password = extractBaiduLink(line)
cloudType = "baidu"
}
if url != "" {
links = append(links, model.Link{
Type: cloudType,
URL: url,
Password: password,
})
}
}
return links
}
```
### 4. 百度网盘密码提取
百度网盘的密码在URL参数中需要单独提取
```go
// 从百度网盘URL中提取链接和密码
func extractBaiduLink(line string) (url, password string) {
// 提取完整URL
re := regexp.MustCompile(`https://pan\.baidu\.com/s/[^?\s]+(\?pwd=[a-zA-Z0-9]+)?`)
matches := re.FindStringSubmatch(line)
if len(matches) > 0 {
fullURL := matches[0]
// 提取密码参数
if strings.Contains(fullURL, "?pwd=") {
parts := strings.Split(fullURL, "?pwd=")
url = parts[0]
password = parts[1]
} else {
url = fullURL
}
}
return
}
```
### 5. 唯一ID生成
由于API返回数据没有唯一ID字段需要基于游戏名称生成
```go
import "crypto/md5"
func generateUniqueID(gameName string) string {
// 使用游戏名称的MD5哈希的前12位
hash := md5.Sum([]byte(gameName))
return fmt.Sprintf("nsgame-%x", hash)[:20]
}
```
## 错误处理
### 常见错误类型
1. **API请求失败**: 网络连接失败或服务器错误
2. **JSON解析错误**: 响应格式不符合预期
3. **链接格式异常**: url字段格式不符合预期
4. **空结果**: 关键词搜索无结果
### 容错机制
- **部分失败容忍**: 单个链接解析失败不影响其他链接
- **数据验证**: 验证必填字段存在性
- **默认值处理**: 缺失字段使用合理默认值
- **日志记录**: 详细记录异常情况
## 性能优化建议
1. **分页控制**: 默认每页100条避免过多请求
2. **缓存策略**: 游戏资源更新不频繁,可设置较长缓存时间
3. **超时设置**: 合理设置请求超时时间建议10秒
4. **连接复用**: 使用HTTP连接池
5. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高相关性
## 开发注意事项
1. **链接解析**: 正确处理url字段中的多行链接文本
2. **密码位置**: 百度网盘密码在URL参数中不在password字段
3. **版本信息**: password字段是版本信息应作为Content展示
4. **游戏名称**: name字段包含中英文用竖线分隔
5. **标签设置**: 添加"NS游戏"、"Switch"等标签帮助分类
6. **唯一ID**: 基于游戏名称生成稳定的唯一标识
7. **字符编码**: 确保正确处理中文字符
8. **请求头**: 设置合适的User-Agent避免反爬虫
## 示例代码结构
```go
// API响应结构
type NSGameResponse struct {
Success bool `json:"success"`
Data struct {
PageData struct {
TotalCount int `json:"totalCount"`
PageNum int `json:"pageNum"`
Data []NSGameItem `json:"data"`
} `json:"pageData"`
PageView interface{} `json:"pageView"`
} `json:"data"`
Code string `json:"code"`
Message interface{} `json:"message"`
}
// 游戏资源项
type NSGameItem struct {
Name string `json:"name"` // 游戏名称
URL string `json:"url"` // 网盘链接(多行文本)
Password string `json:"password"` // 版本信息
}
```
## API调用示例
### 搜索马里奥游戏
```bash
curl "https://nsthwj.com/thwj/game/query?pageNum=1&pageSize=100&type=&queryName=%E9%A9%AC%E9%87%8C%E5%A5%A5"
```
### 搜索塞尔达游戏
```bash
curl "https://nsthwj.com/thwj/game/query?pageNum=1&pageSize=100&type=&queryName=%E5%A1%9E%E5%B0%94%E8%BE%BE"
```
## 总结
NSGame API 的主要特点:
- ✅ 专注于 Nintendo Switch 游戏资源
- ✅ 支持多种主流网盘百度、夸克、UC
- ✅ 提供详细的版本信息和金手指说明
- ✅ 简单的分页接口设计
- ⚠️ url字段格式特殊需要特殊解析
- ⚠️ password字段不是提取码是版本信息
实现此插件的关键在于正确解析 `url` 字段中的多行链接文本,并正确识别各网盘类型和提取密码。

299
plugin/nsgame/nsgame.go Normal file
View File

@@ -0,0 +1,299 @@
package nsgame
import (
"context"
"crypto/md5"
"fmt"
"io"
"net/http"
"net/url"
"pansou/model"
"pansou/plugin"
"pansou/util/json"
"regexp"
"strings"
"time"
)
const (
// 插件名称
pluginName = "nsgame"
// API地址
apiURL = "https://nsthwj.com/thwj/game/query"
// 优先级
defaultPriority = 2
// 超时时间
defaultTimeout = 10 * time.Second
// 每页大小
pageSize = 1000
)
// 预编译的正则表达式
var (
// 提取URL的正则表达式
urlRegex = regexp.MustCompile(`https?://[^\s]+`)
// 百度网盘链接和密码提取
baiduLinkRegex = regexp.MustCompile(`https://pan\.baidu\.com/s/[^?\s]+`)
baiduPwdRegex = regexp.MustCompile(`\?pwd=([a-zA-Z0-9]+)`)
)
// NSGameAsyncPlugin NSGame异步插件
type NSGameAsyncPlugin struct {
*plugin.BaseAsyncPlugin
}
// NSGameResponse API响应结构
type NSGameResponse struct {
Success bool `json:"success"`
Data struct {
PageData struct {
TotalCount int `json:"totalCount"`
PageNum int `json:"pageNum"`
Data []NSGameItem `json:"data"`
} `json:"pageData"`
PageView interface{} `json:"pageView"`
} `json:"data"`
Code string `json:"code"`
Message interface{} `json:"message"`
}
// NSGameItem 游戏资源项
type NSGameItem struct {
Name string `json:"name"` // 游戏名称
URL string `json:"url"` // 网盘链接(多行文本)
Password string `json:"password"` // 版本信息
}
// 在init函数中注册插件
func init() {
plugin.RegisterGlobalPlugin(NewNSGamePlugin())
}
// NewNSGamePlugin 创建新的NSGame异步插件
func NewNSGamePlugin() *NSGameAsyncPlugin {
return &NSGameAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
}
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *NSGameAsyncPlugin) 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 *NSGameAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
// searchImpl 实现具体的搜索逻辑
func (p *NSGameAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 1. 构建搜索URL
searchURL := fmt.Sprintf("%s?pageNum=1&pageSize=%d&type=&queryName=%s",
apiURL, pageSize, url.QueryEscape(keyword))
// 2. 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
// 3. 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 4. 设置请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://nsthwj.com/")
// 5. 发送请求(带重试机制)
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 6. 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err)
}
// 7. 解析JSON响应
var apiResp NSGameResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
// 8. 检查响应状态
if !apiResp.Success || apiResp.Code != "200" {
return nil, fmt.Errorf("[%s] API返回错误: success=%v, code=%s", p.Name(), apiResp.Success, apiResp.Code)
}
// 9. 转换为标准格式
var results []model.SearchResult
for _, item := range apiResp.Data.PageData.Data {
// 解析网盘链接
links := p.parseLinks(item.URL)
if len(links) == 0 {
continue
}
// 生成唯一ID
uniqueID := p.generateUniqueID(item.Name)
// 将版本信息拼接到标题中
title := item.Name
if item.Password != "" {
// 将换行符替换为空格,使标题更紧凑
versionInfo := strings.ReplaceAll(item.Password, "\n", " ")
title = fmt.Sprintf("%s%s", item.Name, versionInfo)
}
// 构建结果
result := model.SearchResult{
UniqueID: uniqueID,
Title: title, // 标题包含版本信息
Content: item.Password, // 保留原始版本信息在Content中
Links: links,
Tags: []string{"NS游戏", "Switch"},
Channel: "", // 插件搜索结果 Channel 必须为空
Datetime: time.Now(),
}
results = append(results, result)
}
// 10. 关键词过滤
return plugin.FilterResultsByKeyword(results, keyword), nil
}
// parseLinks 解析url字段中的多个网盘链接
func (p *NSGameAsyncPlugin) parseLinks(urlText string) []model.Link {
var links []model.Link
// 按换行符分割
lines := strings.Split(urlText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 判断链接类型并提取
if strings.Contains(line, "[夸克网盘]") {
// 夸克网盘格式: [夸克网盘]https://pan.quark.cn/s/xxx
if url := p.extractURL(line); url != "" && strings.Contains(url, "pan.quark.cn") {
links = append(links, model.Link{
Type: "quark",
URL: url,
Password: "",
})
}
} else if strings.Contains(line, "[UC网盘]") {
// UC网盘格式: [UC网盘]https://drive.uc.cn/s/xxx
if url := p.extractURL(line); url != "" && strings.Contains(url, "drive.uc.cn") {
links = append(links, model.Link{
Type: "uc",
URL: url,
Password: "",
})
}
} else if strings.Contains(line, "pan.baidu.com") {
// 百度网盘格式: https://pan.baidu.com/s/xxx?pwd=xxxx
url, password := p.extractBaiduLink(line)
if url != "" {
links = append(links, model.Link{
Type: "baidu",
URL: url,
Password: password,
})
}
}
}
return links
}
// extractURL 从文本中提取URL
func (p *NSGameAsyncPlugin) extractURL(text string) string {
matches := urlRegex.FindString(text)
return strings.TrimSpace(matches)
}
// extractBaiduLink 从百度网盘链接中提取URL和密码
func (p *NSGameAsyncPlugin) extractBaiduLink(line string) (url, password string) {
// 提取完整URL
fullURL := urlRegex.FindString(line)
if fullURL == "" {
return
}
// 提取基础链接
linkMatches := baiduLinkRegex.FindString(fullURL)
if linkMatches == "" {
return
}
url = linkMatches
// 提取密码
pwdMatches := baiduPwdRegex.FindStringSubmatch(fullURL)
if len(pwdMatches) >= 2 {
password = pwdMatches[1]
}
return
}
// generateUniqueID 基于游戏名称生成唯一ID
func (p *NSGameAsyncPlugin) generateUniqueID(gameName string) string {
// 使用MD5哈希生成稳定的唯一ID
hash := md5.Sum([]byte(gameName))
return fmt.Sprintf("%s-%x", p.Name(), hash)[:28]
}
// doRequestWithRetry 带重试机制的HTTP请求
func (p *NSGameAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 指数退避重试
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求避免并发问题
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}