(この記事は Web Advent Calendar 2025 の記事【4つ目】です)
はじめに
既存のツールで対応できるものではありますが、諸事情により「Webサイト用のファイルで孤立ファイルを見つける」という機能を実現する「オフラインで動作する Webアプリ(ブラウザに放り込むだけの、単一の HTMLファイル)」を作った話です。
内容的には、フォルダ・ファイルを扱えるブラウザの API とテキスト処理あたりを組み合わせたら、技術的には実現可能だと分かるものです。それで、これをやろうとしたタイミングが Gemini 3.0 Pro がでたあたりだったのがあり、Gemini 3.0 Pro を試すついでにまずは生成AI丸投げで進めてみました(あと、プロンプトもそんなに複雑なものを使わず、ざっくりな内容で)。
具体的には、必要最低限のプロンプトでのやりとりをして Gemini 3.0 Pro でのコーディングを試しました。ちなみに、結果として自身での手直しは必要なものが、自分が欲しいものは満たされた状態で、サクッとできあがった感じです(自分が必要なとりあえず版のツールとして、という観点では十分に要件を満たせるという感じのもの)。
開発を進めた流れなど
当初の流れ
仕様について
今回の内容に着手したきかっけ・関連する情報や、自分が考えたことなどは、以下のとおりです。
- 自由にツールなどを導入できない、とある制限された環境での孤立ファイルのチェックを以下の要件でやりたかった
- 対象となるファイル群は、HTML・Javascript・CSS、および画像・ドキュメント類(ドキュメントは、PDF・Officeファイル)
- HTMLファイルを更新する中で、更新前は掲載していた画像・ドキュメントのリンクが、更新後になくなってどこからもリンクされないファイルがいくつか出てきたが、それを機械的に検出する仕組みを用意したくなった
- 孤立ファイルチェックの実現方法について、OS標準でついている環境で、ソフト・開発環境を追加せず実現する方法でやりたかった(そして、これについて個人のモノづくり・技術コミュニティ活動でよく使う、ブラウザ上で動作する Javascript の処理で実現したかった)
- 技術的には、ローカルにあるとあるフォルダ以下のファイル・フォルダ名とHTMLファイルの中身を読めればよく、それを考えたときにブラウザの File API(+テキスト処理)で実現できる内容のはず
最初に Gemini になげたプロンプト
当初、作りたいツールの作成のみにしぼった内容にするのではなく、実装方法のバリエーションを提示してみてもらうというのをやりたくて、Gemini 3.0 Pro に以下の内容で依頼をしてみました。
ローカルにあるHTML+フォルダ+ファイルで、HTMLの更新をしてリンクされなくなったファイル、というのを検出したいです。
開発者ツールや特定のソフト、サービス、標準機能などで実現する方法は?
そうすると、孤立ファイルを探すための Windows用・Mac用のソフトがいくつか提示されつつ、Pythonなどのスクリプトを使った方法もある、という内容も提示されました。
Gemini とのやりとりの続き
Gemini になげたプロンプトなど(続き)
上記の Gemini とのやりとりでは HTML+JavaScript で実現する話が出てなかったので、明示的に以下の依頼や機能追加の依頼を出しました。こちらでコードをざっとみて、実際に動作確認も行いましたが、とりあえず版として使っていくのに困らない内容のものが 3ステップほど(最初のものを除いて実質 2ステップ?)でサクッとできあがりました。
Pythonなどのスクリプトで実現できるという内容を、
ローカルに用意したWebアプリ(ブラウザでHTMLを開いて使う)で実現するための実装を、HTML+CSS+Javascriptが1ファイルになる構成で示して
「別途、機能追加を行ってもらった」という話のプロンプトは、以下のとおりです。
検出ファイルの一覧を表示したあと、その結果を取得する機能用に、テキストでクリップボードにコピーするためのボタンと、テキストファイルとしてダウンロードするためのボタン、それぞれの処理を追加して。
作成されたもの・動作確認など
以下で、作成されたものについての話を書いていきます。
上記のやりとりの結果、最終的に提示されたコードです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孤立ファイル検出ツール (エクスポート機能付き)</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
color: #333;
}
h1 {
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
/* コントロールパネル */
.control-panel {
background: #f4f6f8;
padding: 2rem;
border-radius: 8px;
text-align: center;
border: 2px dashed #ccc;
margin-bottom: 2rem;
}
.control-panel:hover {
border-color: #666;
background: #eef;
}
input[type="file"] {
display: none;
}
/* ボタン類 */
.btn {
background: #007bff;
color: white;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
display: inline-block;
font-weight: bold;
border: none;
font-size: 1rem;
text-decoration: none;
}
.btn:hover {
background: #0056b3;
}
.btn-action {
background: #28a745;
margin-right: 10px;
font-size: 0.9rem;
padding: 8px 16px;
}
.btn-action:hover {
background: #218838;
}
.action-bar {
margin: 15px 0;
padding: 10px;
background: #eee;
border-radius: 4px;
display: none; /* 初期は非表示 */
}
#status {
margin-top: 10px;
font-weight: bold;
color: #666;
}
#results {
display: none;
}
/* リスト表示 */
.list-group {
list-style: none;
padding: 0;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 500px;
overflow-y: auto;
background: #fff;
}
.list-group li {
padding: 10px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
word-break: break-all;
}
.list-group li:last-child {
border-bottom: none;
}
.badge {
background: #dc3545;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8em;
white-space: nowrap;
margin-left: 10px;
}
.summary {
margin-bottom: 10px;
font-size: 1.1em;
}
/* ローダー */
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<h1>📂 孤立ファイル検出ツール</h1>
<p>
フォルダを選択すると、HTML/CSS内でリンクされていないファイルを検出します。<br />※PDFや画像、Officeファイルなども検出対象です。
</p>
<div class="control-panel" id="dropArea">
<label for="dirInput" class="btn">📂 フォルダを選択して解析</label>
<input type="file" id="dirInput" webkitdirectory directory multiple />
<div id="status">
ここにフォルダをドラッグ&ドロップ、またはボタンから選択
</div>
</div>
<div id="results">
<div class="summary">
検出数: <strong id="count" style="color: #dc3545">0</strong> 件
<small>(全ファイル: <span id="total">0</span>)</small>
</div>
<div id="actionBar" class="action-bar">
<button id="btnCopy" class="btn btn-action">
📋 クリップボードにコピー
</button>
<button id="btnDownload" class="btn btn-action">
💾 テキストで保存
</button>
</div>
<ul id="fileList" class="list-group"></ul>
</div>
<script>
const dirInput = document.getElementById("dirInput");
const statusDiv = document.getElementById("status");
const resultsDiv = document.getElementById("results");
const fileListUl = document.getElementById("fileList");
const countSpan = document.getElementById("count");
const totalSpan = document.getElementById("total");
// 追加した要素
const actionBar = document.getElementById("actionBar");
const btnCopy = document.getElementById("btnCopy");
const btnDownload = document.getElementById("btnDownload");
// 現在の結果を保持する変数
let currentOrphans = [];
// 解析対象とするソースファイルの拡張子
const SOURCE_EXTS = [".html", ".htm", ".css", ".js", ".php", ".scss"];
dirInput.addEventListener("change", async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
// UIリセット
statusDiv.innerHTML = '<div class="loader"></div> 解析中...';
fileListUl.innerHTML = "";
resultsDiv.style.display = "block";
actionBar.style.display = "none"; // ボタンを隠す
currentOrphans = []; // 結果リセット
// 1. 全ファイルのパスをマップ化
const allFilesMap = new Map();
files.forEach((f) => {
// システムファイル除外 (.gitフォルダや .から始まる隠しファイル)
if (f.webkitRelativePath.includes("/.git/") || f.name.startsWith("."))
return;
allFilesMap.set(f.webkitRelativePath, f);
});
const referencedPaths = new Set();
const promises = [];
// 2. ソースファイルを読み込んでリンクを抽出
files.forEach((file) => {
const ext = file.name
.substring(file.name.lastIndexOf("."))
.toLowerCase();
if (SOURCE_EXTS.includes(ext)) {
const p = readFileContent(file).then((content) => {
extractLinks(
content,
file.webkitRelativePath,
allFilesMap,
referencedPaths
);
});
promises.push(p);
}
});
await Promise.all(promises);
// 3. 孤立ファイルを特定
allFilesMap.forEach((fileObj, pathStr) => {
if (!referencedPaths.has(pathStr)) {
currentOrphans.push(pathStr);
}
});
// 4. 結果表示
statusDiv.textContent = "解析完了";
totalSpan.textContent = allFilesMap.size;
countSpan.textContent = currentOrphans.length;
if (currentOrphans.length === 0) {
fileListUl.innerHTML =
"<li>孤立したファイルは見つかりませんでした👏</li>";
} else {
// ソートして表示
currentOrphans.sort().forEach((pathStr) => {
const li = document.createElement("li");
li.innerHTML = `<span>${pathStr}</span> <span class="badge">Unused</span>`;
fileListUl.appendChild(li);
});
// ボタンエリアを表示
actionBar.style.display = "block";
}
});
// --- ボタン機能の実装 ---
// 1. クリップボードにコピー
btnCopy.addEventListener("click", () => {
if (currentOrphans.length === 0) return;
const textToCopy = currentOrphans.join("\n");
navigator.clipboard
.writeText(textToCopy)
.then(() => {
const originalText = btnCopy.textContent;
btnCopy.textContent = "✅ コピー完了!";
setTimeout(() => (btnCopy.textContent = originalText), 2000);
})
.catch((err) => {
alert("コピーに失敗しました: " + err);
});
});
// 2. テキストファイルとしてダウンロード
btnDownload.addEventListener("click", () => {
if (currentOrphans.length === 0) return;
const textToSave = currentOrphans.join("\n");
const blob = new Blob([textToSave], { type: "text/plain" });
// ダウンロードリンクを生成してクリック
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// 日付入りファイル名
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
a.download = `orphan_files_${dateStr}.txt`;
document.body.appendChild(a);
a.click();
// 後始末
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// --- 内部ロジック ---
function readFileContent(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve("");
reader.readAsText(file);
});
}
function extractLinks(
content,
currentFilePath,
allFilesMap,
referencedSet
) {
const regex =
/(?:src|href|url)\s*=\s*['"]([^'"]+)['"]|url\(['"]?([^'")]+)['"]?\)/g;
let match;
const currentDir = currentFilePath.substring(
0,
currentFilePath.lastIndexOf("/") + 1
);
while ((match = regex.exec(content)) !== null) {
let link = match[1] || match[2];
if (
!link ||
link.startsWith("http") ||
link.startsWith("//") ||
link.startsWith("mailto:")
)
continue;
link = link.split("?")[0].split("#")[0];
let candidatePath = "";
if (link.startsWith("/")) {
candidatePath = link.substring(1);
} else {
candidatePath = resolvePath(currentDir, link);
}
if (allFilesMap.has(candidatePath)) {
referencedSet.add(candidatePath);
}
}
}
function resolvePath(baseDir, relativePath) {
const stack = baseDir.split("/");
if (stack[stack.length - 1] === "") stack.pop();
const parts = relativePath.split("/");
for (let part of parts) {
if (part === ".") continue;
if (part === "..") {
stack.pop();
} else {
stack.push(part);
}
}
return stack.join("/");
}
</script>
</body>
</html>
最初の部分に CSS の実装が出てきています。今回、デザイン面は指示に入れてないですが、サイズ感や余白など、自分が個人ツールとして使うのには十分なできになっていました。
その後を見ていくと、JavaScript の実装では「対象とするファイルの限定・読み込んだファイル内でのリンクの抽出・抽出したリンクとファイル一覧との比較・クリップボードへの内容のコピー(3つの目のプロンプトで追加の依頼をしたもの)・ページ上への結果の出力」などの処理がありました。
動作確認
最後に動作確認です。記事で提示するテスト用のファイル構成と、HTMLファイルの内容は以下のとおりです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>メインページ</title>
</head>
<body>
<h1>ここはトップページです</h1>
<p>以下のリンクから詳細ページへ移動できます。</p>
<a href="test.html">画像ギャラリーページへ</a>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>画像ページ</title>
<style>
body {
background-color: #eee;
text-align: center;
}
img {
max-width: 200px;
margin: 10px;
border: 5px solid white;
}
</style>
</head>
<body>
<h1>画像ギャラリー</h1>
<p>ここはメインページからリンクされている正規のページです。</p>
<div>
<img src="img/image01.jpg" alt="画像1" />
<img src="image02.jpg" alt="画像2" />
</div>
</body>
</html>
上記を用いてテストをした結果、以下の意図通りの出力を得ることができました。
別のデータセットで試したところ、そちらもうまくいってます。
(想定外のデータセットで、検出漏れがでないかとかは気になりつつ、そのあたりも出てきたら改善できればと)


