o2o-infinith-demo/supporters/scripts/collect_news.mjs
2026-09-08 14:14:42 +09:00

226 lines
20 KiB
JavaScript

// 뉴스룸 수집기 (v2 §8 "병원 무관 기사 관련성 필터", v3 §2-1 워커 단계). 네이버 뉴스 검색 + (있으면) 홈페이지 언론보도 게시판 → src/data/news.json
// node scripts/collect_news.mjs --clinic <id> --name <상호> --url <홈> [--site <astro 사이트 폴더>] [--out <news.json>]
// [--board <게시판 URL> | --no-board] [--no-naver(테스트용)] [--max-naver 1000(질의당)] [--max-detail 600] [--max-pages 60]
// 규칙 (사이트 원칙 §3-8, 게이트 rules.mjs 와 같은 정규식):
// 1) 관련성: 제목 또는 요약에 병원 이름이 있어야 한다. 관계사(코스메틱·다른 지점)·동명 기사는 뺀다. 게시판 항목은 병원이 직접 올린 것이라 이 검사를 생략한다.
// 2) 종류: 병원이 알린 것 release · 원장이 설명한 것 column · 그 밖의 언급 mention.
// 3) 제외: 규제·사건 보도(NEGATIVE_NEWS), 후기·전후 비교·변신 계열 제목(BANNED_NEWS_TITLE). 걸러진 건수는 stats 에 남긴다.
// 4) 제목·매체·날짜·원문 링크만 쓴다. 기사 본문은 저장하지 않는다.
import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'node-html-parser';
import * as R from './gate/rules.mjs';
import { OUTLETS } from './news_outlets.mjs';
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
const flag = (k) => args.includes(`--${k}`);
const here = fileURLToPath(new URL('.', import.meta.url));
(function loadEnv() { let dir = here; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } dir = dirname(dir); } })();
const clinic = opt('clinic'); const HOME = opt('url');
if (!clinic || !HOME) { console.error('사용법: --clinic <id> --url <https://...> --name <상호>'); process.exit(2); }
const SITE = opt('site') ? resolve(opt('site')) : null;
const DATA = SITE ? join(SITE, 'src/data') : null;
const OUT = resolve(opt('out', DATA ? join(DATA, 'news.json') : join(here, '../src/data/news.json')));
const ID = process.env.NAVER_CLIENT_ID, SECRET = process.env.NAVER_CLIENT_SECRET;
if (!ID || !SECRET) { console.error('NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 없음'); process.exit(2); }
const MAX_NAVER = Number(opt('max-naver', '1000')), MAX_DETAIL = Number(opt('max-detail', '600')), MAX_PAGES = Number(opt('max-pages', '60')); // max-naver 는 질의당
const today = new Date().toISOString().slice(0, 10);
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128 Safari/537.36 INFINITH-supporters/1.0';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
// ---------- 병원 이름·의료진 ----------
const fact = DATA && existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
const authors = DATA && existsSync(join(DATA, 'authors.json')) ? readJson(join(DATA, 'authors.json')) : {};
const names = [...new Set([opt('name'), fact.shortName, fact.name].filter(Boolean).map((n) => n.replace(/\s+/g, '')))];
if (!names.length) { console.error('--name 또는 factSheet.name 필요'); process.exit(2); }
const variants = [...new Set(names.flatMap((n) => [n, n.replace(/의원$/, '')]).filter((n) => n.length >= 3))];
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const loose = (s) => s.split('').map(esc).join('\\s?'); // "원진성형외과" → 띄어쓰기 변형 허용
const NAME = variants.map(loose).join('|');
const NAME_RX = new RegExp(`(${NAME})`);
const doctorNames = Object.values(authors.physicians ?? {}).map((p) => p.name).filter((n) => /^[가-힣]{2,4}$/.test(n));
const DOCTOR = doctorNames.length ? `(${doctorNames.map(esc).join('|')})` : null;
// 보도자료: "상호," / "상호 ○○○ 원장" / "상호 … 도입·개최·수상" / "[상호]"
const RELEASE = new RegExp(`(${NAME})(의원)?\\s*[,:·\\-–—]|(${NAME})(의원)?\\s*[가-힣]{2,4}\\s*(대표)?원장|(${NAME})(의원)?.*(도입|진행|실시|이수|참여|선정|수상|위촉|개최|강화|출시|후원|기부|협약|체결|발표|공개|오픈|운영|확대|인터뷰|노하우|전수|기념)|\\[(${NAME})\\]`);
// 원장 기고·인터뷰: "상호 ○○○ 원장" / "○○○ 상호 원장" / "상호 원장·전문의" / 의료진 실명 + 원장
const COLUMN = new RegExp(`(${NAME})(의원)?\\s*(피부과\\s*|안티에이징센터\\s*)?[가-힣]{2,4}\\s*(대표)?원장|[가-힣]{2,4}\\s*(${NAME})(의원)?\\s*(대표)?원장|(${NAME})(의원)?\\s*(대표)?원장|(${NAME})(의원)?\\s*(성형외과\\s*)?전문의${DOCTOR ? `|${DOCTOR}\\s*(대표)?원장` : ''}`);
const TOPICS = [
['가슴성형', /가슴|유방|보형물|모티바|멘토|프리저베|맘모톰/], ['안면윤곽·양악', /윤곽|양악|광대|사각턱|턱|돌출입/], ['눈·코성형', /쌍꺼풀|눈매|눈성형|눈밑|안검|코성형|코재수술|콧|비중격|기능코/],
['리프팅·피부·안티에이징', /리프팅|거상|주름|피부|울쎄라|써마지|리쥬란|보톡스|필러|줄기세포|안티에이징|온다/], ['지방·바디', /지방|복부|바디|체형/], ['모발·남성', /모발|탈모|남자|남성/],
['수상·인증', /수상|어워드|표창|인증|선정|위촉|KOL|1위/], ['행사·교육·협약', /심포지엄|세미나|학회|교육|견학|협약|MOU|박람회|개막|참여|참가|초청|심사위원|강연|발표/],
['해외·의료관광', /해외|외국인|의료관광|글로벌|중국|베트남|인도네시아|몽골|태국|일본|러시아|K-뷰티/], ['안전·시스템', /안전|마취|CCTV|검진|무사고|실명제|응급|회복|사후관리|애프터케어|방역|고압산소/],
['사회공헌', /기부|후원|봉사|나눔|장학|기탁/], ['방송·출연', /방송|TV|출연|건강한의사/],
];
const topicOf = (t) => TOPICS.find(([, rx]) => rx.test(t))?.[0] ?? '기타';
const kindOf = (title, desc = '') => (RELEASE.test(title) ? 'release' : COLUMN.test(`${title} ${desc}`) ? 'column' : 'mention');
const norm = (t) => t.replace(/^\[[^\]]+\]\s*/, '').replace(/[^\p{L}\p{N}]+/gu, '').toLowerCase(); // \W 는 한글도 지우므로 쓰지 않는다
const stripTags = (s) => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#39;|&apos;/g, "'").replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
const kstDate = (d) => { const x = new Date(d); return isNaN(x) ? null : x.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' }); };
const DATE_RX = /(20\d\d)[.\-/년]\s?(\d{1,2})[.\-/월]\s?(\d{1,2})/;
const dateIn = (s) => { const m = String(s ?? '').match(DATE_RX); return m ? `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}` : null; };
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
const outletOf = (u) => { const h = host(u); if (!h) return '출처 미상'; if (OUTLETS[h]) return OUTLETS[h]; const k = Object.keys(OUTLETS).find((d) => h.endsWith('.' + d) || h === d); return k ? OUTLETS[k] : h; };
const items = []; const seenT = new Set(); const seenU = new Set();
const stats = { naver: { fetched: 0, irrelevant: 0, excludedNegative: 0, excludedBanned: 0, dup: 0, kept: 0 }, board: { pages: 0, listed: 0, noDate: 0, excludedNegative: 0, excludedBanned: 0, dup: 0, kept: 0, detailFetched: 0 } };
function push(it, bucket) {
if (R.NEGATIVE_NEWS.test(it.title)) { bucket.excludedNegative++; return; }
if (R.BANNED_NEWS_TITLE.test(it.title)) { bucket.excludedBanned++; return; }
const k = norm(it.title);
if (seenT.has(k) || seenU.has(it.url)) { bucket.dup++; return; }
seenT.add(k); seenU.add(it.url); bucket.kept++; items.push(it);
}
// ---------- 1. 홈페이지 언론보도 게시판 (선택) ----------
async function get(u) {
try {
const r = await fetch(u, { headers: { 'user-agent': UA, accept: 'text/html,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(20000) });
return { ok: r.ok, status: r.status, url: r.url ?? u, html: r.ok ? await r.text() : '' };
} catch (e) { stats.board.fetchErrors = (stats.board.fetchErrors ?? 0) + 1; return { ok: false, status: 0, url: u, html: '', error: String(e?.message ?? e) }; }
}
const abs = (h, base) => { try { return new URL(h, base).toString(); } catch { return null; } };
async function detectBoard() {
const r = await get(HOME); if (!r.ok) return null;
const origin = new URL(r.url).origin;
const cands = [];
for (const a of parse(r.html).querySelectorAll('a[href]')) {
const href = abs(a.getAttribute('href'), r.url); if (!href || !href.startsWith(origin)) continue;
const text = `${a.text} ${a.getAttribute('title') ?? ''} ${a.querySelector('img')?.getAttribute('alt') ?? ''}`.replace(/\s+/g, ' ').trim();
if (/언론|보도|press|in\s?the\s?news|미디어|media|뉴스|news/i.test(text) && !/뉴스레터|newsletter|유튜브|youtube|블로그|blog/i.test(text)) cands.push({ href, text, score: /언론|보도|press/i.test(text) ? 2 : 1 });
}
cands.sort((a, b) => b.score - a.score);
return cands[0] ?? null;
}
async function crawlBoard(listUrl) {
const origin = new URL(listUrl).origin;
const pageUrl = (param, n) => { const u = new URL(listUrl); u.searchParams.set(param, String(n)); return u.toString(); };
const boardPath = new URL(listUrl).pathname.replace(/\/$/, '');
// 같은 글이 pageid 만 다른 링크로 여러 번 나오므로(kboard) 목록 페이지 인자를 떼고 비교한다
const canon = (h) => { const u = new URL(h); for (const k of ['page', 'pageid', 'page_id', 'pg', 'p']) u.searchParams.delete(k); u.hash = ''; return u.toString(); };
const parseList = (html, base) => {
const found = [];
for (const a of parse(html).querySelectorAll('a[href]')) {
const raw = abs(a.getAttribute('href'), base); if (!raw || !raw.startsWith(origin)) continue;
const href = canon(raw); if (href === canon(listUrl)) continue;
if (!new URL(href).pathname.replace(/\/$/, '').startsWith(boardPath)) continue; // 게시판 경로 아래 링크만 (메뉴·푸터 제외)
if (!/[?&](uid|id|idx|no|seq|wr_id|num|document_srl|bbs_no|article_no)=\d+|\/(view|detail|read|article|post|document)\/\d+|\/\d{2,}\/?($|\?)/i.test(href)) continue; // 상세 글 링크만
const title = stripTags(a.text) || a.getAttribute('title') || a.querySelector('img')?.getAttribute('alt') || '';
if (title.length < 8 || /^(이전|다음|처음|마지막|더보기|목록|글쓰기|prev|next|first|last|more|list)$/i.test(title)) continue;
found.push({ href, title, listDate: dateIn(a.parentNode?.text) }); // 목록의 날짜는 상세 글에 날짜가 없을 때만 쓴다
}
return found;
};
let param = 'page'; const seenHref = new Set(); const rows = [];
for (let n = 1; n <= MAX_PAGES; n++) {
const r = await get(n === 1 ? listUrl : pageUrl(param, n)); if (!r.ok) break;
stats.board.pages++;
let list = parseList(r.html, r.url).filter((x) => !seenHref.has(x.href));
if (n === 2 && !list.length && param === 'page') { param = 'pageid'; const r2 = await get(pageUrl(param, n)); if (r2.ok) list = parseList(r2.html, r2.url).filter((x) => !seenHref.has(x.href)); }
if (!list.length) break;
for (const x of list) { seenHref.add(x.href); rows.push(x); }
await sleep(250);
}
stats.board.listed = rows.length;
// 상세 글에서 작성일과 원문 매체 링크를 읽는다(상한 --max-detail, 동시 4). 날짜는 메타 → "작성일·등록일" 표기 → 본문 첫 날짜 → 목록 날짜 순.
const detail = async (x) => {
x.source = null;
const m = x.title.match(/^\[([^\]]+)\]\s*(.*)$/);
if (m) { x.title = m[2].trim(); if (/칼럼|기고|인터뷰|Q&A/i.test(m[1])) x.kindHint = 'column'; else x.outletHint = m[1].trim(); } // "[칼럼]"은 매체가 아니라 종류
if (stats.board.detailFetched >= MAX_DETAIL) return;
stats.board.detailFetched++;
const d = await get(x.href);
if (!d.ok) return;
const doc = parse(d.html);
const body = doc.querySelector('#kboard-document, .kboard-document, .kboard-document-wrap, .board-view, .board_view, .board-detail, .view-content, article, main, #content') ?? doc.querySelector('body');
const meta = doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content') ?? doc.querySelector('meta[itemprop="datePublished"], meta[name="date"]')?.getAttribute('content');
const labeled = (body?.text ?? '').replace(/\s+/g, ' ').match(/(작성일|등록일|게시일|날짜|Date)\s*:?\s*(20\d\d[.\-/년]\s?\d{1,2}[.\-/월]\s?\d{1,2})/);
const cell = doc.querySelector('.detail-date .detail-value, .kboard-detail-date, .board-date, .date, time[datetime]');
// 메타(article:published_time)는 마지막에 본다. 워드프레스 게시판은 글이 아니라 게시판 페이지의 생성일을 넣어 전 글이 같은 날짜가 된다.
x.date = dateIn(cell?.getAttribute?.('datetime') ?? cell?.text) ?? (labeled ? dateIn(labeled[2]) : null) ?? (meta ? kstDate(meta) : null) ?? dateIn(body?.text);
for (const a of (body ?? doc).querySelectorAll('a[href]')) { const h = abs(a.getAttribute('href'), d.url); if (h && !h.startsWith(origin) && OUTLETS[host(h)] !== undefined) { x.source = h; break; } }
};
const queue = [...rows];
await Promise.all(Array.from({ length: 4 }, async () => { while (queue.length) { await detail(queue.shift()); await sleep(80); } }));
for (const x of rows) {
x.date = x.date ?? x.listDate ?? null;
if (!x.date) { stats.board.noDate++; continue; }
const url = x.source ?? x.href;
// 게시판 글은 병원이 올린 것이므로 release 가 기본. 제목에 병원 이름이 없는 시술 설명형은 원장 기고(column)로 본다(수작업 build_news.py 와 같은 규칙)
const kind = x.kindHint ?? (kindOf(x.title) === 'column' || !NAME_RX.test(x.title) ? 'column' : 'release');
push({ date: x.date, title: x.title, outlet: x.outletHint ?? (x.source ? outletOf(x.source) : `${fact.shortName ?? names[0]} 언론보도 게시판`), url, kind, origin: 'board', topic: topicOf(x.title), ...(x.source ? { boardUrl: x.href } : {}) }, stats.board);
}
}
// ---------- 2. 네이버 뉴스 검색 ----------
async function naver(query) {
let fetched = 0, idle = 0;
for (let start = 1; start <= 1000 && fetched < MAX_NAVER && idle < 2; start += 100) {
const before = stats.naver.kept;
const u = new URL('https://openapi.naver.com/v1/search/news.json'); u.searchParams.set('query', `"${query}"`); u.searchParams.set('display', '100'); u.searchParams.set('start', String(start)); u.searchParams.set('sort', 'date');
let r, tries = 0;
for (;;) { // 초당 10건 제한. 429 면 물러났다가 다시(최대 5회)
r = await fetch(u, { headers: { 'X-Naver-Client-Id': ID, 'X-Naver-Client-Secret': SECRET } });
if (r.status !== 429 || ++tries > 5) break;
stats.naver.rateLimited = (stats.naver.rateLimited ?? 0) + 1; await sleep(1500 * tries);
}
if (!r.ok) throw new Error(`네이버 뉴스 API ${r.status} ${(await r.text()).slice(0, 200)}`);
const j = await r.json(); const list = j.items ?? [];
for (const n of list) {
stats.naver.fetched++; fetched++;
const title = stripTags(n.title), desc = stripTags(n.description);
if (!NAME_RX.test(title) && !NAME_RX.test(desc)) { stats.naver.irrelevant++; continue; } // v2 §8: 병원 무관 기사
const date = kstDate(n.pubDate); if (!date) continue;
const url = n.originallink || n.link;
push({ date, title, outlet: outletOf(url), url, kind: kindOf(title, desc), origin: 'naver', topic: topicOf(`${title} ${desc}`) }, stats.naver);
}
if (list.length < 100) break;
idle = stats.naver.kept === before ? idle + 1 : 0;
await sleep(350);
}
}
// ---------- 실행 ----------
// 순서: 네이버 → 게시판. 같은 기사가 양쪽에 있으면 매체 원문 URL 이 있는 네이버 항목을 남기고 게시판 사본은 중복으로 센다.
// 질의 한 개는 최근 1,000건까지만 돌려주므로 여러 각도로 묻는다. 관련성 필터는 질의와 무관하게 같다.
const TOPIC_WORDS = ['가슴성형', '안면윤곽', '코성형', '눈성형', '리프팅', '재수술', '의료관광', '수상', '안전'];
const queries = flag('no-naver') ? [] : [...new Set(variants.slice(0, 2).flatMap((v) => [v, `${v} 원장`, ...doctorNames.slice(0, 8).map((d) => `${v} ${d}`), ...TOPIC_WORDS.map((t) => `${v} ${t}`)]))];
for (const q of queries) await naver(q);
stats.naver.queries = queries.length;
if (queries.length) console.log(`네이버: 질의 ${queries.length} · 수신 ${stats.naver.fetched} · 무관 ${stats.naver.irrelevant} · 채택 ${stats.naver.kept} · 제외 사건 ${stats.naver.excludedNegative} 후기·변신 ${stats.naver.excludedBanned} · 중복 ${stats.naver.dup}`);
else console.log('네이버: 건너뜀 (--no-naver)');
let boardUrl = null, boardNote = '건너뜀';
if (!flag('no-board')) {
const pinPath = join(here, '..', 'briefs', clinic, 'clinic.json');
const pin = existsSync(pinPath) ? readJson(pinPath) : {};
boardUrl = opt('board') ?? pin.newsBoard ?? null;
if (!boardUrl) { const d = await detectBoard(); if (d) { boardUrl = d.href; boardNote = `자동 발견 "${d.text}"`; } else boardNote = '홈페이지에 언론보도 게시판 링크 없음'; }
else boardNote = opt('board') ? '--board 지정' : 'clinic.json newsBoard';
if (boardUrl) { console.log(`게시판: ${boardUrl} (${boardNote})`); await crawlBoard(boardUrl); console.log(` 목록 ${stats.board.listed} · 채택 ${stats.board.kept} · 날짜 없음 ${stats.board.noDate} · 제외 사건 ${stats.board.excludedNegative} 후기·변신 ${stats.board.excludedBanned} · 중복(네이버와 겹침 포함) ${stats.board.dup}`); }
else console.log(`게시판: ${boardNote}`);
}
items.sort((a, b) => b.date.localeCompare(a.date));
const count = (f) => items.reduce((m, x) => ((m[f(x)] = (m[f(x)] ?? 0) + 1), m), {});
const kinds = count((x) => x.kind), outlets = count((x) => x.outlet);
const topOutlets = Object.entries(outlets).sort((a, b) => b[1] - a[1]).map(([k]) => k).filter((k) => k !== '출처 미상' && !/게시판$/.test(k));
const gate = R.checkNewsItems(items); if (gate.length) { console.error(gate.map((g) => ` ✗ [${g.code}] ${g.msg}`).join('\n')); process.exit(1); } // 수집기 자체 검사. 여기서 걸리면 규칙과 필터가 어긋난 것
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({ generatedAt: today, clinic, queries, sources: { board: boardUrl, boardNote, naver: `네이버 뉴스 검색, ${today}` }, stats: { kinds, outlets: Object.keys(outlets).length, naver: stats.naver, board: stats.board, years: count((x) => x.date.slice(0, 4)) }, items }, null, 1) + '\n');
// 사이트 문구: 매체 예시·출처 문장 (비어 있을 때만. 사람이 적은 값은 유지)
if (DATA && existsSync(join(DATA, 'site.json'))) {
const site = readJson(join(DATA, 'site.json')); const clinicName = fact.shortName || names[0]; const before = JSON.stringify(site);
if (!site.newsOutletsExample && topOutlets.length) site.newsOutletsExample = `${topOutlets.slice(0, 4).join(', ')} 등`;
if (!site.newsNote) site.newsNote = boardUrl
? `${clinicName} 홈페이지 언론보도 게시판과 네이버 뉴스 검색에서 모은 기사입니다(${today} 기준). 병원이 알리거나 원장이 설명한 기사, 병원 이름이 제목이나 요약에 나온 기사만 모았고, 병원에 대한 규제·사건 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있습니다.`
: `네이버 뉴스 검색에서 ${clinicName} 이름이 제목이나 요약에 나온 기사를 모았습니다(${today} 기준). 병원이 알리거나 원장이 설명한 기사가 앞에 오고, 병원에 대한 규제·사건 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있습니다.`;
if (JSON.stringify(site) !== before) writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n'); // 바뀐 게 없으면 사람이 맞춘 포맷을 건드리지 않는다
}
console.log(`뉴스 ${items.length}건 (보도자료 ${kinds.release ?? 0} · 기고·인터뷰 ${kinds.column ?? 0} · 언급 ${kinds.mention ?? 0}) · 매체 ${Object.keys(outlets).length} · 게시판 ${boardUrl ? '있음' : '없음'} → ${OUT}`);