fix(supporters): collect_news 가 site.json 을 값이 바뀔 때만 다시 쓴다 (포맷만 바뀌는 diff 방지)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
484601b0f5
commit
4e73798cce
@ -215,11 +215,11 @@ writeFileSync(OUT, JSON.stringify({ generatedAt: today, clinic, queries, sources
|
||||
|
||||
// 사이트 문구: 매체 예시·출처 문장 (비어 있을 때만. 사람이 적은 값은 유지)
|
||||
if (DATA && existsSync(join(DATA, 'site.json'))) {
|
||||
const site = readJson(join(DATA, 'site.json')); const clinicName = fact.shortName || names[0];
|
||||
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} 기준). 병원이 알리거나 원장이 설명한 기사가 앞에 오고, 병원에 대한 규제·사건 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있습니다.`;
|
||||
writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n');
|
||||
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}`);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// 뉴스룸 수집기 (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] [--max-naver 1000(질의당)] [--max-detail 600] [--max-pages 60]
|
||||
// [--board <게시판 URL> | --no-board] [--no-naver(테스트용)] [--max-naver 1000(질의당)] [--max-detail 600] [--max-pages 60]
|
||||
// 규칙 (사이트 원칙 §3-8, 게이트 rules.mjs 와 같은 정규식):
|
||||
// 1) 관련성: 제목 또는 요약에 병원 이름이 있어야 한다. 관계사(코스메틱·다른 지점)·동명 기사는 뺀다. 게시판 항목은 병원이 직접 올린 것이라 이 검사를 생략한다.
|
||||
// 2) 종류: 병원이 알린 것 release · 원장이 설명한 것 column · 그 밖의 언급 mention.
|
||||
@ -57,7 +57,7 @@ const TOPICS = [
|
||||
];
|
||||
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(/[\s\W]+/g, '').toLowerCase();
|
||||
const norm = (t) => t.replace(/^\[[^\]]+\]\s*/, '').replace(/[^\p{L}\p{N}]+/gu, '').toLowerCase(); // \W 는 한글도 지우므로 쓰지 않는다
|
||||
const stripTags = (s) => String(s ?? '').replace(/<[^>]+>/g, '').replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'|'/g, "'").replace(/ /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})/;
|
||||
@ -128,16 +128,19 @@ async function crawlBoard(listUrl) {
|
||||
// 상세 글에서 작성일과 원문 매체 링크를 읽는다(상한 --max-detail, 동시 4). 날짜는 메타 → "작성일·등록일" 표기 → 본문 첫 날짜 → 목록 날짜 순.
|
||||
const detail = async (x) => {
|
||||
x.source = null;
|
||||
const m = x.title.match(/^\[([^\]]+)\]\s*(.*)$/); if (m) { x.outletHint = m[1].trim(); x.title = m[2].trim(); }
|
||||
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, .board-view, .board_view, .view, article, main, #content') ?? doc.querySelector('body');
|
||||
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 ?? '').match(/(작성일|등록일|게시일|날짜|Date)\s*:?\s*(20\d\d[.\-/년]\s?\d{1,2}[.\-/월]\s?\d{1,2})/);
|
||||
x.date = (meta ? kstDate(meta) : null) ?? (labeled ? dateIn(labeled[2]) : null) ?? dateIn(body?.text);
|
||||
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];
|
||||
@ -146,7 +149,9 @@ async function crawlBoard(listUrl) {
|
||||
x.date = x.date ?? x.listDate ?? null;
|
||||
if (!x.date) { stats.board.noDate++; continue; }
|
||||
const url = x.source ?? x.href;
|
||||
push({ date: x.date, title: x.title, outlet: x.outletHint ?? (x.source ? outletOf(x.source) : `${fact.shortName ?? names[0]} 언론보도 게시판`), url, kind: kindOf(x.title) === 'column' ? 'column' : 'release', origin: 'board', topic: topicOf(x.title), ...(x.source ? { boardUrl: x.href } : {}) }, stats.board);
|
||||
// 게시판 글은 병원이 올린 것이므로 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,7 +161,12 @@ async function naver(query) {
|
||||
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');
|
||||
const r = await fetch(u, { headers: { 'X-Naver-Client-Id': ID, 'X-Naver-Client-Secret': SECRET } });
|
||||
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) {
|
||||
@ -169,11 +179,20 @@ async function naver(query) {
|
||||
}
|
||||
if (list.length < 100) break;
|
||||
idle = stats.naver.kept === before ? idle + 1 : 0;
|
||||
await sleep(100);
|
||||
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');
|
||||
@ -181,15 +200,9 @@ if (!flag('no-board')) {
|
||||
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}`); }
|
||||
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}`);
|
||||
}
|
||||
// 질의 한 개는 최근 1,000건까지만 돌려주므로 여러 각도로 묻는다. 관련성 필터는 질의와 무관하게 같다.
|
||||
const TOPIC_WORDS = ['가슴성형', '안면윤곽', '코성형', '눈성형', '리프팅', '재수술', '의료관광', '수상', '안전'];
|
||||
const queries = [...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;
|
||||
console.log(`네이버: 수신 ${stats.naver.fetched} · 무관 ${stats.naver.irrelevant} · 채택 ${stats.naver.kept} · 제외 사건 ${stats.naver.excludedNegative} 후기·변신 ${stats.naver.excludedBanned} · 중복 ${stats.naver.dup}`);
|
||||
|
||||
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), {});
|
||||
@ -202,11 +215,11 @@ writeFileSync(OUT, JSON.stringify({ generatedAt: today, clinic, queries, sources
|
||||
|
||||
// 사이트 문구: 매체 예시·출처 문장 (비어 있을 때만. 사람이 적은 값은 유지)
|
||||
if (DATA && existsSync(join(DATA, 'site.json'))) {
|
||||
const site = readJson(join(DATA, 'site.json')); const clinicName = fact.shortName || names[0];
|
||||
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} 기준). 병원이 알리거나 원장이 설명한 기사가 앞에 오고, 병원에 대한 규제·사건 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있습니다.`;
|
||||
writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n');
|
||||
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}`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user