diff --git a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md index 429a61c..773328c 100644 --- a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md +++ b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md @@ -121,6 +121,38 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI - **템플릿에 넣기 전 빈 데이터로 빌드해 본다.** 한 병원의 데이터로만 확인하면 하드코딩과 데이터 참조가 구별되지 않는다. 화면이 같아 보이기 때문이다. - **게이트가 통과해도 화면은 깨질 수 있다.** 게이트는 의료광고 문구와 금칙어를 보지, 빈 값이 만든 비문을 보지 않는다. 배포 전 로컬 확인이 필요한 이유다. +## 5-3. 이미지 (2026-09-12) · 사람 게이트와 남은 일 + +### 자동 분류를 믿을 수 없다 + +뷰성형외과 수집 이미지 12장 중 6장이 의료광고 금지 유형인데 전부 시설(clinic)로 분류돼 있었다. +전후 사진(밑선 절개 흉터 1개월·6개월 경과), 시술 행위 사진(팔 주사·드레싱), 광고 모델 신체 사진 4장, +장비 제조사 성능 주장 그래픽, 가슴 라인 일러스트(랜딩 카드 썸네일로 노출)다. + +**파일명·alt 로는 한 장도 걸리지 않는다.** 91장을 그 방식으로 검사해 0장이 걸렸다. +파일명이 `img02` 이고 alt 가 "가슴확대 시설 사진" 이면 어떤 규칙도 전후 사진임을 알 수 없다. +규칙 문서 §4 의 `IMAGE_RULE`(파일명·alt 검사) 계획은 이 유형에 효과가 없다. + +→ **이미지 육안 확인은 사람 게이트로 남긴다.** `scripts/build_image_review.mjs` 가 검토 요청서를 만든다. +화면에 실제로 나가는 것을 먼저 보여주고, 체크하면 `excludeImages` 에 넣을 목록이 만들어진다. +`node scripts/build_image_review.mjs --out <파일.html> --site =` + +### 대표 이미지 배정 (워커 heroes 단계) + +`PostCard` 의 대체 순서가 건물 사진 하나로 수렴해 목록에 같은 사진이 나란히 섰다. +`scripts/assign_post_heroes.mjs` 가 글 분류별로 어울리는 유형을 골라 글마다 다른 사진을 배정한다. +의료진 사진은 쓰지 않고(저자·검토자 표시가 지정 용도), 수집 alt 가 효과·결과를 주장하면 중립 문구로 바꾸며, +이미지가 모자라면 다시 쓰지 않고 비운다. + +### 남은 일 + +| 항목 | 상태 | +|---|---| +| 원진 66장 육안 검토 | **요청 상태.** 그중 15장이 화면에 나간다. 아직 아무도 보지 않았다 | +| 뷰 17장 | 12장 확인 완료(6장 제외). 의료진 13장은 검토자 표시 용도라 별도 | +| 오라클 이미지 | **0장.** 근거 수집일 2026-09-10 이 EUC-KR 디코딩(2026-09-11 반영)보다 하루 빨라 `images[].alt` 가 전부 `????` 로 깨졌고 분류기가 한 장도 고르지 못한다. **코드는 이미 고쳐져 있으므로 `--from evidence` 재수집이면 풀린다.** 다만 `oracleclinic.com` 이 응답하지 않는다(포트 80 연결 거부, 2026-09-12 기준). 그룹 사이트는 살아 있으나 Next.js 이미지 최적화 뒤에 있고 내용이 브랜드 배너·인증 마크라 대부분 제외 대상이다 | +| 뷰 이미지 부족 | 제외 후 쓸 수 있는 것이 4장이라 글 5편 중 2편이 건물 사진으로 떨어진다. 병원 제공 시설 사진이 필요하다 | + ## 6. 함정 (제품 관점) - **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만. diff --git a/supporters/scripts/build_image_review.mjs b/supporters/scripts/build_image_review.mjs index 43b3c25..e4163c0 100644 --- a/supporters/scripts/build_image_review.mjs +++ b/supporters/scripts/build_image_review.mjs @@ -1,10 +1,11 @@ -// 수집 이미지 육안 검토 페이지를 만든다. +// 수집 이미지 육안 검토 요청서를 만든다. // node scripts/build_image_review.mjs --out <파일.html> --site = [--site ...] // // 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진· -// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 규칙(docs/INFINITH_Supporters_Image_Rules.md)이 -// 금지하는 유형은 결국 사람이 봐야 갈린다. 이 페이지는 그 육안 확인을 한 화면에서 하게 하고, -// 제외할 파일명을 briefs//clinic.json 의 excludeImages 에 넣을 수 있게 목록으로 내준다. +// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 2026-09-12 뷰성형외과에서 91장을 +// 파일명·alt 로 검사했을 때 걸린 것이 0장이었고, 눈으로 보니 12장 중 6장이 금지 유형이었다. +// 그래서 이 단계는 사람 게이트로 둔다. 이 페이지는 그 판정을 한 화면에서 하고, +// 결과를 briefs//clinic.json 의 excludeImages 에 넣을 수 있는 형태로 돌려준다. import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join, resolve, dirname, basename } from 'node:path'; @@ -13,29 +14,26 @@ const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[ const sites = args.reduce((acc, a, i) => (args[i - 1] === '--site' ? [...acc, a] : acc), []); const OUT = resolve(opt('out', 'image-review.html')); const ASSETS = join(dirname(OUT), 'image-review-files'); +const ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..', '..'); const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); - -// 파일명·alt 로 걸러지는 것만 미리 표시한다. 나머지는 사람이 본다. -const FLAG = [ - [/전후|비포|애프터|before|after|bna|b_a/i, '전후 사진 의심'], - [/모델|model/i, '광고 모델 의심'], - [/후기|리뷰|review|사례|case/i, '치료 경험담 의심'], - [/수상|award|인증|certif/i, '인증·수상 의심'], - [/vs|비교/i, '비교 광고 의심'], -]; +// 파일명에서 수집기가 붙인 해시를 떼어 excludeImages 에 쓸 접두어를 만든다. +const prefixOf = (src) => basename(src).replace(/-[0-9a-f]{10}\.\w+$/, '').replace(/\.\w+$/, ''); const blocks = []; for (const spec of sites) { const [id, dir] = spec.split('='); const SITE = resolve(dir); const DATA = join(SITE, 'src', 'data'); - if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, items: [], note: 'images.json 없음 (수집된 이미지 없음)' }); continue; } + const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {}; + const briefPath = join(ROOT, 'supporters', 'briefs', id, 'clinic.json'); + const already = existsSync(briefPath) ? (readJson(briefPath).excludeImages ?? []) : []; + if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, clinic: fact.shortName || id, items: [], already, note: '수집된 이미지가 없습니다. 검토할 것이 없습니다.' }); continue; } + const manifest = readJson(join(DATA, 'images.json')); const site = existsSync(join(DATA, 'site.json')) ? readJson(join(DATA, 'site.json')) : {}; - const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {}; - // 화면 어디에 쓰이는지 + // 화면 어디에 쓰이는지. 실제로 노출되는 것부터 보게 한다. const usage = new Map(); const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); }; mark(site.heroImage?.src, '홈 히어로'); @@ -45,87 +43,138 @@ for (const spec of sites) { const POSTS = join(SITE, 'src', 'content', 'posts'); if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) { const t = readFileSync(join(POSTS, f), 'utf8'); - for (const m of t.matchAll(/(?:thumbnail:|src:)\s*["']?(\/img\/[^\s"',}]+)/g)) mark(m[1], `글 ${f.replace(/\.md$/, '')}`); + for (const m of t.matchAll(/(?:thumbnail:|src:)[ \t]*["']?(\/img\/[^\s"',}]+)/g)) mark(m[1], `글 카드 ${f.replace(/\.md$/, '')}`); } mkdirSync(join(ASSETS, id), { recursive: true }); const items = (manifest.items ?? []).map((it) => { - const rel = it.src.replace(/^\//, ''); - const from = join(SITE, 'public', rel); + const from = join(SITE, 'public', it.src.replace(/^\//, '')); const name = `${it.category}__${basename(it.src)}`; if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name)); - const hay = `${it.src} ${it.alt} ${it.page?.url ?? ''}`; - return { - ...it, name, - flags: FLAG.filter(([re]) => re.test(hay)).map(([, label]) => label), - used: usage.get(it.src) ?? [], - }; - }); - blocks.push({ id, items, clinic: fact.shortName || id, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt }); + return { ...it, name, prefix: prefixOf(it.src), used: usage.get(it.src) ?? [] }; + }).sort((a, b) => (b.used.length > 0) - (a.used.length > 0) || a.category.localeCompare(b.category) || a.src.localeCompare(b.src)); + blocks.push({ id, clinic: fact.shortName || id, items, already, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt }); } const style = ` -:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--ok:#1F7A4D;--bg:#fbfafd} -*{box-sizing:border-box}body{margin:0;font:15px/1.65 Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)} -.wrap{max-width:1180px;margin:0 auto;padding:2.4rem 20px 4rem} -h1{font-size:1.7rem;margin:0 0 .4rem}h2{font-size:1.25rem;margin:2.6rem 0 .3rem;padding-top:1.4rem;border-top:1px solid var(--line)} -.lede{color:var(--muted);margin:0 0 1.6rem} -.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;margin-top:1rem} -.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column} -.card.flagged{border-color:var(--bad);box-shadow:0 0 0 2px rgba(179,38,30,.10)} -.card img{width:100%;height:170px;object-fit:cover;background:#f1eff6;display:block} -.meta{padding:.7rem .8rem .9rem;font-size:.8rem} -.cat{display:inline-block;font-weight:700;font-size:.7rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} -.fn{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;word-break:break-all;color:var(--muted);margin:.25rem 0 .4rem} -.alt{color:var(--ink);margin:.2rem 0} -.use{margin-top:.45rem;font-size:.72rem;color:var(--ok)} -.flag{margin-top:.45rem;font-size:.74rem;color:var(--bad);font-weight:700} -.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.1rem;margin:1rem 0} -table{border-collapse:collapse;width:100%;font-size:.85rem}th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid var(--line)} -code{background:#f1eff6;padding:.1rem .3rem;border-radius:4px;font-size:.85em} +:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--live:#7A4B00;--bg:#fbfafd;--accent:#4F1DA1} +*{box-sizing:border-box}body{margin:0;font:15px/1.65 Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);word-break:keep-all} +.wrap{max-width:1200px;margin:0 auto;padding:2.4rem 20px 5rem} +h1{font-size:1.75rem;margin:0 0 .5rem}h2{font-size:1.3rem;margin:2.8rem 0 .2rem;padding-top:1.5rem;border-top:1px solid var(--line)} +.lede{color:var(--muted);margin:0 0 1.5rem;max-width:70ch} +.ask{background:#fff;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:10px;padding:1rem 1.2rem;margin:1.2rem 0 1.8rem} +.ask p{margin:.3rem 0} +.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin:1.2rem 0} +table{border-collapse:collapse;width:100%;font-size:.87rem}th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);vertical-align:top} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:1rem;margin-top:1rem} +.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative} +.card.live{border-color:#E0B000;box-shadow:0 0 0 2px rgba(224,176,0,.16)} +.card.out{opacity:.45} +.card img{width:100%;height:180px;object-fit:contain;background:#f1eff6;display:block} +.meta{padding:.7rem .8rem .9rem;font-size:.8rem;flex:1;display:flex;flex-direction:column;gap:.3rem} +.cat{font-weight:700;font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)} +.fn{font-family:ui-monospace,Menlo,monospace;font-size:.7rem;word-break:break-all;color:var(--muted)} +.alt{color:var(--ink)} +.use{font-size:.72rem;color:var(--live);font-weight:700} +.pick{display:flex;gap:.4rem;align-items:center;padding:.55rem .8rem;border-top:1px solid var(--line);background:#fdfcff;font-size:.85rem;cursor:pointer} +.pick input{width:16px;height:16px;accent-color:var(--bad);cursor:pointer} +.out-box{position:sticky;bottom:0;background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin-top:1.6rem;box-shadow:0 -4px 18px rgba(29,0,36,.07)} +textarea{width:100%;min-height:96px;font-family:ui-monospace,Menlo,monospace;font-size:.78rem;border:1px solid var(--line);border-radius:8px;padding:.6rem;background:#fbfafd;color:var(--ink)} +button{font:inherit;font-size:.85rem;padding:.45rem .9rem;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer} +code{background:#f1eff6;padding:.1rem .35rem;border-radius:4px;font-size:.86em} +.badge{display:inline-block;font-size:.7rem;font-weight:700;padding:.1rem .4rem;border-radius:5px;background:#f1eff6;color:var(--muted);margin-left:.3rem} .none{color:var(--muted);padding:1rem 0} -@media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:120px}} +@media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:130px}} `; const body = blocks.map((b) => { - if (!b.items.length) return `

