Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
216 lines
5.0 KiB
Go
216 lines
5.0 KiB
Go
package seoser
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"videoweb/app/service/configser"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ResolveSiteURL 解析对外站点根地址(无尾斜杠)。
|
|
// 优先有效正式域名;跳过占位域名后回退到当前请求 Host。
|
|
func ResolveSiteURL(c *gin.Context, cfg configser.AppConfigVM) string {
|
|
candidates := []string{
|
|
cfg.SeoInfo.CanonicalURL,
|
|
cfg.ForeverURLAddr,
|
|
cfg.NewURLAddr,
|
|
}
|
|
for _, raw := range candidates {
|
|
if u := usableSiteURL(raw); u != "" {
|
|
return u
|
|
}
|
|
}
|
|
if c != nil && c.Request != nil {
|
|
return requestOrigin(c.Request)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 根路径;其他页用 siteURL + 当前 path + query。
|
|
// 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 ensureTrailingSlash(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 路由。
|
|
func ContentCanonicalURL(siteURL, fallback string, data map[string]any) string {
|
|
if detail, ok := asPlaylet(data["detail"]); ok {
|
|
if path := playletPath("/detail", detail.Slug, detail.ID); path != "" {
|
|
return AbsURL(siteURL, path)
|
|
}
|
|
} else if chapter, ok := asChapter(data["chapter"]); ok {
|
|
playlet, _ := asPlaylet(data["playlet"])
|
|
playletID := chapter.PlayletID
|
|
playletSlug := ""
|
|
if playlet != nil {
|
|
playletID = playlet.ID
|
|
playletSlug = playlet.Slug
|
|
}
|
|
path := playletPath("/play", playletSlug, playletID)
|
|
if path == "" {
|
|
return fallback
|
|
}
|
|
if chapter.ID > 0 {
|
|
path += strconv.FormatInt(chapter.ID, 10) + "/"
|
|
}
|
|
return AbsURL(siteURL, path)
|
|
}
|
|
return ensureTrailingSlash(fallback)
|
|
}
|
|
|
|
// ensureTrailingSlash 将 HTML 页面 URL 统一为目录式尾斜杠形式,并保留查询参数。
|
|
func ensureTrailingSlash(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 != "/" && !strings.HasSuffix(u.Path, "/") {
|
|
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, "/")
|
|
}
|