'use strict'; // 참고 사이트/기존 사이트 본문을 서버에서 직접 가져와 텍스트로 저장한다(.refs/ref-N.txt). // 인증서 검증 실패(self-signed 등)나 EUC-KR 인코딩 사이트도 읽을 수 있게 하여, Claude 의 WebFetch 실패에 의존하지 않는다. const { safeFetch } = require('./netguard'); const fs = require('fs'); const path = require('path'); const REFS_DIR = '.refs'; const MAX_BYTES = 3 * 1024 * 1024; const MAX_CHARS = 30000; const TIMEOUT = 15000; /** * 참고 주소 내려받기. v1.3.17: lib/netguard 로 단계마다 공개 주소인지 확인하고 그 주소로만 접속하며(S08 — 내부망 요청 차단), * 잘못된 리다이렉트 주소에도 프로세스가 죽지 않는다(S12). 관리자 소유 사이트(allowPrivate)만 사설 주소를 허용한다. * 인증서 검증 실패(self-signed 등) 사이트도 읽을 수 있게 rejectUnauthorized 는 끈다(본문 텍스트만 참고용으로 쓰므로). */ async function fetchRaw(url, opts = {}) { const r = await safeFetch(url, { allowPrivate: !!opts.allowPrivate, maxBytes: MAX_BYTES, timeout: TIMEOUT, rejectUnauthorized: false, accept: 'text/html,*/*;q=0.8', headers: { 'Accept-Language': 'ko,en;q=0.8' }, userAgent: 'Mozilla/5.0 (compatible; WebHostingAgent/1.0)' }); if (r.error) throw new Error(r.error); if (r.status >= 400) throw new Error(`HTTP ${r.status}`); return { buf: r.buf, contentType: r.type, finalUrl: r.finalUrl }; } function decode(buf, contentType) { let cs = (contentType.match(/charset=([\w-]+)/i) || [])[1]; if (!cs) { const head = buf.slice(0, 4096).toString('latin1'); cs = (head.match(/charset=["']?([\w-]+)/i) || [])[1]; } cs = (cs || 'utf-8').toLowerCase(); if (cs === 'ks_c_5601-1987' || cs === 'euc_kr' || cs === 'ksc5601') cs = 'euc-kr'; try { return new TextDecoder(cs).decode(buf); } catch (_) { return buf.toString('utf8'); } } function htmlToText(html) { const title = (html.match(/]*>([\s\S]*?)<\/title>/i) || [, ''])[1].replace(/\s+/g, ' ').trim(); const desc = (html.match(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || [, ''])[1]; const navLinks = [...html.matchAll(/]*>([\s\S]*?)<\/a>/gi)].map((m) => m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()).filter((t) => t && t.length < 30); let body = html.replace(//gi, ' ').replace(//gi, ' ').replace(//g, ' '); body = body.replace(/<(br|p|div|li|h[1-6]|tr|section|article|header|footer|nav)[^>]*>/gi, '\n').replace(/<[^>]+>/g, ' '); body = body.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); body = body.split('\n').map((l) => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n'); return { title, desc, navLinks: [...new Set(navLinks)].slice(0, 40), text: body.slice(0, MAX_CHARS) }; } /** 참고 사이트 목록을 가져와 siteDir/.refs/ref-N.txt 로 저장. 반환: [{no,url,notes,file,ok,error,title}] */ async function prefetchReferences(siteDir, refs, existingUrl, opts = {}) { // opts.allowPrivate: 관리자 사이트만 내부 주소 허용 (v1.3.17) const dir = path.join(siteDir, REFS_DIR); fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); const items = [...(existingUrl ? [{ url: existingUrl, notes: '기존 사이트(현재 운영 중)', existing: true }] : []), ...(refs || [])]; const out = await Promise.all(items.map(async (r, i) => { const no = i + 1; const file = `${REFS_DIR}/ref-${no}.txt`; const rec = { no, url: r.url, notes: r.notes || '', existing: !!r.existing, file, ok: false, error: null, title: '' }; try { const raw = await fetchRaw(r.url, opts); const t = htmlToText(decode(raw.buf, raw.contentType)); rec.title = t.title; const head = [`URL: ${r.url}`, raw.finalUrl !== r.url ? `최종 URL: ${raw.finalUrl}` : '', `제목: ${t.title}`, t.desc ? `설명: ${t.desc}` : '', r.notes ? `사용자가 참고/반영하려는 점: ${r.notes}` : '', t.navLinks.length ? `메뉴/링크: ${t.navLinks.join(' | ')}` : '', '', '--- 본문 텍스트 ---'].filter((x) => x !== '').join('\n'); fs.writeFileSync(path.join(siteDir, file), head + '\n' + t.text, { mode: 0o644 }); rec.ok = true; } catch (e) { rec.error = e.message; fs.writeFileSync(path.join(siteDir, file), `URL: ${r.url}\n${r.notes ? `사용자가 참고/반영하려는 점: ${r.notes}\n` : ''}\n(가져오기 실패: ${e.message}. 사용자가 적은 참고 포인트만 반영하세요.)`, { mode: 0o644 }); } return rec; })); return out; } module.exports = { prefetchReferences, REFS_DIR, htmlToText, decode, fetchRaw };