o2o-site-AEO/solution/site/scripts/mockup/audit-all.mjs
Mina Choi ea1daae576 [fix] site/scripts: 시연본을 레포 안에서 자급자족하게 — 다른 기계에서 흰 화면이던 것
구워진 index.html 이 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 루트 절대경로로
가리켰다. 그 파일들은 레포가 아니라 도커 볼륨에만 있어서, 레포만 받은 사람은 흰 화면을 본다.
번들은 더 나쁘다 — 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
실측: 구운 html 이 /assets 를 63곳 가리키고 있었다.

- img/mirror/ 61장 · vendor/ 2개를 레포로 끌어들였다
- patch_stay.py: 경로를 `/s/stay/img/mirror/` · `/s/stay/vendor/` 로 바꿔 박고,
  레포에 없는 자산이 있으면 **빌드를 멈춘다**. 끝에 `/assets` 잔재가 남아도 멈춘다
- audit-all.mjs: 히어로 사진 검사를 새 경로로
- SECTIONS.md: 설명서에서 **작업 지시서**로 다시 씀. 프롬프트를 여기 다시 적지 않고
  PROMPTS.md 의 절을 가리킨다(일정은 §3-1/3-2/3-3 세 단계다). 제품 쪽 짝 파일 표 추가
- README.md: "보고 규칙" 절 신설 — 실행하지 않은 것을 됐다고 쓰지 않는다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:51:46 +09:00

129 lines
9.4 KiB
JavaScript

