64 lines
4.6 KiB
JavaScript
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(/ /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 };
|