227 lines
5.6 KiB
Go
227 lines
5.6 KiB
Go
package seoser
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"videoweb/app/service/configser"
|
|
"videoweb/app/service/videoser"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ResolveSiteURL 解析对外站点根地址(无尾斜杠)。
|
|
// 优先有效正式域名;跳过占位域名后回退到当前请求 Host。
|
|
func ResolveSiteURL(c *gin.Context, cfg configser.AppConfigVM) string {
|
|
if siteURL := PrimarySiteURL(cfg); siteURL != "" {
|
|
return siteURL
|
|
}
|
|
if c != nil && c.Request != nil {
|
|
return requestOrigin(c.Request)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PrimarySiteURL 返回配置接口动态下发的永久地址,供 canonical、OG 等站点元数据使用。
|
|
// 页面是否允许索引由页面类型和内容状态决定,与请求 Host 是否匹配此地址无关。
|
|
func PrimarySiteURL(cfg configser.AppConfigVM) string {
|
|
return usableSiteURL(cfg.ForeverURLAddr)
|
|
}
|
|
|
|
func usableSiteURL(raw string) string {
|
|
u := sanitizeBase(raw)
|
|
if u == "" {
|
|
return ""
|
|
}
|
|
host := strings.ToLower(originHost(u))
|
|
if isPlaceholderHost(host) {
|
|
return ""
|
|
}
|
|
return u
|
|
}
|
|
|
|
func originHost(raw string) string {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
func isPlaceholderHost(host string) bool {
|
|
host = strings.TrimSpace(strings.ToLower(host))
|
|
switch host {
|
|
case "", "localhost", "127.0.0.1", "0.0.0.0", "www.xx.xxx", "xx.xxx", "example.com", "www.example.com":
|
|
return true
|
|
}
|
|
return strings.HasSuffix(host, ".example.com") || strings.HasSuffix(host, ".local")
|
|
}
|
|
|
|
// AbsURL 将 path 或完整 URL 转为绝对地址。
|
|
func AbsURL(siteURL, raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
if siteURL == "" {
|
|
return "/"
|
|
}
|
|
return siteURL + "/"
|
|
}
|
|
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
|
return raw
|
|
}
|
|
if siteURL == "" {
|
|
if strings.HasPrefix(raw, "/") {
|
|
return raw
|
|
}
|
|
return "/" + raw
|
|
}
|
|
if strings.HasPrefix(raw, "/") {
|
|
return siteURL + raw
|
|
}
|
|
return siteURL + "/" + raw
|
|
}
|
|
|
|
// PageCanonicalURL 首页用 siteURL 根路径;其他页使用当前 router 的无尾斜杠规范路径。
|
|
// siteURL 为空时回退 foreverURLAddr。
|
|
func PageCanonicalURL(siteURL, foreverURLAddr string, c *gin.Context) string {
|
|
base := strings.TrimRight(strings.TrimSpace(siteURL), "/")
|
|
if base == "" {
|
|
base = strings.TrimRight(strings.TrimSpace(usableSiteURL(foreverURLAddr)), "/")
|
|
}
|
|
if c == nil || c.Request == nil || c.Request.URL == nil {
|
|
return AbsURL(base, "/")
|
|
}
|
|
path := c.Request.URL.Path
|
|
if path == "" || path == "/" {
|
|
return AbsURL(base, "/")
|
|
}
|
|
return normalizePageURL(AbsURL(base, relativeRequestURI(c.Request.URL)))
|
|
}
|
|
|
|
// CanonicalWithoutQuery 移除 canonical 中的查询参数与片段,避免参数变体形成重复 URL。
|
|
func CanonicalWithoutQuery(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
u.RawQuery = ""
|
|
u.ForceQuery = false
|
|
u.Fragment = ""
|
|
return u.String()
|
|
}
|
|
|
|
// ContentCanonicalURL 由实际内容生成 slug 路由。
|
|
// 短剧规范入口是 /play/{slug},与 sitemap-playletdetail 一致;各集页 noindex,不能标成 canonical。
|
|
func ContentCanonicalURL(siteURL, fallback string, data map[string]any) string {
|
|
if detail, ok := asPlaylet(data["detail"]); ok {
|
|
if path := playletPath("/play", detail.Slug, detail.ID); path != "" {
|
|
return AbsURL(siteURL, path)
|
|
}
|
|
}
|
|
if playlet, ok := asPlaylet(data["playlet"]); ok {
|
|
if path := playletPath("/play", playlet.Slug, playlet.ID); path != "" {
|
|
return AbsURL(siteURL, path)
|
|
}
|
|
}
|
|
if video, ok := asVideo(data["video"]); ok {
|
|
if path := videoser.VideoPlayURL(video.Slug, video.ID); path != "/series" {
|
|
return AbsURL(siteURL, path)
|
|
}
|
|
}
|
|
if chapter, ok := asChapter(data["chapter"]); ok {
|
|
if path := playletPath("/play", "", chapter.PlayletID); path != "" {
|
|
return AbsURL(siteURL, path)
|
|
}
|
|
}
|
|
return normalizePageURL(fallback)
|
|
}
|
|
|
|
func asVideo(v any) (*videoser.VideoInfoResp, bool) {
|
|
switch value := v.(type) {
|
|
case *videoser.VideoInfoResp:
|
|
if value != nil {
|
|
return value, true
|
|
}
|
|
case videoser.VideoInfoResp:
|
|
return &value, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// normalizePageURL 去掉非首页 URL 的尾斜杠,并保留查询参数。
|
|
func normalizePageURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
if u.Path == "" {
|
|
u.Path = "/"
|
|
}
|
|
if u.Path != "/" {
|
|
u.Path = strings.TrimRight(u.Path, "/")
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
func relativeRequestURI(u *url.URL) string {
|
|
if u == nil {
|
|
return "/"
|
|
}
|
|
path := u.EscapedPath()
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
if u.RawQuery != "" {
|
|
return path + "?" + u.RawQuery
|
|
}
|
|
return path
|
|
}
|
|
|
|
func sanitizeBase(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
if !strings.Contains(raw, "://") && strings.Contains(raw, ".") {
|
|
return sanitizeBase("https://" + strings.TrimPrefix(raw, "/"))
|
|
}
|
|
return ""
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(u.Scheme+"://"+u.Host, "/")
|
|
}
|
|
|
|
func requestOrigin(r *http.Request) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
|
if proto == "" {
|
|
if r.TLS != nil {
|
|
proto = "https"
|
|
} else {
|
|
proto = "http"
|
|
}
|
|
}
|
|
if i := strings.IndexByte(proto, ','); i >= 0 {
|
|
proto = strings.TrimSpace(proto[:i])
|
|
}
|
|
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
|
if host == "" {
|
|
host = strings.TrimSpace(r.Host)
|
|
}
|
|
if i := strings.IndexByte(host, ','); i >= 0 {
|
|
host = strings.TrimSpace(host[:i])
|
|
}
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(proto+"://"+host, "/")
|
|
}
|