구워진 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>
129 lines
9.4 KiB
JavaScript
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);
|