Добавлен headless Chrome fallback для OG метаданных
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 22s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 22s
This commit is contained in:
@@ -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)<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
|
||||
// Использует 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)<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])
|
||||
// Проверяем, что это изображение
|
||||
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, пытаемся взять <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)
|
||||
// Отправляем изображение
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user