import { BusinessInfo } from '../types'; /** * 이미지 파일의 간단한 해시를 생성합니다 (파일 크기 + 첫 바이트). */ const getImageFingerprint = async (file: File): Promise => { const size = file.size; const slice = file.slice(0, 1024); const buffer = await slice.arrayBuffer(); const bytes = new Uint8Array(buffer); let sum = 0; for (let i = 0; i < bytes.length; i++) { sum = (sum + bytes[i]) % 65536; } return `${size}-${sum}`; }; export interface CrawlOptions { maxImages?: number; existingFingerprints?: Set; } /** * Google Maps URL에서 장소 정보를 추출합니다. * 지원 URL 형식: * - https://maps.google.com/maps?q=장소이름 * - https://www.google.com/maps/place/장소이름 * - https://goo.gl/maps/... * - https://maps.app.goo.gl/... */ export const parseGoogleMapsUrl = (url: string): string | null => { try { // maps.google.com 또는 google.com/maps 형식 if (url.includes('google.com/maps') || url.includes('maps.google.com')) { // /place/장소이름 형식 const placeMatch = url.match(/\/place\/([^\/\?]+)/); if (placeMatch) { return decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); } // ?q=장소이름 형식 const urlObj = new URL(url); const query = urlObj.searchParams.get('q'); if (query) { return decodeURIComponent(query.replace(/\+/g, ' ')); } } return null; } catch { return null; } }; /** * Google Maps URL 또는 검색어로 장소 정보를 가져옵니다. */ export const crawlGooglePlace = async ( urlOrQuery: string, onProgress?: (msg: string) => void, options?: CrawlOptions ): Promise> => { const maxImages = options?.maxImages ?? 15; const existingFingerprints = options?.existingFingerprints ?? new Set(); onProgress?.("Google 지도 정보 가져오는 중..."); try { // URL에서 검색어 추출 시도 let query = parseGoogleMapsUrl(urlOrQuery); // URL 파싱 실패 시 직접 검색어로 사용 if (!query) { // URL 형식이 아니면 검색어로 간주 if (!urlOrQuery.startsWith('http')) { query = urlOrQuery; } else { throw new Error("지원되지 않는 Google Maps URL 형식입니다."); } } onProgress?.(`'${query}' 검색 중...`); // Google Places API로 검색 const placeDetails = await searchPlaceDetails(query); if (!placeDetails) { throw new Error("Google Places에서 해당 장소를 찾을 수 없습니다."); } const totalPhotos = placeDetails.photos?.length || 0; onProgress?.(`'${placeDetails.displayName.text}' 정보 수신 완료. 이미지 다운로드 중... (총 ${totalPhotos}장 중 최대 ${maxImages}장)`); // 사진을 랜덤하게 섞기 const photos = placeDetails.photos || []; const shuffledPhotos = [...photos].sort(() => Math.random() - 0.5); // 사진 다운로드 (중복 검사 포함) const imageFiles: File[] = []; let skippedDuplicates = 0; for (let i = 0; i < shuffledPhotos.length && imageFiles.length < maxImages; i++) { try { onProgress?.(`이미지 다운로드 중 (${imageFiles.length + 1}/${maxImages})...`); const file = await fetchPlacePhoto(shuffledPhotos[i].name); if (file) { const newFile = new File([file], `google_${i}.jpg`, { type: 'image/jpeg' }); // 중복 검사 const fingerprint = await getImageFingerprint(newFile); if (existingFingerprints.has(fingerprint)) { console.log(`중복 이미지 발견, 건너뜁니다`); skippedDuplicates++; continue; } imageFiles.push(newFile); } } catch (e) { console.warn("이미지 다운로드 실패:", e); } } if (skippedDuplicates > 0) { console.log(`총 ${skippedDuplicates}장의 중복 이미지를 건너뛰었습니다.`); } onProgress?.(`${imageFiles.length}장의 이미지를 가져왔습니다.`); // 설명 생성 let description = ''; if (placeDetails.generativeSummary?.overview?.text) { description = placeDetails.generativeSummary.overview.text; } else if (placeDetails.reviews && placeDetails.reviews.length > 0) { description = placeDetails.reviews[0].text.text; } return { name: placeDetails.displayName.text, description: description || `${placeDetails.displayName.text} - ${placeDetails.primaryTypeDisplayName?.text || ''} (${placeDetails.formattedAddress})`, images: imageFiles, address: placeDetails.formattedAddress, category: placeDetails.primaryTypeDisplayName?.text, sourceUrl: urlOrQuery }; } catch (error: any) { console.error("Google Places 크롤링 실패:", error); throw new Error(error.message || "Google Places 정보를 가져오는데 실패했습니다."); } }; interface PlaceDetails { displayName: { text: string }; formattedAddress: string; rating?: number; userRatingCount?: number; reviews?: { name: string; relativePublishTimeDescription: string; text: { text: string }; rating: number; }[]; photos?: { name: string; widthPx: number; heightPx: number; }[]; generativeSummary?: { overview: { text: string } }; primaryTypeDisplayName?: { text: string }; } /** * Google Places API를 사용하여 장소를 검색하고 상세 정보를 가져옵니다. */ export const searchPlaceDetails = async (query: string): Promise => { try { // 1. 텍스트 검색 (Text Search) - 백엔드 프록시 사용 const searchRes = await fetch(`/api/google/places/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ textQuery: query, languageCode: "ko" }) }); if (!searchRes.ok) { const errorData = await searchRes.json(); throw new Error(errorData.error || `Server error: ${searchRes.statusText}`); } const searchData = await searchRes.json(); if (!searchData.places || searchData.places.length === 0) return null; const placeId = searchData.places[0].name.split('/')[1]; // 2. 상세 정보 요청 (Details) - 백엔드 프록시 사용 const fieldMask = [ 'displayName', 'formattedAddress', 'rating', 'userRatingCount', 'reviews', 'photos', 'primaryTypeDisplayName' ].join(','); const detailRes = await fetch(`/api/google/places/details`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ placeId, fieldMask }) }); if (!detailRes.ok) { const errorData = await detailRes.json(); throw new Error(errorData.error || `Server error: ${detailRes.statusText}`); } return await detailRes.json(); } catch (error: any) { console.error("Google Places API Error (Frontend):", error); throw new Error(error.message || "Google Places 정보를 가져오는데 실패했습니다."); } }; /** * 사진 리소스 이름(media key)을 사용하여 실제 이미지 Blob을 다운로드합니다. * 백엔드 프록시 이용. */ export const fetchPlacePhoto = async (photoName: string, maxWidth = 800): Promise => { try { if (!photoName) return null; const response = await fetch('/api/google/places/photo', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ photoName, maxWidthPx: maxWidth }) }); if (!response.ok) return null; const blob = await response.blob(); return new File([blob], "place_photo.jpg", { type: "image/jpeg" }); } catch (error: any) { console.error("Photo Fetch Error (Frontend):", error); return null; } };