Webサイトの更新検知スクリプト
「朝日奈アンテナ」「なつみかん」「WDB」「rAntenna」の4つのアンテナシステム(Webサイトの更新時刻を取得するシステム)を設置してきましたが,今でも問題なく動作しているのは,Ruby製の「rAntenna」のみ。サーバーのRubyが古いおかげで,サイトアンテナと称して利用していますが,先日インストールした独自パスのRuby 3.3.7では動作しませんでした。そろそろ代わりのスクリプトに移行したくなったので,Webサイトの更新を検知するスクリプトをGeminiを利用して作成しました。「Teku2-Log(テク・スクエア・ログ)」と名付けましたが,ブラウザでの実行とサーバー内部からの実行のどちらにも対応しています。コマンドラインやCRONで実行する場合はパスワード不要ですが,ブラウザから実行する場合はURLパラメータとしてパスワードが必要です。以下,スクリプトの特徴や更新検知の優先順位などについてまとめておきます。ソースコードは最後に掲載しました。

→ サンプル(スクリプトから自動生成されたHTMLファイル)

■ Teku2-Log の特徴
- 多様なサイトに合わせた「五段階の更新検知」
RSSフィードがあるサイトはもちろん,それがない静的なページでも,HTTPヘッダー(H),HTML内の日付テキスト(G),特定のキーワード(K),ファイルサイズの変化(L)という5つのロジックから最適な方法で更新を判定します。 - 正規表現を用いた柔軟なキーワード監視
単なる文字列の一致だけでなく,正規表現を利用したパターンマッチが可能です。「日付が取得できないページ内にある,特定のコードや数値が変化したとき」だけを狙って更新を検知するといった運用ができます。 - 二次利用しやすい「HTMLとJSON」の同時出力
人間がブラウザで確認するためのHTML一覧と同時に,プログラムが読み取りやすいJSON形式のデータも自動で生成します。自作の通知ツールや別スクリプトとの連携など,データの二次利用が容易です。 - サイトの健康状態を判別する「ステータス表示」
単に更新の有無を表示するだけでなく,応答が遅い「タイムアウト(T)」や,リンク切れなどの「取得失敗(0)」を明確に区別して表示します。巡回対象サイトのメンテナンス時期を把握するのにも役立ちます。 - 設定のみで完結する「容易な導入とカスタマイズ」
配色やリンクの挙動などのデザイン変更,パスワードの設定などは,すべてスクリプト冒頭の変数書き換えだけで完結します。複雑なディレクトリ設定も不要で,アップロード後すぐに運用を開始できます。
■ 更新検知の優先順位(判定ロジックの流れ)
Teku2-Log は、以下の 1 → 5 の順でチェックを行い,最も確実な情報を優先して採用します。
上にあるほど精度が高く,下に行くほど『泥臭く』変化を拾い上げる設計です。
- [R] フィード解析(最優先)
RSSやAtomフィードから,システムが生成した正確な更新日時を読み取ります。 - [H] HTTPヘッダー
サイトのサーバーが返信してくる「最終更新日(Last-Modified)」をチェックします。 - [K] キーワード監視
あらかじめ設定した「キーワード(正規表現)」がページ内に出現・変化したかを探します。 - [L] サイズ比較
日付情報が見つからない場合,前回の巡回時と「ファイルサイズ」を比較し,1バイトでも差があれば更新とみなします。 - [G] 本文テキスト解析
上記すべてが空振りだった場合,ページ内の文字情報を解析し,「2026/03/12」のような日付テキストを探し出します。
- [T] タイムアウト / [0] 取得失敗
通信が途切れたり,ページが存在しない場合は,これらのエラーマークが付きます。
■ 注意事項
- 文字コードと改行コード
動作不良を防ぐため,ファイルは必ず UTF-8 / LF で保存してください。 - ディレクトリの権限設定
HTMLとJSONを自動生成するため,設置フォルダには書き込み権限(パーミッション 705 や 707 など)が必要です。 - セキュリティ設定(重要)
第三者による不正実行を防ぐため,初期設定の$accessKeyは必ず自分専用の複雑な文字列に変更してください。 - サーバー負荷への配慮
相手先サーバーへの礼儀として,過度な頻度の自動実行(cron等)は避け,常識の範囲内(例:1時間に1回など)での運用を心がけましょう。 - タイムアウトと連続アクセス
本スクリプトは1サイトにつき最大10秒でタイムアウトし,各サイト間に0.2秒の待機時間を設けています。環境に合わせて調整してください。
■ 免責事項
- 本スクリプトはフリーソフトですが,著作権は放棄しておりません。
- 本スクリプトを使用したことによって生じた,いかなる損害(データ消失、サーバー停止、その他トラブル等)についても,作者は一切の責任を負いかねます。
- 各自の環境で十分にテストを行った上で,自己責任においてご利用ください。
- 相手先サーバーの設定や構造により,全てのサイトで正確な更新日時取得を保証するものではありません。
<?php
/**
* 【 Teku2-Log (テク・スクエア・ログ) 】
* Version : v0.1.0
* Date : 2026/03/12
* Summary : 五段構えの判定ロジックでサイトを「てくてく」巡回。
* 一覧HTMLと共有用JSONを自動生成する更新検知スクリプト。
* Author : Yasuhiro Ishibashi | Blog FreeSide
* URL : https://freeside.skr.jp/wordpress/update-detection-script/
*
* [ 主な判定ロジック ]
* (R) フィード解析 / (H) HTTPヘッダー / (G) 本文内日付解析
* (K) キーワード監視(正規表現対応) / (L) ファイルサイズ比較
*
* [ 使い方 ]
* 1. $sites 配列に監視したいサイトとキーワード(任意)を追加。
* 2. $accessKey を自分専用のパスワードに変更。
* 3. サーバーへアップ後、 `teku2log.php?key=パスワード` を実行。
* 4. 同一階層に更新リスト(HTML)と共有用データ(JSON)が自動生成されます。
*
* [ 補足:環境設定 ]
* ・保存形式:UTF-8 / LF(BOMなし推奨)
* ・フォルダ権限:705 または 707(HTML/JSON/キャッシュ生成のため)
*
* [ 免責事項 ]
* 本スクリプトの使用による損害について、作者は一切の責任を負いません。
* 設置・運用は自己責任において行ってください。
*/
// 実行開始時間を記録
$startTime = microtime(true);
// ============================================================
// 1. 基本設定
// ============================================================
// --- 監視対象のサイト ---
$sites = [
// 1. 標準的なサイト(HEAD取得やフィード解析)
["title" => "公式サイト(HEAD取得)", "link" => "https://www.example.com", "check" => "https://www.example.com"],
["title" => "更新ブログ(RSSフィード)", "link" => "https://blog.example.com", "check" => "https://blog.example.com/feed/"],
// 2. キーワード監視の例(正規表現で特定のパターンを監視)
["title" => "【例】在庫チェック", "link" => "https://...", "check" => "https://...", "keyword" => "/在庫あり|残り[0-9]+個/"],
["title" => "【例】最新話の更新", "link" => "https://...", "check" => "https://...", "keyword" => "/第[0-9]+話/"],
["title" => "【例】ソフトのVerUP", "link" => "https://...", "check" => "https://...", "keyword" => "/Version\s*[0-9\.]+/"],
// 3. 特殊なケース(ヘッダーなし・日付解析)
["title" => "【例】解析(G)判定(本文の日付を抽出)", "link" => "https://old-site.example.jp", "check" => "https://old-site.example.jp"],
// 4. エラー確認用のサンプル
["title" => "【テスト】存在しないページ", "link" => "https://example.com", "check" => "https://example.com"],
];
// --- 動作設定 ---
$scriptTitle = "Teku2-Log v0.1.0"; // ページタイトル・見出し
$accessKey = "mysecret123"; // ※必ず変更してください
$targetFileName = "teku2log_list.html"; // 出力ファイル名
$dataFileName = "teku2log_data.json"; // サイズ記録用ファイル名
$resultFileName = "teku2log_results.json"; // 共有用JSON出力名
$threshold = 86400; // NEWマーク保持期間 (秒) ※86400=24時間
$message = "今日も元気にサイトをてくてく巡回してきました!";
// --- デザイン設定(カラー・装飾) ---
$c_bg = "#ffffff"; // ページ全体の背景色
$c_list_bg = "#ffffff"; // リストエリアの背景色
$c_title = "#333333"; // メインタイトルの文字色
$c_info = "#888888"; // 最終確認日時・処理実行時間の文字色
$c_msg = "#666666"; // 巡回メッセージの文字色
$c_list = "#666666"; // リスト内の基本文字色(更新日時・カッコなど)
$c_other = "#888888"; // リスト内の凡例・注釈などのアクセント色
$c_footer = "#888888"; // フッター(署名・JSONリンク)の文字色
$c_link = "#0044cc"; // サイト名のリンク色(未訪問)
$c_visited = "#0044cc"; // サイト名のリンク色(訪問済み)
$c_hover = "#cc001f"; // マウスホバー時のリンク色
$u_line = "none"; // 通常時のリンク下線(none:なし / underline:あり)
$u_hover = "underline"; // ホバー時のリンク下線(none:なし / underline:あり)
$c_new_bg = "#cc001f"; // NEWバッジの背景色
$c_new_txt = "#ffffff"; // NEWバッジの文字色
$c_border = "#aaaaaa"; // リスト外枠の線の色
// ============================================================
// 2. システムロジック(実行処理)※ここから下は変更不要
// ============================================================
$saveFile = __DIR__ . '/' . $targetFileName;
$dataFile = __DIR__ . '/' . $dataFileName;
$resultFile = __DIR__ . '/' . $resultFileName;
$protocol = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? "https" : "http";
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$currentDir = dirname($_SERVER['SCRIPT_NAME'] ?? '');
$saveFileUrl = $protocol . "://" . $host . rtrim($currentDir, '/\\') . "/" . $targetFileName;
if (php_sapi_name() !== 'cli' && (!isset($_GET['key']) || $_GET['key'] !== $accessKey)) {
header("HTTP/1.0 403 Forbidden"); exit("アクセス権限がありません。");
}
$prevData = file_exists($dataFile) ? json_decode(file_get_contents($dataFile), true) : [];
$currentData = [];
$userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
ini_set('user_agent', $userAgent);
ini_set('default_socket_timeout', 10);
$ctx = stream_context_create(['http' => ['method' => 'GET', 'header' => "User-Agent: $userAgent\r\n", 'timeout' => 10]]);
$now = time();
foreach ($sites as $key => $site) {
$timestamp = 0; $methodMark = "0"; $content = false;
$siteId = md5($site['check']);
// R. フィード解析
if (preg_match('/(feed|xml|rss)/i', $site['check'])) {
$content = @file_get_contents($site['check'], false, $ctx);
if ($content !== false) {
$methodMark = "R";
$xml = @simplexml_load_string($content, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($xml) {
if (isset($xml->channel->item->pubDate)) { $timestamp = strtotime((string)$xml->channel->item->pubDate); }
elseif (isset($xml->channel->lastBuildDate)) { $timestamp = strtotime((string)$xml->channel->lastBuildDate); }
elseif (isset($xml->entry->updated)) { $timestamp = strtotime((string)$xml->entry->updated); }
}
} else { $methodMark = "T"; }
}
// H. HEAD解析
if ($timestamp === 0 && $methodMark !== "T") {
$headers = @get_headers($site['check'], 1);
if ($headers !== false) {
$statusLine = is_array($headers) ? ($headers[0] ?? '') : $headers;
if (strpos($statusLine, '200 OK') !== false) {
if (isset($headers['Last-Modified'])) {
$methodMark = "H";
$lastMod = is_array($headers['Last-Modified']) ? end($headers['Last-Modified']) : $headers['Last-Modified'];
$timestamp = strtotime($lastMod);
}
}
}
}
// G, L & K. コンテンツ取得・比較・解析
if ($timestamp === 0 && $methodMark !== "T") {
if ($content === false) { $content = @file_get_contents($site['check'], false, $ctx); }
if ($content !== false) {
$currentSize = strlen($content);
$currentData[$siteId] = $currentSize;
// K. キーワード監視(正規表現対応)
$isMatch = false;
if (isset($site['keyword']) && $site['keyword'] !== '') {
if (preg_match('/^\/.*\/[a-z]*$/i', $site['keyword'])) {
if (@preg_match($site['keyword'], $content)) { $isMatch = true; }
} else {
if (strpos($content, $site['keyword']) !== false) { $isMatch = true; }
}
}
if ($isMatch) {
$methodMark = "K";
$timestamp = $now;
}
elseif (isset($prevData[$siteId]) && $prevData[$siteId] !== $currentSize) {
$methodMark = "L";
$timestamp = $now;
}
else {
$pattern = '/(\d{4}[\/\.\-]\d{1,2}[\/\.\-]\d{1,2})|(\d{4}年\d{1,2}月\d{1,2}日)/u';
if (preg_match($pattern, $content, $matches)) {
$dateStr = mb_convert_kana($matches[0], "as", "UTF-8");
$dateStr = str_replace(['年', '月', '日'], ['/', '/', ''], $dateStr);
$timestamp = strtotime($dateStr);
if ($timestamp > 0) { $methodMark = "G"; }
}
}
} else { $methodMark = "T"; }
} else {
if ($content === false) { $content = @file_get_contents($site['check'], false, $ctx); }
if ($content !== false) { $currentData[$siteId] = strlen($content); }
}
$sites[$key]['timestamp'] = $timestamp;
$sites[$key]['date_text'] = ($timestamp > 0) ? date("Y/m/d H:i:s", $timestamp) : "----/--/-- --:--:--";
$sites[$key]['method'] = $methodMark;
$sites[$key]['is_new'] = ($now - $timestamp) < $threshold && $timestamp > 0;
usleep(200000);
}
// データの記録
file_put_contents($dataFile, json_encode($currentData));
// 結果共有用JSONの出力
$resultJson = [
'info' => [
'script' => $scriptTitle,
'last_update' => date("Y/m/d H:i:s", $now),
'execution_time' => round(microtime(true) - $startTime, 2),
],
'sites' => $sites
];
file_put_contents($resultFile, json_encode($resultJson, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
// HTML出力用にソート
usort($sites, function($a, $b) { return $b['timestamp'] - $a['timestamp']; });
$executionTime = round(microtime(true) - $startTime, 2);
// ============================================================
// 3. HTML生成
// ============================================================
ob_start();
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($scriptTitle); ?></title>
<style>
body { font-family: "Helvetica Neue", Arial, sans-serif; margin: 15px; background-color: <?php echo $c_bg; ?>; color: <?php echo $c_list; ?>; line-height: 1.4; }
h1 { font-size: 1.2rem; margin-bottom: 5px; color: <?php echo $c_title; ?>; }
/* 最終確認日時・処理実行時間に $c_info を適用 */
.info-area { font-size: 0.7rem; color: <?php echo $c_info; ?>; margin-bottom: 10px; line-height: 1.6; }
/* 巡回メッセージだけに $c_msg を適用 */
.message-text { margin: 2px 0 0 0; color: <?php echo $c_msg; ?>; font-weight: bold; }
/* 注釈(※NEW)と凡例に $c_other を適用 */
.legend { font-size: 0.75rem; color: <?php echo $c_other; ?>; margin-bottom: 8px; }
.status-legend { font-size: 0.65rem; color: <?php echo $c_other; ?>; margin-top: 10px; line-height: 1.3; }
.update-list-container { border: 1px solid <?php echo $c_border; ?>; padding: 12px 15px; background-color: <?php echo $c_list_bg; ?>; border-radius: 5px; display: inline-block; width: auto; max-width: 100%; box-sizing: border-box; }
ul { list-style-type: none; margin: 0; padding: 0; }
li { margin-bottom: 4px; word-wrap: break-word; overflow-wrap: break-word; line-height: 1.2; }
/* 日付とカッコに $c_list を適用 */
.date { color: <?php echo $c_list; ?>; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; margin-right: 3px; }
.bracket { font-size: 0.85rem; font-family: monospace; color: <?php echo $c_list; ?>; }
a { text-decoration: <?php echo $u_line; ?>; font-weight: bold; font-size: 0.85rem; }
a:link { color: <?php echo $c_link; ?>; }
a:visited { color: <?php echo $c_visited; ?>; }
a:hover { color: <?php echo $c_hover; ?>; text-decoration: <?php echo $u_hover; ?>; }
.method-link { font-family: monospace; }
.page-link { margin-left: 3px; }
.new-badge { background: <?php echo $c_new_bg; ?>; color: <?php echo $c_new_txt; ?>; font-size: 0.6rem; padding: 2px 5px; border-radius: 3px; font-weight: bold; margin-left: 5px; line-height: 1; }
/* フッターエリアに $c_footer を適用 */
.footer-area { font-size: 0.65rem; color: <?php echo $c_footer; ?>; margin-top: 15px; line-height: 1.6; }
.footer-area a { font-size: 0.65rem; color: <?php echo $c_footer; ?>; font-weight: normal; text-decoration: underline; }
.footer-area a:hover { color: <?php echo $c_hover; ?>; }
</style>
</head>
<body>
<h1><?php echo htmlspecialchars($scriptTitle); ?></h1>
<div class="info-area">
<p style="margin:0;">最終確認日時:<?php echo date("Y/m/d H:i:s"); ?></p>
<p style="margin:0;">処理実行時間:<?php echo $executionTime; ?> 秒</p>
<p class="message-text"><?php echo htmlspecialchars($message); ?></p>
</div>
<p class="legend">※ <span class="new-badge" style="margin:0;">NEW</span>:<?php echo round($threshold/3600); ?>時間以内更新</p>
<div class="update-list-container">
<ul>
<?php foreach ($sites as $site): ?>
<li>
<span class="date"><?php echo $site['date_text']; ?></span>
<span class="bracket">(</span><a href="<?php echo htmlspecialchars($site['check']); ?>" class="method-link" target="_blank"><?php echo $site['method']; ?></a><span class="bracket">)</span>
<a href="<?php echo htmlspecialchars($site['link']); ?>" class="page-link" target="_blank"><?php echo htmlspecialchars($site['title']); ?></a>
<?php if ($site['is_new']): ?><span class="new-badge">NEW</span><?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="status-legend">R:フィード、H:HEAD、G:解析、K:キーワード、L:サイズ、T:タイムアウト、0:失敗</div>
<div class="footer-area">
Generated by Teku2-Log v0.1.0<br>
<a href="<?php echo htmlspecialchars($resultFileName); ?>" target="_blank">View JSON Data</a>
</div>
</body>
</html>
<?php
$htmlContent = ob_get_clean();
file_put_contents($saveFile, $htmlContent);
echo "HTMLファイルを生成しました。<br>";
echo "共有用JSON (" . $resultFileName . ") を生成しました。<br>";
echo "確認用URL: <a href='" . htmlspecialchars($saveFileUrl) . "' target='_blank'>" . htmlspecialchars($saveFileUrl) . "</a>";
PHP