監視が「異常なし」と言い続けた二週間
競合の料金ページを追う監視タスクを組んでいました。毎日三回巡回し、差分があればレポートを出す。数週間、ログには「✅ 変更なし」が並び、私はそれを平和の証だと受け取っていました。
ある日、別件でその競合のページを開いて、指が止まりました。上位プランの価格が変わっている。導入プランが一本増えている。監視は、二週間その変化に一度も気づいていませんでした。
胸が冷たくなりました。監視が動いていなかったわけではありません。毎回きちんと起動し、ページを取得し、「変更なし」と結論していた。つまり監視は、自分が何も見ていないことに気づかないまま、正常だと報告し続けていたのです。
その二週間の沈黙をどう突き止め、二度と起こさないためにどんな計測を足したのか。個人開発で複数のサイトを回している私自身の運用から、実際に使っているコードとともに書き残しておきます。競合監視・価格追跡・変更検知を Cowork で回している方に、同じ落とし穴を避けてほしいという気持ちで書いています。
「取得成功」と「抽出成功」は別物
原因を追ってわかったのは、監視パイプラインが二つのまったく別の成否を、一つの成功として扱っていたことでした。
ページの取得(HTTP 200 が返り、テキストが手に入る)は成功していました。しかし、そのテキストから価格を抜き出す抽出は、静かに失敗していました。競合がページを作り直し、価格を包んでいた要素のクラス名が変わっていたのです。抽出セレクタは何もマッチせず、空文字を返す。空文字と空文字を比較すれば、当然「変更なし」になります。
| 段階 | この事例での状態 | 監視の解釈 |
| ページ取得 | 成功(200・本文あり) | 正常 |
| 要素抽出 | 失敗(セレクタ不一致で空) | 正常と誤認 |
| 差分比較 | 空 vs 空 → 差分ゼロ | 「変更なし」 |
言い換えれば、監視は「取れなかった」を「変わらなかった」と読み替えていました。この二つは意味が正反対です。前者は監視の故障、後者は監視の正常な結果。ここを同じ棚に入れている限り、故障は永遠に平常運転の顔をして通り過ぎます。
最初にやるべきことは、抽出が成功したのかどうかを、比較とは独立した信号として持つことでした。
監視対象にアンカーを埋める — 抽出の生死を測る
抽出が生きているかを測るために、私は各監視対象へ「アンカー」を定義しました。アンカーとは、そのページが正しく取れていれば必ず存在するはずの目印です。価格ページなら「通貨記号を含む数値が最低3つある」「プラン名らしき見出しが2つ以上ある」といった、構造ではなく内容に根ざした期待です。
アンカーが満たされなければ、たとえ HTTP が 200 でも、その巡回は「抽出失敗」として扱います。差分比較には進みません。
// anchor-check.mjs — 抽出が生きているかを内容ベースで検証する
// 目的: セレクタ不一致による「空の成功」を故障として顕在化させる
const ANCHORS = {
'saas-pricing': [
{ name: 'price-tokens', test: (t) => (t.match(/[$¥€]\s?\d[\d,]*/g) || []).length >= 3 },
{ name: 'plan-headings', test: (t) => (t.match(/\b(Free|Pro|Team|Business|Enterprise)\b/g) || []).length >= 2 },
{ name: 'min-length', test: (t) => t.replace(/\s+/g, '').length >= 400 },
],
};
export function checkAnchors(siteId, text) {
const rules = ANCHORS[siteId] || [];
const results = rules.map((r) => ({ name: r.name, passed: r.test(text) }));
const failed = results.filter((r) => !r.passed).map((r) => r.name);
return {
// すべてのアンカーが通って初めて「抽出は生きている」と見なす
alive: failed.length === 0,
failed,
coverage: rules.length ? (rules.length - failed.length) / rules.length : 0,
};
}
ここで大切なのは、アンカーを構造(CSS セレクタ)ではなく内容(正規表現やテキスト長)で書いている点です。構造は競合の都合でいつでも変わります。しかし「価格ページには通貨付きの数値が並ぶ」という内容の性質は、そう簡単には変わりません。監視の生死判定を、監視対象がコントロールできない性質に預けておくと、故障が故障として立ち上がってきます。
巡回タスク側では、この判定を差分比較の手前に挟みます。
// crawl-guard.mjs — アンカーが通らない巡回は差分に進ませない
import { checkAnchors } from './anchor-check.mjs';
export function ingest(siteId, text, { onExtractionFailure, onAlive }) {
const anchor = checkAnchors(siteId, text);
if (!anchor.alive) {
// 「変更なし」ではなく「監視が見えていない」として扱う
onExtractionFailure({ siteId, failed: anchor.failed, coverage: anchor.coverage });
return { proceeded: false };
}
onAlive({ siteId, coverage: anchor.coverage });
return { proceeded: true };
}
この一枚を挟んだだけで、あの二週間のような沈黙は「抽出失敗が14回連続」というはっきりした異常として、ログの一番上に浮かんでくるようになりました。
「静かすぎる」を異常として扱う
アンカーを入れても、まだ油断はできませんでした。抽出は生きているのに、比較のロジック側で取りこぼす可能性が残るからです。そこで私は、逆側からも監視を見張ることにしました。すなわち、「あまりに長く変化がない」こと自体を、一つの弱いアラートとして扱うのです。
現実の競合サイトは、程度の差こそあれ動きます。文言の微修正、日付の更新、キャンペーンの出し入れ。何週間も一文字も変わらないページは、変わっていないのではなく、こちらが見えていない可能性の方が高い。監視の世界では、完全な静寂はしばしば健康ではなく麻痺の兆候です。
// quietness-watch.mjs — 変更ゼロの連続を「静かすぎる」異常として拾う
import { readFileSync, existsSync } from 'fs';
// サイトごとに「これ以上静かなら疑わしい」という日数を設定する
const QUIET_THRESHOLD_DAYS = {
'saas-pricing': 30, // 料金は動きが遅いので長め
'competitor-blog': 5, // ブログは頻繁に動くはずなので短め
};
export function evaluateQuietness(siteId, changeHistory) {
// changeHistory: [{ date, changed }] を日付昇順で受け取る
let streak = 0;
for (let i = changeHistory.length - 1; i >= 0; i--) {
if (changeHistory[i].changed) break;
streak++;
}
const threshold = QUIET_THRESHOLD_DAYS[siteId] ?? 14;
return {
quietStreak: streak,
suspicious: streak >= threshold,
// 「疑わしい」は故障の断定ではなく、人間に一度見に行かせるための合図
action: streak >= threshold ? 'manual-spotcheck' : 'none',
};
}
このアラートは「壊れている」と断定しません。「不自然に静かなので、一度人間の目で確かめてください」と促すだけです。実際、月に一度あるかないかの頻度でしか鳴りませんが、鳴ったときはたいてい、抽出の劣化かこちらの見落としのどちらかが背後にありました。誤検知に近い弱い信号でも、頻度が低ければ運用の負担にはならず、代わりに沈黙の危険を確実に下げてくれます。
セレクタドリフトを構造フィンガープリントで検出する
アンカーは「抽出が完全に死んだ」ときには強く効きますが、「一部だけずれた」ときには鈍いことがあります。三つの価格のうち二つは取れて一つだけ取りこぼす、といった中途半端な劣化です。これを捉えるために、ページの構造そのものの指紋(フィンガープリント)を毎回取り、その変化を追うようにしました。
構造フィンガープリントは、価格を包んでいる要素の「形」を要約した短い文字列です。クラス名の集合、要素の深さ、兄弟の数といった、抽出の足場になっている構造的特徴をまとめます。値そのものではなく足場が変わったときに反応します。
// structure-fingerprint.mjs — 抽出の足場が変わっていないかを追う
// Claude in Chrome の javascript_tool で対象ページ上で実行する想定
export function fingerprintScript() {
return `JSON.stringify((() => {
const nodes = Array.from(document.querySelectorAll('[class*="price"], [class*="plan"], [data-price]'));
return {
count: nodes.length,
classes: [...new Set(nodes.flatMap(n => Array.from(n.classList)))].sort(),
depths: nodes.map(n => { let d = 0, e = n; while (e.parentElement) { d++; e = e.parentElement; } return d; }),
hasDataPrice: nodes.some(n => n.hasAttribute('data-price')),
};
})())`;
}
// 前回と今回のフィンガープリントを比較し、足場のズレを数値化する
export function compareFingerprint(prev, curr) {
if (!prev) return { drift: 0, notes: ['baseline'] };
const notes = [];
if (prev.count !== curr.count) notes.push(`要素数 ${prev.count} → ${curr.count}`);
const prevClasses = new Set(prev.classes);
const currClasses = new Set(curr.classes);
const removed = [...prevClasses].filter((c) => !currClasses.has(c));
const added = [...currClasses].filter((c) => !prevClasses.has(c));
if (removed.length) notes.push(`消えたクラス: ${removed.join(', ')}`);
if (added.length) notes.push(`増えたクラス: ${added.join(', ')}`);
// ドリフト量: クラス集合の非対称差をユニオンで正規化(0=不変, 1=総入れ替え)
const union = new Set([...prevClasses, ...currClasses]).size || 1;
const drift = (removed.length + added.length) / union;
return { drift: Number(drift.toFixed(2)), notes };
}
運用では、このドリフト量にしきい値を設けました。0.3 を超えたら「セレクタの見直しが必要かもしれない」という中優先度の通知を出します。値の変更(価格が上がった)と足場の変更(クラス名が変わった)を別々の信号として持つと、「価格が変わったのか、それとも監視がずれたのか」を毎朝迷わずに済みます。前者はビジネス上の発見、後者は監視の保守。混ぜると、どちらも埋もれます。
カバレッジを数値化する — 監視の健全性ダッシュボード
ここまでの信号がそろうと、監視そのものの健康状態を一枚のダッシュボードにまとめられます。私が毎朝見ているのは、検出された競合の変化ではなく、まず「監視は今日ちゃんと見えているか」です。
// health-dashboard.mjs — 監視自身の健全性を集計する
import { readdirSync, readFileSync, writeFileSync } from 'fs';
export function buildHealth(siteIds, loadDaily) {
// loadDaily(siteId) → { alive, coverage, quietStreak, drift } を返す想定
const rows = siteIds.map((id) => {
const d = loadDaily(id);
const status = !d.alive ? '🔴 抽出失敗'
: d.drift > 0.3 ? '🟡 ドリフト'
: d.suspicious ? '🟡 静寂過多'
: '🟢 健全';
return { id, ...d, status };
});
const healthy = rows.filter((r) => r.status.startsWith('🟢')).length;
const ratio = (healthy / rows.length * 100).toFixed(0);
let md = `# 監視ヘルス — ${new Date().toISOString().slice(0, 10)}\n\n`;
md += `健全率: ${ratio}%(${healthy}/${rows.length})\n\n`;
md += `| サイト | 状態 | カバレッジ | 静寂日数 | ドリフト |\n|---|---|---|---|---|\n`;
for (const r of rows) {
md += `| ${r.id} | ${r.status} | ${(r.coverage * 100).toFixed(0)}% | ${r.quietStreak} | ${r.drift} |\n`;
}
return { markdown: md, healthyRatio: Number(ratio) };
}
この健全率という一つの数字を持ってから、監視に対する見方が変わりました。以前は「今日は競合に動きがあったか」だけを気にしていました。今は「今日、私の監視は何割のサイトをちゃんと見られていたか」を先に確かめます。健全率が下がった日は、競合レポートの中身がどれだけ穏やかでも、それを信用しません。穏やかなのではなく、目を閉じていただけかもしれないからです。
数字にしておくと、劣化が緩やかに進むときにも気づけます。ある月、健全率が 100% から 90%、80% とゆっくり下がっていく様子がグラフに出て、複数の競合が同時期に似たフレームワークへ移行していたことに後から気づいた、ということもありました。一枚の巡回では見えない変化が、健全率の推移には出ます。
運用に組み込む — スケジュールタスクへの落とし込み
最後に、これらをスケジュールタスクの中でどう回しているかを書いておきます。順序が肝心で、差分検出よりも健全性判定を先に置くのが要点です。
| 順 | ステップ | 失敗時の扱い |
| 1 | Claude in Chrome で巡回・本文とフィンガープリント取得 | 取得失敗を記録し次サイトへ |
| 2 | アンカー健全性チェック | 抽出失敗として記録・差分に進まない |
| 3 | フィンガープリント比較でドリフト算出 | 0.3 超で保守通知 |
| 4 | 差分検出(アンカーを通過したサイトのみ) | 差分をレポート化 |
| 5 | 静寂連続の評価 | しきい値超で目視依頼 |
| 6 | 健全率を集計しヘルスを更新 | 低下日は競合レポートに警告帯 |
スケジュールタスクのプロンプトには、この順序をそのまま日本語の手順として書き下しています。プレースホルダーの値は自分の監視対象へ置き換えてください。
## タスク: 競合監視(健全性ファースト版)
Step 1: web-monitor スキルの監視対象リストを順に巡回する。
各URLで get_page_text で本文を、javascript_tool で
構造フィンガープリントを取得し、当日分として保存する。
Step 2: 各サイトについて anchor-check を実行する。
アンカーが通らないサイトは「抽出失敗」として health ログに記録し、
そのサイトの差分検出はスキップする(「変更なし」と書かない)。
Step 3: フィンガープリントを前日と比較し、drift を算出する。
drift > 0.3 のサイトは「保守要」として通知に載せる。
Step 4: アンカーを通過したサイトのみ差分を取り、
変更があればレポートに追記する。
Step 5: 変更ゼロの連続日数を評価し、しきい値を超えたサイトは
「目視スポットチェック依頼」として記録する。
Step 6: 全サイトの健全率を集計し、health-YYYY-MM-DD.md を更新する。
健全率が前日より下がっていれば、競合レポート冒頭に警告帯を付ける。
ひとつ実装上の注意として、健全性ログと競合レポートは必ず別ファイルに分けています。同じファイルに混ぜると、「監視の故障」と「競合の動き」が視覚的に混ざり、結局どちらも見落とします。故障は保守の対象、動きはビジネスの対象。出口を分けておくと、朝の数分でどちらにも正しく反応できます。
まとめ
私がこの二週間の沈黙から学んだのは、監視で本当に怖いのは派手なエラーではなく、正常を装った無反応だということでした。エラーは気づけます。しかし「変更なし」という穏やかな一行の裏に取りこぼしが隠れているとき、それを暴くのは監視自身に組み込んだ計測だけです。
まず「取得成功」と「抽出成功」を分ける。次にアンカーで抽出の生死を測り、静寂の連続とセレクタドリフトで劣化を先回りする。最後に健全率という一つの数字で、監視が今日ちゃんと目を開けているかを確かめる。派手さはありませんが、この地味な層こそが、競合レポートを信用してよいかどうかの土台になります。
もし今、静かに動いている監視をお持ちなら、明日の巡回に一つだけアンカーチェックを足してみてください。それが通らなかった回数を数えるだけでも、見えていなかったものが見えてきます。実運用の参考になれば幸いです。お読みいただきありがとうございました。