カスタマイズ

記事で使う画面キャプチャを、サーバー側で自動化しました。URLを渡すとヘッドレスChromeがページを開き、指定したCSSセレクタの部分だけを切り出して、WordPressのメディアライブラリに登録するところまで一気通貫です。本記事はAmazon Linux 2023での環境構築から、PHPからの呼び出し、そして実際にハマった点(日本語が豆腐になる・EC2では必須のSSRF対策)までをまとめたものです。

構成 — puppeteer-core+chrome-headless-shellをPHPからexecで呼ぶ

WordPressはPHPですが、ブラウザ操作の道具立てはNode.js側が圧倒的に成熟しています。そこで役割を分けました。

  • Node側: puppeteer-coreで撮影する小さなCLIスクリプト(引数でURL・セレクタ・保存先を受け取り、結果をJSONで返す)
  • PHP側: そのCLIをexec()で呼び、返ってきたPNGをmedia_handle_sideload()でメディアに登録する

ブラウザ本体はchrome-headless-shellを使います。フルのChromeから画面表示系を削った撮影・スクレイピング用のビルドで、依存ライブラリが少なく起動も速い。Amazon Linux 2023にはchromiumのdnfパッケージが存在しないため、Puppeteerの公式ダウンローダーで入れるのが確実です。

セットアップ — 先に日本語フォントを入れる(入れないと全部豆腐になる)

最初にハマりどころを言うと、サーバーには日本語フォントが入っていません。フォント無しでも撮影自体は成功するので、「動いた」と思って開いた画像の日本語が全部「□□□」になっているのが典型的な事故です。共有ライブラリと一緒に最初から入れておきます。

# 描画に必要な共有ライブラリ + 日本語フォント(これが肝)
sudo dnf install -y nss nspr atk at-spi2-atk cups-libs libdrm libxkbcommon \
  libXcomposite libXdamage libXfixes libXrandr mesa-libgbm pango cairo \
  alsa-lib expat google-noto-sans-cjk-ttc-fonts

# 撮影スクリプトの置き場所を作って puppeteer-core を入れる
sudo mkdir -p /opt/shot && cd /opt/shot
sudo npm init -y && sudo npm install puppeteer-core

# ブラウザ本体(chrome-headless-shell)を公式ダウンローダーで取得
sudo npx --yes @puppeteer/browsers install chrome-headless-shell@stable --path /opt/shot/browsers

手元の環境では chrome-headless-shell@151.0.7922.71 が入りました。メモリ2GB・撮影は同時1プロセスの想定で、実測でも問題なく動いています。

Node側 — セレクタを渡すと「ページ内のその部分だけ」を撮る

撮影スクリプトの本体です(要点だけに絞っています)。ポイントは3つ。deviceScaleFactor: 2でRetina相当の解像度にすること、読み込み完了はnetworkidle2で待つこと、セレクタ指定時は要素を画面中央へスクロールしてから要素.screenshot()で切り出すことです。

const puppeteer = require('puppeteer-core');

const browser = await puppeteer.launch({
  executablePath: '/opt/shot/browsers/…/chrome-headless-shell',
  args: ['--no-sandbox', '--disable-dev-shm-usage', '--hide-scrollbars', '--lang=ja-JP'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
await page.goto(cfg.url, { waitUntil: 'networkidle2', timeout: 30000 });

if (cfg.selector) {
  const el = await page.waitForSelector(cfg.selector, { timeout: 10000 });
  await page.evaluate(
    (s) => document.querySelector(s).scrollIntoView({ block: 'center' }),
    cfg.selector
  );
  await new Promise((r) => setTimeout(r, 250)); // スクロール後の描画安定待ち
  await el.screenshot({ path: cfg.out });       // この要素だけを切り出す
} else {
  await page.screenshot({ path: cfg.out, fullPage: !!cfg.fullPage });
}

--no-sandboxはWebサーバーのユーザー(apache等)で動かすための指定、--disable-dev-shm-usage/dev/shmが小さいサーバーでのクラッシュ避けです。どちらもサーバー運用では実質必須でした。

PHP側 — execで呼んでメディアライブラリに登録する

$cmd = sprintf(
    'timeout 75 /usr/bin/node /opt/shot/capture.js %s 2>&1',
    escapeshellarg( wp_json_encode( $cfg ) )
);
exec( $cmd, $lines, $code );
$result = json_decode( (string) end( $lines ), true );

// 成功したPNGをメディアライブラリへ
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';

$att_id = media_handle_sideload(
    array( 'name' => 'shot-example-com.png', 'tmp_name' => $png_path ),
    $post_id
);

細かいけれど実際に踏んだ点を2つ。①保存先の拡張子は必ず.pngにすること。Chromeは保存パスの拡張子で画像形式を決めるので、WordPressのwp_tempnam()が返す拡張子なしのパスをそのまま渡すと失敗します。②プロセスはtimeoutコマンドで包むこと。相手サイトが重いとChromeごと待ち続けるので、外側から時間で殺せるようにしておきます。

SSRF対策 — クラウドのサーバーでは飾りではなく必須

「URLを渡すと取りに行く」機能は、そのままだとサーバー内部への攻撃口になります。特にEC2ではhttp://169.254.169.254/(インスタンスメタデータ)にアクセスされるとIAM認証情報まで見えてしまうため、この対策を飛ばして公開してはいけません。

実装は「ホスト名をDNS解決して、結果のIPがプライベート・予約レンジなら拒否」です。文字列でのURL判定はバイパスされやすいので、必ず解決後のIPで判定します。さらにリダイレクトで内部へ飛ばされるケースがあるので、ページ読み込み後の最終URLでも同じ検査を繰り返します。

const addrs = (await dns.lookup(host, { all: true })).map((a) => a.address);
if (addrs.some(isPrivateOrReserved)) {
  throw new Error('プライベート/予約アドレスへのアクセスは拒否: ' + host);
}
// ページ読み込み後、リダイレクトされた最終URLでも再検査する
await page.goto(url, { waitUntil: 'networkidle2' });
assertPublic(page.url());

実際にメタデータのURLを渡したときの応答がこれです。ここが通ることを確認してから公開してください。

$ node capture.js '{"url":"http://169.254.169.254/latest/meta-data/", ...}'
{"ok":false,"error":"プライベート/予約アドレスへのアクセスは拒否: 169.254.169.254"}

何に使えるか — 「部分切り出し」が思った以上に効く

ページ全体のスクショは情報が多すぎて記事に貼りにくいのですが、セレクタで「料金表だけ」「設定フォームだけ」を切り出せると、そのまま図版として使えます。撮影からメディア登録まで自動なので、「解説記事のこの位置に実物の画面を貼る」という作業が、URLとセレクタを指定するだけになりました。

運用面の実測もひとつ: 撮影1回にかかるのはChromeの起動込みで数秒〜十数秒、メモリはピークで数百MB程度です。常駐させず撮影のたびに起動・終了する使い捨て方式にしておくと、小さいサーバーでも安全に同居できます。

日本発!ECオープンプラットフォーム「EC-CUBE」 EC-CUBEゴールドパートナー EC-CUBEは株式会社イーシーキューブの商標です

カスタマイズに関するお問い合わせはこちら