161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
import { BusinessInfo } from '../types';
|
|
|
|
/**
|
|
* 헬퍼 함수: URL에서 파일을 가져와 File 객체로 변환합니다.
|
|
* CORS 문제를 해결하기 위해 백엔드 프록시를 사용합니다.
|
|
*/
|
|
const urlToFile = async (url: string, filename: string): Promise<File> => {
|
|
try {
|
|
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);
|
|
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;
|
|
existingFingerprints?: Set<string>;
|
|
}
|
|
|
|
/**
|
|
* Instagram 프로필 URL에서 username을 추출합니다.
|
|
*/
|
|
export const parseInstagramUrl = (url: string): string | null => {
|
|
try {
|
|
// instagram.com/username 형식
|
|
const match = url.match(/instagram\.com\/([a-zA-Z0-9._]+)/);
|
|
if (match && match[1] && !['p', 'reel', 'stories', 'explore', 'accounts'].includes(match[1])) {
|
|
return match[1];
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 인스타그램 프로필 정보를 크롤링하는 함수입니다.
|
|
* 클라이언트에서 백엔드 API를 호출하여 인스타그램 프로필 정보를 가져옵니다.
|
|
* @param {string} url - 인스타그램 프로필 URL
|
|
* @param {(msg: string) => void} [onProgress] - 진행 상황을 알리는 콜백 함수
|
|
* @param {CrawlOptions} [options] - 추가 옵션 (maxImages, existingFingerprints)
|
|
* @returns {Promise<Partial<BusinessInfo>>} - 비즈니스 정보의 부분 객체
|
|
*/
|
|
export const crawlInstagramProfile = async (
|
|
url: string,
|
|
onProgress?: (msg: string) => void,
|
|
options?: CrawlOptions
|
|
): Promise<Partial<BusinessInfo>> => {
|
|
const maxImages = options?.maxImages ?? 100;
|
|
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
|
|
|
|
onProgress?.("인스타그램 프로필 정보 가져오는 중 (서버 요청)...");
|
|
|
|
try {
|
|
// 백엔드 Express 서버의 /api/instagram/crawl 엔드포인트 호출
|
|
const response = await fetch('/api/instagram/crawl', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.json().catch(() => ({}));
|
|
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})...`);
|
|
const file = await urlToFile(imgUrl, `instagram_${data.username}_${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} - Instagram (@${data.username})`,
|
|
images: imageFiles,
|
|
address: '',
|
|
category: 'Instagram',
|
|
sourceUrl: url
|
|
};
|
|
|
|
} catch (error: any) {
|
|
console.error("인스타그램 크롤링 실패:", error);
|
|
throw new Error(error.message || "인스타그램 프로필 정보를 가져오는데 실패했습니다.");
|
|
}
|
|
};
|