Compare commits

..

No commits in common. "4e73798ccecb0f8eab2371b39fadc5cf92a7fa45" and "f6701c27884e494bb5975ab09019aacfb408a7de" have entirely different histories.

17 changed files with 386 additions and 836 deletions

View File

@ -32,18 +32,17 @@ 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 | | 자동 채점·리포트 | `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_youtube.mjs`, 워커 youtube 단계, 고정값 `supporters/briefs/<id>/clinic.json` | 원진 736편 집계, @wjwonjin 고정 |
| 이미지 | `collect_images.mjs` + `img_process.py`, 규칙 `docs/INFINITH_Supporters_Image_Rules.md` | 원진 66장 | | 이미지 | `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 | | 기획·생성·게이트 | `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) | 라이브. **폴러 설치됨(2026-09-08, Mac launchd 5분, 로그 `~/supporters-builds/poll.log`)**. Mac이 켜져 있어야 돈다 | | 워커·큐·승인 UI | `workers/supporters-build/run.mjs`·`poll.mjs`, `supporter_builds`·`supporter_inputs`, `ClinicInputsPanel`(/discovery/:id·/supporters/:id) | 라이브. 폴러는 미설치 |
| 입력 반영 | `apply_inputs.mjs` (검토·승인 → frontmatter, 자유 답변 → clinicAnswers 근거 [A]) | dry-run 검증 | | 입력 반영 | `apply_inputs.mjs` (검토·승인 → frontmatter, 자유 답변 → clinicAnswers 근거 [A]) | dry-run 검증 |
| 미리보기 | supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex) | 배포됨 | | 미리보기 | supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex) | 배포됨 |
## 2. 남은 일 (권장 순서) ## 2. 남은 일 (권장 순서)
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건)을 뉴스룸에 보일지. 1. **뉴스룸 단계**: `supporters/scripts/build_news.py`(홈페이지 언론 게시판 + 네이버 뉴스 API, 부정 기사 제외)를 워커 단계로. 원진은 게시판 위치부터 확인. 병원 무관 기사 본문 관련성 필터(v2 §8 미반영) 포함.
2. **감사 재실행 단계(Phase 9)**: 색인 허용 빌드를 로컬에서 `audit_aeo_geo.py`로 다시 채점해 공식 vs 서포터즈 비교표를 리포트 카드에. `scripts/build_supporters_audit_xlsx.py` 로직 재사용. 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 명령 참조). 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. ~~워커 상시 실행~~ **완료(09-08)**: Mac launchd 폴러 설치(haewon 지시). 해제 `launchctl bootout gui/$(id -u)/com.infinith.supporters-poll`. Trigger.dev 이관은 월 수십 건이 되면. 실제 랜딩 신청 1건으로 큐 → 빌드 완주를 아직 못 봤다(큐가 비어 있었음). 첫 신청이 오면 `poll.log`를 본다. 4. **워커 상시 실행**: Mac launchd 폴러 설치(`workers/supporters-build/launchd/`) 또는 Trigger.dev 이관(가입·키만 haewon). 랜딩 URL 신청 → 큐 → 자동 빌드가 이어지려면 이게 필요하다.
5. **병원 송부 전 사람 확인**: 원진 이미지 갤러리 육안 확인(얼굴·신체 검출 없음), "확인 필요" 채널 12건 판정, 쇼츠 정리 답(사람이 영상 보고 한 문단). 5. **병원 송부 전 사람 확인**: 원진 이미지 갤러리 육안 확인(얼굴·신체 검출 없음), "확인 필요" 채널 12건 판정, 쇼츠 정리 답(사람이 영상 보고 한 문단).
6. **검토 파이프라인**: 병원이 `/supporters/wonjin` 폼에 입력 → `run.mjs --from data --from-supabase --deploy --build-id 87cb75a2-2037-4cbb-ae27-c06e39f022e8` 재빌드. 이 재빌드도 폴러가 자동으로 집게 하려면 supporter_inputs insert 트리거를 큐에 연결. 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 차단 대비)이 붙어야 좁혀진다. 7. **뷰성형외과 자동 빌드 미리보기**(supporters-viewclinic)를 수작업 샘플(view-supporters-sample) 수준으로: 뷰는 자막·쇼츠 답이 사람 작업이라 격차가 있다. 자막 파이프라인(v1 §6, IP 차단 대비)이 붙어야 좁혀진다.
@ -53,10 +52,9 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
1. 원진 "확인 필요" 채널 12건 중 병원 것(영문 채널 Wonjin Plastic Surgery Korea, WONJIN_OFFICIAL 등)을 사이트에 연결할지. 1. 원진 "확인 필요" 채널 12건 중 병원 것(영문 채널 Wonjin Plastic Surgery Korea, WONJIN_OFFICIAL 등)을 사이트에 연결할지.
2. 지원 관계 고지 문구(site.sponsorNotice)를 켤지와 문안. 켜면 글 첫 부분에 노출되고 게이트가 필수 검사로 바뀐다(v2 §12-2). 2. 지원 관계 고지 문구(site.sponsorNotice)를 켤지와 문안. 켜면 글 첫 부분에 노출되고 게이트가 필수 검사로 바뀐다(v2 §12-2).
3. ~~워커 상시 실행 위치~~ 결정됨(09-08): 파일럿은 Mac 폴러. Trigger.dev는 정착 단계에. 3. 워커 상시 실행 위치(Mac 폴러 설치 vs Trigger.dev 가입).
4. 원진에 미리보기·리포트 송부 시점과 병원 입력 담당자. 4. 원진에 미리보기·리포트 송부 시점과 병원 입력 담당자.
5. 경영진 미결 그대로: 도메인(별도 vs 서브도메인), POC 범위·가격, 서포터즈 실존 개인 모집. 5. 경영진 미결 그대로: 도메인(별도 vs 서브도메인), POC 범위·가격, 서포터즈 실존 개인 모집.
6. 뉴스룸 "언론 언급" 종류(mention)를 보일지. 병원이 알린 기사·원장 기고만 남기면 원진은 1,049건 중 535건이 된다. 보이면 관계사(WJ코스메틱)·투자·대학 기사가 섞인다. 기본값은 지금처럼 보이되 뒤로 정렬.
## 4. 이번 세션들(09-07 세션 3 ~ 09-08)에서 확정한 것 ## 4. 이번 세션들(09-07 세션 3 ~ 09-08)에서 확정한 것
@ -71,9 +69,6 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
- 모델 근거 대조기는 "근거에 있다"고 적으면서 플래그를 세운다. 항목별 supported 판정으로 거른다. - 모델 근거 대조기는 "근거에 있다"고 적으면서 플래그를 세운다. 항목별 supported 판정으로 거른다.
- 게이트는 영상 제목도 본다. "부작용은 없을까?" 제목이 홈·영상 페이지에서 배포를 막았다. 수집 단계에서 같은 규칙으로 거른다. - 게이트는 영상 제목도 본다. "부작용은 없을까?" 제목이 홈·영상 페이지에서 배포를 막았다. 수집 단계에서 같은 규칙으로 거른다.
- 인터넷이 끊기면 생성 단계가 fetch failed 로 멈춘다. `--from generate` 로 재개. - 인터넷이 끊기면 생성 단계가 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 번들로 돌린다. - `node --experimental-strip-types` 는 확장자 없는 import 를 못 푼다. TS 스크립트는 esbuild 번들로 돌린다.
## 6. 자주 쓰는 명령 ## 6. 자주 쓰는 명령
@ -85,8 +80,6 @@ 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/run.mjs --clinic <id> --url <홈> --work ~/supporters-builds/<id> --from <단계> --deploy --build-id <id>
# 큐 비우기 / 상주 # 큐 비우기 / 상주
node workers/supporters-build/poll.mjs [--loop 300] 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/) # 게이트 테스트 · 뷰 정답지 회귀 · 템플릿 내보내기 (supporters/)
npm run gate:test · npm run generate:regress · npm run export:template npm run gate:test · npm run generate:regress · npm run export:template
# 진단 리포트 HTML/PDF # 진단 리포트 HTML/PDF

