Добавлен headless Chrome fallback для OG метаданных
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 22s

This commit is contained in:
poignatov
2026-01-22 20:11:29 +03:00
parent d569960ec1
commit 6fceafaa67
7 changed files with 507 additions and 211 deletions

View File

@@ -1 +1 @@
3.26.2 3.27.0

View File

@@ -10,7 +10,7 @@ COPY play-life-web/ .
RUN npm run build RUN npm run build
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.21-alpine AS backend-builder FROM golang:1.24-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
COPY play-life-backend/go.mod play-life-backend/go.sum ./ COPY play-life-backend/go.mod play-life-backend/go.sum ./
RUN go mod download RUN go mod download
@@ -27,7 +27,12 @@ RUN apk --no-cache add \
nginx \ nginx \
supervisor \ supervisor \
curl \ curl \
tzdata tzdata \
chromium \
chromium-chromedriver \
udev \
ttf-freefont \
font-noto-emoji
# Создаем директории # Создаем директории
WORKDIR /app WORKDIR /app

View File

@@ -1,8 +1,10 @@
module play-eng-backend module play-eng-backend
go 1.21 go 1.24
require ( require (
github.com/chromedp/chromedp v0.14.2
github.com/disintegration/imaging v1.6.2
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
@@ -13,6 +15,12 @@ require (
) )
require ( require (
github.com/disintegration/imaging v1.6.2 // indirect github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/sys v0.34.0 // indirect
) )

View File

@@ -1,19 +1,40 @@
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -26,6 +26,7 @@ import (
"time" "time"
"unicode/utf16" "unicode/utf16"
"github.com/chromedp/chromedp"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -4288,6 +4289,7 @@ func main() {
protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/proxy-image", app.proxyImageHandler).Methods("GET", "OPTIONS")
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!) // Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
@@ -12864,7 +12866,351 @@ type LinkMetadataResponse struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
// extractMetadataViaHTTP извлекает метаданные через HTTP-запрос и парсинг HTML
// Это стандартный метод, используемый Telegram, Facebook и другими сервисами
func extractMetadataViaHTTP(targetURL string) (*LinkMetadataResponse, error) {
// Валидация URL
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return nil, fmt.Errorf("invalid URL format: %s", targetURL)
}
// HTTP клиент с увеличенным таймаутом и поддержкой редиректов
transport := &http.Transport{
DisableKeepAlives: false,
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
},
}
httpReq, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
// Устанавливаем заголовки, максимально имитирующие реальный браузер Chrome
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
httpReq.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
httpReq.Header.Set("Connection", "keep-alive")
httpReq.Header.Set("Upgrade-Insecure-Requests", "1")
httpReq.Header.Set("Sec-Fetch-Dest", "document")
httpReq.Header.Set("Sec-Fetch-Mode", "navigate")
httpReq.Header.Set("Sec-Fetch-Site", "none")
httpReq.Header.Set("Sec-Fetch-User", "?1")
httpReq.Header.Set("Sec-Ch-Ua", `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`)
httpReq.Header.Set("Sec-Ch-Ua-Mobile", "?0")
httpReq.Header.Set("Sec-Ch-Ua-Platform", `"macOS"`)
httpReq.Header.Set("Cache-Control", "max-age=0")
httpReq.Header.Set("DNT", "1")
if parsedURL.Host != "" {
referer := fmt.Sprintf("%s://%s/", parsedURL.Scheme, parsedURL.Host)
httpReq.Header.Set("Referer", referer)
}
time.Sleep(100 * time.Millisecond)
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("error fetching URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
limitedReader := io.LimitReader(resp.Body, 512*1024)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
if len(bodyBytes) >= 2 && bodyBytes[0] == 0x1f && bodyBytes[1] == 0x8b {
gzipReader, err := gzip.NewReader(bytes.NewReader(bodyBytes))
if err == nil {
defer gzipReader.Close()
decompressed, err := io.ReadAll(gzipReader)
if err == nil {
bodyBytes = decompressed
}
}
}
body := string(bodyBytes)
metadata := &LinkMetadataResponse{}
// Извлекаем Open Graph теги
ogTitleRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogTitleRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`)
ogImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`)
ogDescRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogDescRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`)
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
} else if matches := ogTitleRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
}
if matches := ogImageRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
} else if matches := ogImageRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
}
if matches := ogDescRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Description = strings.TrimSpace(matches[1])
} else if matches := ogDescRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Description = strings.TrimSpace(matches[1])
}
if metadata.Title == "" {
titleRe := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
if strings.Contains(strings.ToLower(metadata.Title), "робот") ||
strings.Contains(strings.ToLower(metadata.Title), "captcha") ||
strings.Contains(strings.ToLower(metadata.Title), "вы не робот") {
metadata.Title = ""
metadata.Image = ""
metadata.Description = ""
}
}
}
if metadata.Image == "" {
twitterImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']twitter:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
if matches := twitterImageRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
} else {
twitterImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["']`)
if matches := twitterImageRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
}
}
}
// Поиск цены
jsonLdRe := regexp.MustCompile(`(?i)<script[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)</script>`)
jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1)
for _, match := range jsonLdMatches {
if len(match) > 1 {
jsonStr := match[1]
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if priceMatches := priceRe.FindStringSubmatch(jsonStr); len(priceMatches) > 1 {
priceStr := strings.ReplaceAll(priceMatches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
break
}
}
}
}
if metadata.Price == nil {
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(matches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
}
}
}
if metadata.Price == nil {
metaPriceRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["'](?:price|product:price)["'][^>]*content\s*=\s*["']([^"']+)["']`)
if matches := metaPriceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(strings.TrimSpace(matches[1]), ",", ".")
priceStr = regexp.MustCompile(`[^\d.]`).ReplaceAllString(priceStr, "")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
}
}
}
// Нормализуем URL изображения
if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") {
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
if strings.HasPrefix(metadata.Image, "//") {
metadata.Image = parsedURL.Scheme + ":" + metadata.Image
} else if strings.HasPrefix(metadata.Image, "/") {
metadata.Image = baseURL + metadata.Image
} else {
metadata.Image = baseURL + "/" + metadata.Image
}
}
metadata.Title = html.UnescapeString(metadata.Title)
metadata.Description = html.UnescapeString(metadata.Description)
return metadata, nil
}
// extractMetadataViaChrome извлекает метаданные через headless Chrome
// Используется как fallback для JavaScript-рендеринга страниц
func extractMetadataViaChrome(targetURL string) (*LinkMetadataResponse, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf))
defer cancel()
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
metadata := &LinkMetadataResponse{}
// Используем map для получения данных из JavaScript
var result map[string]interface{}
err := chromedp.Run(ctx,
chromedp.Navigate(targetURL),
chromedp.WaitVisible("body", chromedp.ByQuery),
chromedp.Sleep(2*time.Second), // Даем время на выполнение JavaScript
chromedp.Evaluate(`
(function() {
const result = {
title: '',
image: '',
description: '',
price: null
};
// Извлекаем og:title
const ogTitle = document.querySelector('meta[property="og:title"]');
if (ogTitle) {
result.title = ogTitle.getAttribute('content') || '';
} else {
// Fallback на обычный title
const titleEl = document.querySelector('title');
if (titleEl) {
result.title = titleEl.textContent || '';
}
}
// Извлекаем og:image
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogImage) {
result.image = ogImage.getAttribute('content') || '';
} else {
// Fallback на twitter:image
const twitterImage = document.querySelector('meta[name="twitter:image"]');
if (twitterImage) {
result.image = twitterImage.getAttribute('content') || '';
}
}
// Извлекаем og:description
const ogDesc = document.querySelector('meta[property="og:description"]');
if (ogDesc) {
result.description = ogDesc.getAttribute('content') || '';
}
// Извлекаем цену из JSON-LD
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of jsonLdScripts) {
try {
const data = JSON.parse(script.textContent);
if (data.offers && data.offers.price) {
result.price = parseFloat(data.offers.price);
break;
}
if (data.price) {
result.price = parseFloat(data.price);
break;
}
} catch (e) {}
}
// Если не нашли в JSON-LD, ищем в meta тегах
if (!result.price) {
const priceMeta = document.querySelector('meta[property="product:price:amount"]');
if (priceMeta) {
result.price = parseFloat(priceMeta.getAttribute('content'));
}
}
// Нормализуем URL изображения
if (result.image && !result.image.startsWith('http')) {
const baseURL = window.location.origin;
if (result.image.startsWith('//')) {
result.image = window.location.protocol + result.image;
} else if (result.image.startsWith('/')) {
result.image = baseURL + result.image;
} else {
result.image = baseURL + '/' + result.image;
}
}
return result;
})();
`, &result),
)
if err != nil {
return nil, fmt.Errorf("error extracting metadata via Chrome: %w", err)
}
// Преобразуем map в структуру
if title, ok := result["title"].(string); ok {
metadata.Title = strings.TrimSpace(title)
}
if image, ok := result["image"].(string); ok {
metadata.Image = strings.TrimSpace(image)
}
if desc, ok := result["description"].(string); ok {
metadata.Description = strings.TrimSpace(desc)
}
if priceVal := result["price"]; priceVal != nil {
if priceFloat, ok := priceVal.(float64); ok {
if priceFloat > 0 && priceFloat < 100000000 {
metadata.Price = &priceFloat
}
}
}
// Валидация и очистка данных
if metadata.Title != "" {
metadata.Title = strings.TrimSpace(metadata.Title)
if strings.Contains(strings.ToLower(metadata.Title), "робот") ||
strings.Contains(strings.ToLower(metadata.Title), "captcha") ||
strings.Contains(strings.ToLower(metadata.Title), "вы не робот") {
metadata.Title = ""
metadata.Image = ""
metadata.Description = ""
}
}
if metadata.Price != nil && (*metadata.Price <= 0 || *metadata.Price >= 100000000) {
metadata.Price = nil
}
return metadata, nil
}
// extractLinkMetadataHandler извлекает метаданные (Open Graph, Title, Image) из URL // extractLinkMetadataHandler извлекает метаданные (Open Graph, Title, Image) из URL
// Использует HTTP-метод как основной (стандартный подход), chromedp как fallback
func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) { func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
setCORSHeaders(w) setCORSHeaders(w)
@@ -12890,242 +13236,157 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
} }
// Валидация URL // Валидация URL
parsedURL, err := url.Parse(req.URL) _, err := url.Parse(req.URL)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { if err != nil {
log.Printf("Invalid URL format: %s, error: %v", req.URL, err) log.Printf("Invalid URL format: %s, error: %v", req.URL, err)
sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest) sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest)
return return
} }
// HTTP клиент с увеличенным таймаутом и поддержкой редиректов log.Printf("Extracting metadata for URL: %s", req.URL)
// Используем Transport с настройками для лучшей совместимости
transport := &http.Transport{
DisableKeepAlives: false,
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{ // Шаг 1: Пытаемся получить метаданные через HTTP-метод (основной, быстрый метод)
Timeout: 30 * time.Second, // Увеличиваем таймаут до 30 секунд metadata, err := extractMetadataViaHTTP(req.URL)
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Разрешаем до 10 редиректов
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
},
}
httpReq, err := http.NewRequest("GET", req.URL, nil)
if err != nil { if err != nil {
log.Printf("Error creating request for URL %s: %v", req.URL, err) log.Printf("HTTP method failed for URL %s: %v", req.URL, err)
metadata = &LinkMetadataResponse{} // Инициализируем пустую структуру для fallback
}
// Шаг 2: Проверяем, достаточно ли данных из HTTP-метода
// Если нет title и image, используем chromedp fallback
needsFallback := (metadata.Title == "" && metadata.Image == "")
if needsFallback {
log.Printf("HTTP method didn't return enough data for URL %s, trying Chrome fallback", req.URL)
chromeMetadata, chromeErr := extractMetadataViaChrome(req.URL)
if chromeErr != nil {
log.Printf("Chrome fallback failed for URL %s: %v", req.URL, chromeErr)
// Возвращаем результаты HTTP-метода, даже если они пустые
} else {
// Объединяем результаты: приоритет у HTTP, дополняем из Chrome
if metadata.Title == "" && chromeMetadata.Title != "" {
metadata.Title = chromeMetadata.Title
log.Printf("Chrome fallback provided title: %s", chromeMetadata.Title)
}
if metadata.Image == "" && chromeMetadata.Image != "" {
metadata.Image = chromeMetadata.Image
log.Printf("Chrome fallback provided image: %s", chromeMetadata.Image)
}
if metadata.Description == "" && chromeMetadata.Description != "" {
metadata.Description = chromeMetadata.Description
}
if metadata.Price == nil && chromeMetadata.Price != nil {
metadata.Price = chromeMetadata.Price
}
}
} else {
log.Printf("HTTP method successfully extracted metadata for URL %s", req.URL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)
}
// proxyImageHandler проксирует изображение через бэкенд для обхода CORS
func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
imageURL := r.URL.Query().Get("url")
if imageURL == "" {
sendErrorWithCORS(w, "URL parameter is required", http.StatusBadRequest)
return
}
// Валидация URL
parsedURL, err := url.Parse(imageURL)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
log.Printf("Invalid image URL: %s", imageURL)
sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest)
return
}
log.Printf("Proxying image for user %d: %s", userID, imageURL)
// Создаем HTTP запрос к изображению
client := &http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest("GET", imageURL, nil)
if err != nil {
log.Printf("Error creating image request: %v", err)
sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError) sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError)
return return
} }
// Устанавливаем заголовки, максимально имитирующие реальный браузер Chrome // Устанавливаем User-Agent для имитации браузера
// Актуальный User-Agent для Chrome на macOS req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/")
// Accept заголовки для максимальной совместимости resp, err := client.Do(req)
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
httpReq.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
// Заголовки для имитации реального браузера
httpReq.Header.Set("Connection", "keep-alive")
httpReq.Header.Set("Upgrade-Insecure-Requests", "1")
httpReq.Header.Set("Sec-Fetch-Dest", "document")
httpReq.Header.Set("Sec-Fetch-Mode", "navigate")
httpReq.Header.Set("Sec-Fetch-Site", "none")
httpReq.Header.Set("Sec-Fetch-User", "?1")
httpReq.Header.Set("Sec-Ch-Ua", `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`)
httpReq.Header.Set("Sec-Ch-Ua-Mobile", "?0")
httpReq.Header.Set("Sec-Ch-Ua-Platform", `"macOS"`)
// Дополнительные заголовки для лучшей совместимости
httpReq.Header.Set("Cache-Control", "max-age=0")
httpReq.Header.Set("DNT", "1")
// Устанавливаем Referer - для некоторых сайтов это важно для обхода защиты
// Используем главную страницу домена как Referer, чтобы имитировать переход с главной
if parsedURL.Host != "" {
referer := fmt.Sprintf("%s://%s/", parsedURL.Scheme, parsedURL.Host)
httpReq.Header.Set("Referer", referer)
}
// Добавляем небольшую задержку перед запросом для имитации человеческого поведения
// Это может помочь избежать триггера капчи при слишком быстрых запросах
time.Sleep(100 * time.Millisecond)
resp, err := client.Do(httpReq)
if err != nil { if err != nil {
log.Printf("Error fetching URL %s: %v", req.URL, err) log.Printf("Error fetching image: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error fetching URL: %v", err), http.StatusBadRequest) sendErrorWithCORS(w, fmt.Sprintf("Error fetching image: %v", err), http.StatusBadGateway)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// Принимаем успешные статусы (200-299) и некоторые редиректы, которые могут содержать контент
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Для некоторых сайтов можно попробовать прочитать тело даже при не-200 статусе log.Printf("Image fetch returned status %d", resp.StatusCode)
// Но для большинства случаев это ошибка sendErrorWithCORS(w, fmt.Sprintf("HTTP %d", resp.StatusCode), http.StatusBadGateway)
log.Printf("Non-OK status code for URL %s: %d", req.URL, resp.StatusCode)
sendErrorWithCORS(w, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status), http.StatusBadRequest)
return return
} }
// Ограничиваем размер ответа (первые 512KB) // Ограничиваем размер (максимум 5MB)
// ВАЖНО: Go's http.Client автоматически декодирует gzip, если заголовок Accept-Encoding установлен limitedReader := io.LimitReader(resp.Body, 5*1024*1024)
// Но нужно убедиться, что мы читаем декодированные данные
limitedReader := io.LimitReader(resp.Body, 512*1024)
bodyBytes, err := io.ReadAll(limitedReader) bodyBytes, err := io.ReadAll(limitedReader)
if err != nil { if err != nil {
log.Printf("Error reading response: %v", err) log.Printf("Error reading image: %v", err)
sendErrorWithCORS(w, "Error reading response", http.StatusInternalServerError) sendErrorWithCORS(w, "Error reading image", http.StatusInternalServerError)
return return
} }
// Проверяем, является ли это gzip (магические байты: 1f 8b) // Определяем Content-Type
// Go's http.Client должен автоматически декодировать gzip, но на всякий случай проверяем contentType := resp.Header.Get("Content-Type")
if len(bodyBytes) >= 2 && bodyBytes[0] == 0x1f && bodyBytes[1] == 0x8b { if contentType == "" {
// Пытаемся декодировать вручную, если автоматическое декодирование не сработало // Пытаемся определить по содержимому
gzipReader, err := gzip.NewReader(bytes.NewReader(bodyBytes)) if len(bodyBytes) >= 2 {
if err == nil { if bodyBytes[0] == 0xFF && bodyBytes[1] == 0xD8 {
defer gzipReader.Close() contentType = "image/jpeg"
decompressed, err := io.ReadAll(gzipReader) } else if len(bodyBytes) >= 8 && string(bodyBytes[0:8]) == "\x89PNG\r\n\x1a\n" {
if err == nil { contentType = "image/png"
bodyBytes = decompressed } else if len(bodyBytes) >= 4 && string(bodyBytes[0:4]) == "RIFF" {
contentType = "image/webp"
} else {
contentType = "application/octet-stream"
} }
} }
} }
body := string(bodyBytes) // Проверяем, что это изображение
metadata := &LinkMetadataResponse{} if !strings.HasPrefix(contentType, "image/") {
log.Printf("Invalid content type: %s", contentType)
// Извлекаем Open Graph теги (более гибкие регулярные выражения с case-insensitive) sendErrorWithCORS(w, "Not an image", http.StatusBadRequest)
// Поддерживаем различные варианты: property/content, content/property, одинарные/двойные кавычки, пробелы return
ogTitleRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogTitleRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`)
ogImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`)
ogDescRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogDescRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`)
// og:title
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
} else if matches := ogTitleRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
} }
// og:image // Отправляем изображение
if matches := ogImageRe.FindStringSubmatch(body); len(matches) > 1 { w.Header().Set("Content-Type", contentType)
metadata.Image = strings.TrimSpace(matches[1]) w.Header().Set("Content-Length", strconv.Itoa(len(bodyBytes)))
} else if matches := ogImageRe2.FindStringSubmatch(body); len(matches) > 1 { w.Header().Set("Cache-Control", "public, max-age=3600")
metadata.Image = strings.TrimSpace(matches[1]) w.WriteHeader(http.StatusOK)
} w.Write(bodyBytes)
// og:description
if matches := ogDescRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Description = strings.TrimSpace(matches[1])
} else if matches := ogDescRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Description = strings.TrimSpace(matches[1])
}
// Если нет og:title, пытаемся взять <title>
if metadata.Title == "" {
titleRe := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
// Проверяем, не попали ли мы на страницу капчи
if strings.Contains(strings.ToLower(metadata.Title), "робот") ||
strings.Contains(strings.ToLower(metadata.Title), "captcha") ||
strings.Contains(strings.ToLower(metadata.Title), "вы не робот") {
metadata.Title = ""
metadata.Image = ""
metadata.Description = ""
}
}
}
// Если нет og:image, пытаемся найти другие meta теги для изображения
if metadata.Image == "" {
// Twitter Card image
twitterImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']twitter:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
if matches := twitterImageRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
} else {
twitterImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["']`)
if matches := twitterImageRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
}
}
}
// Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны)
// Сначала ищем в JSON-LD (Schema.org)
jsonLdRe := regexp.MustCompile(`(?i)<script[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)</script>`)
jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1)
for _, match := range jsonLdMatches {
if len(match) > 1 {
jsonStr := match[1]
// Ищем цену в JSON-LD
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if priceMatches := priceRe.FindStringSubmatch(jsonStr); len(priceMatches) > 1 {
priceStr := strings.ReplaceAll(priceMatches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
break
}
}
}
}
// Если не нашли в JSON-LD, ищем в обычном HTML
if metadata.Price == nil {
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(matches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
}
}
}
// Также ищем цену в meta тегах (некоторые сайты используют это)
if metadata.Price == nil {
metaPriceRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["'](?:price|product:price)["'][^>]*content\s*=\s*["']([^"']+)["']`)
if matches := metaPriceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(strings.TrimSpace(matches[1]), ",", ".")
// Убираем валюту и лишние символы
priceStr = regexp.MustCompile(`[^\d.]`).ReplaceAllString(priceStr, "")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
}
}
}
// Нормализуем URL изображения (делаем абсолютным)
if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") {
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
if strings.HasPrefix(metadata.Image, "//") {
metadata.Image = parsedURL.Scheme + ":" + metadata.Image
} else if strings.HasPrefix(metadata.Image, "/") {
metadata.Image = baseURL + metadata.Image
} else {
metadata.Image = baseURL + "/" + metadata.Image
}
}
// Декодируем HTML entities (используем стандартную библиотеку)
metadata.Title = html.UnescapeString(metadata.Title)
metadata.Description = html.UnescapeString(metadata.Description)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)
} }
// decodeHTMLEntities декодирует базовые HTML entities // decodeHTMLEntities декодирует базовые HTML entities

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.26.2", "version": "3.27.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -335,8 +335,9 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
// Загружаем изображение только если нет текущего // Загружаем изображение только если нет текущего
if (metadata.image && !imageUrl) { if (metadata.image && !imageUrl) {
try { try {
// Загружаем изображение напрямую // Загружаем изображение через бэкенд прокси для обхода CORS
const imgResponse = await fetch(metadata.image) const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(metadata.image)}`
const imgResponse = await authFetch(proxyUrl)
if (imgResponse.ok) { if (imgResponse.ok) {
const blob = await imgResponse.blob() const blob = await imgResponse.blob()
// Проверяем размер (максимум 5MB) // Проверяем размер (максимум 5MB)