o2o-site-AEO/solution/site/scripts/mockup/audit-all.mjs
Mina Choi 0da9b92423 [feat] site/mockup: 시연본 갱신 — 주변 맛집 31곳·국가유산 야행·차 동선 분기·번들 최신화
/s/stay 시연본에 오늘 쌓인 변경을 한꺼번에 반영한다. 원본 8곳이 전부 467m 안이라
'걸어서 10분 이상' 탭의 맛집 칸이 비었던 것, xlsx 대조로 사이트에 없던 축제(국가유산
야행)가 빠져 있던 것, 차로 온 손님도 걸어온 손님과 같은 동선(짐 맡기고 저녁에 복귀)을
따르던 것, 번들이 2026-09-09 판이라 숙박 예약 섹션이 아예 안 그려지던 것 — 전부 발견한
순서대로 고쳤다.

- patch_stay.py: 날짜 값(UPDATED_AT) 한 곳으로 모음 · 날씨 문구 케이스당 1개→5개
  무작위 · '오늘의 엽서' 탭 비움(엽서 쓰기 섹션으로 대체) · 주변 맛집 8→31곳 +
  사진 없는 집에 시연 전용 사진 채움 · 군산 국가유산 야행 추가(날짜 미확정이라
  기간 값은 안 넣음) · 오늘의 한 장 탭 숨김 · 예약 섹션 켬 · head 메타의 날짜도
  같이 갱신 · 번들을 최신 빌드로 교체하고 `<body class="site">` 보정(빠지면 카로셀
  overflow 가 죽어 가로 스크롤이 생겼다) · 군산 읽기/엽서 카로셀에 쓸
  embla-carousel UMD 번들 도입
- build_itinerary.py: 차로 온 손님의 마지막 날은 복귀 칸 없이 마지막 정거장에서
  끝나도록 동선을 가름(도보 뼈대에서 복귀 칸만 떼어 파생 — 두 벌로 안 둔다)
- build_story.py: 진희경은 익산 출생·군산에서 통학 — "군산 출신"으로 뭉뚱그리지 않게 정정
- fetch_restaurants.py · fill_restaurant_photos.py · restaurants.json ·
  restaurant-photos.json · img/mirror/*: TourAPI 로 받은 맛집 31곳과, 사진 없는
  집에 채운 시연 전용 사진(공공누리 아님 — 제품으로는 안 옮긴다)
- vendor/: embla-carousel.umd.js 추가, 번들을 index-C3fFNl3r.css·index-DDxwteyn.js
  로 교체하고 옛 파일은 vendor/retired/ 로 보관
- audit-all.mjs · audit_schedule.py · SCHEDULE.md: 위 변경에 맞춘 점검 항목·산출물 갱신
- AUTOPLAY.md: 새로고침 직후 배경음악 자동재생을 시도한 것과 실측값 — 사람 입력 없이는
  브라우저가 막아서 안 된다는 결론 기록

검증: `python3 patch_stay.py` 정상 종료, 킹서버 반영 후 md5 대조.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
2026-09-14 17:07:01 +09:00

203 lines
15 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('#w4d-reading .w4d-read-card').length,
domReadGroups: document.querySelectorAll('#w4d-reading .w4d-read-group').length,
domReadSrc: document.querySelectorAll('#w4d-reading .w4d-read-src a').length,
// 레일이 아니어야 한다 — 가로로 밀어야 보이면 '펼쳐 둔다' 가 아니다.
// 신문 조판이라 단(column)으로 흐른다(2026-09-14 대표: "신문처럼").
readCols: (() => { const el = document.querySelector('#w4d-reading .w4d-read-grid');
if (!el) return null;
return {cols: getComputedStyle(el).columnCount, overflow: el.scrollWidth - el.clientWidth}; })(),
readPaper: !!document.querySelector('#w4d-reading .w4d-read-head h2')
&& !!document.querySelector('#w4d-reading .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 h1')?.nextElementSibling?.textContent.trim(),
sub: document.getElementById('w4d-sub')?.textContent.trim(),
subIsCatch: (() => { const t = document.getElementById('w4d-sub')?.textContent.trim(); return !!t && (P.narrative.catchphrases?.items || []).some((x) => x.text.replace(/\s+/g, ' ').trim() === t); })(),
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.getElementById('w4d-sub')?.textContent.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')}`);
ok('군산 읽기 — 한 화면에 다 편다',
d.reading.items >= 30 && d.domReadCards === d.reading.items
&& d.readCols && d.readCols.overflow <= 2 && Number(d.readCols.cols) >= 2,
`payload ${d.reading.items} · 화면 ${d.domReadCards}꼭지 · ${d.domReadGroups}면 · ${d.readCols?.cols}단 · 가로넘침 ${d.readCols?.overflow}px`);
ok('군산 읽기 신문 조판', d.readPaper, d.readPaper ? '제호 · 발행일 줄 있음' : '없음');
ok('군산 읽기 출처', d.reading.sourced === d.reading.items && d.domReadSrc === d.reading.items,
`payload ${d.reading.sourced}/${d.reading.items} · 화면 ${d.domReadSrc}`);
ok('오늘의 한 장', d.daily >= 39 && d.domDailyPages === d.daily,
`payload ${d.daily}장 · 화면 ${d.domDailyPages}장 · ${d.dailyCats.join('/')}`);
ok('오늘의 한 장 출처', d.dailySourced === d.daily, `${d.dailySourced}/${d.daily}`);
ok('이야기 탭에 일력', d.storyTabs.includes('오늘의 한 장'), d.storyTabs.join(','));
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);