Matomoヒートマップでフォントが読み込まれない原因と修正方法

Matomoのヒートマップスクリーンショットで @font-face のファイルがCORSでブロックされたり、許可ドメインに制限されていたり、DOMシリアライズ後に読み込まれたりすると、Times New Roman や Arial にフォールバックします。原因と対処法を解説します。

Matomoのヒートマップを開いたら、ヒーローテキストが Times New Roman になっていて、ボタンが Arial になっていて、クリックマーカーが本来あるべき要素から数ピクセルずれていた——という経験はないでしょうか。クリック自体は問題ありません。Matomoは正しい座標に記録しています。壊れているのはその下にあるスクリーンショットです。Matomoサーバーがページを再レンダリングしたとき、ブランドフォントが読み込まれず、システムフォントにフォールバックしました。フォントが変わればメトリクスも変わり、レイアウトがずれます。

最も手早い修正は、Matomo Heatmap Helper という無料の Chrome 拡張機能です。ヒートマップキャプチャの直前にページ上のすべてのフォントを base64 で埋め込みます。スタイルシートの変更は不要です。この記事の残りは、拡張機能を使わずに同じことをやる方法と、そもそも Matomo がフォントを読み込めない理由を説明します。

拡張機能なしで修正する方法

キャプチャ直前に問題を回避するコンソールスニペットと、サイトに組み込める恒久的な修正がいくつかあります。スタックに合うものを選んでください。最後に、どの修正を入れてもスクリーンショットが壊れる JS パターンについても触れます。知っておく価値があります。

何が失敗しているかを診断する

何かを変更する前に、どのフォントが読み込まれていないかを確認しましょう。Chrome でページを開き、F12 で DevTools を開き、Console に切り替えて以下を貼り付けます:

js
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
  [...document.fonts].forEach(f =>
    console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});

loaded 以外のステータスになっているものが候補です。次に DevTools → Network → "Font" でフィルタして、@font-face の URL に 403 や CORS エラーが出ていないか確認します。CDN の問題なのか、Adobe Fonts の問題なのか、シリアライズの問題なのかが分かります。

手早い修正:コンソールからすべてのフォントを base64 で埋め込む

これは、Matomo のヒートマップキャプチャをトリガーする直前にブラウザコンソールに貼り付けるスニペットです。ページ上のすべての @font-face ルール(インライン、同一オリジン、クロスオリジンのスタイルシートを含む)を走査し、フォントのバイナリをフェッチして base64 エンコードし、自己完結したデータ URI を含む新しい <style> タグを注入します。Matomo が DOM をシリアライズするときには、フォントがすでに埋め込まれた状態になっています。

