tools/shot.mjs

既存の検品コード。今回の作業では実行していない

128行 · 6,538 bytes · SHA-256 e2e2117487f6c103b7f9f1b5440181d6dafa92fc2ec6a59c557f8f8be46bcb6b

分割 1/1
/* 画面を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);