Files

64 lines
4.6 KiB
JavaScript

'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(/<title[^>]*>([\s\S]*?)<\/title>/i) || [, ''])[1].replace(/\s+/g, ' ').trim();
const desc = (html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || [, ''])[1];
const navLinks = [...html.matchAll(/<a[^>]*>([\s\S]*?)<\/a>/gi)].map((m) => m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()).filter((t) => t && t.length < 30);
let body = html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
body = body.replace(/<(br|p|div|li|h[1-6]|tr|section|article|header|footer|nav)[^>]*>/gi, '\n').replace(/<[^>]+>/g, ' ');
body = body.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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 };