castad-pre-v0.3/castad-data/services/googlePlacesService.ts

260 lines
8.9 KiB
TypeScript

import { BusinessInfo } from '../types';
/**
* 이미지 파일의 간단한 해시를 생성합니다 (파일 크기 + 첫 바이트).
*/
const getImageFingerprint = async (file: File): Promise<string> => {
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<string>;
}
/**
* 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<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 100;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
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<PlaceDetails | null> => {
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<File | null> => {
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;
}
};