あとで読むURLのリンク切れを開く前に発見|GASで応答状態をdry-run監査

あとで読むURLのリンク切れを開く前に発見|GASで応答状態をdry-run監査

ツールおじ|GoogleアカウントをAIエージェント化

ツールおじ|GoogleアカウントをAIエージェント化

「あとで読む」「あとで見る」に保存したURLを週末に開いたら、リンク切れや入力ミスばかり。1件ずつブラウザで確認する前に、GASで応答状態だけをまとめて点検できます。

この記事では、元のウォッチリストを変更せず、外部ページの本文やURLをログへ残さない無料のdry-run監査を作ります。初期設定では結果シートも作らず、実行ログに行番号・判定・HTTPステータスだけを出します。

先に知っておきたいこと

  • 対象は、認証を必要としない公開URLです。
  • 初期値はDRY_RUNをtrueに固定し、元シートの編集・ファイル作成・メール送信・削除を行いません。
  • URLそのもの、ページ本文、Cookie、認証情報はログへ出しません。
  • 1回の確認は最大20件です。少量のコピー環境から試してください。
  • 外部サイトへRange付きGETリクエストを送るため、完全なオフライン検査ではありません。

準備するシート

スプレッドシートにWatchlistという名前のシートを作り、1行目へ次の見出しを入れます。

  • A列: ID。自分で付ける重複しない管理番号
  • B列: 種類。記事、動画、資料など
  • C列: URL。httpまたはhttpsで始まる公開URL
  • D列: 状態。未確認、確認済みなど。今回のコードは変更しません

2行目以降へテスト用URLを2〜3件だけ入れてください。APIキー、署名付きURL、社内限定URL、個人情報を含むURLは入力しません。

コピーして使える最小コード

const LINK_AUDIT_CONFIG = Object.freeze({
  sheetName: 'Watchlist',
  maxRows: 20,
  dryRun: true,
  allowedSchemes: ['http:', 'https:']
});

function auditWatchlistLinksFree() {
  const config = LINK_AUDIT_CONFIG;
  validateLinkAuditConfig_(config);

  if (config.dryRun !== true) {
    throw new Error('安全のためdryRun=trueで実行してください。');
  }

  const sheet = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(config.sheetName);
  if (!sheet) {
    throw new Error('Watchlistシートが見つかりません。');
  }

  const lastRow = sheet.getLastRow();
  if (lastRow < 2) {
    console.log('対象0件です。');
    return;
  }

  const rowCount = Math.min(lastRow - 1, config.maxRows);
  const rows = sheet.getRange(2, 1, rowCount, 4).getDisplayValues();
  const seenIds = new Set();
  const results = [];

  rows.forEach((row, index) => {
    const sheetRow = index + 2;
    const id = String(row[0] || '').trim();
    const rawUrl = String(row[2] || '').trim();

    const inputError = validateWatchlistRow_(id, rawUrl, seenIds);
    if (inputError) {
      results.push({ sheetRow, result: 'INPUT_HOLD', status: null });
      console.log(`行${sheetRow}: INPUT_HOLD / ${inputError}`);
      return;
    }
    seenIds.add(id);

    try {
      const response = UrlFetchApp.fetch(rawUrl, {
        method: 'get',
        headers: { Range: 'bytes=0-0' },
        followRedirects: false,
        muteHttpExceptions: true,
        validateHttpsCertificates: true
      });
      const status = response.getResponseCode();
      const result = classifyHttpStatus_(status);
      results.push({ sheetRow, result, status });
      console.log(`行${sheetRow}: ${result} / HTTP ${status}`);
    } catch (error) {
      results.push({ sheetRow, result: 'REQUEST_ERROR', status: null });
      console.log(`行${sheetRow}: REQUEST_ERROR / 詳細は非表示`);
    }
  });

  const summary = results.reduce((count, item) => {
    count[item.result] = (count[item.result] || 0) + 1;
    return count;
  }, {});
  console.log(`監査完了: ${JSON.stringify(summary)}`);
  console.log('URL・ページ本文・認証情報はログへ出していません。');
}

function validateLinkAuditConfig_(config) {
  if (config.dryRun !== true) {
    throw new Error('dryRunはtrue固定です。');
  }
  if (!Number.isInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 20) {
    throw new Error('maxRowsは1〜20の整数にしてください。');
  }
}

function validateWatchlistRow_(id, rawUrl, seenIds) {
  if (!id) return 'IDが空欄です';
  if (seenIds.has(id)) return 'IDが重複しています';
  if (!rawUrl) return 'URLが空欄です';

  let parsed;
  try {
    parsed = new URL(rawUrl);
  } catch (error) {
    return 'URL形式が不正です';
  }

  if (!LINK_AUDIT_CONFIG.allowedSchemes.includes(parsed.protocol)) {
    return 'http/https以外は対象外です';
  }
  if (parsed.username || parsed.password) {
    return '認証情報を含むURLは対象外です';
  }
  if (isPrivateHost_(parsed.hostname)) {
    return 'ローカル・プライベート宛先は対象外です';
  }
  return '';
}

