From 6fceafaa6722a1505a43d804b97170e340c47401 Mon Sep 17 00:00:00 2001 From: poignatov Date: Thu, 22 Jan 2026 20:11:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20headless=20Chrome=20fallback=20=D0=B4=D0=BB=D1=8F=20OG?= =?UTF-8?q?=20=D0=BC=D0=B5=D1=82=D0=B0=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/Dockerfile | 9 +- play-life-backend/go.mod | 12 +- play-life-backend/go.sum | 21 + play-life-backend/main.go | 667 ++++++++++++------ play-life-web/package.json | 2 +- play-life-web/src/components/WishlistForm.jsx | 5 +- 7 files changed, 507 insertions(+), 211 deletions(-) diff --git a/VERSION b/VERSION index d801246..8c53120 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.26.2 +3.27.0 diff --git a/play-life-backend/Dockerfile b/play-life-backend/Dockerfile index 91913ef..e389ec1 100644 --- a/play-life-backend/Dockerfile +++ b/play-life-backend/Dockerfile @@ -10,7 +10,7 @@ COPY play-life-web/ . RUN npm run build # Stage 2: Build Backend -FROM golang:1.21-alpine AS backend-builder +FROM golang:1.24-alpine AS backend-builder WORKDIR /app/backend COPY play-life-backend/go.mod play-life-backend/go.sum ./ RUN go mod download @@ -27,7 +27,12 @@ RUN apk --no-cache add \ nginx \ supervisor \ curl \ - tzdata + tzdata \ + chromium \ + chromium-chromedriver \ + udev \ + ttf-freefont \ + font-noto-emoji # Создаем директории WORKDIR /app diff --git a/play-life-backend/go.mod b/play-life-backend/go.mod index de9d41a..d33d227 100644 --- a/play-life-backend/go.mod +++ b/play-life-backend/go.mod @@ -1,8 +1,10 @@ module play-eng-backend -go 1.21 +go 1.24 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/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/mux v1.8.1 @@ -13,6 +15,12 @@ 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/sys v0.34.0 // indirect ) diff --git a/play-life-backend/go.sum b/play-life-backend/go.sum index eeb8368..296295b 100644 --- a/play-life-backend/go.sum +++ b/play-life-backend/go.sum @@ -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/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/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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 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/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/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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 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/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= diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 17e2f5a..8cfd49c 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -26,6 +26,7 @@ import ( "time" "unicode/utf16" + "github.com/chromedp/chromedp" "github.com/disintegration/imaging" "github.com/go-telegram-bot-api/telegram-bot-api/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/completed", app.getWishlistCompletedHandler).Methods("GET", "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} чтобы избежать конфликта роутов!) protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS") @@ -12864,7 +12866,351 @@ type LinkMetadataResponse struct { 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)]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`) + ogTitleRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`) + ogImageRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`) + ogImageRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`) + ogDescRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`) + ogDescRe2 := regexp.MustCompile(`(?i)]*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)]*>([^<]+)`) + 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)]*(?: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)]*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)]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)`) + 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)]*(?: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 +// Использует HTTP-метод как основной (стандартный подход), chromedp как fallback func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) @@ -12890,242 +13236,157 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) } // Валидация URL - parsedURL, err := url.Parse(req.URL) - if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + _, err := url.Parse(req.URL) + if err != nil { log.Printf("Invalid URL format: %s, error: %v", req.URL, err) sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest) return } - // HTTP клиент с увеличенным таймаутом и поддержкой редиректов - // Используем Transport с настройками для лучшей совместимости - transport := &http.Transport{ - DisableKeepAlives: false, - MaxIdleConns: 10, - IdleConnTimeout: 90 * time.Second, - } - - client := &http.Client{ - Timeout: 30 * time.Second, // Увеличиваем таймаут до 30 секунд - 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 - }, + log.Printf("Extracting metadata for URL: %s", req.URL) + + // Шаг 1: Пытаемся получить метаданные через HTTP-метод (основной, быстрый метод) + metadata, err := extractMetadataViaHTTP(req.URL) + if err != nil { + log.Printf("HTTP method failed for URL %s: %v", req.URL, err) + metadata = &LinkMetadataResponse{} // Инициализируем пустую структуру для fallback } - httpReq, err := http.NewRequest("GET", req.URL, nil) + // Шаг 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 request for URL %s: %v", req.URL, err) + log.Printf("Error creating image request: %v", err) sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError) return } - // Устанавливаем заголовки, максимально имитирующие реальный браузер Chrome - // Актуальный User-Agent для Chrome на macOS - 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") - - // Accept заголовки для максимальной совместимости - 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) + // Устанавливаем User-Agent для имитации браузера + 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") + req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/") - resp, err := client.Do(httpReq) + resp, err := client.Do(req) if err != nil { - log.Printf("Error fetching URL %s: %v", req.URL, err) - sendErrorWithCORS(w, fmt.Sprintf("Error fetching URL: %v", err), http.StatusBadRequest) + log.Printf("Error fetching image: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error fetching image: %v", err), http.StatusBadGateway) return } defer resp.Body.Close() - // Принимаем успешные статусы (200-299) и некоторые редиректы, которые могут содержать контент if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // Для некоторых сайтов можно попробовать прочитать тело даже при не-200 статусе - // Но для большинства случаев это ошибка - 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) + log.Printf("Image fetch returned status %d", resp.StatusCode) + sendErrorWithCORS(w, fmt.Sprintf("HTTP %d", resp.StatusCode), http.StatusBadGateway) return } - // Ограничиваем размер ответа (первые 512KB) - // ВАЖНО: Go's http.Client автоматически декодирует gzip, если заголовок Accept-Encoding установлен - // Но нужно убедиться, что мы читаем декодированные данные - limitedReader := io.LimitReader(resp.Body, 512*1024) + // Ограничиваем размер (максимум 5MB) + limitedReader := io.LimitReader(resp.Body, 5*1024*1024) bodyBytes, err := io.ReadAll(limitedReader) if err != nil { - log.Printf("Error reading response: %v", err) - sendErrorWithCORS(w, "Error reading response", http.StatusInternalServerError) + log.Printf("Error reading image: %v", err) + sendErrorWithCORS(w, "Error reading image", http.StatusInternalServerError) return } - // Проверяем, является ли это gzip (магические байты: 1f 8b) - // Go's http.Client должен автоматически декодировать gzip, но на всякий случай проверяем - 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 + // Определяем Content-Type + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + // Пытаемся определить по содержимому + if len(bodyBytes) >= 2 { + if bodyBytes[0] == 0xFF && bodyBytes[1] == 0xD8 { + contentType = "image/jpeg" + } else if len(bodyBytes) >= 8 && string(bodyBytes[0:8]) == "\x89PNG\r\n\x1a\n" { + contentType = "image/png" + } else if len(bodyBytes) >= 4 && string(bodyBytes[0:4]) == "RIFF" { + contentType = "image/webp" + } else { + contentType = "application/octet-stream" } } } - body := string(bodyBytes) - metadata := &LinkMetadataResponse{} - - // Извлекаем Open Graph теги (более гибкие регулярные выражения с case-insensitive) - // Поддерживаем различные варианты: property/content, content/property, одинарные/двойные кавычки, пробелы - ogTitleRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`) - ogTitleRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`) - ogImageRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`) - ogImageRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`) - ogDescRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`) - ogDescRe2 := regexp.MustCompile(`(?i)]*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]) + // Проверяем, что это изображение + if !strings.HasPrefix(contentType, "image/") { + log.Printf("Invalid content type: %s", contentType) + sendErrorWithCORS(w, "Not an image", http.StatusBadRequest) + return } - // og:image - 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]) - } - - // 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, пытаемся взять - if metadata.Title == "" { - titleRe := regexp.MustCompile(`(?i)<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)]*(?: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)]*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)]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)`) - 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)]*(?: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) + // Отправляем изображение + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(bodyBytes))) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.WriteHeader(http.StatusOK) + w.Write(bodyBytes) } // decodeHTMLEntities декодирует базовые HTML entities diff --git a/play-life-web/package.json b/play-life-web/package.json index 8e34f5b..5354268 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.26.2", + "version": "3.27.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx index 6b3a74b..bd436ae 100644 --- a/play-life-web/src/components/WishlistForm.jsx +++ b/play-life-web/src/components/WishlistForm.jsx @@ -335,8 +335,9 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b // Загружаем изображение только если нет текущего if (metadata.image && !imageUrl) { try { - // Загружаем изображение напрямую - const imgResponse = await fetch(metadata.image) + // Загружаем изображение через бэкенд прокси для обхода CORS + const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(metadata.image)}` + const imgResponse = await authFetch(proxyUrl) if (imgResponse.ok) { const blob = await imgResponse.blob() // Проверяем размер (максимум 5MB)