/** * Web Fetch — Bridge 무관 직접 URL fetch (vscode 의존 없음 — 테스트 용이). * * 배경: 일반 챗의 URL 주입(urlContext.ts)은 Datacollect Bridge(:3002)에 100% * 의존했다. Bridge가 꺼져 있으면 — 확장은 Bridge를 자동 시작하지 않는다 — * "접근 실패" 블록이 떠서 모델이 "사이트 방문 불가"라고 답하는 공백이 있었다. * 이 모듈은 그 폴백: extension host의 global fetch(Node 18+, bridgeClient가 * 이미 사용 중)로 직접 페이지를 가져와 본문 텍스트를 추출한다. * * 설계 원칙: 절대 throw 하지 않는다 — 모든 실패는 {ok:false, error} 로 반환. */ /** * 스킴 없는 도메인 인식용 보수적 TLD 목록 — 사용자는 "koritips.com 가서 분석해줘" * 처럼 https:// 를 생략하기 마련이라, 흔한 TLD 만 허용해 파일명(utils.ts, * package.json 등) 오인을 차단한다. */ const BARE_DOMAIN_TLDS = 'com|net|org|io|co|kr|jp|dev|app|ai|me|info|blog|shop|site|xyz|cc|tv|us|uk|edu|gov'; /** * http(s) URL 추출 — dedupe + trailing 구두점 제거. 슬래시 명령은 자체 처리하므로 * 제외. https:// 가 없는 bare 도메인(koritips.com, www.foo.net 등)도 인식해 * https:// 를 붙여 반환한다. */ export function extractUrls(text: string, max = 2): string[] { const t = (text || '').trim(); // 슬래시 *명령* (/wikify 등)만 제외 — 절대경로("/Volumes/... koritips.com 봐줘")로 // 시작하는 프롬프트는 URL 추출 대상이다 (startsWith('/') 는 경로를 오인했던 버그). if (!t || /^\/[a-zA-Z][\w-]*(\s|$)/.test(t)) return []; const seen = new Set(); const out: string[] = []; // ① 스킴 있는 URL 우선. const re = /https?:\/\/[^\s<>"'`)\]]+/gi; let m: RegExpExecArray | null; while ((m = re.exec(t)) !== null && out.length < max) { // 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거. const url = m[0].replace(/[.,;:!?…」』)]+$/, ''); if (!seen.has(url)) { seen.add(url); out.push(url); } } if (out.length >= max) return out; // ② Bare 도메인 — 이미 찾은 URL 영역은 마스킹해 이중 매칭 방지. // 직전 문자가 @(이메일)·/(경로 일부)·.(서브파트) 면 제외. let masked = t; for (const u of out) masked = masked.split(u).join(' '.repeat(Math.min(u.length, 8))); const bareRe = new RegExp( `(^|[\\s("'\`「『<>])((?:[a-z0-9-]+\\.)+(?:${BARE_DOMAIN_TLDS})(?:\\.[a-z]{2})?(?::\\d{2,5})?(?:/[^\\s<>"'\`)\\]]*)?)`, 'gi', ); while ((m = bareRe.exec(masked)) !== null && out.length < max) { const candidate = m[2].replace(/[.,;:!?…」』)]+$/, ''); if (!candidate.includes('.')) continue; const url = `https://${candidate}`; if (!seen.has(url) && !seen.has(`http://${candidate}`)) { seen.add(url); out.push(url); } } return out; } export interface WebFetchResult { ok: boolean; url: string; title: string; text: string; error?: string; } const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_CHARS = 20_000; /** * URL 본문을 직접 fetch 해 텍스트로 변환. HTML 이면 태그를 걷어내고, * 그 외(text/json 등)는 원문 그대로 cap. http/https 만 허용. */ export async function fetchUrlDirect( url: string, opts: { timeoutMs?: number; maxChars?: number } = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS; const fail = (error: string): WebFetchResult => ({ ok: false, url, title: '', text: '', error }); if (!/^https?:\/\//i.test(url)) return fail('http/https URL만 지원합니다.'); if (typeof fetch !== 'function') return fail('이 환경은 직접 fetch를 지원하지 않습니다.'); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { signal: controller.signal, redirect: 'follow', headers: { // 일부 사이트가 UA 없는 요청을 차단 — 평범한 브라우저 UA 로. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5', }, }); if (!res.ok) return fail(`HTTP ${res.status} ${res.statusText || ''}`.trim()); const contentType = (res.headers.get('content-type') || '').toLowerCase(); const raw = await res.text(); if (!raw.trim()) return fail('응답 본문이 비어 있습니다.'); if (contentType.includes('html') || /^\s*<(!doctype|html)/i.test(raw)) { const title = _extractTitle(raw); const text = htmlToText(raw).slice(0, maxChars); if (text.trim().length < 50) { return fail('본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).'); } return { ok: true, url, title, text }; } return { ok: true, url, title: '', text: raw.slice(0, maxChars) }; } catch (e: any) { const msg = e?.name === 'AbortError' ? `타임아웃 (${Math.round(timeoutMs / 1000)}s)` : String(e?.message ?? e).slice(0, 120); return fail(msg); } finally { clearTimeout(timer); } } function _extractTitle(html: string): string { const m = html.match(/]*>([\s\S]*?)<\/title>/i); return m ? decodeEntities(m[1]).replace(/\s+/g, ' ').trim().slice(0, 200) : ''; } /** HTML → 평문. script/style/noscript 제거 → 블록 태그를 줄바꿈으로 → 태그 strip → 엔티티 → 공백 정리. */ export function htmlToText(html: string): string { let s = html .replace(//gi, ' ') .replace(//gi, ' ') .replace(//gi, ' ') .replace(//g, ' '); // 블록 요소 경계를 줄바꿈으로 보존 — 문단 구조가 텍스트에도 남게. s = s.replace(/<\/(p|div|section|article|li|tr|h[1-6]|blockquote|pre)>/gi, '\n') .replace(/<(br|hr)\s*\/?>/gi, '\n'); s = s.replace(/<[^>]+>/g, ' '); s = decodeEntities(s); // 공백 정리: 줄 내 다중 공백 → 1개, 3개 이상 연속 줄바꿈 → 2개. return s .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } /** 최소 엔티티 디코드 — 본문 가독에 필요한 흔한 것만. */ export function decodeEntities(s: string): string { return s .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/�?39;|'/gi, "'") .replace(/&#(\d+);/g, (_, code) => { const n = Number(code); return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : ''; }); }