積読リストの見直し期限切れを放置する前に|GASで登録日と次回確認日をdry-run監査

積読リストの見直し期限切れを放置する前に|GASで登録日と次回確認日をdry-run監査

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

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

「あとで読む」に追加した本や動画が増え、何から見直せばよいか分からなくなっていませんか。登録日は残っていても、次回確認日が空欄だったり、確認期限を過ぎた項目が埋もれたりすると、リストは少しずつ使いにくくなります。

この記事では、スプレッドシートの積読・動画リストを変更せずに、見直し期限切れ・日付不備・長期放置の候補だけをGASで確認します。初期設定はdry-runで、元データのセル、ステータス、URLを更新しません。

先に知っておきたいこと

  • できること:登録日と次回確認日を検査し、要確認の行番号と理由をログへ出します。
  • できること:次回確認日が空欄でも、登録日から一定日数を超えた行を長期放置候補として見つけます。
  • できないこと:記事や動画を自動で開く、視聴済みにする、行を削除する、外部へ送信する処理は含みません。
  • 安全設定:`dryRun: true` と `writeReport: false` が初期値です。元の一覧は常に読み取り専用です。
  • ログへ出すのは行番号、区分、経過日数、判定理由だけです。タイトル、URL、メモ本文は出しません。

準備するシート

シート名を `Watchlist` にし、1行目へ次の見出しを入れます。

  • A列:タイトル
  • B列:区分(本、動画、記事など)
  • C列:登録日
  • D列:次回確認日
  • E列:状態(未着手、進行中、完了、保留など)

2行目以降へ確認したい項目を入れてください。URLや個人的なメモが別の列にあっても、このコードは読みません。

判定日は実行日の午前0時です。状態が `完了` または `視聴済み` の行は対象外にします。次回確認日が過ぎた行に加え、次回確認日が空欄で登録から30日を超えた行も確認候補になります。

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

function auditWatchlistReviewDatesFree() {
  const config = {
    sourceSheetName: 'Watchlist',
    reportSheetName: 'ReviewAudit',
    staleAfterDays: 30,
    maxRows: 1000,
    dryRun: true,
    writeReport: false,
    completedStatuses: ['完了', '視聴済み']
  };

  validateWatchlistAuditConfig_(config);

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const source = spreadsheet.getSheetByName(config.sourceSheetName);
  if (!source) {
    throw new Error('対象シートが見つかりません: ' + config.sourceSheetName);
  }

  const lastRow = source.getLastRow();
  if (lastRow < 2) {
    console.log('監査対象は0件です。見出しの下へデータを入力してください。');
    return;
  }

  const targetRows = lastRow - 1;
  if (targetRows > config.maxRows) {
    throw new Error(
      '対象行が上限を超えています。少量で確認してから maxRows を調整してください。' +
      ' rows=' + targetRows
    );
  }

  const headers = source.getRange(1, 1, 1, 5).getDisplayValues()[0];
  const expectedHeaders = ['タイトル', '区分', '登録日', '次回確認日', '状態'];
  expectedHeaders.forEach(function(expected, index) {
    if (normalizeWatchlistText_(headers[index]) !== expected) {
      throw new Error(
        '見出しが一致しません。' + (index + 1) + '列目は「' + expected + '」にしてください。'
      );
    }
  });

  const values = source.getRange(2, 1, targetRows, 5).getValues();
  const today = startOfLocalDay_(new Date());
  const findings = [];
  const summary = {
    checked: 0,
    skippedCompleted: 0,
    invalid: 0,
    overdue: 0,
    staleWithoutReviewDate: 0,
    ok: 0
  };

  values.forEach(function(row, offset) {
    const rowNumber = offset + 2;
    const title = normalizeWatchlistText_(row[0]);
    const itemType = normalizeWatchlistText_(row[1]) || '未設定';
    const registeredAt = parseWatchlistDate_(row[2]);
    const reviewAt = parseWatchlistDate_(row[3]);
    const status = normalizeWatchlistText_(row[4]);

    if (!title && !row[2] && !row[3] && !status) return;
    summary.checked += 1;

    if (config.completedStatuses.includes(status)) {
      summary.skippedCompleted += 1;
      return;
    }

    const reasons = [];
    let elapsedDays = '';

    if (!title) reasons.push('タイトル未入力');
    if (row[2] && !registeredAt) reasons.push('登録日の形式不正');
    if (row[3] && !reviewAt) reasons.push('次回確認日の形式不正');

    if (registeredAt) {
      elapsedDays = diffDays_(registeredAt, today);
      if (elapsedDays < 0) reasons.push('登録日が未来');
    }

    if (reviewAt) {
      const overdueDays = diffDays_(reviewAt, today);
      if (overdueDays > 0) {
        reasons.push('次回確認日を' + overdueDays + '日超過');
        summary.overdue += 1;
      }
    } else if (registeredAt && elapsedDays > config.staleAfterDays) {
      reasons.push('次回確認日なしで登録から' + elapsedDays + '日経過');
      summary.staleWithoutReviewDate += 1;
    }

    if (reasons.length === 0) {
      summary.ok += 1;
      return;
    }

    if (reasons.some(function(reason) { return reason.includes('未入力') || reason.includes('形式不正') || reason.includes('未来'); })) {
      summary.invalid += 1;
    }

    findings.push([
      rowNumber,
      itemType,
      elapsedDays,
      reasons.join(' / ')
    ]);
  });

  console.log(JSON.stringify({
    mode: config.dryRun ? 'DRY_RUN' : 'REPORT_ONLY',
    summary: summary,
    findingRows: findings.map(function(item) { return item[0]; })
  }));
  console.log('タイトル・URL・メモ本文はログへ出していません。');

  if (config.dryRun === true || config.writeReport !== true) {
    console.log('dry-runのためシートへ書き込みません。要確認件数=' + findings.length);
    return;
  }

  writeWatchlistAuditReport_(spreadsheet, config.reportSheetName, findings, today);
}

