tools/shot.mjs
既存の検品コード。今回の作業では実行していない
/* 画面を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);