260 lines
8.9 KiB
TypeScript
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 ?? 15;
|
|
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;
|
|
}
|
|
};
|