patch_stay.py·inject.js/css·audit-all.mjs 갱신, 벤더 번들 교체(index-D9cPXCE3 → index-D4jgGFP4 등), stay3 백업 스냅샷과 stay2/stay3 빌드 스크립트(build_stay2.py· build_stay3.py) 및 원본 템플릿(tpl-story·tpl-creative) 추가. 옛 번들은 vendor/retired/ 에 보관. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
202 lines
15 KiB
JavaScript
202 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('#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 ? '있음' : '사라짐');
|
|
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);
|