🦆

【GAS】収集した情報のタグ付けを自動化したい

に公開

なぜ作成したのか

  • 情報収集を自動化したら分類がおざなりになってタグ付けに追われる羽目になったため

やりたいこと

  • 毎日収集しているRSSFeedの情報のタイトルと登録済みタグをマッチングし、合致するものがあった場合は記事とタグを紐づける関係情報を追加する
  • タグは「AWS」「AWS ECS」みたいに詳細化したタグも存在するので長い文字列のタグからマッチングする必要がある
  • 一致しないやつはタグ付けしないでよい(あとでタグ登録含め、人力でやる)

既存の環境

シート「db」

column description
feed_id RSSFeed取得元情報源を示すID(別シートのプライマリKey)
title RSSFeedで取得した記事タイトル
timestamp RSSFeedから取得した公開日
link RSSFeedから取得した記事URL
content RSSFeedから取得した記事概要
create_date 取得処理を実行した日
exclude 表示除外フラグ
bookmark ブックマークフラグ

シート「Tags」

column description
id タグを一意に識別するID
tag タグ文字列
description タグの解説文
parent-tag 親子関係にある親タグのID(同一シート内IDを参照)

シート「tagRelations」

column description
id タグと記事の関連性を識別する一意のID
tag tagsのidを参照
db dbのlinkを参照

処理としてやりたいことの記述

処理対象とするスコープ
シート「db」のうち、シート「tagRelations」に関連レコードが存在しないレコード

処理概要
処理はGASで実装する
対象レコードのtitleに対して、「Tags」のTagに登録されている長い文字列から順にタグ文字列に一致する箇所を検索し、一致した場合は該当タグと記事との関連性があるものとみなし、その関連性を「tagRelations」にレコード追加する
一致しないものは既存タグに該当するものがないとして、レコードは追加しない
処理がタイムアウトにより中断されることを考慮し、繰り返し実行に耐えられるよう処理の一貫性を担保する

GAS

/**
 * ──────────────────────────────────────────────
 *  RSS 記事タイトルと Tags をマッチングし tagRelations に追加
 *  - idempotent:同じ (link, tag_id) を二重登録しない
 *  - 長いタグから順に検索
 *  - 処理件数を制限してもデータ整合性が崩れない
 * ──────────────────────────────────────────────
 */
const DB_SHEET      = 'db';
const TAGS_SHEET    = 'Tags';
const REL_SHEET     = 'tagRelations';
const BATCH_SIZE    = 400;                     // 1 回で処理する db 行数(タイムアウト対策)
const PROGRESS_KEY  = 'tagger_last_row';       // ScriptProperties に進捗を保存

function tagArticles() {
  const ss      = SpreadsheetApp.getActiveSpreadsheet();
  const dbSh    = ss.getSheetByName(DB_SHEET);
  const tagsSh  = ss.getSheetByName(TAGS_SHEET);
  let   relSh   = ss.getSheetByName(REL_SHEET);

  // ---------- tagRelations シートの自動生成 & ヘッダー ----------
  if (!relSh) {
    relSh = ss.insertSheet(REL_SHEET);
    relSh.appendRow(['id','tag','db']);        // ヘッダー
  }

  // ---------- 既存 tagRelations をセット化 ----------
  const relValues   = relSh.getDataRange().getValues();
  const relHeader   = relValues.shift();
  const relKeySet   = new Set(relValues.map(r => `${r[1]}|${r[2]}`));   // "tagId|link"

  // ---------- Tags シートを読み込んで tag 長さで降順ソート ----------
  const tagValues   = tagsSh.getDataRange().getValues();
  tagValues.shift();                                                     // ヘッダー除外
  const tags = tagValues
      .filter(r => r[1])                                                 // tag 文字列が空でない
      .map(r => ({id: String(r[0]), tag: String(r[1])}))
      .sort((a,b) => b.tag.length - a.tag.length);                       // 長い -> 短い

  // ---------- db シートで未処理行をバッチ取得 ----------
  const lastRowProp = PropertiesService.getScriptProperties();
  let startRow      = Number(lastRowProp.getProperty(PROGRESS_KEY) || 2); // 1 行目はヘッダー
  const lastRow     = dbSh.getLastRow();
  if (startRow > lastRow) startRow = 2;                                   // 巡回完了 → 先頭へ

  const endRow      = Math.min(startRow + BATCH_SIZE - 1, lastRow);
  const dbValues    = dbSh.getRange(startRow, 1, endRow - startRow + 1, dbSh.getLastColumn())
                          .getValues();

  const linkIdx     = 3;   // db シートにおける link 列 (0-based)
  const titleIdx    = 1;   // title 列

  const newRows = [];
  for (const row of dbValues) {
    const link  = String(row[linkIdx]);
    if (!link)       continue;                                      // 空行防止
    const title = String(row[titleIdx]);

    // 既に 1 レコードでも tagRelations があればスキップ
    if ([...relKeySet].some(key => key.endsWith(`|${link}`))) continue;

    // タグ検索(長い順)※同じ記事に複数タグ付与可
    const lowerTitle = title.toLowerCase();
    for (const {id: tagId, tag} of tags) {
      if (lowerTitle.includes(tag.toLowerCase())) {
        const relKey = `${tagId}|${link}`;
        if (!relKeySet.has(relKey)) {
          relKeySet.add(relKey);
          newRows.push([Utilities.getUuid(), tagId, link]);
        }
      }
    }
  }

  // ---------- 追加分を一括書き込み ----------
  if (newRows.length) {
    relSh.getRange(relSh.getLastRow()+1, 1, newRows.length, newRows[0].length)
         .setValues(newRows);
  }

  // ---------- 進捗保存(次回は続きから) ----------
  const nextStart = (endRow >= lastRow) ? 2 : endRow + 1;
  lastRowProp.setProperty(PROGRESS_KEY, String(nextStart));

  console.log(`Checked rows ${startRow}-${endRow}. Added ${newRows.length} relations.`);
}

所感

  • 処理は数秒で完了してるのでタイムアウトは今のところ大丈夫そう
  • 今のところ毎日の更新が100件強だからこの敷居で良いけれど、情報源、タグともに増加要員なので適宜チューニングが必要になるかも
  • タグもちゃんと用意してないと「Amazon Quicksight」が「Amazon Q」でタグ付けされたりするから地道にタグを充実させよう。。。

GitHubで編集を提案

Discussion