package wallpaper import ( "context" "fmt" "net/url" "time" "github.com/gogf/gf/v2/encoding/gjson" "service.xpcool.com/internal/model/dto" ) // WallhavenProvider Wallhaven 壁纸站。 // // 特点:专门做壁纸,分辨率与横竖版筛选最贴合本需求,缩略图与预览图质量都高。 // // 鉴权:**纯 SFW 检索不需要 API Key**(purity=100 匿名可用),故本适配器 // RequiresKey 为 false;配置了 Key 之后才允许放开更高分级。 // // 安全约束:purity 默认锁死 SFW。只有「配置了 Key」且「后台显式改成 // sketchy/nsfw」两个条件同时满足,才会放开过滤 —— 避免误把成人内容 // 推到公网首页。 type WallhavenProvider struct{} const wallhavenBase = "https://wallhaven.cc/api/v1" func (p *WallhavenProvider) Code() string { return "wallhaven" } func (p *WallhavenProvider) Name() string { return "Wallhaven" } func (p *WallhavenProvider) RequiresKey() bool { return false } func (p *WallhavenProvider) List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) { page, size := normalizePage(q.Page, q.Size) key := cacheKey(p.Code(), q) if v, err := openCache.Get(ctx, key); err == nil && !v.IsNil() { if cached, ok := v.Val().([]dto.WallpaperItem); ok { return cached, len(cached), nil } } // 该站单页上限 24;需要本地按朝向过滤时就直接取满,免得过滤后不够一页。 perPage := size if q.Orientation != 0 || perPage > 24 { perPage = 24 } params := url.Values{} params.Set("page", fmt.Sprint(page)) params.Set("per_page", fmt.Sprint(perPage)) params.Set("purity", wallhavenPurity(cfg)) params.Set("categories", firstNonEmpty(cfg.Categories, "111")) params.Set("atleast", "1920x1080") // 有搜索词按相关度排,否则取月榜 —— 对壁纸站来说「热门」比「最新」更耐看 if keyword := firstNonEmpty(q.Query, cfg.DefaultQuery); keyword != "" { params.Set("q", keyword) params.Set("sorting", "relevance") } else { params.Set("sorting", "toplist") params.Set("topRange", "1M") } if cfg.ApiKey != "" { params.Set("apikey", cfg.ApiKey) } // 超时给得比其它平台短:wallhaven.cc 在境内遭 DNS 污染(实测解析到境外无关 IP), // 直连必然超时。这里快速失败,避免一个不可达的平台拖慢整个来源列表的响应。 // 需要使用时把后台配置里的「接口地址」指向自建反代即可。 base := baseOf(cfg.BaseUrl, wallhavenBase) j, err := httpGetJSON(ctx, base+"/search?"+params.Encode(), nil, 8*time.Second) if err != nil { return nil, 0, fmt.Errorf("获取 Wallhaven 列表失败(该站境内多不可直连,可在平台配置里填写反代地址): %w", err) } if errMsg := j.Get("error").String(); errMsg != "" { return nil, 0, fmt.Errorf("Wallhaven 返回错误: %s", errMsg) } out := make([]dto.WallpaperItem, 0, size) for _, raw := range j.Get("data").Array() { item := gjson.New(raw) id := item.Get("id").String() if id == "" { continue } w := item.Get("dimension_x").Int() h := item.Get("dimension_y").Int() if !matchOrientation(w, h, q.Orientation) { continue } out = append(out, dto.WallpaperItem{ Id: id, Title: firstNonEmpty(item.Get("source").String(), "Wallhaven "+id), Width: w, Height: h, Orientation: orientationOf(w, h), ThumbUrl: item.Get("thumbs.small").String(), PreviewUrl: firstNonEmpty(item.Get("thumbs.original").String(), item.Get("thumbs.large").String()), FullUrl: item.Get("path").String(), Source: p.Code(), SourceName: p.Name(), FromOpen: true, Author: "Wallhaven 用户投稿", AuthorUrl: item.Get("url").String(), PageUrl: item.Get("url").String(), License: "Wallhaven(版权归上传者,请遵守站点条款)", }) if len(out) >= size { break } } _ = openCache.Set(ctx, key, out, cacheTTLSearch) return out, j.Get("meta.total").Int(), nil } // wallhavenPurity 计算分级过滤参数。 // // 编码规则:三位分别代表 SFW / Sketchy / NSFW,1 为放开、0 为过滤。 // 默认 "100"(只要 SFW)。未配置 Key 时无论后台怎么填都强制 "100"。 func wallhavenPurity(cfg dto.WallpaperSourceConfig) string { if cfg.ApiKey == "" { return "100" } switch cfg.Purity { case "sketchy": return "010" case "nsfw": return "001" case "all": return "111" default: return "100" } }