mirror of
https://github.com/fish2018/pansou.git
synced 2026-09-01 15:38:30 +08:00
新增插件yingso,yunso,zlxapp,zxzj
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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))), " ")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, " "))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user