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