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)`)
+ 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)]*>([^<]+)`)
- 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)`)
- 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)