${esc(b.clinic ?? b.id)}

${esc(b.note ?? '수집된 이미지 없음')}

`; + const head = `

${esc(b.clinic)} ${esc(Object.entries(b.counts ?? {}).map(([k, v]) => `${k} ${v}`).join(' · '))}${b.capturedAt ? ` · ${esc(b.capturedAt)} 수집` : ''}

`; + if (!b.items.length) return `${head}

${esc(b.note ?? '수집된 이미지가 없습니다.')}

`; + const liveCount = b.items.filter((i) => i.used.length).length; const cards = b.items.map((it) => ` -
- ${esc(it.alt).slice(0, 120)} +
+ ${esc(String(it.alt).slice(0, 120))}
- ${esc(it.category)} + ${esc(it.category)}${it.used.length ? '화면에 노출' : ''}
${esc(basename(it.src))}
-
${esc(String(it.alt).slice(0, 110))}${String(it.alt).length > 110 ? '…' : ''}
- ${it.used.length ? `
쓰이는 곳: ${esc(it.used.join(' · '))}
` : ''} - ${it.flags.length ? `
${esc(it.flags.join(' · '))}
` : ''} +
${esc(String(it.alt).slice(0, 100))}${String(it.alt).length > 100 ? '…' : ''}
+ ${it.used.length ? `
${esc(it.used.join(' · '))}
` : ''}
+
`).join(''); - const counts = Object.entries(b.counts).map(([k, v]) => `${k} ${v}`).join(' · '); - return `

