/** * 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> = { 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; }