/* 画面をiPhoneの実寸で写して検品するコマンド(2026-09-02 K1指示 「ブラウザ使えないなら使えるようにしたれよ」)。 Codexは自前のブラウザを持てないので、こちらのPlaywright(WebKit=Safariと同じ土台)を 叩けるようにした。Codex・Claudeのどちらから呼んでもいい。 使い方: node tools/shot.mjs <対象> … 390x797 と 390x664 の2枚を撮って検品 node tools/shot.mjs <対象> --h 900 … 高さを指定して1枚だけ node tools/shot.mjs <対象> --w 375 … 横幅を指定(既定390。SE・miniは375、Pro Maxは440) node tools/shot.mjs <対象> --out tmp/x … 出力先の頭(既定 tmp/shot) node tools/shot.mjs <対象> --wait 4000 … 撮る前に待つms(既定 2500) node tools/shot.mjs <対象> --click "#id" … 撮る前に押す(複数可) <対象>は次のどれでもいい: ・プロジェクト内の相対パス 例) mock_shopname.html ・絶対パス 例) C:\平成ゾンビ学園\index.html ・http(s)のURL 例) https://heisei-zombie-kyoshitsu.pages.dev/ 出るもの: ・PNG(高さごとに1枚) ・JSエラー / 404 / 横のはみ出し / 一番下の要素が画面に収まっているか これで「見た目は確かめていません」と書かずに済む。 */ /* playwright は平成ゾンビ学園のnode_modulesに1つだけ入れてある。 他のプロジェクトへこのファイルをコピーしても動くよう、無ければそちらから借りる。 */ let webkit; try { ({ webkit } = await import('playwright')); } catch (e) { ({ webkit } = await import('file:///C:/平成ゾンビ学園/node_modules/playwright/index.mjs')); } import http from 'http'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); /* 静的ファイルは public/ が本体(Workersの[assets]と同じ)。 無ければプロジェクト直下も見る(作りかけの見本ページ用) */ const pick = (u) => { const a = path.join(ROOT, 'public', u); return fs.existsSync(a) ? a : path.join(ROOT, u); }; const MIME = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.webmanifest':'application/json', '.webp':'image/webp', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.svg':'image/svg+xml', '.gif':'image/gif', '.mp3':'audio/mpeg', '.wav':'audio/wav', '.m4a':'audio/mp4', '.ogg':'audio/ogg', '.woff':'font/woff', '.woff2':'font/woff2', '.ttf':'font/ttf', }; const argv = process.argv.slice(2); if (!argv.length) { console.log('使い方: node tools/shot.mjs <対象> [--h 797] [--out tmp/shot] [--wait 2500] [--click "#id"]'); process.exit(1); } const target = argv[0]; const opt = (name, def) => { const i = argv.indexOf('--' + name); return i > 0 ? argv[i + 1] : def; }; const clicks = argv.reduce((a, v, i) => (v === '--click' ? a.concat([argv[i + 1]]) : a), []); const outBase = opt('out', 'tmp/shot'); const waitMs = +opt('wait', 2500); const heights = opt('h', null) ? [+opt('h')] : [797, 664]; const vw = +opt('w', 390); // 端末の横幅(2026-09-03: iPhoneは375〜440まである) let url = target, srv = null; if (!/^https?:\/\//i.test(target)) { const port = 8900 + Math.floor(Math.random() * 90); srv = http.createServer((q, r) => { let u = decodeURIComponent(q.url.split('?')[0]); if (u === '/') u = '/index.html'; const f = pick(u); fs.readFile(f, (e, d) => { if (e) { r.writeHead(404); r.end(); return; } r.writeHead(200, { 'Content-Type': MIME[path.extname(f).toLowerCase()] || 'application/octet-stream' }); r.end(d); }); }); await new Promise(ok => srv.listen(port, ok)); const rel = path.isAbsolute(target) ? path.relative(ROOT, target) : target; url = 'http://localhost:' + port + '/' + rel.split(path.sep).join('/'); } fs.mkdirSync(path.dirname(path.join(ROOT, outBase)), { recursive: true }); const browser = await webkit.launch(); let ng = 0; for (const h of heights) { const page = await browser.newPage({ viewport: { width: vw, height: h } }); const errs = [], bad = []; page.on('pageerror', e => errs.push(String(e.message).slice(0, 160))); page.on('response', r => { if (r.status() >= 400) bad.push(r.status() + ' ' + r.url().split('/').pop()); }); await page.goto(url, { waitUntil: 'load' }).catch(e => errs.push('開けない: ' + e.message.slice(0, 80))); await page.waitForTimeout(waitMs); for (const c of clicks) { try { await page.locator(c).first().click({ timeout: 3000 }); await page.waitForTimeout(600); } catch (e) { errs.push('押せない ' + c); } } const st = await page.evaluate(() => { const doc = document.documentElement; const all = [...document.body.querySelectorAll('*')]; let low = 0, lowName = ''; for (const e of all) { const r = e.getBoundingClientRect(); if (!(r.width > 0 && r.height > 0)) continue; const cs = getComputedStyle(e); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity === 0) continue; // 隠れている物は数えない if (r.bottom > low) { low = r.bottom; lowName = (e.id || e.className || e.tagName).toString().slice(0, 24); } } return { 横はみ出し: doc.scrollWidth > doc.clientWidth, 一番下: Math.round(low), 画面の高さ: window.innerHeight, 一番下の物: lowName, }; }); const file = outBase + '_' + h + '.png'; await page.screenshot({ path: path.join(ROOT, file), fullPage: false }); const over = st.一番下 > st.画面の高さ + 1; if (errs.length || bad.length || st.横はみ出し || over) ng++; console.log( vw + 'x' + h + ' → ' + file + '\n JSエラー: ' + (errs.length ? errs.slice(0, 3).join(' / ') : 'なし') + '\n 404など : ' + (bad.length ? bad.slice(0, 3).join(' / ') : 'なし') + '\n 横はみ出し: ' + (st.横はみ出し ? 'あり' : 'なし') + '\n 一番下の物: ' + st.一番下 + 'px (' + st.一番下の物 + ') / 画面 ' + st.画面の高さ + 'px' + (over ? ' ← 画面からはみ出している' : ' 収まっている') ); await page.close(); } await browser.close(); if (srv) srv.close(); console.log(ng ? ('検品: ' + ng + '件の高さで問題あり') : '検品: 問題なし'); process.exit(0);