View File

@ -100,8 +100,6 @@ add('data/factSheet.json', R.checkNextCheck(fact, today));
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' })); add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' })); add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다. // "채널 인기 전체" 탭은 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); const errors = findings.filter((x) => x.level === R.E);

View File

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

View File

@ -246,23 +246,6 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`)); .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부터 유지. // 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [ export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/, /완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,

View File

@ -89,25 +89,5 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과'); 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}`); console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1); if (failCount) process.exit(1);

View File

@ -1,2 +0,0 @@
// 매체 도메인 → 매체명. 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,6 +6323,16 @@
"topic": "가슴성형", "topic": "가슴성형",
"boardUid": 697 "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", "date": "2019-04-30",
"title": "양악수술 및 돌출입수술 등… 치과교정과 전문의 협진 필요", "title": "양악수술 및 돌출입수술 등… 치과교정과 전문의 협진 필요",
@ -6800,6 +6810,16 @@
"topic": "가슴성형", "topic": "가슴성형",
"boardUid": 659 "boardUid": 659
}, },
{
"date": "2019-01-09",
"title": "얼굴 커 보이는 원인, 단순히 광대뼈 크기만 줄이는 광대축소술은 지양해야..",
"outlet": "칼럼",
"url": "http://www.pointn.net",
"kind": "column",
"origin": "board",
"topic": "안면윤곽·양악",
"boardUid": 657
},
{ {
"date": "2019-01-08", "date": "2019-01-08",
"title": "원인에 따라 수술방법 달라지는 짧은코성형, 체계적인 분석과 계획이 중요", "title": "원인에 따라 수술방법 달라지는 짧은코성형, 체계적인 분석과 계획이 중요",
@ -8597,6 +8617,33 @@
"origin": "naver", "origin": "naver",
"topic": "안면윤곽·양악" "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", "date": "2014-09-10",
"title": "주걱턱 태국인 '렛미인4' 니차컨, 인형같은 화보 화제", "title": "주걱턱 태국인 '렛미인4' 니차컨, 인형같은 화보 화제",
@ -8615,6 +8662,24 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "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", "date": "2014-09-05",
"title": "[뷰티한국] '렛미인' 태국인 등장, 되찾은 환한 미소", "title": "[뷰티한국] '렛미인' 태국인 등장, 되찾은 환한 미소",
@ -8624,6 +8689,33 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "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", "date": "2014-09-05",
"title": "태국 '렛미인' 니차컨의 작고 입체적인 얼굴의 비결은 '이마코성형'", "title": "태국 '렛미인' 니차컨의 작고 입체적인 얼굴의 비결은 '이마코성형'",
@ -8651,6 +8743,15 @@
"origin": "naver", "origin": "naver",
"topic": "수상·인증" "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", "date": "2014-09-05",
"title": "주걱턱 태국인 니차컨, ‘렛미인4’통해 “웃음 되찾았다”", "title": "주걱턱 태국인 니차컨, ‘렛미인4’통해 “웃음 되찾았다”",
@ -8733,6 +8834,33 @@
"origin": "naver", "origin": "naver",
"topic": "방송·렛미인" "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", "date": "2014-08-15",
"title": "'세 딸을 책임지는 어린 엄마' 렛미인4 강은수, 반전 미모 화제", "title": "'세 딸을 책임지는 어린 엄마' 렛미인4 강은수, 반전 미모 화제",
@ -8796,6 +8924,15 @@
"origin": "naver", "origin": "naver",
"topic": "안면윤곽·양악" "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", "date": "2014-07-11",
"title": "한.중 겸용출판서 <한국 미용성형의 고수18>에 윤창운 원장님이 소개되었습니다.", "title": "한.중 겸용출판서 <한국 미용성형의 고수18>에 윤창운 원장님이 소개되었습니다.",
@ -8806,6 +8943,15 @@
"topic": "기타", "topic": "기타",
"boardUid": 509 "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", "date": "2014-07-08",
"title": "‘렛미인4' 여배우 외모 하성욱부터 황신혜 닮은꼴 윤단비까지…", "title": "‘렛미인4' 여배우 외모 하성욱부터 황신혜 닮은꼴 윤단비까지…",
@ -8824,6 +8970,15 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "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", "date": "2014-07-01",
"title": "렛미인4 거대가슴 ' i컵녀' 화제에 '여자가 되고 싶은 남자' 하성욱도 주...", "title": "렛미인4 거대가슴 ' i컵녀' 화제에 '여자가 되고 싶은 남자' 하성욱도 주...",
@ -8833,6 +8988,15 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "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", "date": "2014-06-24",
"title": "아이돌급 외모 렛미남 화제에 '여자가 되고 싶은 남자' 하성욱도 주목", "title": "아이돌급 외모 렛미남 화제에 '여자가 되고 싶은 남자' 하성욱도 주목",
@ -8842,6 +9006,15 @@
"origin": "naver", "origin": "naver",
"topic": "모발·남성" "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", "date": "2014-06-17",
"title": "렛미인4 노안녀 화제에 '여자가 되고 싶은 남자' 하성욱도 주목", "title": "렛미인4 노안녀 화제에 '여자가 되고 싶은 남자' 하성욱도 주목",
@ -8851,6 +9024,15 @@
"origin": "naver", "origin": "naver",
"topic": "모발·남성" "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", "date": "2014-06-10",
"title": "‘렛미인4’ 하성욱,남자에서 여자의 삶으로… ‘여배우 비주얼’ 화제", "title": "‘렛미인4’ 하성욱,남자에서 여자의 삶으로… ‘여배우 비주얼’ 화제",
@ -8860,6 +9042,33 @@
"origin": "naver", "origin": "naver",
"topic": "모발·남성" "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", "date": "2014-06-06",
"title": "MC황신혜도 깜놀... 트랜스젠더 하성욱 \"완벽한 여자로 살고 싶다\"", "title": "MC황신혜도 깜놀... 트랜스젠더 하성욱 \"완벽한 여자로 살고 싶다\"",
@ -8869,6 +9078,24 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "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", "date": "2014-06-06",
"title": "MC황신혜도 놀란 여배우 외모! 렛미인4 트랜스젠더 하성욱", "title": "MC황신혜도 놀란 여배우 외모! 렛미인4 트랜스젠더 하성욱",
@ -8878,6 +9105,15 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "topic": "가슴성형"
}, },
{
"date": "2014-06-06",
"title": "\"여자가 되고 싶은 남자\" 렛미인4, 트랜스젠더 하성욱의 반전 변신",
"outlet": "아주경제",
"url": "http://www.ajunews.com/view/20140605160705390",
"kind": "column",
"origin": "naver",
"topic": "모발·남성"
},
{ {
"date": "2014-06-03", "date": "2014-06-03",
"title": "‘렛미인4’ 첫 방송… 렛미인 레전드 외모 김백주 재조명!", "title": "‘렛미인4’ 첫 방송… 렛미인 레전드 외모 김백주 재조명!",
@ -9491,6 +9727,33 @@
"topic": "가슴성형", "topic": "가슴성형",
"boardUid": 843 "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", "date": "2013-09-06",
"title": "렛미인3, 김백주에 이어 김미영까지…치료하는 닥터스가 더 신기해", "title": "렛미인3, 김백주에 이어 김미영까지…치료하는 닥터스가 더 신기해",
@ -9509,6 +9772,15 @@
"origin": "naver", "origin": "naver",
"topic": "안면윤곽·양악" "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", "date": "2013-08-09",
"title": "치아부식녀 \"렛미인 이번 시즌 통틀어 1등!\"", "title": "치아부식녀 \"렛미인 이번 시즌 통틀어 1등!\"",
@ -9527,6 +9799,24 @@
"origin": "naver", "origin": "naver",
"topic": "안면윤곽·양악" "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", "date": "2013-07-12",
"title": "여성스럽고 세련된 얼굴, 앞모습만큼 옆모습도 중요", "title": "여성스럽고 세련된 얼굴, 앞모습만큼 옆모습도 중요",
@ -9608,6 +9898,24 @@
"origin": "naver", "origin": "naver",
"topic": "안면윤곽·양악" "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", "date": "2013-07-05",
"title": "렛미인3-못생긴 형제들편, 최지혜 남매 렛미인 효과로 활짝", "title": "렛미인3-못생긴 형제들편, 최지혜 남매 렛미인 효과로 활짝",
@ -9644,6 +9952,15 @@
"origin": "naver", "origin": "naver",
"topic": "행사·교육·협약" "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", "date": "2013-06-15",
"title": "<렛미인3>'제2의 손예진' 된 '썩은 가슴女' 화제", "title": "<렛미인3>'제2의 손예진' 된 '썩은 가슴女' 화제",
@ -9671,6 +9988,24 @@
"origin": "naver", "origin": "naver",
"topic": "가슴성형" "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", "date": "2013-06-14",
"title": "<렛미인3> '가슴 복원女' 진짜 가슴을 찾다", "title": "<렛미인3> '가슴 복원女' 진짜 가슴을 찾다",
@ -9756,6 +10091,15 @@
"topic": "기타", "topic": "기타",
"boardUid": 850 "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", "date": "2012-12-14",
"title": "렛미인 최고의 반전녀, \"가족도 못알아 보겠다\"", "title": "렛미인 최고의 반전녀, \"가족도 못알아 보겠다\"",
@ -9792,6 +10136,24 @@
"origin": "naver", "origin": "naver",
"topic": "안면윤곽·양악" "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", "date": "2012-11-09",
"title": "가슴 4개 달린 여자 성형하더니‥", "title": "가슴 4개 달린 여자 성형하더니‥",
@ -10103,6 +10465,16 @@
"topic": "가슴성형", "topic": "가슴성형",
"boardUid": 886 "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", "date": "2012-08-31",
"title": "비행기 승무원들, 가슴성형 가능할까?", "title": "비행기 승무원들, 가슴성형 가능할까?",
@ -10306,53 +10678,5 @@
"origin": "naver", "origin": "naver",
"topic": "리프팅·피부·안티에이징" "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

@ -1,6 +0,0 @@
{
"_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,8 +100,6 @@ add('data/factSheet.json', R.checkNextCheck(fact, today));
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' })); add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' })); add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다. // "채널 인기 전체" 탭은 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); const errors = findings.filter((x) => x.level === R.E);