${esc(b.clinic ?? b.id)} ${esc(counts)} · ${esc(b.capturedAt ?? '')} 수집

+ const alreadyRow = b.already.length + ? `

이미 빠져 있는 것 ${b.already.length}건: ${esc(b.already.join(', '))}

` + : ''; + return `${head} +

${b.items.length}장 · 이 중 ${liveCount}장이 지금 화면에 나갑니다(노란 테두리). 이것부터 보시면 됩니다.${alreadyRow ? '' : ''}

+ ${alreadyRow}
${cards}
`; }).join('\n'); const html = ` -서포터즈 수집 이미지 육안 검토
-

서포터즈 수집 이미지 육안 검토

-

자동 수집이 분류한 이미지를 한 화면에 모았습니다. 빨간 테두리는 파일명·alt 로 걸러진 의심 항목이고, 그 밖의 금지 유형은 파일명에 신호가 없어 눈으로 보셔야 합니다.

-
-

제외해야 하는 유형 (docs/INFINITH_Supporters_Image_Rules.md)

- - - - - - - -
유형근거
전후 사진 · 경과 비교의료광고 심의 기준. 동일 조건 촬영·무보정·동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다
환자 · 광고 모델 신체 사진의료법 56조 2항 2호, 개인정보보호법·초상권
시술 행위 장면의료법 56조 2항 6호. 수술실 공간 사진은 시설로 허용됩니다
인증 · 수상 마크의료법 56조 2항 14호
타 병원 비교 표 · 그래프의료법 56조 2항 4호
-

제외할 파일은 supporters/briefs/<병원>/clinic.jsonexcludeImages 에 파일명 앞부분을 넣으면 다음 빌드부터 빠집니다.

+서포터즈 수집 이미지 육안 검토 요청
+

서포터즈 수집 이미지 육안 검토 요청

+

병원 홈페이지에서 자동으로 모은 이미지입니다. 어떤 이미지를 빼야 하는지는 파일명이나 설명문으로 가려지지 않아 눈으로 보셔야 합니다.

+ +
+

부탁드리는 것

+

아래 표의 유형에 해당하는 이미지를 찾아 카드 아래 "이 이미지 빼기"를 체크해 주세요. 다 고르시면 맨 아래 상자에 제외 목록이 만들어집니다. 그 값을 알려주시면 다음 빌드부터 빠집니다.

+

노란 테두리가 지금 화면에 나가는 이미지입니다. 시간이 없으시면 그것만 보셔도 됩니다.

+ +
+

빼야 하는 유형 · 근거 docs/INFINITH_Supporters_Image_Rules.md

+ + + + + + + + +
유형어떻게 생겼나근거
전후 사진 · 경과 비교같은 부위를 두 시점으로 나란히 놓은 사진. "수술 후 1개월 / 6개월" 같은 글자가 박혀 있기도 합니다의료광고 심의 기준. 동일 조건 촬영·무보정·환자 동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다
환자 · 광고 모델 신체 사진얼굴이나 몸이 나오는 인물 사진. 구석에 "이미지 광고 모델" 표기가 있는 경우가 많습니다의료법 56조 2항 2호 · 개인정보보호법 · 초상권
시술 행위 장면주사·절개·드레싱처럼 시술하는 손과 환자 신체가 함께 나오는 사진. 수술실 공간만 찍은 사진은 괜찮습니다의료법 56조 2항 6호
효과 · 성능 주장 그래픽"개선", "재생", 숫자와 화살표로 성능을 내세운 제조사 홍보 이미지의료법 56조 2항 8호
인증 · 수상 마크상장, 어워드 로고, 인증 배지의료법 56조 2항 14호
타 병원 비교다른 병원과 견주는 표나 그래프의료법 56조 2항 4호
+

판단이 서지 않으면 빼는 쪽으로 봐 주세요. 뺀 이미지는 병원이 조건을 서면으로 확인해 주면 다시 넣을 수 있습니다.

+
+ ${body} -
`; + +
+

제외 목록 체크하면 여기에 쌓입니다. 이 내용을 그대로 알려주시면 됩니다.

+ +

+
+
+ +`; mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, html); -const flagged = blocks.reduce((n, b) => n + b.items.filter((i) => i.flags.length).length, 0); const total = blocks.reduce((n, b) => n + b.items.length, 0); -console.log(`검토 페이지 → ${OUT} (이미지 ${total}장 · 자동 의심 ${flagged}장)`); +const live = blocks.reduce((n, b) => n + b.items.filter((i) => i.used.length).length, 0); +console.log(`검토 요청서 → ${OUT}`); +for (const b of blocks) console.log(` ${b.clinic}: ${b.items.length}장 (화면 노출 ${b.items.filter((i) => i.used.length).length}장 · 이미 제외 ${b.already.length}건)`); +console.log(`합계 ${total}장 · 화면 노출 ${live}장`);