feat(supporters): 뉴스룸 워커 단계 + 뉴스 게이트 규칙, launchd 폴러 설치

- collect_news.mjs: 네이버 뉴스 검색 다중 질의(상호·원장·의료진·주제어, 429 백오프) + 언론보도 게시판 자동 발견/clinic.json newsBoard.
  관련성 = 제목·요약에 병원 이름(v2 §8). 제목·매체·날짜·원문 링크만 저장. news_outlets.mjs 는 build_news.py 매체 표 이식
- gate: BANNED_NEWS_TITLE(전후 사진·비교, 후기, 변신)·NEGATIVE_NEWS(규제·사건, "무사고" 통과)·checkNewsItems → check.mjs·test.mjs(19/19)
- 워커 news 단계(youtube 뒤, images 앞), --news-board/--no-news, status.report.news
- 뷰 news.json: 렛미인 "변신" 기사 41건 규칙으로 제외(excludedByGate 에 목록). 회귀 1,105건(수작업 1,096, 제목 일치 1,033), 원진 1,049건
- launchd 폴러 설치(haewon 지시): PATH 에 프레임워크 python3, bootstrap/bootout 안내
- 함정: JS \W 는 한글을 지움(제목 중복 판정 붕괴), 워드프레스 article:published_time 은 게시판 생성일, 네이버 질의당 1,000건
- v3 문서 §1·§2·§3·§5·§6 갱신, 템플릿 재내보내기

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-08 14:14:08 +09:00
parent f6701c2788
commit 484601b0f5
17 changed files with 823 additions and 386 deletions

View File

@ -32,17 +32,18 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
| 자동 채점·리포트 | `scripts/audit_aeo_geo.py``src/data/discovery_<id>.ts``/discovery/:id` · `scripts/build_discovery_report.ts` → HTML/PDF | 뷰 AEO 42/GEO 56 · 원진 AEO 15/GEO 45 |
| 유튜브·로고 | `collect_youtube.mjs`, 워커 youtube 단계, 고정값 `supporters/briefs/<id>/clinic.json` | 원진 736편 집계, @wjwonjin 고정 |
| 이미지 | `collect_images.mjs` + `img_process.py`, 규칙 `docs/INFINITH_Supporters_Image_Rules.md` | 원진 66장 |
| 뉴스룸 | `collect_news.mjs`(네이버 뉴스 검색 다중 질의 + 언론보도 게시판 자동 발견) → 워커 news 단계, 게이트 `checkNewsItems` | 뷰 회귀 1,105건(수작업 1,096, 제목 일치 1,033, 종류 분포 기고 768/732 · 보도자료 101/107 · 언급 236/257) · 원진 1,049건(게시판 없음). 09-08 |
| 기획·생성·게이트 | `default_briefs.mjs``generate_posts.mjs`(gen/) → `gate/rules.mjs`·`check.mjs`(테스트 16/16) | 편당 약 $0.09 |
| 워커·큐·승인 UI | `workers/supporters-build/run.mjs`·`poll.mjs`, `supporter_builds`·`supporter_inputs`, `ClinicInputsPanel`(/discovery/:id·/supporters/:id) | 라이브. 폴러 설치 |
| 워커·큐·승인 UI | `workers/supporters-build/run.mjs`·`poll.mjs`, `supporter_builds`·`supporter_inputs`, `ClinicInputsPanel`(/discovery/:id·/supporters/:id) | 라이브. **폴러 설치됨(2026-09-08, Mac launchd 5분, 로그 `~/supporters-builds/poll.log`)**. Mac이 켜져 있어야 돈다 |
| 입력 반영 | `apply_inputs.mjs` (검토·승인 → frontmatter, 자유 답변 → clinicAnswers 근거 [A]) | dry-run 검증 |
| 미리보기 | supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex) | 배포됨 |
## 2. 남은 일 (권장 순서)
1. **뉴스룸 단계**: `supporters/scripts/build_news.py`(홈페이지 언론 게시판 + 네이버 뉴스 API, 부정 기사 제외)를 워커 단계로. 원진은 게시판 위치부터 확인. 병원 무관 기사 본문 관련성 필터(v2 §8 미반영) 포함.
1. ~~뉴스룸 단계~~ **완료(09-08)**: `collect_news.mjs` + 워커 `news` 단계(youtube 뒤, images 앞). 관련성 = 제목·요약에 병원 이름(v2 §8 반영). 제외 규칙은 게이트와 같은 정규식(`BANNED_NEWS_TITLE` 전후 사진·비교·후기·변신, `NEGATIVE_NEWS` 규제·사건, "무사고"는 통과). 뷰 수작업 news.json에서 렛미인 "변신" 기사 41건이 이 규칙으로 빠졌다(`excludedByGate`에 목록). 원진 홈페이지에는 언론 게시판이 없어 네이버만. `build_news.py`는 참조용으로 남김. **남은 판단(§3-6)**: "언론 언급"(병원 이름이 요약에만 나오는 기사, 원진 514건)을 뉴스룸에 보일지.
2. **감사 재실행 단계(Phase 9)**: 색인 허용 빌드를 로컬에서 `audit_aeo_geo.py`로 다시 채점해 공식 vs 서포터즈 비교표를 리포트 카드에. `scripts/build_supporters_audit_xlsx.py` 로직 재사용.
3. **수동 실측 항목 채우기(원진)**: 강남언니 2500·바비톡 3143·GBP·네이버 플레이스 NAP 대조(D1·D2·D4·D5). 실측하면 GEO 점수가 바뀐다. 리포트 재생성은 `npx esbuild scripts/build_discovery_report.ts ... && node report.mjs --id wonjin ...` + Chrome PDF (v2 §11 명령 참조).
4. **워커 상시 실행**: Mac launchd 폴러 설치(`workers/supporters-build/launchd/`) 또는 Trigger.dev 이관(가입·키만 haewon). 랜딩 URL 신청 → 큐 → 자동 빌드가 이어지려면 이게 필요하다.
4. ~~워커 상시 실행~~ **완료(09-08)**: Mac launchd 폴러 설치(haewon 지시). 해제 `launchctl bootout gui/$(id -u)/com.infinith.supporters-poll`. Trigger.dev 이관은 월 수십 건이 되면. 실제 랜딩 신청 1건으로 큐 → 빌드 완주를 아직 못 봤다(큐가 비어 있었음). 첫 신청이 오면 `poll.log`를 본다.
5. **병원 송부 전 사람 확인**: 원진 이미지 갤러리 육안 확인(얼굴·신체 검출 없음), "확인 필요" 채널 12건 판정, 쇼츠 정리 답(사람이 영상 보고 한 문단).
6. **검토 파이프라인**: 병원이 `/supporters/wonjin` 폼에 입력 → `run.mjs --from data --from-supabase --deploy --build-id 87cb75a2-2037-4cbb-ae27-c06e39f022e8` 재빌드. 이 재빌드도 폴러가 자동으로 집게 하려면 supporter_inputs insert 트리거를 큐에 연결.
7. **뷰성형외과 자동 빌드 미리보기**(supporters-viewclinic)를 수작업 샘플(view-supporters-sample) 수준으로: 뷰는 자막·쇼츠 답이 사람 작업이라 격차가 있다. 자막 파이프라인(v1 §6, IP 차단 대비)이 붙어야 좁혀진다.
@ -52,9 +53,10 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
1. 원진 "확인 필요" 채널 12건 중 병원 것(영문 채널 Wonjin Plastic Surgery Korea, WONJIN_OFFICIAL 등)을 사이트에 연결할지.
2. 지원 관계 고지 문구(site.sponsorNotice)를 켤지와 문안. 켜면 글 첫 부분에 노출되고 게이트가 필수 검사로 바뀐다(v2 §12-2).
3. 워커 상시 실행 위치(Mac 폴러 설치 vs Trigger.dev 가입).
3. ~~워커 상시 실행 위치~~ 결정됨(09-08): 파일럿은 Mac 폴러. Trigger.dev는 정착 단계에.
4. 원진에 미리보기·리포트 송부 시점과 병원 입력 담당자.
5. 경영진 미결 그대로: 도메인(별도 vs 서브도메인), POC 범위·가격, 서포터즈 실존 개인 모집.
6. 뉴스룸 "언론 언급" 종류(mention)를 보일지. 병원이 알린 기사·원장 기고만 남기면 원진은 1,049건 중 535건이 된다. 보이면 관계사(WJ코스메틱)·투자·대학 기사가 섞인다. 기본값은 지금처럼 보이되 뒤로 정렬.
## 4. 이번 세션들(09-07 세션 3 ~ 09-08)에서 확정한 것
@ -69,6 +71,9 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
- 모델 근거 대조기는 "근거에 있다"고 적으면서 플래그를 세운다. 항목별 supported 판정으로 거른다.
- 게이트는 영상 제목도 본다. "부작용은 없을까?" 제목이 홈·영상 페이지에서 배포를 막았다. 수집 단계에서 같은 규칙으로 거른다.
- 인터넷이 끊기면 생성 단계가 fetch failed 로 멈춘다. `--from generate` 로 재개.
- JS 정규식 `\W`·`\w`·`\b`는 한글을 문자로 보지 않는다. Python 스크립트를 옮길 때 제목 정규화에 `\W`를 쓰면 한글 제목이 전부 같은 키로 뭉쳐 중복으로 버려진다(뉴스 수집기에서 445건 중 397건이 사라졌다). `[^\p{L}\p{N}]`에 `u` 플래그.
- 워드프레스 게시판의 `article:published_time` 메타는 글이 아니라 게시판 페이지 생성일이다. 글 날짜는 "작성일" 셀에서 읽고 메타는 마지막 후보로.
- 네이버 검색 API는 질의당 최근 1,000건, 초당 10건. 여러 질의(상호·상호 원장·상호+의료진·상호+주제어)로 넓히고 429는 물러났다 다시.
- `node --experimental-strip-types` 는 확장자 없는 import 를 못 푼다. TS 스크립트는 esbuild 번들로 돌린다.
## 6. 자주 쓰는 명령
@ -80,6 +85,8 @@ node workers/supporters-build/run.mjs --clinic <id> --url <홈> --name <상호>
node workers/supporters-build/run.mjs --clinic <id> --url <> --work ~/supporters-builds/<id> --from <단계> --deploy --build-id <id>
# 큐 비우기 / 상주
node workers/supporters-build/poll.mjs [--loop 300]
# 뉴스룸만 다시 (supporters/). 게시판은 자동 발견, 없으면 --board 또는 briefs/<id>/clinic.json newsBoard
node scripts/collect_news.mjs --clinic <id> --url <> --name <상호> --site ~/supporters-builds/<id>/site
# 게이트 테스트 · 뷰 정답지 회귀 · 템플릿 내보내기 (supporters/)
npm run gate:test · npm run generate:regress · npm run export:template
# 진단 리포트 HTML/PDF

