🛰️ フロントエンドだけで sitemap.xml をクロールし、IFRAME 内のエラーを一括検出するサイトマップチェッカーを作った話
はじめに
Web サイトを運営していると、「ページ数が多すぎて、ブラウザ検証のエラーを手作業でチェックするのが地獄」 という問題に必ずぶつかります。
- JavaScript の実行エラー
- Promise の未処理 rejection
- リソース読み込みエラー
- console.error
- 初期ロード中のエラー
- タイムアウト
- ブラウザが通知するロードエラー
HTTP ステータスや CORS/CSP など、ブラウザのセキュリティ制約によって直接取得できない情報もあります。
そこで今回、フロントエンドだけで sitemap.xml をクロールし、IFRAME 内で発生したブラウザエラーを自動収集するツールを作りました。
この発想に至った理由
一般的なサイトクローラーは HTTP レベルでページを取得できますが、それだけでは次のような問題は検出できません。
- ページを開いた瞬間に JavaScript が例外を吐く
- 画像や CSS の読み込みが失敗する
- Promise が reject される
これらは ブラウザが実際にページを実行したときに初めて発生するエラーです。
そこで、
「クローラーに解析させるのではなく、ブラウザ自身にページを開かせればいい」
という発想に至りました。
つまり、
sitemap.xml
↓
URL を列挙
↓
IFRAME に読み込み
↓
ブラウザが実際にページを実行
↓
エラーをフロントエンド側で収集
という方式です。
🎯 目的
- sitemap.xml の全 URL を自動で読み込む
- IFRAME 内でページを実際にレンダリング
- ブラウザ実行時のエラーをフロントエンドで収集
- 初期ロードエラーも検出
- 自己参照・二重読み込み・無限ループを防止
- 完全クライアントサイドで動作(サーバー不要)
🧩 仕組みの全体像
1. sitemap.xml を取得して URL を列挙
async function loadSitemap(url) {
const xml = await fetch(url).then(r => r.text());
const dom = new DOMParser().parseFromString(xml, "text/xml");
return [...dom.querySelectorAll("loc")].map(x => x.textContent);
}
2. 子ページ側で初期ロードエラーを検出して親へ postMessage
ここが今回、工夫した点です。
IFRAME 内のページは、onload より前にエラーを吐くことがあります。
例:
-
<script>の即時実行で例外 - Google Analytics の初期化エラー
- リソース読み込み失敗
- DOMContentLoaded より前のエラー
これらは 親ページ側で onload 後にフックしても絶対に拾えません。
そこで、子ページ自身にエラーフックを仕込み、親ページへ postMessage で通知する方式を採用しました。
子ページ(全ページの <head> に仕込む)
<script>
console.log("enter: " + location.href); //動作順を分かりやすくするために念のため不要であればコメントアウトしてください
window.addEventListener('error', function(e) {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: "JS Error",
url: location.href,
msg: e.message,
src: e.filename,
line: e.lineno,
col: e.colno
}, location.origin);
}
});
window.addEventListener('unhandledrejection', function(e) {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: "Promise Rejection",
url: location.href,
msg: String(e.reason)
}, location.origin);
}
});
</script>
例えばこれをVSCodeなどのGrep置換で<head>を<head>+上記コードに置換してください。その際万が一のためにバックアップも取っておいてください。
この書き換えが必要なため、既存HTML群をそのまま検査できるというものではありません。
これにより 通常の onload 後フックでは取りこぼす可能性のある初期ロード時エラーを、子ページ側から捕捉できる。
3. 親ページ側で postMessage を受信する
var globalResults = [];
window.addEventListener("message", function(event) {
if (event.origin !== location.origin) return;
if (event.data && event.data.type) {
globalResults.push({
url: event.data.url,
type: event.data.type,
msg: event.data.msg,
src: event.data.src,
line: event.data.line,
col: event.data.col
});
}
});
4. IFRAME の onload 後に補助的なエラーフックを仕込む(同一オリジン用)
function attachErrorHooksAfterLoad(iframe, url) {
try {
const win = iframe.contentWindow;
if (!win) {
globalResults.push({ url, type: "Cross-Origin", msg: "Cannot access window" });
return;
}
win.onerror = function(msg, src, line, col, err) {
globalResults.push({ url, type: "JS Error", msg, src, line, col });
};
win.onunhandledrejection = function(e) {
globalResults.push({ url, type: "Promise Rejection", msg: e.reason });
};
} catch (e) {
globalResults.push({ url, type: "Access Error", msg: e.message });
}
}
5. 無限ループ防御・自己参照防御・二重読み込み防御
const visited = new Set();
const currentPage = normalize(location.href);
6. 実際のクロール処理(最新版)
/**
* 並列数制限付きクロール + Timeout再試行
* @param {string[]} urls
* @param {number} concurrency 同時実行数(推奨 2~4)
* @param {number} timeoutMs タイムアウト(ミリ秒)
* @param {number} maxRetries 最大再試行回数(通常は1で十分)
*/
async function crawl(urls, concurrency = 3, timeoutMs = 8000, maxRetries = 1) {
globalResults = [];
const visited = new Set();
const normalizedUrls = urls.map(normalize).filter(url => {
if (visited.has(url)) {
globalResults.push({ url, type: "Skip", msg: "Already visited" });
return false;
}
visited.add(url);
return true;
});
// iframeプール作成
const iframePool = [];
for (let i = 0; i < concurrency; i++) {
let iframe = document.getElementById(`checker-frame-${i}`);
if (!iframe) {
iframe = document.createElement("iframe");
iframe.id = `checker-frame-${i}`;
iframe.style.cssText = "width:100%;height:400px;";
document.body.appendChild(iframe);
}
iframePool.push(iframe);
}
// ステータス表示
let statusEl = document.getElementById("crawl-status");
if (!statusEl) {
statusEl = document.createElement("div");
statusEl.id = "crawl-status";
statusEl.style.cssText = "font-weight:bold;margin:10px 0;";
document.body.insertBefore(statusEl, document.body.firstChild);
}
// 1つのURLを処理
async function processOne(url, iframe, isRetry = false) {
return new Promise((resolve) => {
let finished = false;
const timeout = setTimeout(() => {
if (!finished) {
finished = true;
globalResults.push({
url,
type: isRetry ? "Timeout (retry failed)" : "Timeout",
msg: `Load timeout (${timeoutMs / 1000}s)`
});
resolve(false); // 失敗を返す
}
}, timeoutMs);
iframe.onload = () => {
if (finished) return;
attachErrorHooksAfterLoad(iframe, url);
setTimeout(() => {
if (!finished) {
finished = true;
clearTimeout(timeout);
resolve(true); // 成功
}
}, 800);
};
iframe.onerror = () => {
if (!finished) {
finished = true;
clearTimeout(timeout);
globalResults.push({
url,
type: "Load Error",
msg: "iframe load failed"
});
resolve(false);
}
};
iframe.src = url;
});
}
// バッチ処理(並列制御)
async function runBatch(urlList, isRetry = false) {
const queue = [...urlList];
let completed = 0;
const total = urlList.length;
const failedUrls = []; // このバッチで失敗したURL
const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
const iframe = iframePool[workerIndex];
while (queue.length > 0) {
const url = queue.shift();
if (!url) break;
const label = isRetry ? "再試行中" : "巡回中";
statusEl.textContent = `${label} (${++completed}/${total}) [並列${concurrency}]: ${url}`;
const success = await processOne(url, iframe, isRetry);
if (!success && !isRetry) {
failedUrls.push(url); // 初回失敗のみ再試行候補に入れる
}
await new Promise(r => setTimeout(r, 200));
}
});
await Promise.all(workers);
return failedUrls;
}
// ===== 実行 =====
// 1回目
let retryUrls = await runBatch(normalizedUrls, false);
// 再試行(最大 maxRetries 回)
for (let i = 0; i < maxRetries && retryUrls.length > 0; i++) {
statusEl.textContent = `タイムアウトした ${retryUrls.length} 件を再試行します... (${i + 1}/${maxRetries})`;
await new Promise(r => setTimeout(r, 1000)); // 少し休憩
// 再試行時は並列数を下げると安定しやすい(任意)
// concurrency = Math.min(2, concurrency);
retryUrls = await runBatch(retryUrls, true);
}
statusEl.textContent = `すべてのクロールが完了しました!(並列数: ${concurrency})`;
return globalResults;
}
7. 親ページHTML側
<div id="crawl-status"></div>
<iframe id="checker-frame-0" width="100%" height="400"></iframe>
<iframe id="checker-frame-1" width="100%" height="400"></iframe>
<iframe id="checker-frame-2" width="100%" height="400"></iframe>
<div id="log"></div>
表示領域としてこれを親ページに仕込んでください。
🚫 制限・注意点
1. クロスサイトはセキュリティでブロックされる
IFRAME 内の window にアクセスできるのは 同一オリジンのみ。
2. SPA は onload が来ないことがある
そのため タイムアウト処理が必須。
3. 大規模サイトでは負荷が高い
IFRAME の高速切り替えにより CPU とネットワーク負荷が増える。
4. 自分の管理サイト限定で使用してください
このツールは 自分で管理しているサイトの検証用です。
他のサイトに対して使用すると、意図せず先方に負荷をかけたり迷惑をかける可能性があります。
必ず同一オリジン(自分のサイト)に限定して使ってください。
📛 Google Adsense のポリシー違反を避けるための IFRAME 防御
サイトマップチェッカーは IFRAME 内で大量のページを開くため、
広告まで読み込む必要はありません。
また、Adsense 広告が IFRAME 内で表示されると
ポリシー違反扱いになる可能性があります。
そのため、検査用ページでは IFRAME 内では広告を自動スキップする仕組みを入れています。
Google Adsense だけではなく、その他の広告なども同様の仕組みにすることが望まれます。
クロール中に広告やアナリティクスが動くとトラフィックや課金に影響する可能性が考えられます。
autoad.js(広告の自動スキップ)
window.addEventListener('DOMContentLoaded', function() {
if (window.self === window.top) {
// 1. 共通のAdSenseスクリプト
if (!document.querySelector('script[src*="adsbygoogle.js"]')) {
var adsScript = document.createElement('script');
adsScript.src = "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-xxxxxxxxxxxxxxxx";
adsScript.async = true;
adsScript.crossOrigin = "anonymous";
document.head.appendChild(adsScript);
}
// 2. 指定コンテナにラージモバイルバナーを動的生成
var container = document.getElementById('ad-container-xxx');
if (container) {
var ins = document.createElement('ins');
ins.className = 'adsbygoogle';
ins.style.display = 'inline-block';
ins.style.width = '320px';
ins.style.height = '100px';
ins.setAttribute('data-ad-client', 'ca-pub-xxxxxxxxxxxxxxxx');
ins.setAttribute('data-ad-slot', 'xxxxxxxxxx');
container.appendChild(ins);
(adsbygoogle = window.adsbygoogle || []).push({});
}
} else {
console.log("IFRAME内を検知したため、広告の表示を自動スキップしました。");
}
});
HTML 側
<script src="./js/autoad.js" async></script>
<div id="ad-container-xxx"></div>
Adsense を使っているサイトでは、
サイトマップチェッカー実行時の誤検出を防ぐためにも重要な対策です。
📊 実行結果の表示
function showResults(results) {
const log = document.getElementById("log");
log.innerHTML = results.map(r =>
`<div>
<b>${r.url}</b><br>
${r.type}: ${r.msg || r.src}
</div><hr>`
).join("");
}
📌 検出実績(実際に見つかったエラー例)
このサイトマップチェッカーを運用した結果、
長年の運用で風化していたコードの問題が多数発掘できました。
特に以下のような「手作業では気づきにくいエラー」が自動検出されました。
実際に私が見つけたのは1.2.だけでしたが...。
🧩 1. body の onload に存在しない関数が残っていた
長期運用サイトで最も多かったのがこれです。
<body onload="initPage()">
しかし実際には:
// initPage が既に削除されている
このような “死んだイベントハンドラ” は、
ページを開いた瞬間に ReferenceError を投げます。
✔ このツールでしか検出できない理由
- HTTP クローラーでは絶対に検出できない
- DevTools を開かないと見えない
- onload 前に発生するため、通常の iframe onload フックでは拾えない
- 子ページ側の
postMessageによって初期ロードエラーを確実に捕捉できる
🧩 2. コピペで残っていた古い初期化関数
ページを増やす際にコピペした結果、
すでに廃止した関数が残っていたケース。
<body onload="setupOldWidget()">
しかし JS 側では:
// setupOldWidget は数年前に削除済み
✔ 風化したコードの発掘に最適
長期運用サイトでは “昔の名残” が残りがちですが、
このツールは 全ページを実際にブラウザで実行するため、忘れ去られた初期化処理を自動発掘できます。
🧩 3. 存在しない DOM 要素を参照する初期化処理
document.getElementById("old-banner").style.display = "none";
しかし HTML からはすでに削除済み。
✔ TypeError を自動検出
-
null.styleによる TypeError - 初期化処理の “残骸” を発掘できる
- SPA でも検出可能
🧩 4. 廃止した jQuery プラグイン呼び出し
$("#menu").superSlide();
しかし superSlide.js はもう読み込んでいない。
✔ リソース読み込みエラー+関数未定義エラーの両方を検出
-
<script src="superSlide.js">の 404 -
superSlide is not a functionの TypeError - 両方まとめてログ化されるため原因が特定しやすい
🧩 5. 古い外部リソースの読み込み失敗
<script src="https://example.com/old-lib.js"></script>
外部サービス終了により 404。
✔ Resource Load Error として検出
- 画像
- CSS
- JS
- フォント
- CDN
などすべて対象。
これらが、ブラウザの error イベントとして通知されるリソース読み込み失敗ならば検出できる。
🧩 6. Google Analytics / Tag Manager の初期化エラー
古い UA タグや廃止された設定が残っていたケース。
✔ onload 前の GA 初期化エラーも拾える
子ページ側の postMessage により、
初期ロード中の GA エラーも確実に検出可能。
🧩 7. console.error による独自エラーの検出
あなたのサイトの JS が内部で console.error() を呼び出している場合、
現時点の実装では自動的に結果一覧へは取り込まれません。
自動収集の対象に含めたい場合は、console.error をオーバーライド(ラップ)して
親ページへ postMessage する処理を追加してください。
ただし、IFRAME 内で発生した console.error は DevTools のコンソールに
そのまま表示されるため、クロール中の目視確認には十分活用できます。
「IFRAME内のエラーを全部取れるツール」ではなく、「ブラウザ実行型サイト健全性チェッカー」
sitemapに列挙されたURLを、HTTP解析ではなく実ブラウザ環境で実行して検査する
🏢 企業向け:大規模サイト運用で役立つポイントまとめ
このサイトマップチェッカーは、個人開発だけでなく 企業の Web サイト運用・保守でもこのツールを参考にして応用レベルの機能を備えています。
特に、以下のような “企業サイト特有の問題” を解決できる点が大きな強みです。
🧱 1. 大規模サイトで頻発する問題を自動検出できる
企業サイトでは、ページ数が数百〜数千に及ぶことが珍しくありません。
そのため、次のような問題が日常的に発生します。
- 古いページの JavaScript が風化して例外を吐く
- 広告タグや外部 API の遅延で false timeout が起きる
- Mixed Content が古いページだけ残っている
- 404 画像や CSS が大量にある
- sitemap.xml が巨大で手動チェックが不可能
- iframe 内でしか再現しないエラーがある
これらは HTTP レベルのクローラーでは検出できない問題であり、
ブラウザ実行環境でのチェックが必須です。
本ツールは iframe 内で実際にページをレンダリングするため、
DevTools でしか見えないブラウザ内部エラーを自動収集できます。
⚙️ 2. 並列クロールは企業向けの必須機能
企業サイトはページ数が多いため、
「1ページずつ順番に読み込む」方式では現実的ではありません。
本ツールは:
- 並列数制限(concurrency control)
- iframe プール再利用
- キューを worker が消化する構造
を採用しており、
ブラウザ負荷を抑えつつ高速にクロールできるよう設計されています。
特に並列数 2〜4 は、企業サイトでも安定して動作する最適値です。
🔁 3. Timeout + 再試行(Retry)は企業サイトで必須
企業サイトは外部依存が多く、false timeout が頻発します。
- 広告タグの遅延
- 外部 API の応答遅延
- 古い JS が初回ロードで固まる
- CDN のレスポンス低下
本ツールは:
- タイムアウト検出
- 再試行(maxRetries)
- 再試行時の並列数低下(安定化)
を備えており、
企業サイトの不安定なページでも確実にクロールできます。
🧩 4. URL 正規化と自己参照防御は企業サイトで必須
企業サイトでは URL の地雷が非常に多いです。
-
/index.html#topと/index.htmlの重複 - sitemap.xml の重複 URL
- 自己参照リンクによる無限ループ
- リダイレクト後の URL が変わる問題
本ツールは normalize + visited によって、
hashを除去して同一URLの重複実行を防止します。
🧪 5. 実際に検出できた企業向けエラー例
実際に本ツールで検出できたエラーの中には、
企業サイトでも非常に多い典型的な問題が含まれています。
- body の onload に存在しない関数が残っていた
- コピペで消し忘れた古いコードが残っていた
- 時間が経って風化した JS が例外を吐いていた
- 古い広告タグが Promise rejection を大量に出していた
- 404 画像が大量にあった
- Mixed Content が古いページだけ残っていた
これらは 人間が手作業で見つけるのが非常に難しい問題です。
📈 6. 企業での運用フロー例
企業の QA / Web 担当者は、次のような運用でこのツールを活用できます。
- 毎週 sitemap.xml をクロールして全ページの健全性チェック
- 新規ページ追加時に QA が自動チェック
- 広告タグ更新時に全ページチェック
- リダイレクト設定変更時の全ページチェック
- 古いページの JS 風化チェック
- Mixed Content の一括検出
「DevTools を開いて手作業でチェックする」作業を完全に自動化できます。
🏁 まとめ:企業の QA チームでもそのまま導入できる品質
本ツールは:
- 並列制御
- 再試行
- タイムアウト
- 実ブラウザエラー検出
- URL 正規化
- 自己参照防御
- UI 進捗表示
これらを備えているため、
企業の QA チームにも応用可能な品質かもしれません。
大規模サイトの保守において、
「ブラウザ内部の実行エラーを自動収集できる」という点は非常に大きな価値があります。
📝 全体の総括:フロントエンドだけでここまでできる
今回のサイトマップチェッカーは、
- sitemap.xml を自動クロール
- IFRAME 内でブラウザ実行
- JS / Promise / リソース / 初期ロードエラーを自動収集
- 自己参照・二重読み込み・無限ループ防御
- 完全クライアントサイドで動作
という、「手作業で検証する地獄」から解放されるツールです。
Chrome DevTools のエラーを自動収集できるという点で、
既存の HTTP クローラーとはまったく違う価値があります。
「ブラウザ実行型サイト健全性チェッカー」 という位置づけです。
🔧 コード全文
function normalize(url) {
try {
const u = new URL(url, location.origin);
u.hash = ""; // #を無視
return u.toString();
} catch {
return url;
}
}
async function loadSitemap(url) {
const xml = await fetch(url).then(r => r.text());
const dom = new DOMParser().parseFromString(xml, "text/xml");
return [...dom.querySelectorAll("loc")].map(x => x.textContent);
}
function showResults(results) {
const log = document.getElementById("log");
log.innerHTML = results.map(r =>
`<div>
<b>${r.url}</b><br>
${r.type}: ${r.msg || r.src}
</div><hr>`
).join("");
}
// ===============================
// 1. 子ページからの postMessage を受信
// ===============================
var globalResults = [];
window.addEventListener("message", function(event) {
// 同一オリジンのみ受け取る
if (event.origin !== location.origin) return;
if (event.data && event.data.type) {
globalResults.push({
url: event.data.url,
type: event.data.type,
msg: event.data.msg,
src: event.data.src,
line: event.data.line,
col: event.data.col
});
}
});
function attachErrorHooksAfterLoad(iframe, url) {
try {
const win = iframe.contentWindow;
if (!win) {
globalResults.push({ url, type: "Cross-Origin", msg: "Cannot access window" });
return;
}
win.onerror = function(msg, src, line, col, err) {
globalResults.push({ url, type: "JS Error", msg, src, line, col });
};
win.onunhandledrejection = function(e) {
globalResults.push({ url, type: "Promise Rejection", msg: e.reason });
};
} catch (e) {
globalResults.push({ url, type: "Access Error", msg: e.message });
}
}
/**
* 並列数制限付きクロール + Timeout再試行
* @param {string[]} urls
* @param {number} concurrency 同時実行数(推奨 2~4)
* @param {number} timeoutMs タイムアウト(ミリ秒)
* @param {number} maxRetries 最大再試行回数(通常は1で十分)
*/
async function crawl(urls, concurrency = 3, timeoutMs = 8000, maxRetries = 1) {
globalResults = [];
const visited = new Set();
const normalizedUrls = urls.map(normalize).filter(url => {
if (visited.has(url)) {
globalResults.push({ url, type: "Skip", msg: "Already visited" });
return false;
}
visited.add(url);
return true;
});
// iframeプール作成
const iframePool = [];
for (let i = 0; i < concurrency; i++) {
let iframe = document.getElementById(`checker-frame-${i}`);
if (!iframe) {
iframe = document.createElement("iframe");
iframe.id = `checker-frame-${i}`;
iframe.style.cssText = "width:100%;height:400px;";
document.body.appendChild(iframe);
}
iframePool.push(iframe);
}
// ステータス表示
let statusEl = document.getElementById("crawl-status");
if (!statusEl) {
statusEl = document.createElement("div");
statusEl.id = "crawl-status";
statusEl.style.cssText = "font-weight:bold;margin:10px 0;";
document.body.insertBefore(statusEl, document.body.firstChild);
}
// 1つのURLを処理
async function processOne(url, iframe, isRetry = false) {
return new Promise((resolve) => {
let finished = false;
const timeout = setTimeout(() => {
if (!finished) {
finished = true;
globalResults.push({
url,
type: isRetry ? "Timeout (retry failed)" : "Timeout",
msg: `Load timeout (${timeoutMs / 1000}s)`
});
resolve(false); // 失敗を返す
}
}, timeoutMs);
iframe.onload = () => {
if (finished) return;
attachErrorHooksAfterLoad(iframe, url);
setTimeout(() => {
if (!finished) {
finished = true;
clearTimeout(timeout);
resolve(true); // 成功
}
}, 800);
};
iframe.onerror = () => {
if (!finished) {
finished = true;
clearTimeout(timeout);
globalResults.push({
url,
type: "Load Error",
msg: "iframe load failed"
});
resolve(false);
}
};
iframe.src = url;
});
}
// バッチ処理(並列制御)
async function runBatch(urlList, isRetry = false) {
const queue = [...urlList];
let completed = 0;
const total = urlList.length;
const failedUrls = []; // このバッチで失敗したURL
const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
const iframe = iframePool[workerIndex];
while (queue.length > 0) {
const url = queue.shift();
if (!url) break;
const label = isRetry ? "再試行中" : "巡回中";
statusEl.textContent = `${label} (${++completed}/${total}) [並列${concurrency}]: ${url}`;
const success = await processOne(url, iframe, isRetry);
if (!success && !isRetry) {
failedUrls.push(url); // 初回失敗のみ再試行候補に入れる
}
await new Promise(r => setTimeout(r, 200));
}
});
await Promise.all(workers);
return failedUrls;
}
// ===== 実行 =====
// 1回目
let retryUrls = await runBatch(normalizedUrls, false);
// 再試行(最大 maxRetries 回)
for (let i = 0; i < maxRetries && retryUrls.length > 0; i++) {
statusEl.textContent = `タイムアウトした ${retryUrls.length} 件を再試行します... (${i + 1}/${maxRetries})`;
await new Promise(r => setTimeout(r, 1000)); // 少し休憩
// 再試行時は並列数を下げると安定しやすい(任意)
// concurrency = Math.min(2, concurrency);
retryUrls = await runBatch(retryUrls, true);
}
statusEl.textContent = `すべてのクロールが完了しました!(並列数: ${concurrency})`;
return globalResults;
}
(async () => {
const urls = await loadSitemap("/sitemap.xml");
const results = await crawl(urls, 3, 8000, 1); // 並列3・8秒・再試行1回
showResults(results);
})();
作者
GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net
気に入っていただけたら GitHub に ⭐ をいただけると嬉しいです!