js
// Paste into the browser console. Embeds every @font-face on the page as base64.
(async () => {
  const fontFaces = [];
 
  const parseSrc = (srcValue, baseUrl) => {
    const out = [];
    const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
    let m;
    while ((m = re.exec(srcValue)) !== null) {
      const raw = m[1], format = m[2];
      if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
      try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
    }
    return out;
  };
 
  const collect = (rule, baseUrl) => {
    if (rule instanceof CSSFontFaceRule) {
      const s = rule.style;
      const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
      const src = s.getPropertyValue('src');
      if (!family || !src) return;
      fontFaces.push({
        family,
        weight: s.getPropertyValue('font-weight') || '',
        style: s.getPropertyValue('font-style') || '',
        display: s.getPropertyValue('font-display') || '',
        unicodeRange: s.getPropertyValue('unicode-range') || '',
        src: parseSrc(src, baseUrl),
      });
    } else if (rule.cssRules) {
      for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
    }
  };
 
  // 1. Walk accessible stylesheets via the CSSOM
  const crossOriginUrls = [];
  for (const sheet of Array.from(document.styleSheets)) {
    const baseUrl = sheet.href || location.href;
    try {
      for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
    } catch {
      if (sheet.href) crossOriginUrls.push(sheet.href);
    }
  }
 
  // 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks
  const parseCssText = (txt, baseUrl) => {
    const re = /@font-face\s*\{/gi;
    let m;
    while ((m = re.exec(txt)) !== null) {
      const start = m.index + m[0].length;
      let depth = 1, i = start;
      while (depth > 0 && i < txt.length) {
        if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
        i++;
      }
      if (depth !== 0) continue;
      const body = txt.substring(start, i - 1);
      const get = n => {
        const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
        return r ? r[1].trim() : '';
      };
      const family = get('font-family').replace(/['"]/g, '').trim();
      const src = get('src');
      if (!family || !src) continue;
      fontFaces.push({
        family,
        weight: get('font-weight'),
        style: get('font-style'),
        display: get('font-display'),
        unicodeRange: get('unicode-range'),
        src: parseSrc(src, baseUrl),
      });
    }
    // Recurse into @import
    const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
    const imports = [];
    while ((m = importRe.exec(txt)) !== null) {
      const u = m[1] || m[2];
      try { imports.push(new URL(u, baseUrl).href); } catch {}
    }
    return imports;
  };
 
  const visited = new Set();
  const fetchCss = async (url) => {
    if (visited.has(url)) return;
    visited.add(url);
    try {
      const txt = await fetch(url).then(r => r.text());
      const imports = parseCssText(txt, url);
      for (const u of imports) await fetchCss(u);
    } catch (e) { console.warn('CSS fetch failed:', url, e.message); }
  };
  for (const url of crossOriginUrls) await fetchCss(url);
 
  if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
  console.log(`Found ${fontFaces.length} @font-face rules.`);
 
  // 3. Fetch each unique font binary and base64-encode it
  const uniqueUrls = [...new Set(
    fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
  )];
  console.log(`Fetching ${uniqueUrls.length} font files...`);
 
  const dataUris = new Map();
  await Promise.all(uniqueUrls.map(async (url) => {
    try {
      const blob = await fetch(url, { mode: 'cors' }).then(r => {
        if (!r.ok) throw new Error('HTTP ' + r.status);
        return r.blob();
      });
      const dataUri = await new Promise((res, rej) => {
        const r = new FileReader();
        r.onload = () => res(r.result);
        r.onerror = rej;
        r.readAsDataURL(blob);
      });
      dataUris.set(url, dataUri);
    } catch (e) { console.warn('Font fetch failed:', url, e.message); }
  }));
 
  // 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
  const css = fontFaces.map(f => {
    const srcParts = f.src.map(s => {
      const u = dataUris.get(s.url) || s.url;
      return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
    });
    if (srcParts.length === 0) return '';
    const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
    if (f.weight)       props.push(`font-weight: ${f.weight}`);
    if (f.style)        props.push(`font-style: ${f.style}`);
    if (f.display)      props.push(`font-display: ${f.display}`);
    if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
    return `@font-face {\n  ${props.join(';\n  ')};\n}`;
  }).filter(Boolean).join('\n\n');
 
  // 5. Inject into <head> so Matomo's DOM serializer sees self-contained fonts
  const style = document.createElement('style');
  style.setAttribute('data-heatmap-font-fix', 'true');
  style.textContent = css;
  document.head.appendChild(style);
 
  console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} fonts across ${fontFaces.length} @font-face rules.`);
  console.log('Now trigger your Matomo heatmap capture.');
})();

スクリーンショットを撮る前に注入が正常に完了しているか確認するには:

js
// Confirms the injected styles are in <head>
const tag = document.querySelector('style[data-heatmap-font-fix]');
console.log(tag
  ? `OK, injected ${(tag.textContent.match(/@font-face/g) || []).length} @font-face rules, `
    + `${(tag.textContent.match(/data:font/g) || []).length} use base64 data URIs.`
  : 'No injected style tag found, re-run the embed snippet.');

これが手早い方法です。一度きりのキャプチャや、今すぐクリーンなスクリーンショットが欲しいレビューサイクルに使えます。定期的にキャプチャし直すなら、後述する恒久的な修正のどれかを実装しましょう。

フォントをセルフホストする(王道の修正)

ほぼ全員にとって最もきれいな解決策です。google-webfonts-helper から実際に使っている woff2 と woff のバリアントをダウンロードして /public/fonts に置き、こんな CSS を書きます:

css
/* Edit the family name and file paths to match your downloaded files */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-400.woff2") format("woff2"),
       url("/fonts/inter-400.woff") format("woff");
}

これでフォントは自分のドメインから配信されます。ページと同一オリジンなので CORS の交渉は不要で、Matomo サーバーも問題なくフェッチでき、スクリーンショットが正しいフォントで表示されます。実際のユーザーにとっても DNS ルックアップとサードパーティ接続が一つ減るので、ページの読み込みが速くなります。たいていは数時間の作業と小さな CSS の差分で済みます。

CDN の CORS を修正する

フォントを CDN に置いたままにしたい場合は、フォントのレスポンスに Access-Control-Allow-Origin: * を返すよう CDN を設定して、プリロードリンクに crossorigin を追加します:

html
<!-- Edit the URL to your CDN's font path -->
<link rel="preload" as="font" type="font/woff2"
      href="https://your-cdn.example.com/inter.woff2"
      crossorigin="anonymous">

Cloudflare、Fastly、AWS など、自分でコントロールできるものであれば機能します。Adobe Fonts では機能しません。あちらのドメイン許可リストはサーバー側で強制されており、変更できません。Typekit を使っている場合、セルフホストが唯一の選択肢です。Adobe のセルフホストライセンスについては別途確認が必要です。

ビルドに埋め込みを組み込む

スタイルシートを変更できないがビルドはコントロールできる場合は、コンソールスニペットと同じロジックをビルド時に実行します。ヘッドレスブラウザ(Playwright または Puppeteer)を起動して、ビルド済みページに対して埋め込みコードを実行し、生成された <style> ブロックを取得して HTML テンプレートに書き込みます。ページはフォントが最初から埋め込まれた状態で配信されます。ランタイムフェッチもコンソール貼り付けも不要です。

三つの恒久的な修正の中では最も重い方法です。ただ、スタイルシートをいじれないが、ビルドスクリプトは追加できる CMS 上のマーケティングサイトにも対応できます。

シリアライズの落とし穴

もう一つ、よく引っかかる点があります。JS でランタイムに読み込んだフォントは Matomo の DOM シリアライズを生き残れません:

js
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);

フォントはブラウザの document.fonts セットに登録されますが、シリアライザが読む HTML には存在しません。フォントが技術的に「読み込まれた」状態でも、スクリーンショットはシステムデフォルトにフォールバックします。ハイドレーション時に JS フレームワークが注入するフォントも同様です。@font-face ルールを含む <style> タグか、通常の <link rel="stylesheet"> を使ってください。シリアライザが静的 HTML から参照できるものであれば問題ありません。document.fonts.add() はシリアライザから見えません。

Matomo がフォントを読み込めない理由

Matomo のスクリーンショットキャプチャは2段階で動作します。まず、トラッカーがライブの DOM を HTML にシリアライズして送信します。次に、Matomo サーバーがその HTML を再レンダリングして、ヒートマップビューに表示されるスクリーンショットを生成します。ブラウザセッションもクッキーもドメインもありません。別の IP アドレスから HTML ファイルをコールドレンダリングしているだけです。

その HTML の中でサードパーティホストを参照しているものは、すべてそのサードパーティを通過しなければなりません。@font-face の宣言の場合、そこで止まります。

実際に何が失敗しているか

よく見かけるパターンをいくつか挙げます:

  • フォントを配信する CDN が Access-Control-Allow-Origin ヘッダーを返していない。ブラウザは同一オリジンのケースではヘッダーなしでも許容しますが、別オリジンからフェッチする Matomo サーバーは許容しません。
  • Adobe Fonts(Typekit)は OriginReferer ヘッダーをアカウントの許可ドメインリストと照合します。Matomo サーバーはそのリストに絶対に入っておらず、Typekit は 403 を返します。Matomo の IP を追加する方法もありません。
  • fonts.gstatic.com などいくつかの CDN は、ユーザーエージェントや IP レピュテーションによってサーバーサイドレンダラーをレート制限または完全にブロックすることがあります。フォントはブラウザでは動いているのに Matomo のレンダラーでは動かない、という状況になります。
  • new FontFace(...).load() から document.fonts.add(...) で読み込まれたフォントは、シリアライズされた HTML に一切含まれません。シリアライザは DOM を読みますが、ランタイムの FontFace セットは読みません。

ヒートマップスクリーンショットに間違ったフォントが表示されているなら、上のどれかに当たっています。複数同時に該当することも珍しくありません。

これは、同じスクリーンショットで画像、スクロールコンテナ、スティッキーヘッダーが壊れる問題と同じ系統の話です。その他の原因についてはMatomoヒートマップのスクリーンショットが壊れる原因と修正方法で詳しく解説しています。

実際にどうするか

スタイルシートをコントロールできるなら、セルフホストが最善です。ヒートマップが修正され、サードパーティ依存が一つ減り、実際のユーザーのページも速くなります。

それが難しければ、既存のスタイルシートにフォントを base64 データ URI として埋め込みましょう。Matomo のレンダラーへの効果は同じで、インフラ変更は不要です。

どちらも難しい場合は、Matomo Heatmap Helper Chrome 拡張機能が、スタックを変更できないクライアントサイトで使っているものです。上のスニペットと同じ埋め込みロジックに加えて、画像、スクロールコンテナ、スティッキーヘッダーの修正も含まれており、毎回のキャプチャで実行されます。無料でオープンソース、コードは GitHub にあります

Martez は、この拡張機能が生まれた大きなプロジェクトです。Matomo と Meta Ads・Google Ads を連携させ、ROAS、CLV、アトリビューションをウェブ解析の隣に並べます。別のスプレッドシートではなく。現在プライベートベータ中です。興味があればウェイトリストに登録してください

フォントはたいていスタイルシートで直せます。一度やっておく価値があります。

Related Articles

Matomoヒートマップでフォントが読み込まれない原因と修正方法 - Martez Blog