a114d968b0
- Alignment Self-Learning: 자가 조사(질문 전 두뇌 검색)·사용자 답변 두뇌 저장·핵심메시지/프로젝트 컨텍스트 주입 (alignmentResearch.ts 신규)
- 웹 접근: Bridge 폴백 직접 fetch(webFetch.ts 신규)·<fetch_url> 액션 태그·기업 모드 URL/아키텍처 컨텍스트 주입·bare 도메인 인식
- 트리거 버그 수정: startsWith('/') 가 절대경로를 슬래시 명령으로 오인 — 분석 지시·URL 주입 전멸 원인 (회귀 테스트 고정)
- 자기지식 접지: 기능 인벤토리 lazy 재생성·학습 메커니즘 정본 섹션·[인벤토리 대조] 태그 의무화·결정론적 재구현 제안 정정 훅(featureConceptMap.ts 신규)
- 환경 자가점검: HealthCheckMonitor 에 Bridge/두뇌 볼륨/git 자격증명/확장 버전 검사 4종 + readyBar ⚠ 표시
- 두뇌 동기화: 원격 미설정 시 로컬 새로고침 모드·staged 기준 commit 판정·인증 부재 안내
- 기타: outputFormat 기본 markdown(제목 렌더 복구)·레슨/행동제약 truncation 보호 구역 이동·[CONTEXT] 절단 우선순위 재정렬
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
7.2 KiB
TypeScript
168 lines
7.2 KiB
TypeScript
/**
|
|
* 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<string>();
|
|
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<WebFetchResult> {
|
|
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(/<title[^>]*>([\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(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
|
.replace(/<!--[\s\S]*?-->/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) : '';
|
|
});
|
|
}
|