이 목업은 payload 가 없어 프리렌더 재굽기 대상이 아니다 — `index.html` 한 장이 유일본이고 자산이 지워지면 사람이 되돌려 넣어야 한다(CLAUDE.md 의 ★★ 함정). 지금까지 그 한 장을 이 맥에서만 만들고 있었다. 다른 사람이 이어받을 수 있도록 재료를 전부 올린다. `build/index.html` 은 생성물이라 이그노어 그대로다 — `patch_stay.py` 로 다시 나온다. - build_itinerary.py: 테마 21개 일정 생성. 좌표에서 이동시간을 계산하고(도보 4km/h, 1.5km 초과는 차 25km/h + 주차 5분) 입·퇴실·끼니 창을 맞춘다. 규칙 14종 감사가 **빌드 안**에 있어 하나라도 어기면 payload 를 쓰지 않고 멈춘다 — 검사가 빌드 밖에 있던 동안 뼈대를 고칠 때마다 안 보는 규칙이 생겼다(2026-09-11 REVIEW) - build_story.py: 노래 25곡·인물 57명. 사진은 위키백과 문서 pageimages 만 믿는다 (이름 검색은 동명이인을 끌고 온다 — 이수현→걸그룹, 박성현→골퍼) - patch_stay.py: 캐치프레이즈 100개·자작곡 5곡·객실 사진(A동 12/B동 10)을 넣고 payload 를 갈아 끼운 뒤 inject.css/js 를 `</body>` 앞에 주입해 index.html 을 짠다 - inject.js/css: React 가 다시 그려도 살아남아야 하는 다섯 가지(캐치프레이즈 순환· 헤더 미니 플레이어·지도 링크·사진 저작자 표시·카로셀 제어). `#root` 밖에 둔다 - audit-all.mjs / rails-test.mjs: 실제 브라우저로 34종 검사, 레일 13개 자동 넘김 전수 - README.md: 이어받는 사람이 먼저 읽는 문서. 배포·함정·§7 "내가 틀렸던 6가지" - PROMPTS.md / TEXT.md / REVIEW-2026-09-11.md: 문구 생성 프롬프트 · 전체 텍스트 · 검수 보고 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 lines
6.0 KiB
JavaScript
112 lines
6.0 KiB
JavaScript
import {chromium, devices} from 'playwright';
|
|
const url = process.argv[2] || 'http://localhost/s/stay';
|
|
const b = await chromium.launch({channel: 'chrome'});
|
|
|
|
const prep = async (page) => page.evaluate(() => {
|
|
window.__rails = [];
|
|
document.querySelectorAll('#root .slider-viewport').forEach((el) => window.__rails.push(el));
|
|
document.querySelectorAll('#root *').forEach((el) => {
|
|
if (el.classList.contains('slider-viewport')) return;
|
|
const f = getComputedStyle(el).overflowX;
|
|
if ((f === 'auto' || f === 'scroll') && el.scrollWidth - el.clientWidth > 40) window.__rails.push(el);
|
|
});
|
|
window.__pos = (i) => { const r = window.__rails[i]; const c = r.firstElementChild; let m = 0;
|
|
try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} return Math.round(r.scrollLeft + m); };
|
|
window.__info = (i) => { const r = window.__rails[i]; const b = r.getBoundingClientRect();
|
|
return {label: r.getAttribute('aria-label') || '?', vis: !!r.getClientRects().length,
|
|
cx: Math.round(b.left + b.width / 2), cy: Math.round(b.top + b.height / 2)}; };
|
|
return window.__rails.length;
|
|
});
|
|
|
|
// ── 데스크톱 ───────────────────────────────────────────────
|
|
const page = await b.newPage({viewport: {width: 1280, height: 900}});
|
|
await page.goto(url, {waitUntil: 'domcontentloaded'});
|
|
await page.waitForTimeout(6000);
|
|
const n = await prep(page);
|
|
const rows = [];
|
|
for (let i = 0; i < n; i++) {
|
|
let info = await page.evaluate((k) => window.__info(k), i);
|
|
if (!info.vis) { rows.push({레일: info.label, 판정: '숨은 탭'}); continue; }
|
|
await page.mouse.move(5, 5);
|
|
await page.evaluate((k) => window.__rails[k].scrollIntoView({block: 'center'}), i);
|
|
await page.waitForTimeout(9000);
|
|
info = await page.evaluate((k) => window.__info(k), i);
|
|
const y = Math.min(Math.max(info.cy, 100), 800);
|
|
|
|
const p0 = await page.evaluate((k) => window.__pos(k), i);
|
|
await page.waitForTimeout(10000); // ① 가만히
|
|
const idle = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, p0]);
|
|
|
|
await page.mouse.move(info.cx, y); // ② 레일 위 세로 휠
|
|
await page.mouse.wheel(0, 150);
|
|
await page.mouse.move(5, 5); // 커서는 뺀다(호버 정지는 별개)
|
|
const p1 = await page.evaluate((k) => window.__pos(k), i);
|
|
await page.waitForTimeout(4000);
|
|
const during = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
|
await page.waitForTimeout(9000);
|
|
const after = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
|
|
|
const i2 = await page.evaluate((k) => window.__info(k), i); // ③ 손으로 민다
|
|
const y2 = Math.min(Math.max(i2.cy, 100), 800);
|
|
await page.mouse.move(i2.cx + 180, y2);
|
|
await page.mouse.down();
|
|
for (let s = 1; s <= 10; s++) { await page.mouse.move(i2.cx + 180 - s * 24, y2); await page.waitForTimeout(16); }
|
|
await page.mouse.up();
|
|
await page.mouse.move(5, 5);
|
|
await page.waitForTimeout(1500);
|
|
const dragged = await page.evaluate((k) => window.__pos(k), i);
|
|
await page.waitForTimeout(13000);
|
|
const frozen = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, dragged]);
|
|
|
|
rows.push({레일: info.label, '①가만히': idle, '②휠직후': during, '②13초뒤': after, '③민뒤13초': frozen,
|
|
판정: (idle !== 0 && during === 0 && after !== 0 && frozen === 0) ? 'OK' : '확인'});
|
|
}
|
|
console.log('── 데스크톱 (1280) ──'); console.table(rows);
|
|
await page.close();
|
|
|
|
// ── 모바일 ───────────────────────────────────────────────
|
|
const ctx = await b.newContext({...devices['iPhone 13'], hasTouch: true, isMobile: true});
|
|
const mp = await ctx.newPage();
|
|
await mp.goto(url, {waitUntil: 'domcontentloaded'});
|
|
await mp.waitForTimeout(6000);
|
|
const mn = await prep(mp);
|
|
const cdp = await ctx.newCDPSession(mp);
|
|
const mrows = [];
|
|
for (let i = 0; i < mn; i++) {
|
|
let info = await mp.evaluate((k) => window.__info(k), i);
|
|
if (!info.vis) { mrows.push({레일: info.label, 판정: '숨은 탭'}); continue; }
|
|
await mp.evaluate((k) => window.__rails[k].scrollIntoView({block: 'center'}), i);
|
|
await mp.waitForTimeout(9000);
|
|
info = await mp.evaluate((k) => window.__info(k), i);
|
|
const x = info.cx, y = Math.min(Math.max(info.cy, 120), 620);
|
|
const pt = (type, yy, xx) => cdp.send('Input.dispatchTouchEvent',
|
|
{type, touchPoints: type === 'touchEnd' ? [] : [{x: xx ?? x, y: yy ?? y}]});
|
|
|
|
const p0 = await mp.evaluate((k) => window.__pos(k), i);
|
|
await mp.waitForTimeout(10000); // ① 가만히
|
|
const idle = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, p0]);
|
|
|
|
const p1 = await mp.evaluate((k) => window.__pos(k), i); // ② 손가락 대고 11초
|
|
await pt('touchStart');
|
|
for (let s = 0; s < 11; s++) { await mp.waitForTimeout(1000); await pt('touchMove', y - s * 2); }
|
|
const held = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
|
await pt('touchEnd');
|
|
await mp.waitForTimeout(11000);
|
|
const released = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
|
|
|
const i2 = await mp.evaluate((k) => window.__info(k), i); // ③ 가로 스와이프
|
|
const y2 = Math.min(Math.max(i2.cy, 120), 620);
|
|
await pt('touchStart', y2, i2.cx + 120);
|
|
for (let s = 1; s <= 8; s++) { await pt('touchMove', y2, i2.cx + 120 - s * 22); await mp.waitForTimeout(20); }
|
|
await pt('touchEnd');
|
|
await mp.waitForTimeout(1500);
|
|
const dragged = await mp.evaluate((k) => window.__pos(k), i);
|
|
await mp.waitForTimeout(13000);
|
|
const frozen = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, dragged]);
|
|
|
|
mrows.push({레일: info.label, '①가만히': idle, '②손댄채11초': held, '②뗀뒤11초': released, '③민뒤13초': frozen,
|
|
판정: (idle !== 0 && held === 0 && released !== 0 && frozen === 0) ? 'OK' : '확인'});
|
|
}
|
|
console.log('── 모바일 (iPhone 13) ──'); console.table(mrows);
|
|
await b.close();
|