View File

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

View File

@ -1,210 +0,0 @@
// 채널 발견 + 엔티티 검증 (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,23 +246,6 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`)); .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부터 유지. // 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [ export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/, /완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,

View File

@ -89,25 +89,5 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과'); 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}`); console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1); if (failCount) process.exit(1);

View File

@ -1,2 +0,0 @@
// 매체 도메인 → 매체명. 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] 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(로고·파비콘·채널 집계) → news(뉴스룸: 네이버 뉴스 + 언론보도 게시판) → images(의료법 기준 이미지) → inputs → briefs → generate → build → deploy # 단계: evidence → data → discover(채널 발견·엔티티 검증) → youtube(로고·파비콘·채널 집계) → images(의료법 기준 이미지) → inputs → briefs → generate → build → deploy
node workers/supporters-build/poll.mjs [--loop 300] [--no-deploy] # supporter_builds 큐(status=queued)를 집어 run.mjs 실행 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`, `YOUTUBE_API_KEY`, `NAVER_CLIENT_ID`/`NAVER_CLIENT_SECRET`(뉴스룸) - `.env`: `VITE_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY`
- `vercel` CLI 로그인 (배포 시). 병원당 프로젝트 `supporters-<id>` 를 만든다 - `vercel` CLI 로그인 (배포 시). 병원당 프로젝트 `supporters-<id>` 를 만든다
- `templates/supporters-astro/` (supporters 에서 `npm run export:template`) - `templates/supporters-astro/` (supporters 에서 `npm run export:template`)

