CASTAD-v0.1/services/naverService.ts

153 lines
6.3 KiB
TypeScript

import { BusinessInfo } from '../types';
/**
* 헬퍼 함수: URL에서 파일을 가져와 File 객체로 변환합니다.
* CORS 문제나 Blob URL 등을 처리하기 위해 백업 프록시 로직을 포함합니다.
* @param {string} url - 가져올 파일의 URL
* @param {string} filename - 생성할 File 객체의 이름
* @returns {Promise<File>} - File 객체
*/
const urlToFile = async (url: string, filename: string): Promise<File> => {
try {
// 백엔드 프록시를 통해 이미지 다운로드 (CORS 우회)
const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
throw new Error(`Proxy failed with status: ${response.status}`);
}
const blob = await response.blob();
return new File([blob], filename, { type: blob.type || 'image/jpeg' });
} catch (e) {
console.error(`이미지 다운로드 실패 ${url}:`, e);
throw new Error(`이미지 다운로드 실패: ${url}`);
}
};
/**
* 이미지 파일의 간단한 해시를 생성합니다 (파일 크기 + 첫 바이트).
* 완벽한 중복 검사는 아니지만 빠르고 대부분의 경우를 처리합니다.
*/
const getImageFingerprint = async (file: File): Promise<string> => {
const size = file.size;
const slice = file.slice(0, 1024); // 첫 1KB만 읽기
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}`;
};
/**
* 기존 이미지들의 fingerprint 목록을 생성합니다.
*/
export const getExistingFingerprints = async (existingImages: File[]): Promise<Set<string>> => {
const fingerprints = new Set<string>();
for (const img of existingImages) {
try {
const fp = await getImageFingerprint(img);
fingerprints.add(fp);
} catch (e) {
console.warn('Fingerprint 생성 실패:', e);
}
}
return fingerprints;
};
export interface CrawlOptions {
maxImages?: number; // 가져올 최대 이미지 수 (기본값: 15)
existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints
}
/**
* 네이버 플레이스 정보를 크롤링하는 함수입니다.
* 클라이언트에서 백엔드 API를 호출하여 네이버 장소 정보를 가져옵니다.
* @param {string} url - 네이버 플레이스 URL 또는 장소 ID
* @param {(msg: string) => void} [onProgress] - 진행 상황을 알리는 콜백 함수 (선택 사항)
* @param {CrawlOptions} [options] - 추가 옵션 (maxImages, existingFingerprints)
* @returns {Promise<Partial<BusinessInfo>>} - 비즈니스 정보의 부분 객체
*/
export const crawlNaverPlace = async (
url: string,
onProgress?: (msg: string) => void,
options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 15;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)...");
try {
// 백엔드 Express 서버의 /api/naver/crawl 엔드포인트 호출
const response = await fetch('/api/naver/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }) // 크롤링할 URL을 백엔드로 전송
});
if (!response.ok) {
// 서버 응답이 성공적이지 않을 경우 에러 처리
const errData = await response.json().catch(() => ({})); // 에러 JSON 파싱 시도
throw new Error(errData.error || "서버 크롤링 요청 실패");
}
const data = await response.json(); // 성공 시 응답 데이터 파싱
console.log(`백엔드 크롤링 결과: ${data.name}, 총 ${data.totalImages || data.images?.length}장 이미지 (셔플됨)`);
onProgress?.(`'${data.name}' 정보 수신 완료. 이미지 다운로드 중... (총 ${data.totalImages || '?'}장 중 최대 ${maxImages}장)`);
// 크롤링된 이미지 URL들을 File 객체로 변환 (중복 검사 포함)
const imageFiles: File[] = [];
const imageUrls = data.images || [];
let skippedDuplicates = 0;
for (let i = 0; i < imageUrls.length && imageFiles.length < maxImages; i++) {
const imgUrl = imageUrls[i];
try {
onProgress?.(`이미지 다운로드 중 (${imageFiles.length + 1}/${maxImages})...`);
// 각 이미지 URL을 File 객체로 변환
const file = await urlToFile(imgUrl, `naver_${data.place_id}_${i}.jpg`);
// 중복 검사
const fingerprint = await getImageFingerprint(file);
if (existingFingerprints.has(fingerprint)) {
console.log(`중복 이미지 발견, 건너뜁니다: ${imgUrl.slice(-30)}`);
skippedDuplicates++;
continue;
}
imageFiles.push(file);
} catch (e) {
console.warn("이미지 다운로드 실패, 건너뜁니다:", imgUrl, e);
}
}
if (skippedDuplicates > 0) {
console.log(`${skippedDuplicates}장의 중복 이미지를 건너뛰었습니다.`);
}
// 다운로드된 이미지가 없을 경우 에러 발생
if (imageFiles.length === 0) {
throw new Error("유효한 이미지를 하나도 다운로드하지 못했습니다. 다른 URL을 시도해보세요.");
}
onProgress?.(`${imageFiles.length}장의 이미지를 가져왔습니다.`);
// 비즈니스 정보 객체 반환
return {
name: data.name,
description: data.description || `${data.name} - ${data.category} (${data.address})`,
images: imageFiles,
address: data.address,
category: data.category,
sourceUrl: url
};
} catch (error: any) {
console.error("크롤링 실패:", error);
throw new Error(error.message || "네이버 플레이스 정보를 가져오는데 실패했습니다.");
}
};