259 lines
9.0 KiB
TypeScript
259 lines
9.0 KiB
TypeScript
/**
|
|
* classifyUrls — 랜딩 MultiChannelInput 전용 URL 분류 유틸.
|
|
*
|
|
* 사용자가 textarea에 붙여넣은 여러 URL을 7개 채널(holder)로 분류합니다.
|
|
* - homepage · youtube · instagram · facebook · naverPlace · naverBlog · gangnamUnni · unknown
|
|
*
|
|
* 설계 결정:
|
|
* 1) 결정론적 regex 분류 (AI 추측 없음) — 백엔드의 extractSocialLinks 패턴을 브라우저로 복제.
|
|
* ⚠️ 백엔드와 프론트 두 곳의 패턴은 **동일하게 유지**되어야 합니다. SNS 플랫폼 URL 구조 변경 시 같이 수정하세요.
|
|
*
|
|
* 2) 우선순위: naverPlace / gangnamUnni (고유 도메인) → YouTube / Instagram / Facebook / naverBlog
|
|
* → 미매치이면서 `new URL()` 성공한 URL 중 **호스트네임이 SNS 도메인이 아닌 경우만** homepage.
|
|
* 호스트네임이 SNS인데 프로필 패턴 불일치 (예: instagram.com/p/XYZ, youtube.com/watch)는 `unknown`.
|
|
* ▸ "homepage" 필드가 분석 첫 진입점이라서, 여기에 잘못된 URL이 들어가면 전체 파이프라인이
|
|
* 잘못된 축으로 돌아갑니다. 따라서 방어적으로 분류합니다.
|
|
*
|
|
* 3) 값은 **원본 URL 문자열**을 그대로 보관 — backend `discover-channels`의 `manualChannels`는
|
|
* URL을 받아서 내부에서 handle을 추출하므로, 프론트에서 미리 handle로 변환하지 않습니다.
|
|
* (백엔드 discover-channels 워커가 extractHandleFromUrl 처리)
|
|
*
|
|
* 4) 중복 제거 — 공백/줄바꿈/쉼표로 분리 후, 정규화된 URL(소문자 + trailing slash 제거) 기준으로 dedup.
|
|
*/
|
|
|
|
export interface ClassifiedUrls {
|
|
homepage: string[];
|
|
youtube: string[];
|
|
instagram: string[];
|
|
facebook: string[];
|
|
naverPlace: string[];
|
|
naverBlog: string[];
|
|
gangnamUnni: string[];
|
|
/** 파싱 실패 or SNS 도메인이지만 프로필이 아닌 URL (포스트/비디오 등) */
|
|
unknown: string[];
|
|
}
|
|
|
|
/** SNS 호스트네임 집합 — 이들 도메인인데 프로필 패턴에 매치되지 않으면 `unknown`으로 강제. */
|
|
const SNS_HOSTNAMES = new Set([
|
|
'instagram.com',
|
|
'www.instagram.com',
|
|
'm.instagram.com',
|
|
'youtube.com',
|
|
'www.youtube.com',
|
|
'm.youtube.com',
|
|
'youtu.be',
|
|
'facebook.com',
|
|
'www.facebook.com',
|
|
'm.facebook.com',
|
|
'fb.com',
|
|
'blog.naver.com',
|
|
'm.blog.naver.com',
|
|
'place.naver.com',
|
|
'm.place.naver.com',
|
|
'map.naver.com',
|
|
'gangnamunni.com',
|
|
'm.gangnamunni.com',
|
|
'www.gangnamunni.com',
|
|
]);
|
|
|
|
/** Instagram 프로필이 아닌 경로 (포스트/릴/스토리 등) */
|
|
const IG_SKIP = new Set([
|
|
'p', 'reel', 'reels', 'stories', 'explore', 'accounts',
|
|
'about', 'developer', 'legal', 'privacy', 'terms',
|
|
]);
|
|
|
|
/** Facebook 페이지가 아닌 경로 */
|
|
const FB_SKIP = new Set([
|
|
'sharer', 'share', 'login', 'help', 'pages', 'events', 'groups',
|
|
'marketplace', 'watch', 'gaming', 'privacy', 'policies', 'tr',
|
|
'dialog', 'plugins', 'photo', 'video', 'reel',
|
|
]);
|
|
|
|
/** YouTube 프로필 패턴: `@handle`, `channel/UC...`, `c/custom`, `user/...` */
|
|
const YT_PROFILE_RE = /youtube\.com\/(?:@[a-zA-Z0-9._-]+|channel\/UC[a-zA-Z0-9_-]+|c\/[a-zA-Z0-9._-]+|user\/[a-zA-Z0-9._-]+)/i;
|
|
|
|
/** Naver Place: m.place.naver.com/hospital/{id}, place.naver.com/hospital/{id}, map.naver.com/p/entry/place/{id} */
|
|
const NAVER_PLACE_RE = /(?:m\.)?place\.naver\.com\/[a-z]+\/\d+|map\.naver\.com\/p\/entry\/place\/\d+/i;
|
|
|
|
/** Naver Blog: blog.naver.com/{id} (또는 m.blog.naver.com) */
|
|
const NAVER_BLOG_RE = /(?:m\.)?blog\.naver\.com\/[a-zA-Z0-9_-]+/i;
|
|
|
|
/** 강남언니: gangnamunni.com/hospitals/{id-or-slug} */
|
|
const GANGNAMUNNI_RE = /(?:m\.|www\.)?gangnamunni\.com\/hospitals?\/[a-zA-Z0-9_-]+/i;
|
|
|
|
/** Instagram 프로필: instagram.com/{handle} — 포스트/릴 등은 아래 IG_SKIP로 걸러냄 */
|
|
const IG_RE = /(?:www\.|m\.)?instagram\.com\/([a-zA-Z0-9._]+)\/?/i;
|
|
|
|
/** Facebook 페이지: facebook.com/{page} */
|
|
const FB_RE = /(?:www\.|m\.)?facebook\.com\/([a-zA-Z0-9._-]+)\/?/i;
|
|
|
|
/**
|
|
* URL 문자열을 정규화해 중복 체크 키로 사용.
|
|
* - 소문자, trailing slash 제거, scheme 유지
|
|
* - URL 파싱 실패 시 원본 trim.
|
|
*/
|
|
function normalizeForDedup(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
const path = u.pathname.replace(/\/+$/, '');
|
|
return `${u.protocol}//${u.hostname.toLowerCase()}${path}${u.search}`.toLowerCase();
|
|
} catch {
|
|
return url.trim().toLowerCase();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 단일 URL 토큰을 분류해서 어느 버킷에 넣을지 결정.
|
|
* 반환값: [bucketKey, originalUrl] 또는 null (이미 중복).
|
|
*/
|
|
function classifySingle(
|
|
rawToken: string,
|
|
): { bucket: keyof ClassifiedUrls; value: string } | null {
|
|
const token = rawToken.trim();
|
|
if (!token || token.length < 4) return null;
|
|
|
|
// URL 파싱 시도 — 실패하면 unknown
|
|
let parsed: URL;
|
|
try {
|
|
// scheme 없는 경우 https:// 추가 (예: "instagram.com/foo")
|
|
const withScheme = /^https?:\/\//i.test(token) ? token : `https://${token}`;
|
|
parsed = new URL(withScheme);
|
|
} catch {
|
|
return { bucket: 'unknown', value: token };
|
|
}
|
|
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
const fullUrl = parsed.toString();
|
|
|
|
// 1) Naver Place (고유 도메인)
|
|
if (NAVER_PLACE_RE.test(fullUrl)) {
|
|
return { bucket: 'naverPlace', value: fullUrl };
|
|
}
|
|
|
|
// 2) 강남언니 (고유 도메인)
|
|
if (GANGNAMUNNI_RE.test(fullUrl)) {
|
|
return { bucket: 'gangnamUnni', value: fullUrl };
|
|
}
|
|
|
|
// 3) Naver Blog
|
|
if (NAVER_BLOG_RE.test(fullUrl)) {
|
|
return { bucket: 'naverBlog', value: fullUrl };
|
|
}
|
|
|
|
// 4) YouTube — 프로필 패턴만 매치
|
|
if (hostname.endsWith('youtube.com') || hostname === 'youtu.be') {
|
|
if (YT_PROFILE_RE.test(fullUrl)) {
|
|
return { bucket: 'youtube', value: fullUrl };
|
|
}
|
|
// youtube.com 인데 프로필 아님 (watch URL 등) → unknown
|
|
return { bucket: 'unknown', value: fullUrl };
|
|
}
|
|
|
|
// 5) Instagram
|
|
if (hostname.endsWith('instagram.com')) {
|
|
const m = fullUrl.match(IG_RE);
|
|
const firstSeg = m?.[1];
|
|
if (firstSeg && !IG_SKIP.has(firstSeg.toLowerCase())) {
|
|
return { bucket: 'instagram', value: fullUrl };
|
|
}
|
|
return { bucket: 'unknown', value: fullUrl };
|
|
}
|
|
|
|
// 6) Facebook
|
|
if (hostname.endsWith('facebook.com') || hostname.endsWith('fb.com')) {
|
|
const m = fullUrl.match(FB_RE);
|
|
const firstSeg = m?.[1];
|
|
if (firstSeg && !FB_SKIP.has(firstSeg.toLowerCase())) {
|
|
return { bucket: 'facebook', value: fullUrl };
|
|
}
|
|
return { bucket: 'unknown', value: fullUrl };
|
|
}
|
|
|
|
// 7) 남은 케이스: SNS 도메인이면 unknown (프로필 패턴 미매치), 아니면 homepage
|
|
if (SNS_HOSTNAMES.has(hostname)) {
|
|
return { bucket: 'unknown', value: fullUrl };
|
|
}
|
|
|
|
return { bucket: 'homepage', value: fullUrl };
|
|
}
|
|
|
|
/**
|
|
* 사용자 textarea 입력 → 7채널 분류 결과.
|
|
* 공백/쉼표/줄바꿈으로 분리된 각 토큰을 개별 URL로 간주합니다.
|
|
*/
|
|
export function classifyUrls(input: string): ClassifiedUrls {
|
|
const result: ClassifiedUrls = {
|
|
homepage: [],
|
|
youtube: [],
|
|
instagram: [],
|
|
facebook: [],
|
|
naverPlace: [],
|
|
naverBlog: [],
|
|
gangnamUnni: [],
|
|
unknown: [],
|
|
};
|
|
|
|
if (!input || typeof input !== 'string') return result;
|
|
|
|
// 공백/쉼표/줄바꿈으로 분리
|
|
const tokens = input.split(/[\s,]+/).map((t) => t.trim()).filter(Boolean);
|
|
|
|
// 버킷별 중복 체크 — 같은 URL이 textarea에 두 번 나와도 한 번만
|
|
const seen: Record<keyof ClassifiedUrls, Set<string>> = {
|
|
homepage: new Set(),
|
|
youtube: new Set(),
|
|
instagram: new Set(),
|
|
facebook: new Set(),
|
|
naverPlace: new Set(),
|
|
naverBlog: new Set(),
|
|
gangnamUnni: new Set(),
|
|
unknown: new Set(),
|
|
};
|
|
|
|
for (const token of tokens) {
|
|
const classified = classifySingle(token);
|
|
if (!classified) continue;
|
|
|
|
const key = normalizeForDedup(classified.value);
|
|
if (seen[classified.bucket].has(key)) continue;
|
|
seen[classified.bucket].add(key);
|
|
result[classified.bucket].push(classified.value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 채널 중 하나라도 분석 대상이 있는지 — 분석 시작 버튼 활성화 조건.
|
|
* homepage·SNS 중 최소 1건이 있으면 true. unknown은 제외.
|
|
*/
|
|
export function hasAnalyzableChannels(classified: ClassifiedUrls): boolean {
|
|
return (
|
|
classified.homepage.length > 0 ||
|
|
classified.youtube.length > 0 ||
|
|
classified.instagram.length > 0 ||
|
|
classified.facebook.length > 0 ||
|
|
classified.naverPlace.length > 0 ||
|
|
classified.naverBlog.length > 0 ||
|
|
classified.gangnamUnni.length > 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 분석 파이프라인의 "primary URL" 결정.
|
|
* 홈페이지가 있으면 최우선, 없으면 검출된 첫 SNS URL 반환.
|
|
* 백엔드 startAnalysis 요청의 `url` 필드 (필수)로 사용됩니다.
|
|
*/
|
|
export function pickPrimaryUrl(classified: ClassifiedUrls): string | null {
|
|
if (classified.homepage[0]) return classified.homepage[0];
|
|
// 홈페이지 없으면 SNS 중 아무거나 대표값으로
|
|
const fallback =
|
|
classified.naverPlace[0] ||
|
|
classified.instagram[0] ||
|
|
classified.youtube[0] ||
|
|
classified.facebook[0] ||
|
|
classified.naverBlog[0] ||
|
|
classified.gangnamUnni[0];
|
|
return fallback || null;
|
|
}
|