153 lines
6.3 KiB
TypeScript
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 || "네이버 플레이스 정보를 가져오는데 실패했습니다.");
|
|
}
|
|
}; |