新增插件yingso,yunso,zlxapp,zxzj

This commit is contained in:
www.xueximeng.com
2026-09-01 13:53:24 +08:00
parent ec1429fa4a
commit ddb8db2e2d
13 changed files with 2211 additions and 29 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ ENV CACHE_PATH=/app/cache \
ASYNC_MAX_BACKGROUND_TASKS=100 \
ASYNC_CACHE_TTL_HOURS=1 \
CHANNELS=tgsearchers6,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,MCPH02,MCPH03,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,pikpakpan,Q_dongman,yoyokuakeduanju,TG654TG,WFYSFX02,QukanMovie,yeqingjie_GJG666,movielover8888_film3,Baidu_netdisk,D_wusun,FLMdongtianfudi,KaiPanshare,QQZYDAPP,rjyxfx,PikPak_Share_Channel,btzhi,newproductsourcing,cctv1211,duan_ju,QuarkFree,yunpanNB,kkdj001,xxzlzn,pxyunpanxunlei,jxwpzy,kuakedongman,liangxingzhinan,xiangnikanj,solidsexydoll,guoman4K,zdqxm,kduanju,cilidianying,CBduanju,SharePanFilms,dzsgx,BooksRealm,Oscar_4Kmovies,douerpan,baidu_yppan,Q_jilupian,Netdisk_Movies,yunpanquark,ammmziyuan,ciliziyuanku,cili8888,jzmm_123pan,wpan8,mqte5,regengguangya,yunpanguangya,regeng115,regeng123,yoyokuakeduanjujiaoliuqun,yy80986098,pan_guangya,guangyapan_episode,AV688 \
ENABLED_PLUGINS=dyyjpro,duoduo,feikuai,gaoqing888,gying,hunhepan,ikantv,jutoushe,kkv,lingjisp,lou1,melost,nyaa,ouge,panlian,qqpd,quark4k,quarksoo,quarktv,qupanshe,sousou,thepiratebay,wanou,weibo,xb6v,xiaozhang,yunso,zxzj,miosou,ting77,libvio,dy4k,xiaoyu \
ENABLED_PLUGINS=dyyjpro,duoduo,feikuai,gaoqing888,gying,haitunsou,hunhepan,ikantv,jutoushe,kkv,dy4k,libvio,lingjisp,lou1,melost,miosou,nyaa,ouge,panlian,qqpd,quark4k,quarksoo,quarktv,qupanshe,sousou,thepiratebay,ting77,wanou,weibo,xb6v,xiaokupan,xiaozhang,xiaoyu,yingso,yunso,zlxapp,zxzj \
AUTH_ENABLED=false \
AUTH_TOKEN_EXPIRY=24
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- PORT=8888
- CHANNELS=tgsearchers6,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,MCPH02,MCPH03,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,Q_dongman,yoyokuakeduanju,TG654TG,WFYSFX02,QukanMovie,yeqingjie_GJG666,movielover8888_film3,Baidu_netdisk,D_wusun,FLMdongtianfudi,KaiPanshare,QQZYDAPP,rjyxfx,PikPak_Share_Channel,btzhi,newproductsourcing,cctv1211,duan_ju,QuarkFree,yunpanNB,kkdj001,xxzlzn,pxyunpanxunlei,jxwpzy,kuakedongman,liangxingzhinan,xiangnikanj,solidsexydoll,guoman4K,zdqxm,kduanju,cilidianying,CBduanju,SharePanFilms,dzsgx,BooksRealm,Oscar_4Kmovies,douerpan,baidu_yppan,Q_jilupian,Netdisk_Movies,yunpanquark,ammmziyuan,ciliziyuanku,cili8888,jzmm_123pan,Q_dianying,domgmingapk,dianying4k,q_dianshiju,tgbokee,ucshare,godupan,gokuapan,gimy115,WFYSFX03,peccxin,Movie888035,xlwpzy,zyywpzy,wydwpzy,gimy100,ucshare,gimy115iso,wpan8,mqte5,regengguangya,yunpanguangya,regeng115,regeng123,yoyokuakeduanjujiaoliuqun,yy80986098,pan_guangya,guangyapan_episode,AV688
# 必须指定启用的插件,多个插件用逗号分隔
- ENABLED_PLUGINS=dyyjpro,duoduo,feikuai,gaoqing888,gying,hunhepan,ikantv,jutoushe,kkv,dy4k,libvio,lingjisp,lou1,melost,miosou,nyaa,ouge,panlian,qqpd,quark4k,quarksoo,quarktv,qupanshe,sousou,thepiratebay,ting77,wanou,weibo,xb6v,xiaozhang,xiaoyu,yunso,zxzj,miosou,ting77,libvio,dy4k,xiaoyu
- ENABLED_PLUGINS=dyyjpro,duoduo,feikuai,gaoqing888,gying,haitunsou,hunhepan,ikantv,jutoushe,kkv,dy4k,libvio,lingjisp,lou1,melost,miosou,nyaa,ouge,panlian,qqpd,quark4k,quarksoo,quarktv,qupanshe,sousou,thepiratebay,ting77,wanou,weibo,xb6v,xiaokupan,xiaozhang,xiaoyu,yingso,yunso,zlxapp,zxzj
- CACHE_ENABLED=true
- CACHE_PATH=/app/cache
- CACHE_MAX_SIZE=100
+5
View File
@@ -31,6 +31,7 @@ import (
_ "pansou/plugin/feikuai"
_ "pansou/plugin/gaoqing888"
_ "pansou/plugin/gying"
_ "pansou/plugin/haitunsou"
_ "pansou/plugin/hunhepan"
_ "pansou/plugin/ikantv"
_ "pansou/plugin/jutoushe"
@@ -54,9 +55,13 @@ import (
_ "pansou/plugin/wanou"
_ "pansou/plugin/weibo"
_ "pansou/plugin/xb6v"
_ "pansou/plugin/xdpan"
_ "pansou/plugin/xiaokupan"
_ "pansou/plugin/xiaoyu"
_ "pansou/plugin/xiaozhang"
_ "pansou/plugin/yingso"
_ "pansou/plugin/yunso"
_ "pansou/plugin/zlxapp"
_ "pansou/plugin/zxzj"
)
+368
View File
@@ -0,0 +1,368 @@
package haitunsou
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf16"
"unicode/utf8"
"pansou/model"
"pansou/plugin"
jsonutil "pansou/util/json"
)
const (
pluginName = "haitunsou"
defaultBaseURL = "https://www.haitunsou.com"
defaultPriority = 2
requestTimeout = 25 * time.Second
maxResponseSize = 3 << 20
timeLayout = "2006-01-02"
)
var listMarkers = [][]byte{
[]byte("const list = JSON.parse('"),
[]byte("let listItems = JSON.parse('"),
}
type HaitunsouPlugin struct {
*plugin.BaseAsyncPlugin
baseURL string
}
type searchItem struct {
ID int64 `json:"id"`
SourceCategoryID int64 `json:"source_category_id"`
Title string `json:"title"`
IsType int `json:"is_type"`
Code string `json:"code"`
URL string `json:"url"`
Name string `json:"name"`
Times string `json:"times"`
Category interface{} `json:"category"`
}
func init() {
plugin.RegisterGlobalPlugin(NewHaitunsouPlugin())
}
func NewHaitunsouPlugin() *HaitunsouPlugin {
return &HaitunsouPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
baseURL: defaultBaseURL,
}
}
func (p *HaitunsouPlugin) 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
}
func (p *HaitunsouPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
func (p *HaitunsouPlugin) searchImpl(client *http.Client, keyword string, _ map[string]interface{}) ([]model.SearchResult, error) {
keyword = cleanText(keyword)
if keyword == "" {
return []model.SearchResult{}, nil
}
if client == nil {
client = http.DefaultClient
}
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
searchURL := fmt.Sprintf("%s/s/%s.html", strings.TrimRight(p.baseURL, "/"), url.PathEscape(keyword))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建搜索请求失败: %w", p.Name(), err)
}
setRequestHeaders(req, p.baseURL)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("[%s] 搜索请求返回 HTTP %d", p.Name(), resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return nil, fmt.Errorf("[%s] 读取搜索响应失败: %w", p.Name(), err)
}
if len(body) > maxResponseSize {
return nil, fmt.Errorf("[%s] 搜索响应超过 %d 字节", p.Name(), maxResponseSize)
}
items, err := parseEmbeddedList(body)
if err != nil {
return nil, fmt.Errorf("[%s] 解析搜索结果失败: %w", p.Name(), err)
}
results := make([]model.SearchResult, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
result, ok := convertItem(item)
if !ok {
continue
}
key := result.Links[0].URL + "\x00" + result.Links[0].Password
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
results = append(results, result)
}
return plugin.FilterResultsByKeyword(results, keyword), nil
}
func setRequestHeaders(req *http.Request, baseURL string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Referer", strings.TrimRight(baseURL, "/")+"/")
}
func parseEmbeddedList(body []byte) ([]searchItem, error) {
start := -1
for _, marker := range listMarkers {
if index := indexBytes(body, marker); index >= 0 {
start = index + len(marker)
break
}
}
if start < 0 {
return nil, fmt.Errorf("未找到页面内嵌结果")
}
encoded, err := scanSingleQuotedString(body, start)
if err != nil {
return nil, err
}
decoded, err := decodeJSSingleQuotedString(encoded)
if err != nil {
return nil, err
}
var items []searchItem
if err := jsonutil.Unmarshal(decoded, &items); err != nil {
return nil, fmt.Errorf("解析结果 JSON 失败: %w", err)
}
return items, nil
}
func indexBytes(data, marker []byte) int {
return bytes.Index(data, marker)
}
func scanSingleQuotedString(body []byte, start int) ([]byte, error) {
escaped := false
for index := start; index < len(body); index++ {
if escaped {
escaped = false
continue
}
switch body[index] {
case '\\':
escaped = true
case '\'':
return body[start:index], nil
}
}
return nil, fmt.Errorf("内嵌结果字符串未闭合")
}
func decodeJSSingleQuotedString(encoded []byte) ([]byte, error) {
decoded := make([]byte, 0, len(encoded))
for index := 0; index < len(encoded); index++ {
if encoded[index] != '\\' {
decoded = append(decoded, encoded[index])
continue
}
index++
if index >= len(encoded) {
return nil, fmt.Errorf("字符串以转义符结尾")
}
switch encoded[index] {
case '\\', '\'', '"', '/':
decoded = append(decoded, encoded[index])
case 'b':
decoded = append(decoded, '\b')
case 'f':
decoded = append(decoded, '\f')
case 'n':
decoded = append(decoded, '\n')
case 'r':
decoded = append(decoded, '\r')
case 't':
decoded = append(decoded, '\t')
case 'v':
decoded = append(decoded, '\v')
case '0':
decoded = append(decoded, 0)
case '\n':
// JavaScript line continuation.
case '\r':
if index+1 < len(encoded) && encoded[index+1] == '\n' {
index++
}
case 'x':
value, next, err := decodeHexEscape(encoded, index+1, 2)
if err != nil {
return nil, err
}
decoded = utf8.AppendRune(decoded, rune(value))
index = next - 1
case 'u':
value, next, err := decodeHexEscape(encoded, index+1, 4)
if err != nil {
return nil, err
}
r := rune(value)
index = next - 1
if r >= 0xD800 && r <= 0xDBFF && next+6 <= len(encoded) && encoded[next] == '\\' && encoded[next+1] == 'u' {
low, lowNext, lowErr := decodeHexEscape(encoded, next+2, 4)
if lowErr == nil && low >= 0xDC00 && low <= 0xDFFF {
r = utf16.DecodeRune(r, rune(low))
index = lowNext - 1
}
}
decoded = utf8.AppendRune(decoded, r)
default:
decoded = append(decoded, encoded[index])
}
}
return decoded, nil
}
func decodeHexEscape(data []byte, start, length int) (uint64, int, error) {
end := start + length
if start < 0 || end > len(data) {
return 0, start, fmt.Errorf("转义序列不完整")
}
value, err := strconv.ParseUint(string(data[start:end]), 16, 16)
if err != nil {
return 0, start, fmt.Errorf("无效十六进制转义: %w", err)
}
return value, end, nil
}
func convertItem(item searchItem) (model.SearchResult, bool) {
rawTitle := cleanText(item.Title)
if rawTitle == "" {
rawTitle = cleanText(item.Name)
}
if rawTitle == "" {
return model.SearchResult{}, false
}
title, description := splitTitleAndDescription(rawTitle)
if title == "" {
return model.SearchResult{}, false
}
linkType, normalizedURL, password := normalizeLink(item.URL, item.Code, item.IsType)
if normalizedURL == "" {
return model.SearchResult{}, false
}
datetime := time.Now()
if parsed, err := time.ParseInLocation(timeLayout, strings.TrimSpace(item.Times), time.Local); err == nil {
datetime = parsed
}
content := cleanText(item.Name)
if content == "" {
content = description
}
if content == "" {
content = rawTitle
}
uniqueID := ""
if item.ID > 0 {
uniqueID = fmt.Sprintf("%s-%d", pluginName, item.ID)
} else {
uniqueID = fmt.Sprintf("%s-%x", pluginName, sha256.Sum256([]byte(normalizedURL)))
}
return model.SearchResult{
MessageID: uniqueID,
UniqueID: uniqueID,
Channel: "",
Datetime: datetime,
Title: title,
Content: content,
Links: []model.Link{{
Type: linkType,
URL: normalizedURL,
Password: password,
Datetime: datetime,
WorkTitle: title,
}},
Tags: []string{linkType},
}, true
}
func splitTitleAndDescription(value string) (string, string) {
value = strings.TrimSpace(value)
for _, prefix := range []string{"资源标题:", "资源标题:"} {
value = strings.TrimSpace(strings.TrimPrefix(value, prefix))
}
for _, marker := range []string{"资源描述:", "资源描述:"} {
if index := strings.Index(value, marker); index >= 0 {
return strings.TrimSpace(value[:index]), strings.TrimSpace(value[index+len(marker):])
}
}
return value, ""
}
func normalizeLink(rawURL, declaredPassword string, declaredType int) (string, string, string) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", "", ""
}
host := strings.ToLower(parsed.Hostname())
linkType := ""
switch {
case host == "pan.quark.cn":
linkType = "quark"
case host == "www.aliyundrive.com" || host == "aliyundrive.com" || host == "www.alipan.com" || host == "alipan.com":
linkType = "aliyun"
case host == "pan.baidu.com":
linkType = "baidu"
case host == "drive.uc.cn":
linkType = "uc"
case host == "pan.xunlei.com":
linkType = "xunlei"
default:
return "", "", ""
}
declaredTypes := map[int]string{0: "quark", 1: "aliyun", 2: "baidu", 3: "uc", 4: "xunlei"}
if expected, exists := declaredTypes[declaredType]; exists && expected != linkType {
return "", "", ""
}
password := strings.TrimSpace(declaredPassword)
if password == "" {
for _, key := range []string{"pwd", "password", "code"} {
if value := strings.TrimSpace(parsed.Query().Get(key)); value != "" {
password = value
break
}
}
}
return linkType, parsed.String(), password
}
func cleanText(value string) string {
return strings.Join(strings.Fields(html.UnescapeString(strings.TrimSpace(value))), " ")
}
+95
View File
@@ -0,0 +1,95 @@
package haitunsou
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func TestParseEmbeddedListAndConvert(t *testing.T) {
html := `<script>const list = JSON.parse('[{"id":5581,"title":"资源标题:仙逆\u7279\'别篇资源描述:更新说明\n第二行","is_type":0,"code":null,"url":"https:\/\/pan.quark.cn\/s\/abc","name":"仙逆特别篇","times":"2026-09-01","category":null}]');</script>`
items, err := parseEmbeddedList([]byte(html))
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %#v", items)
}
result, ok := convertItem(items[0])
if !ok {
t.Fatal("expected item to convert")
}
if result.UniqueID != "haitunsou-5581" || result.Channel != "" || result.Title != "仙逆特'别篇" {
t.Fatalf("unexpected result: %#v", result)
}
if result.Links[0].Type != "quark" || result.Links[0].URL != "https://pan.quark.cn/s/abc" {
t.Fatalf("unexpected link: %#v", result.Links[0])
}
}
func TestSearchImpl(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.EscapedPath() != "/s/%E4%BB%99%E9%80%86.html" {
t.Errorf("unexpected path: %s", r.URL.EscapedPath())
}
fmt.Fprint(w, `<script>const list = JSON.parse('[{"id":1,"title":"资源标题:仙逆 4K资源描述:最新更新","is_type":0,"code":null,"url":"https:\/\/pan.quark.cn\/s\/abc","name":"仙逆 4K","times":"2026-09-01","category":null},{"id":2,"title":"无关内容","is_type":0,"code":null,"url":"https:\/\/pan.quark.cn\/s\/other","name":"无关内容","times":"2026-09-01","category":null}]');</script>`)
}))
defer server.Close()
p := NewHaitunsouPlugin()
p.baseURL = server.URL
results, err := p.searchImpl(server.Client(), " 仙逆 ", nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Title != "仙逆 4K" {
t.Fatalf("unexpected filtered results: %#v", results)
}
}
func TestDecodeJSSingleQuotedStringEscapes(t *testing.T) {
decoded, err := decodeJSSingleQuotedString([]byte(`A\x42\u4ed9\uD83D\uDE00\'`))
if err != nil {
t.Fatal(err)
}
if got := string(decoded); got != "AB仙😀'" {
t.Fatalf("unexpected decoded string: %q", got)
}
}
func TestNormalizeLinkRejectsMismatchedType(t *testing.T) {
if _, normalized, _ := normalizeLink("https://pan.baidu.com/s/abc", "", 0); normalized != "" {
t.Fatalf("expected mismatched link type to be rejected, got %s", normalized)
}
linkType, normalized, password := normalizeLink("https://pan.baidu.com/s/abc?pwd=6666", "", 2)
if linkType != "baidu" || normalized == "" || password != "6666" {
t.Fatalf("unexpected normalized link: %s %s %s", linkType, normalized, password)
}
}
func TestLiveSearch(t *testing.T) {
if os.Getenv("HAITUNSOU_LIVE_TEST") != "1" {
t.Skip("set HAITUNSOU_LIVE_TEST=1 to query haitunsou.com")
}
keyword := os.Getenv("HAITUNSOU_LIVE_KEYWORD")
if keyword == "" {
keyword = "仙逆"
}
p := NewHaitunsouPlugin()
results, err := p.searchImpl(&http.Client{Timeout: 30 * time.Second}, keyword, nil)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Fatal("live search returned no results")
}
if results[0].Channel != "" || len(results[0].Links) == 0 || !strings.HasPrefix(results[0].Links[0].URL, "http") {
t.Fatalf("live search returned invalid result: %#v", results[0])
}
t.Logf("live search for %q returned %d results; first: %q (%s)", keyword, len(results), results[0].Title, results[0].Links[0].URL)
}
+67 -27
View File
@@ -3,6 +3,7 @@ package xdpan
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
@@ -16,10 +17,11 @@ import (
)
const (
BaseURL = "https://xiongdipan.com"
BaseURL = "https://aipanso.com"
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
MaxConcurrency = 10 // 详情页最大并发数
MaxRetries = 3
maxPageSize = 3 << 20
)
var (
@@ -31,6 +33,7 @@ type XdpanPlugin struct {
*plugin.BaseAsyncPlugin
detailCache sync.Map // 详情页缓存
cacheTTL time.Duration
baseURL string
}
// NewXdpanPlugin 创建兄弟盘插件实例
@@ -38,6 +41,7 @@ func NewXdpanPlugin() *XdpanPlugin {
return &XdpanPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("xdpan", 3), // 优先级3 = 普通质量数据源
cacheTTL: 60 * time.Minute,
baseURL: BaseURL,
}
}
@@ -75,6 +79,7 @@ func (p *XdpanPlugin) searchImpl(client *http.Client, keyword string, ext map[st
// Step 2: 并发获取详情页信息(获取真实的百度网盘链接)
p.enrichWithDetailInfo(client, searchResults)
searchResults = filterResultsWithLinks(searchResults)
// Step 3: 关键词过滤
filteredResults := plugin.FilterResultsByKeyword(searchResults, keyword)
@@ -88,7 +93,7 @@ func (p *XdpanPlugin) searchImpl(client *http.Client, keyword string, ext map[st
// fetchSearchResults 获取搜索结果
func (p *XdpanPlugin) fetchSearchResults(client *http.Client, keyword string) ([]model.SearchResult, error) {
// 构建搜索URL(只获取第一页)
searchURL := fmt.Sprintf("%s/search?page=1&k=%s", BaseURL, url.QueryEscape(keyword))
searchURL := fmt.Sprintf("%s/search?page=1&k=%s&p=baidu", strings.TrimRight(p.baseURL, "/"), url.QueryEscape(keyword))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -115,12 +120,16 @@ func (p *XdpanPlugin) fetchSearchResults(client *http.Client, keyword string) ([
}
// 解析HTML
doc, err := goquery.NewDocumentFromReader(resp.Body)
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxPageSize))
if err != nil {
return nil, fmt.Errorf("解析HTML失败: %w", err)
}
return p.extractSearchResults(doc), nil
results := p.extractSearchResults(doc)
if len(results) == 0 && doc.Find("title").First().Text() == "Just a moment..." {
return nil, fmt.Errorf("站点触发 Cloudflare 浏览器验证")
}
return results, nil
}
// extractSearchResults 从搜索页面提取结果
@@ -158,7 +167,7 @@ func (p *XdpanPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult
detailPath, _ := detailLink.Attr("href")
var detailURL string
if detailPath != "" {
detailURL = BaseURL + detailPath
detailURL = strings.TrimRight(p.baseURL, "/") + detailPath
}
// 提取资源ID
@@ -201,9 +210,12 @@ func (p *XdpanPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult
shareTime = matches[1]
}
formatRegex := regexp.MustCompile(`格式:\s*<b>([^<]+)</b>`)
if matches := formatRegex.FindStringSubmatch(bottomText); len(matches) > 1 {
fileType = matches[1]
fileType = strings.TrimSpace(s.Find("template b").First().Text())
if fileType == "" {
formatRegex := regexp.MustCompile(`格式:\s*([^\s]+)`)
if matches := formatRegex.FindStringSubmatch(bottomText); len(matches) > 1 {
fileType = matches[1]
}
}
// 解析时间
@@ -252,6 +264,11 @@ func (p *XdpanPlugin) enrichWithDetailInfo(client *http.Client, results []model.
if detailURL != "" {
links := p.fetchDetailPageLinks(client, detailURL)
if len(links) > 0 {
for linkIndex := range links {
if links[linkIndex].WorkTitle == "" {
links[linkIndex].WorkTitle = results[index].Title
}
}
results[index].Links = links
if DebugLog {
fmt.Printf("[xdpan] 获取详情页链接成功: %s, 链接数: %d\n", detailURL, len(links))
@@ -289,7 +306,7 @@ func (p *XdpanPlugin) fetchDetailPageLinks(client *http.Client, detailURL string
p.setRequestHeaders(req)
resp, err := client.Do(req)
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return []model.Link{}
}
@@ -299,7 +316,7 @@ func (p *XdpanPlugin) fetchDetailPageLinks(client *http.Client, detailURL string
return []model.Link{}
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxPageSize))
if err != nil {
return []model.Link{}
}
@@ -331,14 +348,19 @@ func (p *XdpanPlugin) extractDetailPageLinks(doc *goquery.Document) []model.Link
// 从JavaScript代码中提取百度网盘链接
doc.Find("script").Each(func(i int, s *goquery.Selection) {
scriptContent := s.Text()
// 查找onDownload函数中的window.open链接
re := regexp.MustCompile(`window\.open\("([^"]*pan\.baidu\.com[^"]*)"`)
matches := re.FindStringSubmatch(scriptContent)
if len(matches) > 1 {
// 兼容 window.open 和 location.href 两种详情页跳转写法。
patterns := []*regexp.Regexp{
regexp.MustCompile(`window\.open\(\s*["']([^"']*pan\.baidu\.com[^"']*)["']`),
regexp.MustCompile(`(?:window\.)?location\.href\s*=\s*["']([^"']*pan\.baidu\.com[^"']*)["']`),
}
for _, pattern := range patterns {
matches := pattern.FindStringSubmatch(scriptContent)
if len(matches) <= 1 {
continue
}
baiduURL := matches[1]
// 如果链接中没有密码参数,但我们从页面中提取到了密码,则添加密码参数
if !strings.Contains(baiduURL, "pwd=") && password != "" {
separator := "?"
@@ -347,16 +369,17 @@ func (p *XdpanPlugin) extractDetailPageLinks(doc *goquery.Document) []model.Link
}
baiduURL = fmt.Sprintf("%s%spwd=%s", baiduURL, separator, password)
}
links = append(links, model.Link{
URL: baiduURL,
Type: "baidu",
Password: password,
})
if DebugLog {
fmt.Printf("[xdpan] 提取到百度网盘链接: %s, 密码: %s\n", baiduURL, password)
}
break
}
})
@@ -418,23 +441,30 @@ func (p *XdpanPlugin) doRequestWithRetry(req *http.Request, client *http.Client)
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
if err != nil {
lastErr = err
continue
}
if strings.EqualFold(resp.Header.Get("cf-mitigated"), "challenge") {
resp.Body.Close()
return nil, fmt.Errorf("站点 %s 已迁移并启用 Cloudflare 浏览器验证,当前服务端请求无法通过", p.baseURL)
}
if resp.StatusCode == http.StatusOK {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
resp.Body.Close()
}
if lastErr == nil {
lastErr = fmt.Errorf("未知请求错误")
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", MaxRetries, lastErr)
}
// setRequestHeaders 设置请求头
func (p *XdpanPlugin) setRequestHeaders(req *http.Request) {
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Referer", BaseURL+"/")
req.Header.Set("Referer", strings.TrimRight(p.baseURL, "/")+"/?p=baidu")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
@@ -447,6 +477,16 @@ type cacheItem struct {
timestamp time.Time
}
func filterResultsWithLinks(results []model.SearchResult) []model.SearchResult {
filtered := make([]model.SearchResult, 0, len(results))
for _, result := range results {
if len(result.Links) > 0 {
filtered = append(filtered, result)
}
}
return filtered
}
func init() {
p := NewXdpanPlugin()
plugin.RegisterGlobalPlugin(p)
+71
View File
@@ -0,0 +1,71 @@
package xdpan
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"pansou/model"
)
func TestSearchImplUsesMigratedBaiduRoute(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
if r.URL.Query().Get("k") != "仙逆" || r.URL.Query().Get("p") != "baidu" || r.URL.Query().Get("page") != "1" {
t.Errorf("unexpected search query: %s", r.URL.RawQuery)
}
fmt.Fprint(w, `<html><body><van-row><a href="/s/test-id"><van-col><van-card><template><div name="content-title"><span>仙</span><span>逆</span><span> 4K</span></div><div>时间: 2026-09-01 格式: <b>文件夹</b></div></template></van-card></van-col></a></van-row></body></html>`)
case "/s/test-id":
fmt.Fprint(w, `<html><body><van-cell title="密码"><b>6666</b></van-cell><script>function onDownload(){window.open('https://pan.baidu.com/s/abc')}</script></body></html>`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
p := NewXdpanPlugin()
p.baseURL = server.URL
results, err := p.searchImpl(server.Client(), "仙逆", nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %#v", results)
}
result := results[0]
if result.Channel != "" || result.Title != "仙逆4K" || len(result.Links) != 1 {
t.Fatalf("unexpected result: %#v", result)
}
if result.Links[0].Type != "baidu" || result.Links[0].Password != "6666" || result.Links[0].WorkTitle != "仙逆4K" || !strings.Contains(result.Links[0].URL, "pwd=6666") {
t.Fatalf("unexpected link: %#v", result.Links[0])
}
}
func TestFetchSearchResultsReportsCloudflareChallenge(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("cf-mitigated", "challenge")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `<title>Just a moment...</title>`)
}))
defer server.Close()
p := NewXdpanPlugin()
p.baseURL = server.URL
_, err := p.fetchSearchResults(server.Client(), "仙逆")
if err == nil || !strings.Contains(err.Error(), "Cloudflare") {
t.Fatalf("expected Cloudflare error, got %v", err)
}
}
func TestFilterResultsWithLinks(t *testing.T) {
results := filterResultsWithLinks([]model.SearchResult{
{Title: "invalid"},
{Title: "valid", Links: []model.Link{{URL: "https://pan.baidu.com/s/abc"}}},
})
if len(results) != 1 || results[0].Title != "valid" {
t.Fatalf("unexpected filtered results: %#v", results)
}
}
+430
View File
@@ -0,0 +1,430 @@
package xiaokupan
import (
"context"
"crypto/sha256"
stdjson "encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"pansou/model"
"pansou/plugin"
)
const (
pluginName = "xiaokupan"
defaultBaseURL = "https://xiaokupan.com"
defaultServerFunctionID = "ffb7ba806a267ced7478dc27716e79ea729a98a801af2ac9c3647bdaca91af78"
defaultPriority = 2
requestTimeout = 30 * time.Second
maxSearchResponseSize = 4 << 20
maxDiscoveryBodySize = 8 << 20
)
var (
indexAssetPattern = regexp.MustCompile(`/assets/index-[A-Za-z0-9_-]+\.js`)
hashPattern = regexp.MustCompile(`[a-f0-9]{64}`)
whitespacePattern = regexp.MustCompile(`\s+`)
)
type XiaokupanPlugin struct {
*plugin.BaseAsyncPlugin
baseURL string
serverFunctionMu sync.RWMutex
serverFunctionID string
}
type serovalNode struct {
Type int `json:"t"`
ID *int `json:"i,omitempty"`
Scalar stdjson.RawMessage `json:"s,omitempty"`
Props *serovalProps `json:"p,omitempty"`
Array []*serovalNode `json:"a,omitempty"`
}
type serovalProps struct {
Keys []string `json:"k"`
Values []*serovalNode `json:"v"`
}
type serovalDecoder struct {
references map[int]*serovalNode
}
func init() {
plugin.RegisterGlobalPlugin(NewXiaokupanPlugin())
}
func NewXiaokupanPlugin() *XiaokupanPlugin {
return &XiaokupanPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
baseURL: defaultBaseURL,
serverFunctionID: defaultServerFunctionID,
}
}
func (p *XiaokupanPlugin) 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
}
func (p *XiaokupanPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
func (p *XiaokupanPlugin) searchImpl(client *http.Client, keyword string, _ map[string]interface{}) ([]model.SearchResult, error) {
keyword = cleanText(keyword)
if keyword == "" {
return []model.SearchResult{}, nil
}
if client == nil {
client = http.DefaultClient
}
functionID := p.currentServerFunctionID()
results, err := p.searchWithFunctionID(client, keyword, functionID)
if err != nil {
refreshedID, refreshErr := p.refreshServerFunctionID(client, functionID)
if refreshErr != nil {
return nil, fmt.Errorf("[%s] 搜索失败: %v;刷新接口标识失败: %w", p.Name(), err, refreshErr)
}
results, err = p.searchWithFunctionID(client, keyword, refreshedID)
}
if err != nil {
return nil, fmt.Errorf("[%s] 搜索失败: %w", p.Name(), err)
}
return plugin.FilterResultsByKeyword(results, keyword), nil
}
func (p *XiaokupanPlugin) searchWithFunctionID(client *http.Client, keyword, functionID string) ([]model.SearchResult, error) {
payload, err := buildSearchPayload(keyword)
if err != nil {
return nil, fmt.Errorf("构造搜索参数失败: %w", err)
}
endpoint := fmt.Sprintf("%s/_serverFn/%s", strings.TrimRight(p.baseURL, "/"), functionID)
parsedEndpoint, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("解析搜索地址失败: %w", err)
}
query := parsedEndpoint.Query()
query.Set("payload", string(payload))
parsedEndpoint.RawQuery = query.Encode()
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedEndpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("创建搜索请求失败: %w", err)
}
p.setSearchHeaders(req, keyword)
body, err := doLimitedRequest(client, req, maxSearchResponseSize)
if err != nil {
return nil, err
}
return parseSearchResponse(body)
}
func buildSearchPayload(keyword string) ([]byte, error) {
payload := map[string]interface{}{
"t": map[string]interface{}{
"t": 10,
"i": 0,
"p": map[string]interface{}{
"k": []string{"data"},
"v": []interface{}{map[string]interface{}{
"t": 10,
"i": 1,
"p": map[string]interface{}{
"k": []string{"query"},
"v": []interface{}{map[string]interface{}{
"t": 1,
"s": keyword,
}},
},
"o": 0,
}},
},
"o": 0,
},
"f": 63,
"m": []interface{}{},
}
return stdjson.Marshal(payload)
}
func (p *XiaokupanPlugin) setSearchHeaders(req *http.Request, keyword string) {
base := strings.TrimRight(p.baseURL, "/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/x-tss-framed, application/x-ndjson, application/json")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Origin", base)
req.Header.Set("Referer", base+"/s/"+url.PathEscape(keyword))
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("x-tsr-serverFn", "true")
}
func doLimitedRequest(client *http.Client, req *http.Request, limit int64) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("响应超过 %d 字节", limit)
}
return body, nil
}
func (p *XiaokupanPlugin) currentServerFunctionID() string {
p.serverFunctionMu.RLock()
defer p.serverFunctionMu.RUnlock()
return p.serverFunctionID
}
func (p *XiaokupanPlugin) refreshServerFunctionID(client *http.Client, staleID string) (string, error) {
p.serverFunctionMu.Lock()
defer p.serverFunctionMu.Unlock()
if p.serverFunctionID != "" && p.serverFunctionID != staleID {
return p.serverFunctionID, nil
}
discoveredID, err := p.discoverServerFunctionID(client)
if err != nil {
return "", err
}
p.serverFunctionID = discoveredID
return discoveredID, nil
}
func (p *XiaokupanPlugin) discoverServerFunctionID(client *http.Client) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
homeURL := strings.TrimRight(p.baseURL, "/") + "/"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, homeURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
homeBody, err := doLimitedRequest(client, req, maxDiscoveryBodySize)
if err != nil {
return "", fmt.Errorf("读取首页失败: %w", err)
}
assetPath := string(indexAssetPattern.Find(homeBody))
if assetPath == "" {
return "", fmt.Errorf("首页未找到入口脚本")
}
assetReq, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(p.baseURL, "/")+assetPath, nil)
if err != nil {
return "", err
}
assetReq.Header.Set("User-Agent", req.Header.Get("User-Agent"))
assetReq.Header.Set("Referer", homeURL)
assetBody, err := doLimitedRequest(client, assetReq, maxDiscoveryBodySize)
if err != nil {
return "", fmt.Errorf("读取入口脚本失败: %w", err)
}
routeIndex := strings.Index(string(assetBody), "/s/$query")
if routeIndex < 0 {
return "", fmt.Errorf("入口脚本未找到搜索路由")
}
windowStart := max(0, routeIndex-2048)
hashes := hashPattern.FindAll(assetBody[windowStart:routeIndex], -1)
if len(hashes) == 0 {
return "", fmt.Errorf("入口脚本未找到搜索接口标识")
}
return string(hashes[len(hashes)-1]), nil
}
func parseSearchResponse(body []byte) ([]model.SearchResult, error) {
var root serovalNode
if err := stdjson.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("解析 Seroval 响应失败: %w", err)
}
decoder := newSerovalDecoder(&root)
resultNode := decoder.objectValue(&root, "result")
searchResultsNode := decoder.objectValue(resultNode, "searchResults")
mergedNode := decoder.objectValue(searchResultsNode, "merged_by_type")
mergedNode = decoder.resolve(mergedNode)
if mergedNode == nil || mergedNode.Props == nil {
return nil, fmt.Errorf("响应缺少 merged_by_type")
}
results := make([]model.SearchResult, 0)
seenURLs := make(map[string]struct{})
for index, linkType := range mergedNode.Props.Keys {
if index >= len(mergedNode.Props.Values) {
break
}
arrayNode := decoder.resolve(mergedNode.Props.Values[index])
if arrayNode == nil || arrayNode.Type != 9 {
continue
}
for _, itemNode := range arrayNode.Array {
resourceURL := strings.TrimSpace(decoder.stringValue(decoder.objectValue(itemNode, "url")))
if !validResourceURL(resourceURL, linkType) {
continue
}
if _, exists := seenURLs[resourceURL]; exists {
continue
}
note := cleanText(decoder.stringValue(decoder.objectValue(itemNode, "note")))
if note == "" {
continue
}
password := strings.TrimSpace(decoder.stringValue(decoder.objectValue(itemNode, "password")))
source := cleanText(decoder.stringValue(decoder.objectValue(itemNode, "source")))
datetime := parseRFC3339(decoder.stringValue(decoder.objectValue(itemNode, "datetime")))
images := decoder.stringArray(decoder.objectValue(itemNode, "images"))
uniqueID := fmt.Sprintf("%s-%x", pluginName, sha256.Sum256([]byte(resourceURL)))
tags := []string{linkType}
if source != "" {
tags = append(tags, source)
}
results = append(results, model.SearchResult{
MessageID: uniqueID,
UniqueID: uniqueID,
Channel: "",
Datetime: datetime,
Title: note,
Content: note,
Links: []model.Link{{
Type: linkType,
URL: resourceURL,
Password: password,
Datetime: datetime,
WorkTitle: note,
}},
Tags: tags,
Images: images,
})
seenURLs[resourceURL] = struct{}{}
}
}
return results, nil
}
func newSerovalDecoder(root *serovalNode) *serovalDecoder {
decoder := &serovalDecoder{references: make(map[int]*serovalNode)}
decoder.collect(root)
return decoder
}
func (d *serovalDecoder) collect(node *serovalNode) {
if node == nil {
return
}
if node.ID != nil {
d.references[*node.ID] = node
}
if node.Props != nil {
for _, child := range node.Props.Values {
d.collect(child)
}
}
for _, child := range node.Array {
d.collect(child)
}
}
func (d *serovalDecoder) resolve(node *serovalNode) *serovalNode {
for depth := 0; node != nil && node.Type == 4 && depth < 16; depth++ {
var referenceID int
if err := stdjson.Unmarshal(node.Scalar, &referenceID); err != nil {
return nil
}
node = d.references[referenceID]
}
return node
}
func (d *serovalDecoder) objectValue(node *serovalNode, key string) *serovalNode {
node = d.resolve(node)
if node == nil || node.Props == nil {
return nil
}
for index, candidate := range node.Props.Keys {
if candidate == key && index < len(node.Props.Values) {
return d.resolve(node.Props.Values[index])
}
}
return nil
}
func (d *serovalDecoder) stringValue(node *serovalNode) string {
node = d.resolve(node)
if node == nil || node.Type != 1 {
return ""
}
var value string
if err := stdjson.Unmarshal(node.Scalar, &value); err != nil {
return ""
}
return value
}
func (d *serovalDecoder) stringArray(node *serovalNode) []string {
node = d.resolve(node)
if node == nil || node.Type != 9 {
return nil
}
values := make([]string, 0, len(node.Array))
for _, child := range node.Array {
if value := strings.TrimSpace(d.stringValue(child)); value != "" {
values = append(values, value)
}
}
return values
}
func validResourceURL(rawURL, linkType string) bool {
if linkType == "magnet" {
return strings.HasPrefix(strings.ToLower(rawURL), "magnet:?")
}
if linkType == "ed2k" {
return strings.HasPrefix(strings.ToLower(rawURL), "ed2k://")
}
parsed, err := url.Parse(rawURL)
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func parseRFC3339(value string) time.Time {
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
return parsed
}
return time.Now()
}
func cleanText(value string) string {
value = whitespacePattern.ReplaceAllString(strings.TrimSpace(value), " ")
for _, suffix := range []string{"投稿链接:", "投稿链接:", "链接:", "链接:"} {
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
}
return value
}
+108
View File
@@ -0,0 +1,108 @@
package xiaokupan
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
const testSearchResponse = `{"t":10,"i":0,"p":{"k":["result"],"v":[{"t":10,"i":1,"p":{"k":["searchResults"],"v":[{"t":10,"i":2,"p":{"k":["total","merged_by_type"],"v":[{"t":0,"s":2},{"t":10,"i":3,"p":{"k":["quark","baidu"],"v":[{"t":9,"i":4,"a":[{"t":10,"i":5,"p":{"k":["url","password","note","datetime","source","images"],"v":[{"t":1,"s":"https://pan.quark.cn/s/abc"},{"t":1,"s":""},{"t":1,"s":"仙逆 4K 链接:"},{"t":1,"s":"2026-09-01T01:02:03Z"},{"t":1,"s":"tg:test"},{"t":9,"i":6,"a":[{"t":1,"s":"https://example.com/poster.jpg"}]}]}}]},{"t":9,"i":7,"a":[{"t":10,"i":8,"p":{"k":["url","password","note","datetime","source","images"],"v":[{"t":1,"s":"https://pan.baidu.com/s/xyz?pwd=6666"},{"t":1,"s":"6666"},{"t":1,"s":"仙逆 1080P"},{"t":1,"s":"2026-08-31T01:02:03Z"},{"t":1,"s":"web:test"},{"t":9,"i":9,"a":[]}]}}]}]}}]}}]}}]}}`
func TestParseSearchResponse(t *testing.T) {
results, err := parseSearchResponse([]byte(testSearchResponse))
if err != nil {
t.Fatal(err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %#v", results)
}
if results[0].Channel != "" || results[0].Title != "仙逆 4K" || results[0].Links[0].Type != "quark" {
t.Fatalf("unexpected first result: %#v", results[0])
}
if len(results[0].Images) != 1 || results[0].Links[0].WorkTitle != "仙逆 4K" {
t.Fatalf("unexpected first result metadata: %#v", results[0])
}
if results[1].Links[0].Password != "6666" || !strings.HasPrefix(results[1].UniqueID, "xiaokupan-") {
t.Fatalf("unexpected second result: %#v", results[1])
}
}
func TestSearchImpl(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/_serverFn/test-function" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("x-tsr-serverFn") != "true" {
t.Errorf("missing server function header")
}
if !strings.Contains(r.URL.Query().Get("payload"), "仙逆") {
t.Errorf("payload does not contain keyword: %s", r.URL.Query().Get("payload"))
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, testSearchResponse)
}))
defer server.Close()
p := NewXiaokupanPlugin()
p.baseURL = server.URL
p.serverFunctionID = "test-function"
results, err := p.searchImpl(server.Client(), " 仙逆 ", nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 2 {
t.Fatalf("expected 2 filtered results, got %#v", results)
}
}
func TestDiscoverServerFunctionID(t *testing.T) {
wantID := strings.Repeat("a", 64)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
fmt.Fprint(w, `<script type="module" src="/assets/index-test.js"></script>`)
case "/assets/index-test.js":
fmt.Fprintf(w, "var search=as(`%s`),route=jc(`/s/$query`);", wantID)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
p := NewXiaokupanPlugin()
p.baseURL = server.URL
gotID, err := p.discoverServerFunctionID(server.Client())
if err != nil {
t.Fatal(err)
}
if gotID != wantID {
t.Fatalf("expected %s, got %s", wantID, gotID)
}
}
func TestLiveSearch(t *testing.T) {
if os.Getenv("XIAOKUPAN_LIVE_TEST") != "1" {
t.Skip("set XIAOKUPAN_LIVE_TEST=1 to query xiaokupan.com")
}
keyword := os.Getenv("XIAOKUPAN_LIVE_KEYWORD")
if keyword == "" {
keyword = "仙逆"
}
p := NewXiaokupanPlugin()
results, err := p.searchImpl(&http.Client{Timeout: 35 * time.Second}, keyword, nil)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Fatal("live search returned no results")
}
if results[0].Channel != "" || len(results[0].Links) == 0 || results[0].Links[0].URL == "" {
t.Fatalf("live search returned invalid result: %#v", results[0])
}
t.Logf("live search for %q returned %d results; first: %q (%s)", keyword, len(results), results[0].Title, results[0].Links[0].URL)
}
+412
View File
@@ -0,0 +1,412 @@
package yingso
import (
"bytes"
"context"
"crypto/rand"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"unicode/utf16"
"pansou/model"
"pansou/plugin"
jsonutil "pansou/util/json"
)
const (
pluginName = "yingso"
defaultAPIBaseURL = "https://ysapi.yingso.fun"
websiteURL = "https://yingso.fun"
bootstrapKey = "12345678"
defaultPriority = 3
pageSize = 30
requestTimeout = 25 * time.Second
maxKeyConcurrency = 8
maxResponseSize = 1 << 20
)
type YingsoPlugin struct {
*plugin.BaseAsyncPlugin
apiBaseURL string
}
type apiEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
type apiConfig struct {
URLVersion string `json:"url_version"`
UserID string `json:"user_id"`
Start int `json:"start"`
End int `json:"end"`
}
type searchPayload struct {
PageSize int `json:"pageSize"`
PageNum int `json:"pageNum"`
Title string `json:"title"`
Root int `json:"root"`
Category string `json:"cat"`
UserID string `json:"userId"`
}
type getKeyPayload struct {
ID int64 `json:"id"`
UserID string `json:"userId"`
}
type encryptedPayload struct {
No string `json:"no"`
Info string `json:"info"`
}
type searchItem struct {
ID int64 `json:"id"`
Title string `json:"title"`
Root int `json:"root"`
}
type resolvedItem struct {
index int
result model.SearchResult
err error
}
func init() {
plugin.RegisterGlobalPlugin(NewYingsoPlugin())
}
func NewYingsoPlugin() *YingsoPlugin {
return &YingsoPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
apiBaseURL: defaultAPIBaseURL,
}
}
func (p *YingsoPlugin) 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
}
func (p *YingsoPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
func (p *YingsoPlugin) searchImpl(client *http.Client, keyword string, _ map[string]interface{}) ([]model.SearchResult, error) {
keyword = cleanText(keyword)
if keyword == "" {
return []model.SearchResult{}, nil
}
if client == nil {
client = http.DefaultClient
}
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
config, err := p.fetchConfig(ctx, client)
if err != nil {
return nil, err
}
items, err := p.search(ctx, client, config, keyword)
if err != nil {
return nil, err
}
if len(items) == 0 {
return []model.SearchResult{}, nil
}
resolved := make([]model.SearchResult, len(items))
valid := make([]bool, len(items))
resultCh := make(chan resolvedItem, len(items))
sem := make(chan struct{}, maxKeyConcurrency)
var wg sync.WaitGroup
for index, item := range items {
wg.Add(1)
go func(index int, item searchItem) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
resultCh <- resolvedItem{index: index, err: ctx.Err()}
return
}
result, resolveErr := p.resolveItem(ctx, client, config, item)
resultCh <- resolvedItem{index: index, result: result, err: resolveErr}
}(index, item)
}
wg.Wait()
close(resultCh)
var firstErr error
resolvedCount := 0
for item := range resultCh {
if item.err != nil {
if firstErr == nil {
firstErr = item.err
}
continue
}
resolved[item.index] = item.result
valid[item.index] = true
resolvedCount++
}
if resolvedCount == 0 && firstErr != nil {
return nil, fmt.Errorf("[%s] 获取分享链接失败: %w", p.Name(), firstErr)
}
results := make([]model.SearchResult, 0, resolvedCount)
seen := make(map[string]struct{}, resolvedCount)
for index, result := range resolved {
if !valid[index] || len(result.Links) == 0 {
continue
}
linkKey := result.Links[0].URL + "\x00" + result.Links[0].Password
if _, exists := seen[linkKey]; exists {
continue
}
seen[linkKey] = struct{}{}
results = append(results, result)
}
return plugin.FilterResultsByKeyword(results, keyword), nil
}
func (p *YingsoPlugin) fetchConfig(ctx context.Context, client *http.Client) (apiConfig, error) {
var response apiEnvelope[string]
if err := p.requestJSON(ctx, client, http.MethodGet, p.apiBaseURL+"/test", nil, &response); err != nil {
return apiConfig{}, fmt.Errorf("[%s] 获取接口配置失败: %w", p.Name(), err)
}
if response.Code != http.StatusOK || response.Data == "" {
return apiConfig{}, fmt.Errorf("[%s] 接口配置异常: code=%d msg=%s", p.Name(), response.Code, response.Msg)
}
decoded := xorUTF16(response.Data, bootstrapKey)
var config apiConfig
if err := jsonutil.UnmarshalString(decoded, &config); err != nil {
return apiConfig{}, fmt.Errorf("[%s] 解析接口配置失败: %w", p.Name(), err)
}
if config.URLVersion == "" || config.UserID == "" || config.Start < 0 || config.End <= config.Start || config.End > 24 {
return apiConfig{}, fmt.Errorf("[%s] 接口配置缺少必要字段", p.Name())
}
return config, nil
}
func (p *YingsoPlugin) search(ctx context.Context, client *http.Client, config apiConfig, keyword string) ([]searchItem, error) {
payload := searchPayload{
PageSize: pageSize,
PageNum: 1,
Title: keyword,
Root: 0,
Category: "all",
UserID: config.UserID,
}
encrypted, err := encryptPayload(payload, config)
if err != nil {
return nil, fmt.Errorf("[%s] 生成搜索参数失败: %w", p.Name(), err)
}
var response apiEnvelope[[]searchItem]
endpoint := fmt.Sprintf("%s/%s/search", p.apiBaseURL, url.PathEscape(config.URLVersion))
if err := p.requestJSON(ctx, client, http.MethodPost, endpoint, encrypted, &response); err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
if response.Code != http.StatusOK {
return nil, fmt.Errorf("[%s] 搜索接口异常: code=%d msg=%s", p.Name(), response.Code, response.Msg)
}
return response.Data, nil
}
func (p *YingsoPlugin) resolveItem(ctx context.Context, client *http.Client, config apiConfig, item searchItem) (model.SearchResult, error) {
payload := getKeyPayload{ID: item.ID, UserID: config.UserID}
encrypted, err := encryptPayload(payload, config)
if err != nil {
return model.SearchResult{}, err
}
var response apiEnvelope[string]
endpoint := fmt.Sprintf("%s/%s/getKey", p.apiBaseURL, url.PathEscape(config.URLVersion))
if err := p.requestJSON(ctx, client, http.MethodPost, endpoint, encrypted, &response); err != nil {
return model.SearchResult{}, err
}
if response.Code != http.StatusOK || strings.TrimSpace(response.Data) == "" {
return model.SearchResult{}, fmt.Errorf("getKey id=%d code=%d msg=%s", item.ID, response.Code, response.Msg)
}
linkType, linkURL, password := buildLink(item.Root, response.Data)
if linkURL == "" {
return model.SearchResult{}, fmt.Errorf("不支持的网盘类型 root=%d id=%d", item.Root, item.ID)
}
title := cleanText(item.Title)
if title == "" {
title = fmt.Sprintf("影搜资源 %d", item.ID)
}
id := fmt.Sprintf("%s-%d", pluginName, item.ID)
now := time.Now()
return model.SearchResult{
MessageID: id,
UniqueID: id,
Channel: "",
Datetime: now,
Title: title,
Content: title,
Links: []model.Link{{
Type: linkType,
URL: linkURL,
Password: password,
Datetime: now,
WorkTitle: title,
}},
}, nil
}
func (p *YingsoPlugin) requestJSON(ctx context.Context, client *http.Client, method string, endpoint string, payload interface{}, target interface{}) error {
var body io.Reader
if payload != nil {
encoded, err := jsonutil.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Origin", websiteURL)
req.Header.Set("Referer", websiteURL+"/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return err
}
if len(data) > maxResponseSize {
return fmt.Errorf("响应超过 %d 字节", maxResponseSize)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
if err := jsonutil.Unmarshal(data, target); err != nil {
return fmt.Errorf("解析响应失败: %w", err)
}
return nil
}
func encryptPayload(payload interface{}, config apiConfig) (encryptedPayload, error) {
no, err := randomToken(24)
if err != nil {
return encryptedPayload{}, err
}
if config.Start < 0 || config.End <= config.Start || config.End > len(no) {
return encryptedPayload{}, fmt.Errorf("无效的加密区间 %d:%d", config.Start, config.End)
}
encoded, err := jsonutil.MarshalString(payload)
if err != nil {
return encryptedPayload{}, err
}
return encryptedPayload{
No: no,
Info: xorUTF16(encoded, no[config.Start:config.End]),
}, nil
}
func randomToken(length int) (string, error) {
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
data := make([]byte, length)
if _, err := rand.Read(data); err != nil {
return "", err
}
for index := range data {
data[index] = alphabet[int(data[index])%len(alphabet)]
}
return string(data), nil
}
func xorUTF16(input string, key string) string {
if key == "" {
return ""
}
inputUnits := utf16.Encode([]rune(input))
keyUnits := utf16.Encode([]rune(key))
output := make([]uint16, len(inputUnits))
for index, unit := range inputUnits {
output[index] = unit ^ keyUnits[index%len(keyUnits)]
}
return string(utf16.Decode(output))
}
func buildLink(root int, rawKey string) (string, string, string) {
shareKey := strings.TrimSpace(rawKey)
if shareKey == "" {
return "", "", ""
}
linkType := ""
prefix := ""
switch root {
case 1:
linkType, prefix = "aliyun", "https://www.aliyundrive.com/s/"
case 2:
linkType, prefix = "quark", "https://pan.quark.cn/s/"
case 3:
linkType, prefix = "xunlei", "https://pan.xunlei.com/s/"
case 4:
linkType, prefix = "baidu", "https://pan.baidu.com/s/"
case 5:
linkType, prefix = "uc", "https://drive.uc.cn/s/"
default:
return "", "", ""
}
linkURL := shareKey
if !strings.HasPrefix(linkURL, "http://") && !strings.HasPrefix(linkURL, "https://") {
linkURL = prefix + strings.TrimLeft(linkURL, "/")
}
parsed, err := url.Parse(linkURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", "", ""
}
password := ""
for _, field := range []string{"pwd", "password", "code"} {
if value := strings.TrimSpace(parsed.Query().Get(field)); value != "" {
password = value
break
}
}
return linkType, parsed.String(), password
}
func cleanText(value string) string {
return strings.Join(strings.Fields(value), " ")
}
+163
View File
@@ -0,0 +1,163 @@
package yingso
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
jsonutil "pansou/util/json"
)
func TestXORUTF16RoundTrip(t *testing.T) {
original := `{"title":"仙逆 Movie"}`
key := "cdef"
encrypted := xorUTF16(original, key)
if decrypted := xorUTF16(encrypted, key); decrypted != original {
t.Fatalf("unexpected decrypted string: %q", decrypted)
}
}
func TestBuildLink(t *testing.T) {
tests := []struct {
root int
key string
linkType string
linkURL string
password string
}{
{root: 1, key: "R2SMZBmfyni", linkType: "aliyun", linkURL: "https://www.aliyundrive.com/s/R2SMZBmfyni"},
{root: 2, key: "8c76976bb12f", linkType: "quark", linkURL: "https://pan.quark.cn/s/8c76976bb12f"},
{root: 3, key: "VNwTcQi?pwd=c6w3", linkType: "xunlei", linkURL: "https://pan.xunlei.com/s/VNwTcQi?pwd=c6w3", password: "c6w3"},
{root: 4, key: "1abc?pwd=8888", linkType: "baidu", linkURL: "https://pan.baidu.com/s/1abc?pwd=8888", password: "8888"},
{root: 5, key: "b2bf7111919e4", linkType: "uc", linkURL: "https://drive.uc.cn/s/b2bf7111919e4"},
}
for _, test := range tests {
linkType, linkURL, password := buildLink(test.root, test.key)
if linkType != test.linkType || linkURL != test.linkURL || password != test.password {
t.Fatalf("root %d: got %q %q %q", test.root, linkType, linkURL, password)
}
}
}
func TestSearchImpl(t *testing.T) {
config := apiConfig{URLVersion: "v1", UserID: "test-user", Start: 2, End: 6}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/test":
encodedConfig, err := jsonutil.MarshalString(config)
if err != nil {
t.Error(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
writeJSON(t, w, apiEnvelope[string]{Code: 200, Msg: "ok", Data: xorUTF16(encodedConfig, bootstrapKey)})
case "/v1/search":
var payload searchPayload
decodeRequest(t, r, config, &payload)
if payload.Title != "仙逆" || payload.UserID != config.UserID || payload.Root != 0 {
t.Errorf("unexpected search payload: %#v", payload)
}
writeJSON(t, w, apiEnvelope[[]searchItem]{Code: 200, Msg: "ok", Data: []searchItem{
{ID: 101, Title: "仙逆 4K\t", Root: 2},
{ID: 102, Title: "仙逆 百度", Root: 4},
{ID: 103, Title: "仙逆 未知", Root: 99},
}})
case "/v1/getKey":
var payload getKeyPayload
decodeRequest(t, r, config, &payload)
keys := map[int64]string{
101: "quark-share",
102: "baidu-share?pwd=1234",
103: "unsupported",
}
writeJSON(t, w, apiEnvelope[string]{Code: 200, Msg: "ok", Data: keys[payload.ID]})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
p := NewYingsoPlugin()
p.apiBaseURL = server.URL
results, err := p.searchImpl(server.Client(), " 仙逆 ", nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d: %#v", len(results), results)
}
if results[0].UniqueID != "yingso-101" || results[0].Title != "仙逆 4K" {
t.Fatalf("unexpected first result: %#v", results[0])
}
if results[0].Links[0].URL != "https://pan.quark.cn/s/quark-share" || results[0].Links[0].WorkTitle != "仙逆 4K" {
t.Fatalf("unexpected first link: %#v", results[0].Links[0])
}
if results[1].Links[0].Type != "baidu" || results[1].Links[0].Password != "1234" {
t.Fatalf("unexpected second link: %#v", results[1].Links[0])
}
}
func TestLiveSearch(t *testing.T) {
if os.Getenv("YINGSO_LIVE_TEST") != "1" {
t.Skip("set YINGSO_LIVE_TEST=1 to query yingso.fun")
}
keyword := os.Getenv("YINGSO_LIVE_KEYWORD")
if keyword == "" {
keyword = "仙逆"
}
p := NewYingsoPlugin()
results, err := p.searchImpl(&http.Client{Timeout: 15 * time.Second}, keyword, nil)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Fatal("live search returned no results")
}
if len(results[0].Links) == 0 || results[0].Links[0].URL == "" {
t.Fatalf("live search returned an invalid first result: %#v", results[0])
}
t.Logf("live search for %q returned %d results; first result: %q (%s)", keyword, len(results), results[0].Title, results[0].Links[0].URL)
}
func decodeRequest(t *testing.T, r *http.Request, config apiConfig, target interface{}) {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
var encrypted encryptedPayload
if err := jsonutil.Unmarshal(body, &encrypted); err != nil {
t.Error(err)
return
}
if len(encrypted.No) != 24 {
t.Errorf("unexpected no length: %d", len(encrypted.No))
return
}
key := encrypted.No[config.Start:config.End]
if err := jsonutil.UnmarshalString(xorUTF16(encrypted.Info, key), target); err != nil {
t.Error(err)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, value interface{}) {
t.Helper()
data, err := jsonutil.Marshal(value)
if err != nil {
t.Error(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := fmt.Fprint(w, string(data)); err != nil && !strings.Contains(err.Error(), "closed") {
t.Error(err)
}
}
+383
View File
@@ -0,0 +1,383 @@
package zlxapp
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"pansou/model"
"pansou/plugin"
jsonutil "pansou/util/json"
)
const (
pluginName = "zlxapp"
baseURL = "https://www.zlxapp.top"
defaultPriority = 3
requestTimeout = 20 * time.Second
maxResponseSize = 2 << 20
maxRetries = 2
timeLayout = "2006-01-02"
)
var listItemsMarker = []byte("const listItemsData = ")
var whitespaceRegex = regexp.MustCompile(`\s+`)
type ZlxappPlugin struct {
*plugin.BaseAsyncPlugin
baseURL string
}
type category struct {
Name string `json:"name"`
}
type sourceLink struct {
ID int64 `json:"id"`
URL string `json:"url"`
Code string `json:"code"`
IsType int `json:"is_type"`
TargetName string `json:"target_name"`
SourceTitle string `json:"source_title"`
Status int `json:"status"`
IsDelete int `json:"is_delete"`
}
type searchItem struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
IsType int `json:"is_type"`
Code string `json:"code"`
URL string `json:"url"`
Times string `json:"times"`
Category category `json:"category"`
Tags []string `json:"tag_names"`
Links []sourceLink `json:"links"`
}
func init() {
plugin.RegisterGlobalPlugin(NewZlxappPlugin())
}
func NewZlxappPlugin() *ZlxappPlugin {
return &ZlxappPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
baseURL: baseURL,
}
}
func (p *ZlxappPlugin) 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
}
func (p *ZlxappPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
func (p *ZlxappPlugin) searchImpl(client *http.Client, keyword string, _ map[string]interface{}) ([]model.SearchResult, error) {
keyword = cleanText(keyword)
if keyword == "" {
return []model.SearchResult{}, nil
}
if client == nil {
client = http.DefaultClient
}
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
searchURL := fmt.Sprintf("%s/s/%s.html", strings.TrimRight(p.baseURL, "/"), url.PathEscape(keyword))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建搜索请求失败: %w", p.Name(), err)
}
setRequestHeaders(req, p.baseURL)
resp, err := doRequestWithRetry(client, req)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return nil, fmt.Errorf("[%s] 读取搜索响应失败: %w", p.Name(), err)
}
if len(body) > maxResponseSize {
return nil, fmt.Errorf("[%s] 搜索响应超过 %d 字节", p.Name(), maxResponseSize)
}
items, err := parseListItems(body)
if err != nil {
return nil, fmt.Errorf("[%s] 解析搜索结果失败: %w", p.Name(), err)
}
results := make([]model.SearchResult, 0, len(items))
for _, item := range items {
if result, ok := convertItem(item); ok {
results = append(results, result)
}
}
return plugin.FilterResultsByKeyword(deduplicateResults(results), keyword), nil
}
func setRequestHeaders(req *http.Request, refererBaseURL string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Referer", strings.TrimRight(refererBaseURL, "/")+"/")
}
func doRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
select {
case <-timer.C:
case <-req.Context().Done():
timer.Stop()
return nil, req.Context().Err()
}
}
resp, err := client.Do(req.Clone(req.Context()))
if err != nil {
lastErr = err
continue
}
if resp.StatusCode == http.StatusOK {
return resp, nil
}
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
resp.Body.Close()
}
if lastErr == nil {
lastErr = fmt.Errorf("请求失败")
}
return nil, lastErr
}
func parseListItems(body []byte) ([]searchItem, error) {
markerIndex := bytes.Index(body, listItemsMarker)
if markerIndex < 0 {
return nil, fmt.Errorf("未找到 listItemsData")
}
arrayStart := markerIndex + len(listItemsMarker)
for arrayStart < len(body) && body[arrayStart] != '[' {
arrayStart++
}
if arrayStart >= len(body) {
return nil, fmt.Errorf("listItemsData 缺少数组")
}
arrayEnd, err := findJSONArrayEnd(body, arrayStart)
if err != nil {
return nil, err
}
var items []searchItem
if err := jsonutil.Unmarshal(body[arrayStart:arrayEnd+1], &items); err != nil {
return nil, err
}
return items, nil
}
func findJSONArrayEnd(data []byte, start int) (int, error) {
depth := 0
inString := false
escaped := false
for index := start; index < len(data); index++ {
current := data[index]
if inString {
if escaped {
escaped = false
continue
}
if current == '\\' {
escaped = true
continue
}
if current == '"' {
inString = false
}
continue
}
switch current {
case '"':
inString = true
case '[':
depth++
case ']':
depth--
if depth == 0 {
return index, nil
}
}
}
return 0, fmt.Errorf("listItemsData 数组未闭合")
}
func convertItem(item searchItem) (model.SearchResult, bool) {
title := cleanText(item.Title)
if title == "" || item.ID <= 0 {
return model.SearchResult{}, false
}
links := make([]model.Link, 0, max(1, len(item.Links)))
seen := make(map[string]struct{})
if len(item.Links) == 0 && strings.TrimSpace(item.URL) != "" {
item.Links = []sourceLink{{
ID: item.ID,
URL: item.URL,
Code: item.Code,
IsType: item.IsType,
SourceTitle: title,
Status: 1,
}}
}
for _, source := range item.Links {
if source.IsDelete != 0 || source.Status < 0 {
continue
}
linkType, linkURL, password := normalizeLink(source.URL, source.Code)
if linkURL == "" {
continue
}
key := linkURL + "\x00" + password
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
workTitle := cleanText(source.TargetName)
if workTitle == "" {
workTitle = cleanText(source.SourceTitle)
}
if workTitle == "" {
workTitle = title
}
links = append(links, model.Link{
Type: linkType,
URL: linkURL,
Password: password,
WorkTitle: workTitle,
})
}
if len(links) == 0 {
return model.SearchResult{}, false
}
datetime := time.Now()
if parsed, err := time.ParseInLocation(timeLayout, strings.TrimSpace(item.Times), time.Local); err == nil {
datetime = parsed
}
tags := append([]string(nil), item.Tags...)
if item.Category.Name != "" && !containsString(tags, item.Category.Name) {
tags = append(tags, item.Category.Name)
}
content := cleanText(item.Description)
if content == "" {
content = title
}
id := fmt.Sprintf("%s-%d", pluginName, item.ID)
return model.SearchResult{
MessageID: id,
UniqueID: id,
Channel: "",
Datetime: datetime,
Title: title,
Content: content,
Links: links,
Tags: tags,
}, true
}
func normalizeLink(rawURL string, declaredPassword string) (string, string, string) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "", "", ""
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", "", ""
}
host := strings.ToLower(parsed.Hostname())
linkType := ""
switch {
case host == "pan.quark.cn":
linkType = "quark"
case host == "drive.uc.cn":
linkType = "uc"
case host == "pan.baidu.com":
linkType = "baidu"
case host == "www.aliyundrive.com" || host == "aliyundrive.com" || host == "www.alipan.com" || host == "alipan.com":
linkType = "aliyun"
case host == "pan.xunlei.com":
linkType = "xunlei"
case host == "guangyapan.com" || strings.HasSuffix(host, ".guangyapan.com"):
linkType = "guangya"
default:
return "", "", ""
}
password := strings.TrimSpace(declaredPassword)
if password == "" {
for _, key := range []string{"pwd", "password", "code"} {
if value := strings.TrimSpace(parsed.Query().Get(key)); value != "" {
password = value
break
}
}
}
return linkType, parsed.String(), password
}
func deduplicateResults(results []model.SearchResult) []model.SearchResult {
unique := make([]model.SearchResult, 0, len(results))
seen := make(map[string]struct{}, len(results))
for _, result := range results {
keyParts := make([]string, 0, len(result.Links))
for _, link := range result.Links {
keyParts = append(keyParts, link.URL+"\x00"+link.Password)
}
key := strings.Join(keyParts, "\x01")
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
unique = append(unique, result)
}
return unique
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func cleanText(value string) string {
return strings.TrimSpace(whitespaceRegex.ReplaceAllString(value, " "))
}
+107
View File
@@ -0,0 +1,107 @@
package zlxapp
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
)
func TestParseListItemsAndConvert(t *testing.T) {
html := `<!doctype html><script>
const listItemsData = [{"id":26226,"title":"仙逆(更至155","description":"","is_type":2,"code":"6666","url":"https:\/\/pan.baidu.com\/s\/fallback?pwd=6666","times":"2026-09-01","category":{"name":"全网搜"},"tag_names":["动漫"],"links":[{"id":26306,"url":"https:\/\/pan.baidu.com\/s\/1abc?pwd=6666","code":"6666","is_type":2,"target_name":"仙逆 百度","source_title":"仙逆(更至155","status":1,"is_delete":0},{"id":26307,"url":"https:\/\/pan.quark.cn\/s\/quark123","code":"","is_type":0,"target_name":"","source_title":"仙逆 夸克","status":1,"is_delete":0}]}];
const otherData = [{"nested":[1,2,3]}];
</script>`
items, err := parseListItems([]byte(html))
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
result, ok := convertItem(items[0])
if !ok {
t.Fatal("expected item to convert")
}
if result.UniqueID != "zlxapp-26226" || result.Channel != "" || result.Title != "仙逆(更至155" {
t.Fatalf("unexpected result: %#v", result)
}
if len(result.Links) != 2 {
t.Fatalf("expected 2 links, got %#v", result.Links)
}
if result.Links[0].Type != "baidu" || result.Links[0].Password != "6666" || result.Links[0].WorkTitle != "仙逆 百度" {
t.Fatalf("unexpected baidu link: %#v", result.Links[0])
}
if result.Links[1].Type != "quark" || result.Links[1].WorkTitle != "仙逆 夸克" {
t.Fatalf("unexpected quark link: %#v", result.Links[1])
}
if len(result.Tags) != 2 || result.Tags[0] != "动漫" || result.Tags[1] != "全网搜" {
t.Fatalf("unexpected tags: %#v", result.Tags)
}
}
func TestSearchImpl(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/s/%E4%BB%99%E9%80%86.html" && r.URL.EscapedPath() != "/s/%E4%BB%99%E9%80%86.html" {
t.Errorf("unexpected path: %s (%s)", r.URL.Path, r.URL.EscapedPath())
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<script>const listItemsData = [
{"id":1,"title":"仙逆 4K","description":"","is_type":0,"code":"","url":"https:\/\/pan.quark.cn\/s\/abc","times":"2026-09-01","category":{"name":"动漫"},"links":[]},
{"id":2,"title":"无关内容","description":"","is_type":0,"code":"","url":"https:\/\/pan.quark.cn\/s\/other","times":"","category":{"name":""},"links":[]},
{"id":3,"title":"仙逆 未知网盘","description":"","is_type":9,"code":"","url":"https:\/\/example.com\/invalid","times":"","category":{"name":""},"links":[]}
];</script>`)
}))
defer server.Close()
p := NewZlxappPlugin()
p.baseURL = server.URL
results, err := p.searchImpl(server.Client(), " 仙逆 ", nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
t.Fatalf("expected 1 filtered result, got %#v", results)
}
if results[0].Links[0].URL != "https://pan.quark.cn/s/abc" || results[0].Links[0].WorkTitle != "仙逆 4K" {
t.Fatalf("unexpected link: %#v", results[0].Links[0])
}
}
func TestFindJSONArrayEndHandlesBracketsInStrings(t *testing.T) {
data := []byte(`[{"title":"测试 ] [ 资源","nested":[1,2]}];`)
end, err := findJSONArrayEnd(data, 0)
if err != nil {
t.Fatal(err)
}
if got := string(data[:end+1]); got != `[{"title":"测试 ] [ 资源","nested":[1,2]}]` {
t.Fatalf("unexpected array: %s", got)
}
}
func TestLiveSearch(t *testing.T) {
if os.Getenv("ZLXAPP_LIVE_TEST") != "1" {
t.Skip("set ZLXAPP_LIVE_TEST=1 to query zlxapp.top")
}
keyword := os.Getenv("ZLXAPP_LIVE_KEYWORD")
if keyword == "" {
keyword = "仙逆"
}
p := NewZlxappPlugin()
results, err := p.searchImpl(&http.Client{Timeout: 20 * time.Second}, keyword, nil)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Fatal("live search returned no results")
}
if len(results[0].Links) == 0 || results[0].Links[0].URL == "" {
t.Fatalf("live search returned invalid result: %#v", results[0])
}
t.Logf("live search for %q returned %d results; first: %q (%s)", keyword, len(results), results[0].Title, results[0].Links[0].URL)
}