View File

@ -2,12 +2,9 @@
<!-- <!--
서포터즈 빌드 큐 폴러 (5분마다). 설치: 서포터즈 빌드 큐 폴러 (5분마다). 설치:
cp workers/supporters-build/launchd/com.infinith.supporters-poll.plist ~/Library/LaunchAgents/ cp workers/supporters-build/launchd/com.infinith.supporters-poll.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.infinith.supporters-poll.plist launchctl load ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
해제: 해제:
launchctl bootout gui/$(id -u)/com.infinith.supporters-poll launchctl unload ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
상태:
launchctl print gui/$(id -u)/com.infinith.supporters-poll | head -20
설치 2026-09-08 (haewon 결정: 파일럿은 Mac 폴러). 같은 라벨은 한 프로세스만 돌므로 빌드가 5분을 넘어도 겹치지 않는다.
로그: ~/supporters-builds/poll.log 로그: ~/supporters-builds/poll.log
저장소 경로가 다르면 WorkingDirectory 와 ProgramArguments 의 경로를 바꾼다. 저장소 경로가 다르면 WorkingDirectory 와 ProgramArguments 의 경로를 바꾼다.
--> -->
@ -22,7 +19,7 @@
<key>WorkingDirectory</key><string>/Users/haewonkam/orca/workspaces/INFINITH/Productize</string> <key>WorkingDirectory</key><string>/Users/haewonkam/orca/workspaces/INFINITH/Productize</string>
<key>EnvironmentVariables</key> <key>EnvironmentVariables</key>
<dict> <dict>
<key>PATH</key><string>/Library/Frameworks/Python.framework/Versions/3.13/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string> <key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
</dict> </dict>
<key>StartInterval</key><integer>300</integer> <key>StartInterval</key><integer>300</integer>
<key>RunAtLoad</key><true/> <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>] // 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] // [--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] [--news-board <URL> | --no-news] // [--deploy] [--build-id <supporter_builds.id>] [--from <phase>] [--skip-generate]
// //
// 단계: evidence → data → discover → youtube → news → images → inputs → briefs → generate → build → deploy → done // 단계: evidence → data → inputs → briefs → generate → build → deploy → done
// 단계마다 <work>/status.json 을 쓰고, --build-id 와 SUPABASE_SERVICE_ROLE_KEY 가 있으면 supporter_builds 행을 갱신한다. // 단계마다 <work>/status.json 을 쓰고, --build-id 와 SUPABASE_SERVICE_ROLE_KEY 가 있으면 supporter_builds 행을 갱신한다.
// 게이트(check.mjs)가 error 를 내면 status=gate_failed 로 멈춘다. 사람 게이트(검토·승인)는 여기서 자동화하지 않는다. // 게이트(check.mjs)가 error 를 내면 status=gate_failed 로 멈춘다. 사람 게이트(검토·승인)는 여기서 자동화하지 않는다.
import { spawnSync } from 'node:child_process'; 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); } if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic))); const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const PHASES = ['evidence', 'data', 'discover', 'youtube', 'news', 'images', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done']; const PHASES = ['evidence', 'data', 'discover', 'youtube', 'images', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
const startFrom = opt('from', 'evidence'); 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, ''); } })(); (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,22 +209,6 @@ await phase('youtube', () => {
return notes.concat(`${v.channel.title} · 영상 ${v.channel.videos} · 설명형 롱폼 ${v.topLong.length} · 쇼츠 ${v.topShorts.length}`).join(' · '); 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). 전후·사례·모델·비교·수상·수술 장면은 자동 제외. // 2c. 홈페이지 이미지 (규칙: docs/INFINITH_Supporters_Image_Rules.md). 전후·사례·모델·비교·수상·수술 장면은 자동 제외.
await phase('images', () => { await phase('images', () => {
if (flag('no-images')) return '건너뜀 (--no-images)'; if (flag('no-images')) return '건너뜀 (--no-images)';