/* /s/stay 시연본 전수 점검 — 사장님이 지시한 항목을 하나씩 화면에서 센다. */
import {chromium} from 'playwright';
const URL = process.argv[2] || 'http://localhost/s/stay';
const b = await chromium.launch({channel: 'chrome'});
const out = [];
const ok = (name, pass, detail) => out.push({name, pass, detail});
// ── 데스크톱 ──────────────────────────────────────────────────────────
const page = await b.newPage({viewport: {width: 1280, height: 900}});
const failed = [];
page.on('response', (r) => { if (r.status() >= 400 && r.url().includes('/s/stay')) failed.push(r.status() + ' ' + decodeURIComponent(r.url().slice(-36))); });
const errs = [];
page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text().slice(0, 90)); });
await page.goto(URL, {waitUntil: 'domcontentloaded'});
await page.waitForTimeout(6500);
const d = await page.evaluate(() => {
const P = window.__SITE_PAYLOAD__;
const sec = (id) => { const s = P.theme.sections.find((x) => x.id === id); try { return JSON.parse(s.data); } catch { return {items: []}; } };
const itin = sec('itinerary').items;
const stops = itin.flatMap((i) => i.days.flatMap((day) => day.stops));
const days = itin.flatMap((i) => i.days);
return {
catch: (P.narrative.catchphrases?.items || []).length,
catchKinds: ['general', 'season', 'month', 'weather'].map((k) => (P.narrative.catchphrases?.items || []).filter((x) => x.kind === k).length),
themes: itin.length,
firstIsStay: days.every((day) => day.stops[0].name.endsWith('스테이 머뭄')),
lastIsStay: days.every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄')),
consecutive: days.filter((day) => day.stops.some((s, i) => i && s.name.split(' · ').pop() === day.stops[i - 1].name.split(' · ').pop())).length,
hasNote: stops.every((s) => s.name.endsWith('스테이 머뭄') || s.note),
stopImages: stops.filter((s) => s.imageUrl).length,
restDays: days.filter((day) => day.stops.some((s) => s.name.startsWith('머뭄에서 쉬는 날'))).length,
endTimes: (() => { const t = days.map((day) => { let c = +day.startTime.slice(0, 2) * 60 + +day.startTime.slice(3); day.stops.forEach((s) => { c += s.moveMinutes + s.minutes; }); return c; }); return [Math.min(...t), Math.max(...t)]; })(),
songs: sec('songs').items.length,
ownInSongs: sec('songs').items.filter((s) => s.own).length,
songLinks: sec('songs').items.filter((s) => s.listenUrl).length,
people: sec('people').items.length,
peoplePhotos: sec('people').items.filter((p) => p.imageUrl).length,
peopleCredits: sec('people').items.filter((p) => p.imageCredit).length,
// 사진 있는 사람이 앞에 모여 있는가 — 레일 앞머리가 글자만 남으면 안 된다
photoFirst: (() => { const a = sec('people').items.map((p) => !!p.imageUrl); const n = a.filter(Boolean).length; return a.slice(0, n).every(Boolean); })(),
ownSongs: (P.narrative.ownSongs?.items || []).length,
// 화면
domPeopleImg: document.querySelectorAll('#people img').length,
domCredit: document.querySelectorAll('#people .w4d-credit').length,
domItinLinks: document.querySelectorAll('#itinerary a.w4d-link').length,
domPostcardLinks: document.querySelectorAll('#postcard a.w4d-link').length,
domItinImg: document.querySelectorAll('#itinerary img:not([src*="openstreetmap"])').length,
miniBtns: [...document.querySelectorAll('#w4d-mini button')].map((x) => x.id),
cassette: !!document.getElementById('w4d-tape'),
redRule: document.querySelectorAll('.w4d-rule').length,
bbqRow: !!document.body.textContent.match(/바비큐 이용/),
divider: (() => { const el = document.querySelector('#root div.border-line.flex.flex-col.gap-3.border-t.pt-6'); return el ? getComputedStyle(el).borderTopWidth : 'none'; })(),
heroImg: (P.media.find((m) => m.isPrimary) || {}).url,
roomA: P.units[0].mediaIds.length, roomB: P.units[1].mediaIds.length,
roomMirror: P.units.flatMap((u) => u.mediaIds).length,
tagline: document.querySelector('#root h1')?.nextElementSibling?.textContent.trim(),
};
});
ok('캐치프레이즈 100개', d.catch === 100, `${d.catch}개 · 일반${d.catchKinds[0]}/계절${d.catchKinds[1]}/월${d.catchKinds[2]}/날씨${d.catchKinds[3]}`);
ok('캐치프레이즈 순환', d.tagline && d.tagline !== '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `현재 "${d.tagline}"`);
ok('일정 테마', d.themes === 21, `${d.themes}개`);
ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남');
ok('마지막 = 스테이 머뭄', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남');
ok('같은 자리 연속 없음', d.consecutive === 0, `${d.consecutive}건`);
ok('정거장마다 할 일 문구', d.hasNote, d.hasNote ? '전 정거장' : '빠짐');
ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`);
ok('숙소에서 쉬는 날', d.restDays >= 3, `${d.restDays}일`);
ok('종료 시각 분산', d.endTimes[1] - d.endTimes[0] > 240, `${(d.endTimes[0] / 60 | 0)}:${String(d.endTimes[0] % 60).padStart(2, '0')} ~ ${(d.endTimes[1] / 60 | 0)}:${String(d.endTimes[1] % 60).padStart(2, '0')}`);
ok('가요 다방 곡', d.songs >= 25, `${d.songs}곡`);
ok('가요 다방에 자작곡 없음', d.ownInSongs === 0, `${d.ownInSongs}곡`);
ok('노래마다 링크', d.songLinks === d.songs, `${d.songLinks}/${d.songs}`);
ok('인물 50명 이상', d.people >= 50, `${d.people}명`);
ok('인물 사진', d.peoplePhotos >= 18 && d.domPeopleImg === d.peoplePhotos, `payload ${d.peoplePhotos} · 화면 ${d.domPeopleImg}`);
// 저작자 표시는 **위키에서 받은 사진에만** 붙는다(라이선스 조건). 원본 사이트가 원래 쓰던
// 미러 사진(채만식)은 이 사이트 것이라 표시 대상이 아니다 — 그래서 두 수가 다르다.
ok('사진 있는 사람이 앞', d.photoFirst, d.photoFirst ? `앞 ${d.peoplePhotos}명` : '흩어져 있다');
ok('사진 저작자 표시', d.domCredit === d.peopleCredits, `${d.domCredit}/${d.peopleCredits} (미러 ${d.peoplePhotos - d.peopleCredits}장 제외)`);
ok('일정 지도 링크', d.domItinLinks > 150, `${d.domItinLinks}개`);
ok('엽서 지도 링크', d.domPostcardLinks === 4, `${d.domPostcardLinks}개`);
ok('플레이어 단추 2개', d.miniBtns.length === 2 && !d.miniBtns.includes('w4d-next'), d.miniBtns.join(','));
ok('카세트 아이콘', d.cassette, d.cassette ? '있음' : '없음');
ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}곡`);
ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}개`);
ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐');
ok('맨 아래 가로선 제거', d.divider === '0px', d.divider);
ok('히어로 원본 사진', /\/img\/mirror\//.test(d.heroImg || ''), d.heroImg);
ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`);
ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음');
ok('콘솔 오류 없음', errs.length === 0, errs.slice(0, 2).join(' / ') || '없음');
// 플레이어 동작
await page.click('#w4d-toggle'); await page.waitForTimeout(350);
await page.click('.w4d-item'); await page.waitForTimeout(2200);
const play = await page.evaluate(() => {
const row = document.querySelector('.w4d-item[data-playing="1"]');
return {playing: !!row, time: row?.querySelector('.w4d-time')?.textContent,
btn: document.getElementById('w4d-play')?.dataset.playing};
});
ok('음원 재생', play.playing && /\d:\d\d \/ \d:\d\d/.test(play.time || ''), `${play.time}`);
await page.close();
// ── 모바일 ────────────────────────────────────────────────────────────
const m = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true});
await m.goto(URL, {waitUntil: 'domcontentloaded'});
await m.waitForTimeout(5500);
await m.click('#w4d-toggle'); await m.waitForTimeout(400);
const mob = await m.evaluate(() => {
const p = document.getElementById('w4d-panel').getBoundingClientRect();
const nav = [...document.querySelectorAll('#root nav')].find((n) => getComputedStyle(n).position === 'fixed');
const mini = document.getElementById('w4d-mini').getBoundingClientRect();
const rows = [...document.querySelectorAll('.w4d-item')].map((el) => Math.round(el.getBoundingClientRect().height));
return {panelIn: p.left >= 0 && p.right <= window.innerWidth, panelW: Math.round(p.width),
rowMin: Math.min(...rows), tabbarOverlap: nav ? mini.bottom > nav.getBoundingClientRect().top : false,
bodyScrollX: document.documentElement.scrollWidth > window.innerWidth};
});
ok('모바일 패널 화면 안', mob.panelIn, `폭 ${mob.panelW}px`);
ok('모바일 줄 높이 ≥48px', mob.rowMin >= 48, `${mob.rowMin}px`);
ok('모바일 탭바 안 겹침', !mob.tabbarOverlap, mob.tabbarOverlap ? '겹침' : '안 겹침');
ok('가로 스크롤 없음', !mob.bodyScrollX, mob.bodyScrollX ? '생김' : '없음');
await m.close();
await b.close();
const bad = out.filter((x) => !x.pass);
out.forEach((x) => console.log(`${x.pass ? ' OK ' : ' ✗✗ '} ${x.name.padEnd(22)} ${x.detail}`));
console.log(`\n${out.length}개 중 통과 ${out.length - bad.length} · 실패 ${bad.length}`);
process.exit(bad.length ? 1 : 0);