function isPrivateHost_(hostname) {
  const host = String(hostname || '').toLowerCase();
  if (host === 'localhost' || host.endsWith('.local')) return true;
  if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) return true;
  if (/^169\.254\./.test(host)) return true;
  const match172 = host.match(/^172\.(\d{1,3})\./);
  return Boolean(match172 && Number(match172[1]) >= 16 && Number(match172[1]) <= 31);
}

function classifyHttpStatus_(status) {
  if (status >= 200 && status < 300) return 'OK';
  if (status >= 300 && status < 400) return 'REDIRECT';
  if (status === 401 || status === 403) return 'AUTH_OR_BLOCKED';
  if (status === 404 || status === 410) return 'MISSING';
  if (status === 405) return 'METHOD_BLOCKED';
  if (status === 429) return 'RATE_LIMITED';
  if (status >= 500) return 'SERVER_ERROR';
  return 'CHECK_REQUIRED';
}

実行手順

  1. 元データではなく、コピーしたスプレッドシートを開きます。
  2. 拡張機能からApps Scriptを開き、コードを貼り付けて保存します。
  3. LINK_AUDIT_CONFIGのdryRunがtrue、maxRowsが20以下であることを確認します。
  4. auditWatchlistLinksFreeを選び、初回だけ必要な権限を確認して実行します。
  5. 実行ログで、行番号ごとのOK、MISSING、METHOD_BLOCKEDなどを確認します。
  6. MISSINGやREQUEST_ERRORの行だけ、URLをブラウザで手動確認します。

判定結果の読み方

  • OK: 200番台の応答です。ページ内容が正しいことまでは保証しません。
  • REDIRECT: 300番台です。移転先が意図したページか手動で確認します。
  • AUTH_OR_BLOCKED: 認証が必要、または自動アクセスが拒否された可能性があります。
  • MISSING: 404または410です。リンク切れ候補として手動確認します。
  • METHOD_BLOCKED: 対象サイトが自動リクエストを許可していない可能性があります。リンク切れとは限りません。
  • RATE_LIMITED: 短時間のアクセス制限です。件数を減らし、時間を置きます。
  • REQUEST_ERROR: 通信、証明書、名前解決などの可能性があります。詳細情報はログへ残しません。

失敗しやすい点と制限

  • Range指定を無視するサイトでは、先頭1 byteの指定でも本文全体を受信することがあります。対象を最大20件に制限し、信頼できる公開URLだけを使ってください。
  • GASのUrlFetchAppでは、この例から個別の通信タイムアウト秒数を指定しません。maxRowsを小さく保ってください。
  • HTTP 200でも、記事削除のお知らせやログイン画面へ置き換わっている場合があります。
  • ドメイン名が実行時にどのIPへ解決されるかまでは、この簡易検査では保証できません。信頼できないURLや社内URLを投入しないでください。
  • 実行回数や外部接続にはGoogle Apps Script側の上限があります。大量URLは分割してください。
  • 認証が必要なAPIを扱う場合も、APIキーをシート、URL、コード、ログへ直書きしません。この無料例は公開URL専用です。

公開前チェックリスト

  • dryRunはtrueのままか
  • 元のWatchlistシートを更新する処理が入っていないか
  • URL、本文、Cookie、認証情報をログへ出していないか
  • maxRowsは20以下か
  • ローカル・プライベート宛先を入力していないか
  • エラー判定した行を人が最終確認する運用か

まとめ

リンク切れ確認は、いきなり一覧を書き換えるより「少量を読み取り、応答状態だけを確認し、怪しい行を人が見る」順番が安全です。この無料コードは、元データを変更せず、秘密情報を残さない最小の入口に絞っています。

積読・動画・学習候補を継続的に整理したい方へ

GasおじのTips商品一覧を見る

※学習・検証用の例です。実データへ適用する前にコピー環境で確認してください。Google公式・Google公認の商品ではありません。


あなたも記事の投稿・販売を
始めてみませんか?

Tipsなら簡単に記事を販売できます!
登録無料で始められます!

Tipsなら、無料ですぐに記事の販売をはじめることができます Tipsの詳細はこちら
 

この記事の販売者

ツールおじ|GoogleアカウントをAIエージェント化

おう、Gasおじだ。 AIツールとSaaSに散々突っ込んで遠回りした結果、最後に強かったのは「今あるGoogleアカウント」だった。 今ではAIエージェントであらゆるタスクを自動化して無双してるぞ。 GAS、スプレッドシート、フォーム、Gmail、AIエージェントを組み合わせれば、個人でも小さな会社でも、手作業だらけの業務はかなり自動化できる。 ここでは机上のAI論じゃなく、現場で動くテンプレ、コード、手順だけを置いていくぞ。 高いツールを増やす前に、まずお前さんのGoogleアカウントを仕事するエージェントに変えようじゃないか。

この販売者が書いた他の記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • Gmailの自動ラベルが競合する前に確認|GASで仕分けルールをdry-run監査

  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)

関連のおすすめ記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化

  • USBメモリで持ち運べるLinux Mint 22.1と永続的な書き込み領域の作成法【Rufus だけで作成する方法を初心者向けに徹底解説】

    ¥500
    1 %獲得
    (5 円相当)
    SmartSenior

    SmartSenior

  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化