function validateWatchlistAuditConfig_(config) {
  if (!config || typeof config !== 'object') {
    throw new Error('configがありません。');
  }
  if (!config.sourceSheetName || !config.reportSheetName) {
    throw new Error('sourceSheetNameとreportSheetNameを指定してください。');
  }
  if (config.sourceSheetName === config.reportSheetName) {
    throw new Error('元シートとレポートシートは別名にしてください。');
  }
  if (!Number.isInteger(config.staleAfterDays) || config.staleAfterDays < 1 || config.staleAfterDays > 3650) {
    throw new Error('staleAfterDaysは1〜3650の整数にしてください。');
  }
  if (!Number.isInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 5000) {
    throw new Error('maxRowsは1〜5000の整数にしてください。');
  }
  if (!Array.isArray(config.completedStatuses) || config.completedStatuses.length === 0) {
    throw new Error('completedStatusesを1件以上指定してください。');
  }
}

function parseWatchlistDate_(value) {
  if (value instanceof Date && !isNaN(value.getTime())) {
    return startOfLocalDay_(value);
  }
  if (typeof value !== 'string' || !value.trim()) return null;
  const matched = value.trim().match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})$/);
  if (!matched) return null;
  const parsed = new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]));
  if (
    parsed.getFullYear() !== Number(matched[1]) ||
    parsed.getMonth() !== Number(matched[2]) - 1 ||
    parsed.getDate() !== Number(matched[3])
  ) return null;
  return startOfLocalDay_(parsed);
}

function startOfLocalDay_(date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

function diffDays_(fromDate, toDate) {
  const oneDay = 24 * 60 * 60 * 1000;
  return Math.floor((toDate.getTime() - fromDate.getTime()) / oneDay);
}

function normalizeWatchlistText_(value) {
  return String(value == null ? '' : value).trim();
}

function writeWatchlistAuditReport_(spreadsheet, reportSheetName, findings, checkedAt) {
  let report = spreadsheet.getSheetByName(reportSheetName);
  if (!report) report = spreadsheet.insertSheet(reportSheetName);
  report.clearContents();
  report.getRange(1, 1, 1, 5).setValues([[
    '確認日', '元の行番号', '区分', '登録からの日数', '判定理由'
  ]]);
  if (findings.length === 0) return;
  const rows = findings.map(function(item) {
    return [checkedAt, item[0], item[1], item[2], item[3]];
  });
  report.getRange(2, 1, rows.length, 5).setValues(rows);
}

実行手順

  1. 元のスプレッドシートをコピーし、検証用ファイルで試します。
  2. `拡張機能` から `Apps Script` を開き、コードを貼り付けます。
  3. シート名と1行目の見出しが説明どおりか確認します。
  4. `dryRun: true` と `writeReport: false` のまま `auditWatchlistReviewDatesFree` を実行します。
  5. 初回だけ、スプレッドシートを読むための権限を確認します。想定外の権限が表示されたら承認せず停止してください。
  6. 実行ログで確認件数と要確認の行番号を見ます。タイトルやURLがログへ出ていないことも確認します。
  7. 少量のサンプルで判定が合うことを確認してから、必要に応じて対象行上限や経過日数を調整します。

レポートシートへ出したい場合

dry-runで結果を確認した後だけ、`dryRun: false` と `writeReport: true` に変更すると、別シート `ReviewAudit` に行番号、区分、経過日数、理由を書き出します。

この処理でも `Watchlist` は変更しません。ただし、同名の `ReviewAudit` がすでにある場合は内容を消して作り直します。必要な既存レポートがある場合は、先にシート名を変更するか、コピーを保存してください。

失敗しやすい点と制限

  • 見出し違い:全角・半角を含め、1行目の5項目が一致しない場合は停止します。
  • 日付形式:スプレッドシートの日付値、または `YYYY-MM-DD`、`YYYY/MM/DD` を対象にします。曖昧な文字列は不正として扱います。
  • 時刻とタイムゾーン:日単位の簡易判定です。実行環境やシートのタイムゾーンが異なる場合は境界日をサンプルで確認してください。
  • 対象件数:初期値は1000行までです。大量データでは実行時間の上限に達する可能性があります。
  • 完了状態:`完了` と `視聴済み` だけを除外します。自分の運用に合わせて一覧を調整してください。
  • 自動判断ではありません。期限切れ候補が、読むべき優先順位や内容の価値を決めるわけではありません。
  • URLの到達確認は行いません。リンク切れ確認とは別の監査です。

公開前チェックリスト

  • 元ファイルではなくコピーで少量テストした
  • `dryRun: true` と `writeReport: false` を確認した
  • 実行ログにタイトル、URL、メモ本文が出ていない
  • 見出し、日付、完了状態の定義を自分のシートに合わせた
  • 要確認行を人が見直してから次の操作を判断する
  • 想定外の権限、行数、日付形式を見つけたら実行を止める

まとめ

積読・動画リストは、登録するだけでなく「いつ見直すか」を確認できると整理しやすくなります。まずは元データを変えないdry-runで、期限切れ、日付不備、長期放置の行だけを安全に絞り込んでください。

読書・動画ウォッチリスト整理GAS Proを見る

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

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


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

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

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

この記事の販売者

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

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

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

  • Gmailの予約送信ミスを送信前に発見|GASで宛先・日時をdry-run監査

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

    ¥2,980
    1 %獲得
    (29 円相当)
  • スプレッドシートの入力ミスを公開前に発見|GASでルール表をdry-run監査

関連のおすすめ記事

  • 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エージェント化