o2o-site-AEO/solution/site/scripts/mockup/audit-all.mjs
Mina Choi 5e2def200b [chore] site/mockup: 번들 갱신·문서 정리 · 2안(사진) 빌더 스크립트 추가
- vendor/ 옛 해시 번들을 retired/ 로 옮기고 새 해시로 교체
- build_photo6.py 신설 — /s/stay6 "2안(사진)" 판, 슬러그별로 다시 구울 수 있다
  (python3 build_photo6.py <slug>)
- build_pension.py·build_reading.py·build_stay6.py·patch_stay.py 정리
- AUTOPLAY.md·README.md·audit-all.mjs 갱신
2026-09-23 13:18:51 +09:00

206 lines
16 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('스테이 머뭄')),
// 떠나는 날은 갈린다 — 차로 온 손님은 짐을 싣고 나서므로 복귀 칸이 없다(2026-09-14 대표 의견).
lastIsStay: itin.every((i) => i.days.slice(0, -1)
.every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄'))),
depart: (() => {
const tail = (i) => i.days[i.days.length - 1];
const car = itin.filter((i) => !tail(i).stops[tail(i).stops.length - 1].name.endsWith('스테이 머뭄'));
const walk = itin.filter((i) => !car.includes(i));
return {
car: car.length, walk: walk.length,
car1: car.filter((i) => i.duration === '1박 2일').length,
car2: car.filter((i) => i.duration === '2박 3일').length,
// 퇴실 칸의 문구가 동선과 같은 말을 하는가 — 차인데 "맡겨 두고" 면 손님이 한 번 더 들른다
carNote: car.every((i) => (tail(i).stops[0].note || '').includes('차에 싣고')),
walkNote: walk.every((i) => (tail(i).stops[0].note || '').includes('맡겨 두고')),
walkEnd: walk.every((i) => tail(i).stops[tail(i).stops.length - 1].name.startsWith('마무리')),
};
})(),
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)]; })(),
daily: sec('daily').items.length,
reading: (() => { const e = sec('reading'); return {
items: e.items.length,
groups: (e.groups || []).length,
sourced: e.items.filter((x) => x.source && x.source.url).length,
}; })(),
domReadCards: document.querySelectorAll('#reading article').length,
domReadGroups: document.querySelectorAll('#w4d-reading-panel .w4d-read-group').length,
domReadSrc: document.querySelectorAll('#w4d-reading-panel .w4d-read-src a').length,
// 레일이 아니어야 한다 — 가로로 밀어야 보이면 '펼쳐 둔다' 가 아니다.
// 신문 조판이라 단(column)으로 흐른다(2026-09-14 대표: "신문처럼").
readCols: (() => { const el = document.querySelector('#w4d-reading-panel .w4d-read-grid');
if (!el) return null;
return {cols: getComputedStyle(el).columnCount, overflow: el.scrollWidth - el.clientWidth}; })(),
readPaper: !!document.querySelector('#w4d-reading-panel .w4d-read-head h2')
&& !!document.querySelector('#w4d-reading-panel .w4d-read-dateline'),
dailyCats: [...new Set(sec('daily').items.map((x) => x.category))].sort(),
dailySourced: sec('daily').items.filter((x) => x.source && x.source.url).length,
domDailyPages: document.querySelectorAll('#daily article').length,
storyTabs: [...document.querySelectorAll('#story [role="tab"]')].map((t) => t.textContent.trim()),
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 .hero-catchphrase-fixed')?.textContent.trim(),
sub: document.querySelector('#root .hero-catchphrase .w4d-line')?.textContent.replace(/\s+/g, ' ').trim(),
subIsCatch: (() => { const t = document.querySelector('#root .hero-catchphrase .w4d-line')?.textContent.replace(/\s+/g, ' ').trim(); return !!t && (P.narrative.catchphrases?.items || []).some((x) => x.text.replace(/\s+/g, ' ').trim() === t); })(),
postcardMaker: !!document.getElementById('postcard-maker'),
restaurants: P.local.restaurants.length,
// 걸어서 10분(800m) 넘는 맛집 — 없으면 '10분 이상' 탭에서 맛집 칸이 빈다(2026-09-11 사장님 지적)
restPhotos: P.local.restaurants.filter((r) => r.imageUrl).length,
restFar: P.local.restaurants.filter((r) => { const m = /^([\d.]+)\s*(km|m)$/i.exec(r.distanceText || ''); return m && Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) > 800; }).length,
};
});
ok('캐치프레이즈 100개', d.catch === 100, `${d.catch}개 · 일반${d.catchKinds[0]}/계절${d.catchKinds[1]}/월${d.catchKinds[2]}/날씨${d.catchKinds[3]}`);
// 대표 문구는 고정, 순환은 그 아래 줄 (2026-09-11 사장님 지시)
ok('대표 문구 고정', d.tagline === '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `"${d.tagline}"`);
ok('아래 줄에 캐치프레이즈', d.subIsCatch, `"${d.sub}"`);
const sub2 = await page.waitForTimeout(7500).then(() => page.evaluate(() => document.querySelector('#root .hero-catchphrase .w4d-line')?.textContent.replace(/\s+/g, ' ').trim()));
ok('아래 줄 순환', !!sub2 && sub2 !== d.sub, `→ "${sub2}"`);
/* 자동재생 (2026-09-14 대표 지시). 브라우저는 조작 없는 소리 재생을 막는다 — 정책이라 못 넘는다.
그래서 **첫 손길에 켜지는가**를 센다. 막지 않는 브라우저면 그 전에 이미 켜져 있다. */
const autoAtLoad = await page.evaluate(() => document.getElementById('w4d-play')?.dataset.playing);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(2500);
const autoAfter = await page.evaluate(() => ({
btn: document.getElementById('w4d-play')?.dataset.playing,
time: document.querySelector('.w4d-item[data-playing="1"] .w4d-time')?.textContent,
}));
ok('자동재생', autoAfter.btn === '1',
`불러온 직후 ${autoAtLoad === '1' ? '바로 재생' : '브라우저가 막음'} → 첫 손길에 ${autoAfter.time || '안 켜짐'}`);
ok('주변 맛집', d.restaurants >= 30 && d.restFar > 0, `${d.restaurants}곳 · 걸어서 10분 넘는 곳 ${d.restFar}`);
ok('맛집 사진', d.restPhotos === d.restaurants, `${d.restPhotos}/${d.restaurants}`);
ok('일정 테마', d.themes === 21, `${d.themes}개`);
ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남');
ok('마지막 = 머뭄 (떠나는 날 빼고)', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남');
ok('떠나는 날 복귀 없음 = 차 테마', d.depart.car === 14 && d.depart.car1 === 7 && d.depart.car2 === 7,
`차 ${d.depart.car}(1박${d.depart.car1}·2박${d.depart.car2}) · 도보 ${d.depart.walk}`);
ok('떠나는 날 퇴실 문구', d.depart.carNote && d.depart.walkNote && d.depart.walkEnd,
d.depart.carNote && d.depart.walkNote && d.depart.walkEnd ? '차=싣고 · 도보=맡기고 찾으러' : '어긋남');
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')}`);
/* 군산 읽기 — 렌더러(ReadingSection)가 꼭지를 **전부** 세운다 (2026-09-16 대표 지시로
무작위 5~6 에서 뒤집었다). 주입분은 껐다 — 둘 다 돌면 같은 제목이 두 번 선다. */
ok('군산 읽기 재료', d.reading.items >= 30, `payload ${d.reading.items}가지`);
ok('군산 읽기 화면', d.domReadCards === d.reading.items, `${d.domReadCards}꼭지 전체`);
/* 오늘의 한 장 — 탭을 **세우지 않는 것**이 정상이다 (2026-09-14 사장님: "오늘의 한장은
주석처리해주쇼", patch_stay.py HIDE_TABS). 값은 stay-payload-new.json 에 그대로 있다. */
ok('오늘의 한 장 숨김', !d.storyTabs.includes('오늘의 한 장') && d.daily === 0,
`탭 없음 · payload ${d.daily}장`);
/* 엽서 — 도시 엽서 4장은 뺐고(2026-09-14 대표), 그 자리는 손님이 직접 쓰는 엽서다. */
ok('엽서 쓰기', d.postcardMaker, d.postcardMaker ? '있음' : '없음');
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('플레이어 단추 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 ? '있음' : '사라짐');
// ★ 'none' = 그 블록이 **아예 없다**. 2026-09-18 재굽기로 /s/stay 가 새 렌더러 번들
// (index-D4tfakmK.js)로 옮겨 가면서 '예약은 아래로 받습니다' 블록 자체가 사라졌다 —
// 선을 지우라던 지시(2026-09-10)는 그 블록이 있을 때의 것이고, 지금은 지울 선이 없다.
// 요소가 없는 것을 실패로 세면 점검이 '고쳐야 할 것' 을 가리키지 않는다.
ok('맨 아래 가로선 제거', d.divider === '0px' || d.divider === 'none', 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);