View File

@ -100,6 +100,8 @@ add('data/factSheet.json', R.checkNextCheck(fact, today));
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다.
const news = existsSync(here('../src/data/news.json')) ? JSON.parse(readFileSync(here('../src/data/news.json'), 'utf8')) : { items: [] };
add('data/news.json', R.checkNewsItems(news.items)); // 뉴스룸: 후기·전후 비교·변신 제목, 규제·사건 보도, 날짜 형식
// ---------- 출력 ----------
const errors = findings.filter((x) => x.level === R.E);

View File

@ -0,0 +1,225 @@
// 뉴스룸 수집기 (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];
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');
}
console.log(`뉴스 ${items.length}건 (보도자료 ${kinds.release ?? 0} · 기고·인터뷰 ${kinds.column ?? 0} · 언급 ${kinds.mention ?? 0}) · 매체 ${Object.keys(outlets).length} · 게시판 ${boardUrl ? '있음' : '없음'}${OUT}`);

View File

@ -246,6 +246,23 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`));
}
/** (news.json). ·· .
* 1) 후기·전후·체험담 계열 제목(경험담 광고, 의료법 56 2 2) 2) 병원에 대한 규제·사건 보도(사이트 원칙: 병원이 알리거나 원장이 설명한 기사만 싣는다)
* 수집기(collect_news.mjs) 같은 규칙으로 먼저 거르고, 여기서는 거르지 않고 넘어온 것을 막는다. */
export const BANNED_NEWS_TITLE = /전후\s*(사진|비교|모습|공개)|비포|애프터|before\s*&?\s*after|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼/i; // "수술 전후 유방검진" 같은 시기 표현은 통과
export const NEGATIVE_NEWS = /공정위|제재|시정명령|뒷광고|적발|철퇴|덜미|기만|소송|사망|(?<!무)사고(?![력])|경찰|검찰|논란|피소|의료사고|압수|내돈내산|조작|가짜|대본|허위|과장\s*광고|벌금|징계|영업정지|고발|수사|불법/; // "무사고 13주년"은 통과
export function checkNewsItems(items, { level = E } = {}) {
const out = [];
for (const n of items ?? []) {
const t = n.title ?? '';
if (BANNED_NEWS_TITLE.test(t)) out.push(r(level, 'BANNED_NEWS_TITLE', `뉴스 제목 금칙: "${t}" (${n.url ?? ''})`));
if (NEGATIVE_NEWS.test(t)) out.push(r(level, 'NEGATIVE_NEWS', `규제·사건 보도는 싣지 않는다: "${t}" (${n.url ?? ''})`));
if (!/^\d{4}-\d{2}-\d{2}$/.test(n.date ?? '')) out.push(r(level, 'NEWS_DATE', `날짜 형식(YYYY-MM-DD) 아님: "${n.date}" · "${t}"`));
if (!['release', 'column', 'mention'].includes(n.kind)) out.push(r(level, 'NEWS_KIND', `kind 값 오류: "${n.kind}" · "${t}"`));
}
return out;
}
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,

View File

@ -89,5 +89,25 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과');
}
// 7) 뉴스룸 항목 (news.json): 후기·전후 비교·변신 제목과 규제·사건 보도는 error, 시기 표현("수술 전후 검진")·"무사고"는 통과
{
const flagged = R.checkNewsItems([
{ title: '조여정 닮은꼴 강은수, 성형 전후 비교해보니', date: '2014-08-15', kind: 'column', url: 'a' },
{ title: '주걱턱 태국소녀, 인형 같은 변신 성공', date: '2014-09-06', kind: 'column', url: 'b' },
{ title: '○○성형외과 공정위 제재… 뒷광고 적발', date: '2026-01-01', kind: 'mention', url: 'c' },
{ title: '날짜 형식이 다른 기사', date: '2026.01.01', kind: 'mention', url: 'd' },
]);
const c = codes(flagged);
['BANNED_NEWS_TITLE', 'NEGATIVE_NEWS', 'NEWS_DATE'].every((x) => c.includes(x)) && flagged.filter((x) => x.code === 'BANNED_NEWS_TITLE').length === 2 ? ok('뉴스 제목 금칙·사건 보도·날짜 검출', c.join(', ')) : bad('뉴스 항목 검출', JSON.stringify(flagged.map((x) => x.code)));
const passed = R.checkNewsItems([
{ title: '가슴수술 전후, 유방검진 왜 중요할까?', date: '2020-03-11', kind: 'column', url: 'e' },
{ title: "'환자안전 제일'… 뷰성형외과, 개원 16년동안 무사고 행보", date: '2020-01-17', kind: 'release', url: 'f' },
]);
passed.length ? bad('뉴스 정상 제목 오탐', passed.map((x) => x.msg).join(' | ')) : ok('"수술 전후 검진"·"무사고" 제목 통과');
const real = JSON.parse(readFileSync(here('../../src/data/news.json'), 'utf8'));
const re = R.checkNewsItems(real.items);
re.length ? bad('news.json 회귀', `${re.length}건: ` + re.slice(0, 3).map((x) => x.msg).join(' | ')) : ok('news.json 전 항목 규칙 통과', `${real.items.length}`);
}
console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1);

View File

@ -0,0 +1,2 @@
// 매체 도메인 → 매체명. build_news.py(뷰성형외과 수작업)의 표를 옮겼다. 없는 도메인은 호스트명을 그대로 쓴다.
export const OUTLETS = {"mdtoday.co.kr": "메디컬투데이", "jeonmin.co.kr": "전민일보", "moneyweek.co.kr": "머니S", "ajunews.com": "아주경제", "rapportian.com": "라포르시안", "n.news.naver.com": "네이버뉴스", "health.cbs.co.kr": "노컷뉴스", "newsis.com": "뉴시스", "biz.heraldcorp.com": "헤럴드경제", "heraldcorp.com": "헤럴드경제", "eroun.net": "이로운넷", "hidoc.co.kr": "하이닥", "sportsworldi.com": "스포츠월드", "newsway.co.kr": "뉴스웨이", "koreadaily.com": "미주중앙일보", "sports.chosun.com": "스포츠조선", "bokuennews.com": "보건뉴스", "medisobizanews.com": "메디소비자뉴스", "megaeconomy.co.kr": "메가경제", "newspim.com": "뉴스핌", "wowtv.co.kr": "한국경제TV", "wowstar.wowtv.co.kr": "한국경제TV 와우스타", "etoday.co.kr": "이투데이", "medipana.com": "메디파나뉴스", "view.asiae.co.kr": "아시아경제", "asiae.co.kr": "아시아경제", "jemin.com": "제민일보", "newstown.co.kr": "뉴스타운", "sports.hankooki.com": "스포츠한국", "hankooki.com": "한국일보", "psychiatricnews.net": "정신의학신문", "mt.co.kr": "머니투데이", "siminilbo.co.kr": "시민일보", "hemophilia.co.kr": "헤모필리아라이프", "beopbo.com": "법보신문", "stardailynews.co.kr": "스타데일리뉴스", "thefirstmedia.net": "더퍼스트미디어", "edu.donga.com": "에듀동아", "ikoreanspirit.com": "코리안스피릿", "biotimes.co.kr": "바이오타임즈", "gosiweek.com": "고시위크", "livesnews.com": "라이브뉴스", "vegannews.co.kr": "비건뉴스", "sisunnews.co.kr": "시선뉴스", "dhnews.co.kr": "대학저널", "yna.co.kr": "연합뉴스", "news.kbs.co.kr": "KBS", "news.sbs.co.kr": "SBS", "biz.sbs.co.kr": "SBS Biz", "imnews.imbc.com": "MBC", "donga.com": "동아일보", "hani.co.kr": "한겨레", "khan.co.kr": "경향신문", "chosun.com": "조선일보", "biz.chosun.com": "조선비즈", "hankookilbo.com": "한국일보", "mbn.mk.co.kr": "MBN", "mk.co.kr": "매일경제", "yonhapnewstv.co.kr": "연합뉴스TV", "news.tvchosun.com": "TV조선", "sisajournal.com": "시사저널", "kizmom.com": "키즈맘", "idsn.co.kr": "아이디에스엔", "e-science.co.kr": "이사이언스", "asiatoday.co.kr": "아시아투데이", "newsworks.co.kr": "뉴스웍스", "gukjenews.com": "국제뉴스", "cstimes.com": "컨슈머타임스", "cnbnews.com": "CNB뉴스", "enewstoday.co.kr": "이뉴스투데이", "fntoday.co.kr": "파이낸스투데이", "seoul.co.kr": "서울신문", "queen.co.kr": "퀸", "medicalworldnews.co.kr": "메디컬월드뉴스", "thereport.co.kr": "더리포트", "getnews.co.kr": "게잇뉴스", "newslock.co.kr": "뉴스락", "dailypop.kr": "데일리팝", "greened.kr": "녹색경제신문", "public25.com": "퍼블릭뉴스", "news.einfomax.co.kr": "연합인포맥스", "safetimes.co.kr": "세이프타임즈", "pennmike.com": "펜앤드마이크", "health.chosun.com": "헬스조선", "kormedi.com": "코메디닷컴", "sportsseoul.com": "스포츠서울", "segye.com": "세계일보", "sedaily.com": "서울경제", "fnnews.com": "파이낸셜뉴스", "ytn.co.kr": "YTN", "news1.kr": "뉴스1", "newsen.com": "뉴스엔", "osen.co.kr": "OSEN", "xportsnews.com": "엑스포츠뉴스", "tvdaily.co.kr": "티브이데일리", "topstarnews.net": "톱스타뉴스", "hankyung.com": "한국경제", "dailian.co.kr": "데일리안", "kukinews.com": "쿠키뉴스", "joongang.co.kr": "중앙일보", "dt.co.kr": "디지털타임스", "kyeonggi.com": "경기일보", "incheonilbo.com": "인천일보", "ggilbo.com": "금강일보", "kwnews.co.kr": "강원일보", "kmib.co.kr": "국민일보", "munhwa.com": "문화일보", "edaily.co.kr": "이데일리", "nocutnews.co.kr": "노컷뉴스", "news.mt.co.kr": "머니투데이", "newsprime.co.kr": "프라임경제", "ilyo.co.kr": "일요신문", "sports.donga.com": "스포츠동아", "medicaltimes.com": "메디칼타임즈", "docdocdoc.co.kr": "청년의사", "whosaeng.com": "후생신보", "bosa.co.kr": "의학신문", "dailymedi.com": "데일리메디", "akomnews.com": "한의신문", "joynews24.com": "조이뉴스24", "inews24.com": "아이뉴스24", "zdnet.co.kr": "지디넷코리아", "ceoscoredaily.com": "CEO스코어데일리", "viva100.com": "브릿지경제", "g-enews.com": "글로벌이코노믹", "thebell.co.kr": "더벨", "businesspost.co.kr": "비즈니스포스트", "ohmynews.com": "오마이뉴스", "pressian.com": "프레시안", "mediatoday.co.kr": "미디어오늘", "nate.com": "네이트뉴스", "news.nate.com": "네이트뉴스", "daum.net": "다음뉴스", "v.daum.net": "다음뉴스", "news.zum.com": "줌뉴스"};

View File

@ -6323,16 +6323,6 @@
"topic": "가슴성형",
"boardUid": 697
},
{
"date": "2019-05-09",
"title": "광대축소술, 단순히 깎기만 하는 것이 아닌 전체 얼굴라인이 중요해",
"outlet": "칼럼",
"url": "http://www.pointn.net/news/articleView.html?idxno=7659",
"kind": "column",
"origin": "board",
"topic": "안면윤곽·양악",
"boardUid": 696
},
{
"date": "2019-04-30",
"title": "양악수술 및 돌출입수술 등… 치과교정과 전문의 협진 필요",
@ -6810,16 +6800,6 @@
"topic": "가슴성형",
"boardUid": 659
},
{
"date": "2019-01-09",
"title": "얼굴 커 보이는 원인, 단순히 광대뼈 크기만 줄이는 광대축소술은 지양해야..",
"outlet": "칼럼",
"url": "http://www.pointn.net",
"kind": "column",
"origin": "board",
"topic": "안면윤곽·양악",
"boardUid": 657
},
{
"date": "2019-01-08",
"title": "원인에 따라 수술방법 달라지는 짧은코성형, 체계적인 분석과 계획이 중요",
@ -8617,33 +8597,6 @@
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2014-09-11",
"title": "렛미인4 주걱턱 태국인 니차컨, 완벽 변신...\"팅커벨 같다\"",
"outlet": "디지털타임스",
"url": "http://www.dt.co.kr/contents.html?article_no=2014091102109919807028",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2014-09-11",
"title": "'렛미인4' 태국인 니차컨 \"베이글녀로 변신한 모습 놀라워\"",
"outlet": "아주경제",
"url": "http://www.ajunews.com/view/20140911110112640",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-11",
"title": "'렛미인IN태국' 니차컨 '아름다운 외모와 몸매로 완벽변신'",
"outlet": "스포츠조선",
"url": "http://sports.chosun.com/news/ntype.htm?id=201409110100100260006440&servicedate=20140911",
"kind": "column",
"origin": "naver",
"topic": "해외·의료관광"
},
{
"date": "2014-09-10",
"title": "주걱턱 태국인 '렛미인4' 니차컨, 인형같은 화보 화제",
@ -8662,24 +8615,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-06",
"title": "'렛미인 태국' 주걱턱 태국소녀, 인형 같은 변신 성공",
"outlet": "스포츠조선",
"url": "http://sports.chosun.com/news/ntype.htm?id=201409060100075310004581&servicedate=20140906",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-05",
"title": "'렛미인4' 주걱턱 태국인에서 베이글녀로 변신 성공",
"outlet": "아주경제",
"url": "http://www.ajunews.com/view/20140905140320488",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2014-09-05",
"title": "[뷰티한국] '렛미인' 태국인 등장, 되찾은 환한 미소",
@ -8689,33 +8624,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-05",
"title": "'렛미인IN태국' 주걱턱 태국인 니차컨의 86일 간의 대변신!",
"outlet": "스포츠월드",
"url": "http://www.sportsworldi.com/content/html/2014/09/05/20140905001562.html?OutUrl=naver",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2014-09-05",
"title": "'렛미인IN태국', 태국 주걱턱녀 니차컨의 놀라운 반전변신",
"outlet": "joseilbo.com",
"url": "http://www.joseilbo.com/news/news_read.php?uid=232503&class=27",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-05",
"title": "렛미인4, 니차컨 화려한 변신",
"outlet": "아이뉴스24",
"url": "http://www.inews24.com/php/news_view.php?g_serial=847084&g_menu=024510&rrf=nv",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-05",
"title": "태국 '렛미인' 니차컨의 작고 입체적인 얼굴의 비결은 '이마코성형'",
@ -8743,15 +8651,6 @@
"origin": "naver",
"topic": "수상·인증"
},
{
"date": "2014-09-05",
"title": "글래머 요정으로 변신에 성공 '렛미인4' 태국인 니차컨의 대반전쇼",
"outlet": "네이버뉴스",
"url": "https://n.news.naver.com/mnews/article/011/0002569072?sid=103",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-09-05",
"title": "주걱턱 태국인 니차컨, 렛미인4통해 “웃음 되찾았다”",
@ -8834,33 +8733,6 @@
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-08-15",
"title": "렛미인4, \"24시간이 부족한 가장\" 어린엄마 강은수의 감동 변신",
"outlet": "아주경제",
"url": "http://www.ajunews.com/view/20140814150940500",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-08-15",
"title": "렛미인4 조여정 닮은꼴 강은수, 성형 전후 비교해보니",
"outlet": "파이낸셜뉴스",
"url": "http://www.fnnews.com/view?ra=Sent1901m_01A&corp=fnnews&arcid=140814151809&cDateYear=2014&cDateMonth=08&cDateDay=15&",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-08-15",
"title": "우월 미모로 변신에 성공한 '렛미인4' 강은수의 71일 대반전쇼!",
"outlet": "한국경제TV 와우스타",
"url": "http://wowstar.wowtv.co.kr/news/view.asp?newsid=38592",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2014-08-15",
"title": "'세 딸을 책임지는 어린 엄마' 렛미인4 강은수, 반전 미모 화제",
@ -8924,15 +8796,6 @@
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2014-07-15",
"title": "‘렛미인’ 트렌스젠더 하성욱, 볼륨감 넘치는 C컵 가슴으로 변신!",
"outlet": "뉴시스",
"url": "http://www.newsis.com/ar_detail/view.html?ar_id=NISX20140715_0013047889&cID=10204&pID=10200",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-07-11",
"title": "한.중 겸용출판서 <한국 미용성형의 고수18>에 윤창운 원장님이 소개되었습니다.",
@ -8943,15 +8806,6 @@
"topic": "기타",
"boardUid": 509
},
{
"date": "2014-07-09",
"title": "'렛미인4' 표정 없는 미용사 윤단비의 반전 변신, 트렌스젠더 하성욱만...",
"outlet": "뉴시스",
"url": "http://www.newsis.com/ar_detail/view.html?ar_id=NISX20140709_0013035850&cID=10204&pID=10200",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-07-08",
"title": "렛미인4' 여배우 외모 하성욱부터 황신혜 닮은꼴 윤단비까지…",
@ -8970,15 +8824,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-07-02",
"title": "“트렌스젠더 하성욱 이어 I컵녀 김호정까지”…렛미인4 대반전 ...",
"outlet": "뉴스웨이",
"url": "http://www.newsway.co.kr/view.php?tp=1&ud=2014070214392610249",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-07-01",
"title": "렛미인4 거대가슴 ' i컵녀' 화제에 '여자가 되고 싶은 남자' 하성욱도 주...",
@ -8988,15 +8833,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-06-25",
"title": "“트렌스젠더 하성욱 이어 렛미남 양정현까지”…렛미인4 대반전 ...",
"outlet": "스포츠월드",
"url": "http://www.sportsworldi.com/content/html/2014/06/25/20140625003439.html?OutUrl=naver",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-06-24",
"title": "아이돌급 외모 렛미남 화제에 '여자가 되고 싶은 남자' 하성욱도 주목",
@ -9006,15 +8842,6 @@
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-18",
"title": "“트렌스젠더 하성욱 이어 문선영까지”…렛미인4 대반전 ‘화제’",
"outlet": "뉴스웨이",
"url": "http://www.newsway.co.kr/view.php?tp=1&ud=2014061815481853567",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-06-17",
"title": "렛미인4 노안녀 화제에 '여자가 되고 싶은 남자' 하성욱도 주목",
@ -9024,15 +8851,6 @@
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-11",
"title": "'렛미인4' 트랜스젠더의 변신! 남성형 골격이 부드러운 여성형 얼굴라인...",
"outlet": "스포츠월드",
"url": "http://www.sportsworldi.com/content/html/2014/06/11/20140611003359.html?OutUrl=naver",
"kind": "column",
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-10",
"title": "렛미인4 하성욱,남자에서 여자의 삶으로… ‘여배우 비주얼’ 화제",
@ -9042,33 +8860,6 @@
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-09",
"title": "‘렛미인’ 하성욱, 볼륨감 넘치는 C컵 가슴으로 변신!",
"outlet": "파이낸셜뉴스",
"url": "http://www.fnnews.com/view?ra=Sent0901m_01A&corp=fnnews&arcid=140609104149&cDateYear=2014&cDateMonth=06&cDateDay=09&",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-06-09",
"title": "완벽한 여자로 '대변신'에 성공한 '렛미인4' 트랜스젠더 하성욱",
"outlet": "스포츠월드",
"url": "http://www.sportsworldi.com/content/html/2014/06/09/20140609001383.html?OutUrl=naver",
"kind": "column",
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-09",
"title": "모두가 놀란 깜짝 변신! 렛미인4 \"여자가 되고 싶은 남자\" 하성욱",
"outlet": "뉴스웨이",
"url": "http://www.newsway.co.kr/view.php?tp=1&ud=2014060910470931399",
"kind": "column",
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-06",
"title": "MC황신혜도 깜놀... 트랜스젠더 하성욱 \"완벽한 여자로 살고 싶다\"",
@ -9078,24 +8869,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-06-06",
"title": "'렛미인' 하성욱, 절벽가슴에서 자연스러운 물방울 가슴으로 변신 성공",
"outlet": "한국경제TV 와우스타",
"url": "http://wowstar.wowtv.co.kr/news/view.asp?newsid=30688",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-06-06",
"title": "렛미인4, 트랜스젠더 하성욱, 여배우급 미모로 변신",
"outlet": "뉴시스",
"url": "http://www.newsis.com/ar_detail/view.html?ar_id=NISX20140605_0012964954&cID=10204&pID=10200",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2014-06-06",
"title": "MC황신혜도 놀란 여배우 외모! 렛미인4 트랜스젠더 하성욱",
@ -9105,15 +8878,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2014-06-06",
"title": "\"여자가 되고 싶은 남자\" 렛미인4, 트랜스젠더 하성욱의 반전 변신",
"outlet": "아주경제",
"url": "http://www.ajunews.com/view/20140605160705390",
"kind": "column",
"origin": "naver",
"topic": "모발·남성"
},
{
"date": "2014-06-03",
"title": "렛미인4 첫 방송… 렛미인 레전드 외모 김백주 재조명!",
@ -9727,33 +9491,6 @@
"topic": "가슴성형",
"boardUid": 843
},
{
"date": "2013-09-08",
"title": "렛미인3, '털많은 여자' 김미영 성공적인 변신에 '비뚤어진 남매' 최지혜...",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2013090617538033779&outlink=1",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2013-09-06",
"title": "렛미인3 김미영 ‘완벽 변신’에 최지혜도 다시 한번 주목",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2013090616418086211&outlink=1",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2013-09-06",
"title": "렛미인 김미영 충격 변신에 최지혜 또 다시 주목",
"outlet": "메디파나뉴스",
"url": "http://medipana.com/news/news_viewer.asp?NewsNum=133315&MainKind=A&NewsKind=5&vCount=12&vKind=1",
"kind": "column",
"origin": "naver",
"topic": "방송·렛미인"
},
{
"date": "2013-09-06",
"title": "렛미인3, 김백주에 이어 김미영까지…치료하는 닥터스가 더 신기해",
@ -9772,15 +9509,6 @@
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-08-09",
"title": "렛미인 김백주, \"인형이 걸어나오는 줄…\" 변신 대성공",
"outlet": "디지털타임스",
"url": "http://www.dt.co.kr/contents.html?article_no=2013080902019919794038",
"kind": "column",
"origin": "naver",
"topic": "눈·코성형"
},
{
"date": "2013-08-09",
"title": "치아부식녀 \"렛미인 이번 시즌 통틀어 1등!\"",
@ -9799,24 +9527,6 @@
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-08-09",
"title": "'렛미인' 치아부식녀, 완벽 변신…역대 최고 미모 '경악'",
"outlet": "한국경제",
"url": "http://www.hankyung.com/news/app/newsview.php?aid=2013080982107",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-08-09",
"title": "'렛미인3' 김백주, '인형미모'로 대반전 메이크오버 성공!",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2013080909468066550&outlink=1",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-07-12",
"title": "여성스럽고 세련된 얼굴, 앞모습만큼 옆모습도 중요",
@ -9898,24 +9608,6 @@
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-07-05",
"title": "‘렛미인’ 주걱턱녀→아나운서 외모 충격변신! ‘수술 방법은…’",
"outlet": "스포츠동아",
"url": "http://sports.donga.com/3/all/20130705/56309442/2",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-07-05",
"title": "렛미인3 주걱턱효녀, 아나운서 외모로 180도 대변신’ 탄성",
"outlet": "스포츠조선",
"url": "http://sports.chosun.com/news/ntype.htm?id=201307060100049290003307&servicedate=20130705",
"kind": "column",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2013-07-05",
"title": "렛미인3-못생긴 형제들편, 최지혜 남매 렛미인 효과로 활짝",
@ -9952,15 +9644,6 @@
"origin": "naver",
"topic": "행사·교육·협약"
},
{
"date": "2013-06-17",
"title": "<렛미인3> 사상 최대 충격! 썩은 가슴女의 변신",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2013061716268081477&outlink=1",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2013-06-15",
"title": "<렛미인3>'제2의 손예진' 된 '썩은 가슴女' 화제",
@ -9988,24 +9671,6 @@
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2013-06-14",
"title": "렛미인3, 썩은 가슴 리틀맘 '손예진 닮은꼴' 파격 변신",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2013061316208021723&outlink=1",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2013-06-14",
"title": "렛미인3 충격적 주인공 썩은 가슴女, 그녀의 변신에 ‘시선집중’",
"outlet": "네이버뉴스",
"url": "https://n.news.naver.com/mnews/article/011/0002347172?sid=103",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2013-06-14",
"title": "<렛미인3> '가슴 복원女' 진짜 가슴을 찾다",
@ -10091,15 +9756,6 @@
"topic": "기타",
"boardUid": 850
},
{
"date": "2012-12-28",
"title": "<김원희의 맞수다>A컵 가슴고민녀, 전문의를 만나 변신!",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2012122809488017370&outlink=1",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2012-12-14",
"title": "렛미인 최고의 반전녀, \"가족도 못알아 보겠다\"",
@ -10136,24 +9792,6 @@
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2012-12-14",
"title": "<렛미인2> '형이라 불리는 여자' 전유화의 대반전 '메이크오버' 핫이슈...",
"outlet": "머니S",
"url": "http://www.moneyweek.co.kr/news/mwView.php?type=1&no=2012121317038067075&outlink=1",
"kind": "mention",
"origin": "naver",
"topic": "안면윤곽·양악"
},
{
"date": "2012-12-01",
"title": "렛미인2 페이스 오프 최고 대반전女는?",
"outlet": "매일경제",
"url": "http://star.mk.co.kr/new/view.php?mc=ST&no=796898&year=2012",
"kind": "column",
"origin": "naver",
"topic": "가슴성형"
},
{
"date": "2012-11-09",
"title": "가슴 4개 달린 여자 성형하더니‥",
@ -10465,16 +10103,6 @@
"topic": "가슴성형",
"boardUid": 886
},
{
"date": "2012-08-31",
"title": "도전 신데렐라 시즌3, 안면윤곽 수술로 도전자들 '대변신'",
"outlet": "엑스포츠뉴스",
"url": "https://www.viewclinic.com/board/media/news/?uid=879&mod=document",
"kind": "column",
"origin": "board",
"topic": "안면윤곽·양악",
"boardUid": 879
},
{
"date": "2012-08-31",
"title": "비행기 승무원들, 가슴성형 가능할까?",
@ -10678,5 +10306,53 @@
"origin": "naver",
"topic": "리프팅·피부·안티에이징"
}
]
],
"excludedByGate": {
"at": "2026-09-08",
"count": 41,
"rule": "BANNED_NEWS_TITLE·NEGATIVE_NEWS (변신·전후 비교 제목)",
"titles": [
"2019-05-09 광대축소술, 단순히 깎기만 하는 것이 아닌 전체 얼굴라인이 중요해",
"2019-01-09 얼굴 커 보이는 원인, 단순히 광대뼈 크기만 줄이는 광대축소술은 지양해야..",
"2014-09-11 렛미인4 주걱턱 태국인 니차컨, 완벽 변신...\"팅커벨 같다\"",
"2014-09-11 '렛미인4' 태국인 니차컨 \"베이글녀로 변신한 모습 놀라워\"",
"2014-09-11 '렛미인IN태국' 니차컨 '아름다운 외모와 몸매로 완벽변신'",
"2014-09-06 '렛미인 태국' 주걱턱 태국소녀, 인형 같은 변신 성공",
"2014-09-05 '렛미인4' 주걱턱 태국인에서 베이글녀로 변신 성공",
"2014-09-05 '렛미인IN태국' 주걱턱 태국인 니차컨의 86일 간의 대변신!",
"2014-09-05 '렛미인IN태국', 태국 주걱턱녀 니차컨의 놀라운 반전변신",
"2014-09-05 렛미인4, 니차컨 화려한 변신",
"2014-09-05 글래머 요정으로 변신에 성공 '렛미인4' 태국인 니차컨의 대반전쇼",
"2014-08-15 렛미인4, \"24시간이 부족한 가장\" 어린엄마 강은수의 감동 변신",
"2014-08-15 렛미인4 조여정 닮은꼴 강은수, 성형 전후 비교해보니",
"2014-08-15 우월 미모로 변신에 성공한 '렛미인4' 강은수의 71일 대반전쇼!",
"2014-07-15 ‘렛미인’ 트렌스젠더 하성욱, 볼륨감 넘치는 C컵 가슴으로 변신!",
"2014-07-09 '렛미인4' 표정 없는 미용사 윤단비의 반전 변신, 트렌스젠더 하성욱만...",
"2014-07-02 “트렌스젠더 하성욱 이어 I컵녀 김호정까지”…렛미인4 대반전 ...",
"2014-06-25 “트렌스젠더 하성욱 이어 렛미남 양정현까지”…렛미인4 대반전 ...",
"2014-06-18 “트렌스젠더 하성욱 이어 문선영까지”…렛미인4 대반전 ‘화제’",
"2014-06-11 '렛미인4' 트랜스젠더의 변신! 남성형 골격이 부드러운 여성형 얼굴라인...",
"2014-06-09 ‘렛미인’ 하성욱, 볼륨감 넘치는 C컵 가슴으로 변신!",
"2014-06-09 완벽한 여자로 '대변신'에 성공한 '렛미인4' 트랜스젠더 하성욱",
"2014-06-09 모두가 놀란 깜짝 변신! 렛미인4 \"여자가 되고 싶은 남자\" 하성욱",
"2014-06-06 '렛미인' 하성욱, 절벽가슴에서 자연스러운 물방울 가슴으로 변신 성공",
"2014-06-06 렛미인4, 트랜스젠더 하성욱, 여배우급 미모로 변신",
"2014-06-06 \"여자가 되고 싶은 남자\" 렛미인4, 트랜스젠더 하성욱의 반전 변신",
"2013-09-08 렛미인3, '털많은 여자' 김미영 성공적인 변신에 '비뚤어진 남매' 최지혜...",
"2013-09-06 렛미인3 김미영 ‘완벽 변신’에 최지혜도 다시 한번 주목",
"2013-09-06 렛미인 김미영 충격 변신에 최지혜 또 다시 주목",
"2013-08-09 렛미인 김백주, \"인형이 걸어나오는 줄…\" 변신 대성공",
"2013-08-09 '렛미인' 치아부식녀, 완벽 변신…역대 최고 미모 '경악'",
"2013-08-09 '렛미인3' 김백주, '인형미모'로 대반전 메이크오버 성공!",
"2013-07-05 ‘렛미인’ 주걱턱녀→아나운서 외모 충격변신! ‘수술 방법은…’",
"2013-07-05 렛미인3 주걱턱효녀, 아나운서 외모로 180도 대변신’ 탄성",
"2013-06-17 <렛미인3> 사상 최대 충격! 썩은 가슴女의 변신",
"2013-06-14 렛미인3, 썩은 가슴 리틀맘 '손예진 닮은꼴' 파격 변신",
"2013-06-14 렛미인3 충격적 주인공 썩은 가슴女, 그녀의 변신에 ‘시선집중’",
"2012-12-28 <김원희의 맞수다>A컵 가슴고민녀, 전문의를 만나 변신!",
"2012-12-14 <렛미인2> '형이라 불리는 여자' 전유화의 대반전 '메이크오버' 핫이슈...",
"2012-12-01 렛미인2 페이스 오프 최고 대반전女는?",
"2012-08-31 도전 신데렐라 시즌3, 안면윤곽 수술로 도전자들 '대변신'"
]
}
}

View File

@ -0,0 +1,6 @@
{
"_comment": "병원별 고정값. 워커가 --youtube 등 인자보다 먼저 읽는다. haewon 결정 2026-09-08: 유튜브 기준 채널은 @wjwonjin (UC-EBM4e0VST1W_PQ0NzFMQg, 원장 설명 영상 736편). @wj2541(UCNBaQBCok5xIViCnrGdisoQ)은 브랜드 필름 채널이라 쓰지 않는다.",
"youtube": "UC-EBM4e0VST1W_PQ0NzFMQg",
"youtubeHandle": "@wjwonjin",
"logo": "supporters/briefs/wonjin/logo-horizontal.svg"
}

View File

@ -100,6 +100,8 @@ add('data/factSheet.json', R.checkNextCheck(fact, today));
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다.
const news = existsSync(here('../src/data/news.json')) ? JSON.parse(readFileSync(here('../src/data/news.json'), 'utf8')) : { items: [] };
add('data/news.json', R.checkNewsItems(news.items)); // 뉴스룸: 후기·전후 비교·변신 제목, 규제·사건 보도, 날짜 형식
// ---------- 출력 ----------
const errors = findings.filter((x) => x.level === R.E);

View File

@ -0,0 +1,212 @@
// 뉴스룸 수집기 (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]
// 규칙 (사이트 원칙 §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(/[\s\W]+/g, '').toLowerCase();
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.outletHint = m[1].trim(); x.title = m[2].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 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);
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;
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);
}
}
// ---------- 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');
const r = await fetch(u, { headers: { 'X-Naver-Client-Id': ID, 'X-Naver-Client-Secret': SECRET } });
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(100);
}
}
// ---------- 실행 ----------
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}`);
}
// 질의 한 개는 최근 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), {});
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];
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');
}
console.log(`뉴스 ${items.length}건 (보도자료 ${kinds.release ?? 0} · 기고·인터뷰 ${kinds.column ?? 0} · 언급 ${kinds.mention ?? 0}) · 매체 ${Object.keys(outlets).length} · 게시판 ${boardUrl ? '있음' : '없음'}${OUT}`);

View File

@ -0,0 +1,210 @@
// 채널 발견 + 엔티티 검증 (datacrawling 스킬 §1~§2 를 코드로). 병원 이름 또는 URL 하나로 유튜브·인스타·페이스북·네이버 블로그·틱톡·카카오·강남언니·네이버 플레이스를 찾고,
// 후보마다 "이 병원의 것인가"를 근거 점수로 판정한다. 부재는 다섯 갈래를 전부 돈 뒤에만 NOT_FOUND, 아니면 UNVERIFIED.
//
// node scripts/discover_channels.mjs --clinic <id> (--name <상호> | --url <홈>) [--evidence <dir>] [--out channels.json] [--registry ../data/clinic_social_registry.json]
//
// 갈래: ① 사이트 전수(홈·법무·소개·연락처 HTML + JSON-LD sameAs + 근거 수집 링크) ② 웹검색(Firecrawl search·네이버 webkr/blog·YouTube API 채널 검색)
// ③ 핸들 변형(도메인 기반 base × 구분자 × 접미사, 존재 확인만) ④ 레지스트리(data/clinic_social_registry.json) ⑤ 플랫폼 내부 검색(YouTube API)
// 판정: 상호 역링크(+3) · 프로필 제목에 상호(+2) · 프로필 설명에 홈페이지 도메인/전화(+2) · 레지스트리(+2) · 검색 스니펫에 상호+플랫폼(+1)
// ≥3 CONFIRMED · 1~2 CANDIDATE(사람 확인) · 존재 404 HANDLE_DEAD · 접근 차단 BLOCKED · 후보 0 NOT_FOUND(전 갈래 실행 시)/UNVERIFIED
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = fileURLToPath(new URL('.', import.meta.url));
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; };
(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 KEYS = { yt: process.env.YOUTUBE_API_KEY, fc: process.env.FIRECRAWL_API_KEY, nid: process.env.NAVER_CLIENT_ID, nsec: process.env.NAVER_CLIENT_SECRET };
const clinic = opt('clinic'); let name = opt('name'); let home = opt('url');
if (!clinic || (!name && !home)) { console.error('--clinic 과 --name 또는 --url 필요'); process.exit(2); }
const OUT = resolve(opt('out', `channels.${clinic}.json`));
const EV = opt('evidence') ? resolve(opt('evidence')) : null;
const REG = resolve(opt('registry', join(here, '../../data/clinic_social_registry.json')));
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.0 Safari/537.36 INFINITH-supporters-discover/1.0 (+mailto:o2oteam@o2o.kr)';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (s) => console.log(s);
// ---------- 플랫폼 패턴 (supabase/functions/_shared/extractSocialLinks.ts 이식 + 병의원 표면) ----------
const PLATFORMS = {
youtube: { re: /(?:www\.|m\.)?youtube\.com\/(?:@([a-zA-Z0-9._-]+)|channel\/(UC[a-zA-Z0-9_-]{20,})|c\/([a-zA-Z0-9._-]+)|user\/([a-zA-Z0-9._-]+))/i, key: (m) => (m[2] ? m[2] : '@' + (m[1] || m[3] || m[4])), url: (k) => (k.startsWith('UC') ? `https://www.youtube.com/channel/${k}` : `https://www.youtube.com/${k}`) },
instagram: { re: /(?:www\.)?instagram\.com\/([a-zA-Z0-9._]{2,30})\/?(?:[?#]|$)/i, key: (m) => m[1].toLowerCase(), url: (k) => `https://www.instagram.com/${k}/`, skip: /^(p|reel|reels|explore|accounts|stories|share|about|legal|directory|tv)$/i },
facebook: { re: /(?:www\.|m\.)?facebook\.com\/([a-zA-Z0-9._-]{2,60})\/?(?:[?#]|$)/i, key: (m) => m[1], url: (k) => `https://www.facebook.com/${k}`, skip: /^(sharer|share|login|help|pages|events|groups|marketplace|watch|gaming|privacy|policies|tr|dialog|plugins|photo|video|reel|profile\.php|people|hashtag|search)$/i },
naverBlog: { re: /blog\.naver\.com\/([a-zA-Z0-9_-]{2,40})/i, key: (m) => m[1].toLowerCase(), url: (k) => `https://blog.naver.com/${k}`, skip: /^(PostView|BlogHome|postview)$/i },
tiktok: { re: /(?:www\.)?tiktok\.com\/@([a-zA-Z0-9._-]+)/i, key: (m) => m[1].toLowerCase(), url: (k) => `https://www.tiktok.com/@${k}` },
kakao: { re: /pf\.kakao\.com\/([a-zA-Z0-9_-]+)/i, key: (m) => m[1], url: (k) => `https://pf.kakao.com/${k}` },
gangnamunni: { re: /gangnamunni\.com\/hospitals\/(\d+)/i, key: (m) => m[1], url: (k) => `https://www.gangnamunni.com/hospitals/${k}` },
naverPlace: { re: /(?:m\.)?place\.naver\.com\/(?:hospital|place)\/(\d+)|map\.naver\.com\/(?:p\/entry\/place|v5\/entry\/place)\/(\d+)/i, key: (m) => m[1] || m[2], url: (k) => `https://m.place.naver.com/hospital/${k}` },
babitalk: { re: /babitalk\.com\/hospitals\/(\d+)/i, key: (m) => m[1], url: (k) => `https://babitalk.com/hospitals/${k}` },
};
const cands = Object.fromEntries(Object.keys(PLATFORMS).map((p) => [p, new Map()])); // platform → key → { key, url, sources:Set, hits:[] }
function addCandidate(rawUrl, source, note = '') {
for (const [p, def] of Object.entries(PLATFORMS)) {
const m = String(rawUrl).match(def.re); if (!m) continue;
const k = def.key(m); if (!k || (def.skip && def.skip.test(k))) return;
const c = cands[p].get(k) ?? { platform: p, key: k, url: def.url(k), sources: new Set(), notes: [] };
c.sources.add(source); if (note) c.notes.push(note); cands[p].set(k, c); return;
}
}
const extractAll = (text, source, note) => { for (const m of String(text).matchAll(/https?:\/\/[^\s"'<>)\]]+/g)) addCandidate(m[0], source, note); };
// ---------- 0. 이름 ↔ URL 보완 ----------
async function fetchText(url, tries = 1) {
for (let i = 0; i <= tries; i++) {
try { const r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(20000) }); return { status: r.status, url: r.url, body: r.ok ? await r.text() : '' }; }
catch (e) { if (i === tries) return { status: 0, url, body: '', error: e.message }; await sleep(800); }
}
}
async function fcSearch(query, limit = 8) {
if (!KEYS.fc) return [];
try { const r = await fetch('https://api.firecrawl.dev/v1/search', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${KEYS.fc}` }, body: JSON.stringify({ query, limit }), signal: AbortSignal.timeout(30000) }); const j = await r.json(); return (j.data ?? []).map((x) => ({ url: x.url, title: x.title ?? '', snippet: x.description ?? '' })); } catch { return []; }
}
async function naverSearch(kind, query, display = 10) {
if (!KEYS.nid) return [];
try { const r = await fetch(`https://openapi.naver.com/v1/search/${kind}.json?query=${encodeURIComponent(query)}&display=${display}`, { headers: { 'X-Naver-Client-Id': KEYS.nid, 'X-Naver-Client-Secret': KEYS.nsec } }); const j = await r.json(); return (j.items ?? []).map((x) => ({ url: x.link, title: (x.title ?? '').replace(/<[^>]+>/g, ''), snippet: (x.description ?? '').replace(/<[^>]+>/g, '') })); } catch { return []; }
}
const PLATFORM_HOST = /youtube\.com|youtu\.be|instagram\.com|facebook\.com|blog\.naver\.com|tiktok\.com|kakao\.com|gangnamunni\.com|place\.naver\.com|map\.naver\.com|babitalk\.com|naver\.com|google\.|daum\.net|tistory\.com|namu\.wiki|wikipedia/i;
const searchLog = [];
if (!home) {
// 상호로 공식 홈페이지 찾기: 플랫폼이 아닌 도메인 중 제목에 상호가 들어간 첫 결과
const q = `${name} 공식 홈페이지`; searchLog.push(q);
const res = [...(await fcSearch(q, 8)), ...(await naverSearch('webkr', name, 10))];
const core = coreName(name);
const hit = res.find((r) => !PLATFORM_HOST.test(r.url) && (r.title.replace(/\s/g, '').includes(core) || r.snippet.replace(/\s/g, '').includes(core)));
if (!hit) { console.error(`홈페이지를 찾지 못함: ${name}`); }
else { const u = new URL(hit.url); home = `${u.protocol}//${u.host}`; log(`홈페이지 추정: ${home} (${hit.title.slice(0, 40)})`); }
}
const homeHtml = home ? await fetchText(home) : { body: '' };
if (!name) {
const t = homeHtml.body.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] ?? '';
const og = homeHtml.body.match(/property="og:site_name"\s+content="([^"]+)"/i)?.[1] ?? '';
name = (og || t).replace(/[|-].*$/, '').replace(/당신만 봅니다/, '').trim() || clinic;
log(`상호 추정: ${name}`);
}
function coreName(n) { return String(n).replace(/\s/g, '').replace(/(성형외과|의원|피부과|클리닉|병원|치과|한의원|WJ|wj)/gi, '') || String(n).replace(/\s/g, ''); }
const core = coreName(name);
const domainBase = home ? new URL(home).host.replace(/^www\./, '').split('.')[0].replace(/^k-|^the-?/, '') : '';
const phone = (homeHtml.body.match(/0\d{1,2}-\d{3,4}-\d{4}/g) ?? [])[0] ?? '';
log(`상호 ${name} · 핵심어 ${core} · 도메인 base ${domainBase} · 전화 ${phone || '?'}`);
// ---------- ① 사이트 전수 ----------
const sitePages = [];
if (home) {
const paths = ['/', '/privacy', '/privacy-policy', '/service/private', '/terms', '/service/terms', '/about', '/hospitalinfo/about', '/contact', '/counsel', '/sitemap.xml'];
for (const p of paths) {
const r = p === '/' ? { ...homeHtml, url: home } : await fetchText(home + p);
if (r.status && r.status < 400 && r.body) {
sitePages.push(p);
extractAll(r.body, 'site', p);
for (const m of r.body.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) extractAll(m[1], 'site:sameAs', p);
for (const m of r.body.matchAll(/<meta[^>]+(?:property|name)="(?:og:see_also|twitter:site)"[^>]+content="([^"]+)"/g)) extractAll(m[1], 'site:meta', p);
}
await sleep(200);
}
}
if (EV && existsSync(join(EV, 'index.json'))) {
const idx = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
for (const p of idx.pages) { try { const pg = JSON.parse(readFileSync(join(EV, p.file), 'utf8')); for (const l of pg.links ?? []) addCandidate(String(l.href ?? l.url ?? l ?? ''), 'evidence', p.url); } catch { /* */ } }
}
const homeReciprocal = (homeHtml.body || '').toLowerCase();
// ---------- ④ 레지스트리 ----------
let registry = null;
if (existsSync(REG)) {
const reg = JSON.parse(readFileSync(REG, 'utf8'));
const key = Object.keys(reg).find((k) => k.replace(/\s/g, '') === name.replace(/\s/g, '') || coreName(k) === core);
if (key) { registry = reg[key]; for (const list of Object.values(registry)) for (const u of list ?? []) addCandidate(u, 'registry'); log(`레지스트리: ${key} (${Object.values(registry).flat().length}개 URL)`); }
}
// ---------- ② 웹검색 + ⑤ 플랫폼 검색 ----------
const searchHits = []; // { url, title, snippet, query }
const queries = [
`"${name}" 인스타그램`, `"${name}" 유튜브`, `"${name}" 페이스북`, `"${name}" 블로그`, `${name} 강남언니`, `${name} 네이버 플레이스`,
`site:instagram.com ${core}`, `site:youtube.com ${name}`, `site:facebook.com ${name}`,
];
for (const q of queries) { searchLog.push(q); const res = await fcSearch(q, 8); for (const r of res) { searchHits.push({ ...r, query: q }); addCandidate(r.url, 'search', q); extractAll(r.snippet, 'search:snippet', q); } await sleep(300); }
for (const q of [`${name} 인스타그램`, `${name} 유튜브`, `${name} 페이스북`, `${name} 블로그`]) { searchLog.push('naver:' + q); for (const r of await naverSearch('webkr', q, 10)) { searchHits.push({ ...r, query: 'naver:' + q }); addCandidate(r.url, 'search', q); } }
for (const r of await naverSearch('blog', name, 10)) { searchHits.push({ ...r, query: 'naver:blog' }); addCandidate(r.url, 'search:naverblog', name); }
if (KEYS.yt) {
for (const q of [name, `${name} 성형외과`]) {
try { const r = await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&type=channel&q=${encodeURIComponent(q)}&maxResults=5&key=${KEYS.yt}`); const j = await r.json(); for (const it of j.items ?? []) { addCandidate(`https://www.youtube.com/channel/${it.id.channelId}`, 'youtube:search', q); searchHits.push({ url: `https://www.youtube.com/channel/${it.id.channelId}`, title: it.snippet.title, snippet: it.snippet.description, query: 'yt:' + q }); } } catch { /* */ }
}
}
// ---------- ③ 핸들 변형 (존재 확인만) ----------
const variantsTried = [];
if (domainBase && domainBase.length >= 3) {
const SUF = ['', 'official', 'ps', 'clinic', 'plasticsurgery', 'kr', 'korea', 'seoul'];
const SEP = { instagram: ['', '.', '_'], youtube: ['', '_', '-'], tiktok: ['', '.', '_'] };
const vs = new Set(); for (const s of SUF) for (const sep of ['', '.', '_', '-']) vs.add(s ? `${domainBase}${sep}${s}` : domainBase);
for (const p of ['instagram', 'youtube']) {
for (const h of [...vs].filter((h) => SEP[p].some((sep) => !h.includes('.') && !h.includes('_') && !h.includes('-') || h.includes(sep)))) {
if (cands[p].has(p === 'youtube' ? '@' + h : h)) continue;
variantsTried.push(`${p}:${h}`);
if (p === 'youtube' && KEYS.yt) { try { const r = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=${encodeURIComponent(h)}&key=${KEYS.yt}`); const j = await r.json(); if (j.items?.length) addCandidate(`https://www.youtube.com/channel/${j.items[0].id}`, 'variant', h); } catch { /* */ } }
if (p === 'instagram') { const r = await fetchText(`https://www.instagram.com/${h}/`, 0); if (r.status === 200 && !/Page Not Found|페이지를 찾을 수 없|login/i.test(r.body.slice(0, 3000))) addCandidate(`https://www.instagram.com/${h}/`, 'variant', h); await sleep(400); }
if (variantsTried.length > 40) break;
}
}
}
// ---------- 검증 ----------
const ytChannels = new Map();
async function ytInfo(key) {
if (!KEYS.yt) return null;
const param = key.startsWith('UC') ? `id=${key}` : `forHandle=${encodeURIComponent(key.slice(1))}`;
try { const r = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&${param}&key=${KEYS.yt}`); const j = await r.json(); const c = j.items?.[0]; if (!c) return { exists: false }; return { exists: true, id: c.id, title: c.snippet.title, description: c.snippet.description ?? '', handle: c.snippet.customUrl, subscribers: Number(c.statistics.subscriberCount ?? 0), videos: Number(c.statistics.videoCount ?? 0), views: Number(c.statistics.viewCount ?? 0) }; } catch { return null; }
}
async function profileInfo(p, c) {
if (p === 'youtube') return ytInfo(c.key);
if (p === 'naverBlog') { const r = await fetchText(`https://rss.blog.naver.com/${c.key}.xml`, 0); if (r.status === 404) return { exists: false }; if (!r.body) return null; return { exists: true, title: r.body.match(/<title>([^<]*)<\/title>/)?.[1] ?? '', description: r.body.match(/<description>([^<]*)<\/description>/)?.[1] ?? '' }; }
const r = await fetchText(c.url, 0);
if (r.status === 404) return { exists: false };
if (!r.body || r.status !== 200) return null; // 차단·로그인 요구
const og = (prop) => r.body.match(new RegExp(`<meta[^>]+(?:property|name)="${prop}"[^>]+content="([^"]*)"`, 'i'))?.[1] ?? '';
const title = og('og:title') || r.body.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] || '';
if (/login|로그인|Page Not Found|콘텐츠를 사용할 수 없|isn't available/i.test(title) && !title.includes(core)) return null;
return { exists: true, title, description: og('og:description') || og('description') };
}
const norm = (s) => String(s ?? '').replace(/\s/g, '').toLowerCase();
const domainHost = home ? new URL(home).host.replace(/^www\./, '') : '';
const results = {};
for (const [p, map] of Object.entries(cands)) {
results[p] = [];
for (const c of map.values()) {
const info = await profileInfo(p, c).catch(() => null);
const ev = []; let score = 0;
const hasSite = [...c.sources].some((s) => s.startsWith('site'));
const hostPath = c.url.replace(/^https?:\/\/(www\.|m\.)?/, '').replace(/\/$/, '').toLowerCase();
// 역링크는 경로 경계까지 맞아야 한다 (instagram.com/wonjin 이 instagram.com/wonjin_official 안에 들어 있는 것은 역링크가 아니다)
const reciprocal = hasSite || new RegExp(hostPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![a-z0-9._-])').test(homeReciprocal);
if (reciprocal) { score += 3; ev.push('홈페이지에서 역링크'); }
if (c.sources.has('registry')) { score += 2; ev.push('레지스트리 등재'); }
if (info?.exists) {
const t = norm(info.title), d = norm(info.description);
if (t.includes(norm(core)) || (domainBase && t.includes(domainBase))) { const w = ['gangnamunni', 'babitalk', 'naverPlace'].includes(p) ? 3 : 2; score += w; ev.push(`프로필 제목에 상호: "${String(info.title).slice(0, 40)}"`); }
if (domainHost && d.includes(domainHost.toLowerCase())) { score += 2; ev.push('프로필 설명에 홈페이지 도메인'); }
else if (phone && d.replace(/[-.]/g, '').includes(phone.replace(/-/g, ''))) { score += 2; ev.push('프로필 설명에 대표 전화'); }
}
const snip = searchHits.filter((h) => norm(h.url).includes(norm(c.key)) && (norm(h.title + h.snippet).includes(norm(core))));
if (snip.length && !ev.some((e) => e.startsWith('프로필 제목'))) { score += 1; ev.push(`검색 결과 제목/요약에 상호 (${snip[0].query})`); }
let status = info === null && !hasSite ? 'BLOCKED' : info && info.exists === false ? 'HANDLE_DEAD' : score >= 3 ? 'CONFIRMED' : score >= 1 ? 'CANDIDATE' : 'UNRELATED';
if (status === 'BLOCKED' && score >= 3) status = 'CONFIRMED';
results[p].push({ platform: p, key: c.key, url: c.url, status, score, evidence: ev, sources: [...c.sources], ...(info?.exists ? { title: info.title, handle: info.handle, channelId: info.id, subscribers: info.subscribers, videos: info.videos, views: info.views } : {}) });
await sleep(150);
}
if (p === 'youtube') { const byId = new Map(); for (const x of results[p]) { const id = x.channelId ?? x.key; const prev = byId.get(id); if (!prev) byId.set(id, x); else { prev.score = Math.max(prev.score, x.score); prev.evidence = [...new Set([...prev.evidence, ...x.evidence])]; prev.sources = [...new Set([...prev.sources, ...x.sources])]; if (x.status === 'CONFIRMED') prev.status = 'CONFIRMED'; } } results[p] = [...byId.values()]; }
results[p].sort((a, b) => b.score - a.score || (b.subscribers ?? 0) - (a.subscribers ?? 0));
}
const allBranches = Boolean(home) && Boolean(KEYS.fc) && Boolean(KEYS.nid) && Boolean(KEYS.yt);
const inventory = Object.entries(results).map(([p, list]) => {
const confirmed = list.filter((x) => x.status === 'CONFIRMED');
const state = confirmed.length ? 'CONFIRMED' : list.some((x) => x.status === 'CANDIDATE') ? 'CANDIDATE' : list.some((x) => x.status === 'BLOCKED') ? 'BLOCKED' : list.length ? 'HANDLE_DEAD' : allBranches ? 'NOT_FOUND' : 'UNVERIFIED';
return { platform: p, state, confirmed: confirmed.length, candidates: list.filter((x) => x.status === 'CANDIDATE').length, tried: `사이트 ${sitePages.length}면 · 검색 ${searchLog.length}건 · 변형 ${variantsTried.filter((v) => v.startsWith(p)).length}` };
});
const out = { clinic, name, url: home ?? null, checkedAt: today, phone: phone || null, branches: { sitePages, searchQueries: searchLog, variantsTried, registry: Boolean(registry), allBranches }, inventory, platforms: results, rules: 'datacrawling 스킬 §1~§2. CONFIRMED=근거 점수 3 이상, CANDIDATE=사람 확인, NOT_FOUND 는 다섯 갈래 전부 실행 시에만' };
writeFileSync(OUT, JSON.stringify(out, null, 1));
for (const row of inventory) log(` ${row.platform.padEnd(11)} ${row.state.padEnd(11)} 확정 ${row.confirmed} 후보 ${row.candidates} ${results[row.platform].filter((x) => x.status === 'CONFIRMED').map((x) => x.key + (x.subscribers ? `(${x.subscribers})` : '')).join(', ')}`);
log(`저장 ${OUT}`);

View File

@ -246,6 +246,23 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`));
}
/** (news.json). ·· .
* 1) 후기·전후·체험담 계열 제목(경험담 광고, 의료법 56 2 2) 2) 병원에 대한 규제·사건 보도(사이트 원칙: 병원이 알리거나 원장이 설명한 기사만 싣는다)
* 수집기(collect_news.mjs) 같은 규칙으로 먼저 거르고, 여기서는 거르지 않고 넘어온 것을 막는다. */
export const BANNED_NEWS_TITLE = /전후\s*(사진|비교|모습|공개)|비포|애프터|before\s*&?\s*after|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼/i; // "수술 전후 유방검진" 같은 시기 표현은 통과
export const NEGATIVE_NEWS = /공정위|제재|시정명령|뒷광고|적발|철퇴|덜미|기만|소송|사망|(?<!무)사고(?![력])|경찰|검찰|논란|피소|의료사고|압수|내돈내산|조작|가짜|대본|허위|과장\s*광고|벌금|징계|영업정지|고발|수사|불법/; // "무사고 13주년"은 통과
export function checkNewsItems(items, { level = E } = {}) {
const out = [];
for (const n of items ?? []) {
const t = n.title ?? '';
if (BANNED_NEWS_TITLE.test(t)) out.push(r(level, 'BANNED_NEWS_TITLE', `뉴스 제목 금칙: "${t}" (${n.url ?? ''})`));
if (NEGATIVE_NEWS.test(t)) out.push(r(level, 'NEGATIVE_NEWS', `규제·사건 보도는 싣지 않는다: "${t}" (${n.url ?? ''})`));
if (!/^\d{4}-\d{2}-\d{2}$/.test(n.date ?? '')) out.push(r(level, 'NEWS_DATE', `날짜 형식(YYYY-MM-DD) 아님: "${n.date}" · "${t}"`));
if (!['release', 'column', 'mention'].includes(n.kind)) out.push(r(level, 'NEWS_KIND', `kind 값 오류: "${n.kind}" · "${t}"`));
}
return out;
}
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,

View File

@ -89,5 +89,25 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과');
}
// 7) 뉴스룸 항목 (news.json): 후기·전후 비교·변신 제목과 규제·사건 보도는 error, 시기 표현("수술 전후 검진")·"무사고"는 통과
{
const flagged = R.checkNewsItems([
{ title: '조여정 닮은꼴 강은수, 성형 전후 비교해보니', date: '2014-08-15', kind: 'column', url: 'a' },
{ title: '주걱턱 태국소녀, 인형 같은 변신 성공', date: '2014-09-06', kind: 'column', url: 'b' },
{ title: '○○성형외과 공정위 제재… 뒷광고 적발', date: '2026-01-01', kind: 'mention', url: 'c' },
{ title: '날짜 형식이 다른 기사', date: '2026.01.01', kind: 'mention', url: 'd' },
]);
const c = codes(flagged);
['BANNED_NEWS_TITLE', 'NEGATIVE_NEWS', 'NEWS_DATE'].every((x) => c.includes(x)) && flagged.filter((x) => x.code === 'BANNED_NEWS_TITLE').length === 2 ? ok('뉴스 제목 금칙·사건 보도·날짜 검출', c.join(', ')) : bad('뉴스 항목 검출', JSON.stringify(flagged.map((x) => x.code)));
const passed = R.checkNewsItems([
{ title: '가슴수술 전후, 유방검진 왜 중요할까?', date: '2020-03-11', kind: 'column', url: 'e' },
{ title: "'환자안전 제일'… 뷰성형외과, 개원 16년동안 무사고 행보", date: '2020-01-17', kind: 'release', url: 'f' },
]);
passed.length ? bad('뉴스 정상 제목 오탐', passed.map((x) => x.msg).join(' | ')) : ok('"수술 전후 검진"·"무사고" 제목 통과');
const real = JSON.parse(readFileSync(here('../../src/data/news.json'), 'utf8'));
const re = R.checkNewsItems(real.items);
re.length ? bad('news.json 회귀', `${re.length}건: ` + re.slice(0, 3).map((x) => x.msg).join(' | ')) : ok('news.json 전 항목 규칙 통과', `${real.items.length}`);
}
console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1);

View File

@ -0,0 +1,2 @@
// 매체 도메인 → 매체명. build_news.py(뷰성형외과 수작업)의 표를 옮겼다. 없는 도메인은 호스트명을 그대로 쓴다.
export const OUTLETS = {"mdtoday.co.kr": "메디컬투데이", "jeonmin.co.kr": "전민일보", "moneyweek.co.kr": "머니S", "ajunews.com": "아주경제", "rapportian.com": "라포르시안", "n.news.naver.com": "네이버뉴스", "health.cbs.co.kr": "노컷뉴스", "newsis.com": "뉴시스", "biz.heraldcorp.com": "헤럴드경제", "heraldcorp.com": "헤럴드경제", "eroun.net": "이로운넷", "hidoc.co.kr": "하이닥", "sportsworldi.com": "스포츠월드", "newsway.co.kr": "뉴스웨이", "koreadaily.com": "미주중앙일보", "sports.chosun.com": "스포츠조선", "bokuennews.com": "보건뉴스", "medisobizanews.com": "메디소비자뉴스", "megaeconomy.co.kr": "메가경제", "newspim.com": "뉴스핌", "wowtv.co.kr": "한국경제TV", "wowstar.wowtv.co.kr": "한국경제TV 와우스타", "etoday.co.kr": "이투데이", "medipana.com": "메디파나뉴스", "view.asiae.co.kr": "아시아경제", "asiae.co.kr": "아시아경제", "jemin.com": "제민일보", "newstown.co.kr": "뉴스타운", "sports.hankooki.com": "스포츠한국", "hankooki.com": "한국일보", "psychiatricnews.net": "정신의학신문", "mt.co.kr": "머니투데이", "siminilbo.co.kr": "시민일보", "hemophilia.co.kr": "헤모필리아라이프", "beopbo.com": "법보신문", "stardailynews.co.kr": "스타데일리뉴스", "thefirstmedia.net": "더퍼스트미디어", "edu.donga.com": "에듀동아", "ikoreanspirit.com": "코리안스피릿", "biotimes.co.kr": "바이오타임즈", "gosiweek.com": "고시위크", "livesnews.com": "라이브뉴스", "vegannews.co.kr": "비건뉴스", "sisunnews.co.kr": "시선뉴스", "dhnews.co.kr": "대학저널", "yna.co.kr": "연합뉴스", "news.kbs.co.kr": "KBS", "news.sbs.co.kr": "SBS", "biz.sbs.co.kr": "SBS Biz", "imnews.imbc.com": "MBC", "donga.com": "동아일보", "hani.co.kr": "한겨레", "khan.co.kr": "경향신문", "chosun.com": "조선일보", "biz.chosun.com": "조선비즈", "hankookilbo.com": "한국일보", "mbn.mk.co.kr": "MBN", "mk.co.kr": "매일경제", "yonhapnewstv.co.kr": "연합뉴스TV", "news.tvchosun.com": "TV조선", "sisajournal.com": "시사저널", "kizmom.com": "키즈맘", "idsn.co.kr": "아이디에스엔", "e-science.co.kr": "이사이언스", "asiatoday.co.kr": "아시아투데이", "newsworks.co.kr": "뉴스웍스", "gukjenews.com": "국제뉴스", "cstimes.com": "컨슈머타임스", "cnbnews.com": "CNB뉴스", "enewstoday.co.kr": "이뉴스투데이", "fntoday.co.kr": "파이낸스투데이", "seoul.co.kr": "서울신문", "queen.co.kr": "퀸", "medicalworldnews.co.kr": "메디컬월드뉴스", "thereport.co.kr": "더리포트", "getnews.co.kr": "게잇뉴스", "newslock.co.kr": "뉴스락", "dailypop.kr": "데일리팝", "greened.kr": "녹색경제신문", "public25.com": "퍼블릭뉴스", "news.einfomax.co.kr": "연합인포맥스", "safetimes.co.kr": "세이프타임즈", "pennmike.com": "펜앤드마이크", "health.chosun.com": "헬스조선", "kormedi.com": "코메디닷컴", "sportsseoul.com": "스포츠서울", "segye.com": "세계일보", "sedaily.com": "서울경제", "fnnews.com": "파이낸셜뉴스", "ytn.co.kr": "YTN", "news1.kr": "뉴스1", "newsen.com": "뉴스엔", "osen.co.kr": "OSEN", "xportsnews.com": "엑스포츠뉴스", "tvdaily.co.kr": "티브이데일리", "topstarnews.net": "톱스타뉴스", "hankyung.com": "한국경제", "dailian.co.kr": "데일리안", "kukinews.com": "쿠키뉴스", "joongang.co.kr": "중앙일보", "dt.co.kr": "디지털타임스", "kyeonggi.com": "경기일보", "incheonilbo.com": "인천일보", "ggilbo.com": "금강일보", "kwnews.co.kr": "강원일보", "kmib.co.kr": "국민일보", "munhwa.com": "문화일보", "edaily.co.kr": "이데일리", "nocutnews.co.kr": "노컷뉴스", "news.mt.co.kr": "머니투데이", "newsprime.co.kr": "프라임경제", "ilyo.co.kr": "일요신문", "sports.donga.com": "스포츠동아", "medicaltimes.com": "메디칼타임즈", "docdocdoc.co.kr": "청년의사", "whosaeng.com": "후생신보", "bosa.co.kr": "의학신문", "dailymedi.com": "데일리메디", "akomnews.com": "한의신문", "joynews24.com": "조이뉴스24", "inews24.com": "아이뉴스24", "zdnet.co.kr": "지디넷코리아", "ceoscoredaily.com": "CEO스코어데일리", "viva100.com": "브릿지경제", "g-enews.com": "글로벌이코노믹", "thebell.co.kr": "더벨", "businesspost.co.kr": "비즈니스포스트", "ohmynews.com": "오마이뉴스", "pressian.com": "프레시안", "mediatoday.co.kr": "미디어오늘", "nate.com": "네이트뉴스", "news.nate.com": "네이트뉴스", "daum.net": "다음뉴스", "v.daum.net": "다음뉴스", "news.zum.com": "줌뉴스"};

View File

@ -4,7 +4,7 @@
```
node workers/supporters-build/run.mjs --clinic <id> --url <https://...> [--name 상호] [--logo file.svg] [--youtube 채널] [--deploy] [--build-id <uuid>] [--max-cost-usd 10]
# 단계: evidence → data → discover(채널 발견·엔티티 검증) → youtube(로고·파비콘·채널 집계) → images(의료법 기준 이미지) → inputs → briefs → generate → build → deploy
# 단계: evidence → data → discover(채널 발견·엔티티 검증) → youtube(로고·파비콘·채널 집계) → news(뉴스룸: 네이버 뉴스 + 언론보도 게시판) → images(의료법 기준 이미지) → inputs → briefs → generate → build → deploy
node workers/supporters-build/poll.mjs [--loop 300] [--no-deploy] # supporter_builds 큐(status=queued)를 집어 run.mjs 실행
```
@ -26,7 +26,7 @@ VPS(서버 직접 운영)와 Vercel Functions(최대 800초)는 권하지 않는
## 필요한 것
- `.env`: `VITE_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY`
- `.env`: `VITE_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY`, `YOUTUBE_API_KEY`, `NAVER_CLIENT_ID`/`NAVER_CLIENT_SECRET`(뉴스룸)
- `vercel` CLI 로그인 (배포 시). 병원당 프로젝트 `supporters-<id>` 를 만든다
- `templates/supporters-astro/` (supporters 에서 `npm run export:template`)

View File

@ -2,9 +2,12 @@
<!--
서포터즈 빌드 큐 폴러 (5분마다). 설치:
cp workers/supporters-build/launchd/com.infinith.supporters-poll.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
해제:
launchctl unload ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
launchctl bootout gui/$(id -u)/com.infinith.supporters-poll
상태:
launchctl print gui/$(id -u)/com.infinith.supporters-poll | head -20
설치 2026-09-08 (haewon 결정: 파일럿은 Mac 폴러). 같은 라벨은 한 프로세스만 돌므로 빌드가 5분을 넘어도 겹치지 않는다.
로그: ~/supporters-builds/poll.log
저장소 경로가 다르면 WorkingDirectory 와 ProgramArguments 의 경로를 바꾼다.
-->
@ -19,7 +22,7 @@
<key>WorkingDirectory</key><string>/Users/haewonkam/orca/workspaces/INFINITH/Productize</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
<key>PATH</key><string>/Library/Frameworks/Python.framework/Versions/3.13/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
<key>StartInterval</key><integer>300</integer>
<key>RunAtLoad</key><true/>

View File

@ -3,9 +3,9 @@
//
// node workers/supporters-build/run.mjs --clinic <id> --url <https://...> [--name 상호] [--work ~/supporters-builds/<id>]
// [--inputs inputs.json | --from-supabase] [--brief briefs.json] [--max-posts 6] [--max-cost-usd 3]
// [--deploy] [--build-id <supporter_builds.id>] [--from <phase>] [--skip-generate]
// [--deploy] [--build-id <supporter_builds.id>] [--from <phase>] [--skip-generate] [--news-board <URL> | --no-news]
//
// 단계: evidence → data → inputs → briefs → generate → build → deploy → done
// 단계: evidence → data → discover → youtube → news → images → inputs → briefs → generate → build → deploy → done
// 단계마다 <work>/status.json 을 쓰고, --build-id 와 SUPABASE_SERVICE_ROLE_KEY 가 있으면 supporter_builds 행을 갱신한다.
// 게이트(check.mjs)가 error 를 내면 status=gate_failed 로 멈춘다. 사람 게이트(검토·승인)는 여기서 자동화하지 않는다.
import { spawnSync } from 'node:child_process';
@ -26,7 +26,7 @@ const clinic = opt('clinic'); const url = opt('url');
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
const today = new Date().toISOString().slice(0, 10);
const PHASES = ['evidence', 'data', 'discover', 'youtube', 'images', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
const PHASES = ['evidence', 'data', 'discover', 'youtube', 'news', 'images', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
const startFrom = opt('from', 'evidence');
(function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; 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, ''); } })();
@ -209,6 +209,22 @@ await phase('youtube', () => {
return notes.concat(`${v.channel.title} · 영상 ${v.channel.videos} · 설명형 롱폼 ${v.topLong.length} · 쇼츠 ${v.topShorts.length}`).join(' · ');
});
// 2d. 뉴스룸: 네이버 뉴스 검색 + (있으면) 홈페이지 언론보도 게시판. 병원 이름이 제목·요약에 있는 기사만 싣고, 규제·사건 보도와 후기·전후 비교·변신 제목은 뺀다(게이트 NEGATIVE_NEWS·BANNED_NEWS_TITLE 와 같은 규칙, v3 §5 "수집 단계에서 같은 규칙으로 거른다").
// 게시판 위치: --news-board > supporters/briefs/<clinic>/clinic.json newsBoard > 홈 링크 자동 발견. 없으면 네이버만.
await phase('news', () => {
if (flag('no-news')) return '건너뜀 (--no-news)';
const fact = readJson(join(DATA, 'factSheet.json'));
const name = opt('name') || fact.shortName || fact.name;
if (!name) throw new Error('뉴스 검색에 쓸 상호가 없음 (--name 또는 factSheet.name)');
const argv = ['scripts/collect_news.mjs', '--clinic', clinic, '--url', url, '--site', SITE, '--name', name];
if (opt('news-board')) argv.push('--board', opt('news-board'));
const r = run('node', argv);
if (!r.ok || !existsSync(join(DATA, 'news.json'))) throw new Error(`뉴스 수집 실패\n${tail(r.out)}`);
const n = readJson(join(DATA, 'news.json'));
status.report = { ...(status.report ?? {}), news: { total: n.items.length, kinds: n.stats.kinds, outlets: n.stats.outlets, board: n.sources.board, boardNote: n.sources.boardNote, excluded: { irrelevant: n.stats.naver.irrelevant, negative: n.stats.naver.excludedNegative + n.stats.board.excludedNegative, banned: n.stats.naver.excludedBanned + n.stats.board.excludedBanned } } };
return tail(r.out, 1);
});
// 2c. 홈페이지 이미지 (규칙: docs/INFINITH_Supporters_Image_Rules.md). 전후·사례·모델·비교·수상·수술 장면은 자동 제외.
await phase('images', () => {
if (flag('no-images')) return '건너뜀 (--no-images)';