メインコンテンツへスキップ
見出し画像

RSSを収集するためのシンプルなGAS

    Geminiに書いてもらいました

    /**
     * アプリケーション全体で共有する設定値
     * ※シート名や列番号を変更する場合はここだけを修正します
     */
    const CONFIG = {
      SHEET_FEEDS: "feeds",
      SHEET_ARTICLES: "articles",
      URL_COLUMN_INDEX: 5 // articlesシートのURLが保存されている列(E列 = 5)
    };
    
    /**
     * feedsシートから有効なRSSフィードの一覧を取得する
     * @returns {Array<Object>} フィード情報の配列
     */
    function getFeeds() {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.SHEET_FEEDS);
      // データが存在しない場合は空配列を返す
      if (sheet.getLastRow() <= 1) return [];
    
      const values = sheet.getDataRange().getValues();
      const feeds = [];
    
      // 1行目はヘッダーなので index 1 からループ開始
      for (let i = 1; i < values.length; i++) {
        const [source, source_category, url, enabled] = values[i]; // 分割代入で可読性向上
    
        // 有効フラグが true のものだけを対象とする
        if (enabled !== true) {
          continue;
        }
    
        feeds.push({ source, source_category, url });
      }
    
      return feeds;
    }
    
    /**
     * articlesシートから既に保存されているURLの一覧を取得する
     * @returns {Set<string>} 既存URLのSet(重複判定の高速化のためSetを使用)
     */
    function getExistingUrls() {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.SHEET_ARTICLES);
      const lastRow = sheet.getLastRow();
    
      // 記事データが1件もない(ヘッダーのみ、または空)場合
      if (lastRow <= 1) {
        return new Set();
      }
    
      // URL列のデータのみを抽出
      const urls = sheet.getRange(2, CONFIG.URL_COLUMN_INDEX, lastRow - 1, 1).getValues();
    
      // 2次元配列を1次元のSetに変換して返す
      return new Set(urls.map(row => row[0]));
    }
    
    /**
     * RSS収集のメイン処理
     * 定期実行(タイムドリブン)トリガーにはこの関数を設定します
     */
    function collectRSS() {
      const feeds = getFeeds();
      const existingUrls = getExistingUrls();
      const newArticlesData = []; // 一括保存用の配列
    
      Logger.log(`対象RSS数: ${feeds.length}`);
    
      feeds.forEach(feed => {
        try {
          Logger.log(`取得開始: ${feed.source} / ${feed.source_category}`);
    
          // RSSデータの取得とパース
          // muteHttpExceptions: true をつけると404エラーなどでもスクリプトが停止しない
          const response = UrlFetchApp.fetch(feed.url, { muteHttpExceptions: true });
          if (response.getResponseCode() !== 200) {
            Logger.log(`取得失敗: HTTPステータス ${response.getResponseCode()} (${feed.url})`);
            return; // 次のフィードへ
          }
    
          const xml = response.getContentText();
          const document = XmlService.parse(xml);
          const root = document.getRootElement();
          const rootName = root.getName(); 
    
          let articles = [];
    
          // ==========================================
          // 1. Qiitaなどの Atom 形式(ルートが <feed>)
          // ==========================================
          if (rootName === "feed") {
            const atomNs = XmlService.getNamespace('http://www.w3.org/2005/Atom');
            const entries = root.getChildren("entry", atomNs);
    
            articles = entries.map(entry => {
              const linkElement = entry.getChild("link", atomNs);
              const url = linkElement ? linkElement.getAttribute("href").getValue() : "";
              const title = entry.getChildText("title", atomNs) || "";
              const published_at = entry.getChildText("published", atomNs) || entry.getChildText("updated", atomNs) || "";
    
              return { source: feed.source, source_category: feed.source_category, title, url, published_at };
            });
          }
          // ==========================================
          // 2. Zennなどの RSS 2.0 形式(ルートが <rss>)
          // ==========================================
          else if (rootName === "rss") {
            const channel = root.getChild("channel");
            const items = channel.getChildren("item");
    
            articles = items.map(item => {
              const title = item.getChildText("title") || "";
              const url = item.getChildText("link") || "";
              const published_at = item.getChildText("pubDate") || "";
    
              return { source: feed.source, source_category: feed.source_category, title, url, published_at };
            });
          }
          // ==========================================
          // 3. 未対応の形式
          // ==========================================
          else {
            Logger.log(`未対応のフィード形式です: ${rootName}`);
            return;
          }
    
          Logger.log(`記事数: ${articles.length}`);
    
          // ==========================================
          // 4. 重複チェックと保存データの生成
          // ==========================================
          articles.forEach(article => {
            // 重複している場合はスキップ
            if (existingUrls.has(article.url)) return;
    
            // 日付のフォーマット整形
            const publishedAt = article.published_at ? new Date(article.published_at).toISOString() : "";
            const createdAt = new Date().toISOString();
    
            // スプレッドシートの列順に合わせて配列化(一括保存用)
            newArticlesData.push([
              createdAt,
              article.source,
              article.source_category,
              article.title,
              article.url,
              publishedAt
            ]);
    
            // 同一実行内での重複を防ぐためSetに追加
            existingUrls.add(article.url);
            Logger.log(`新規記事: ${article.title}`);
          });
    
        } catch (error) {
          Logger.log(`ERROR: ${feed.url} - ${error.toString()}`);
        }
      });
    
      // ==========================================
      // 5. スプレッドシートへの一括保存(パフォーマンス改善)
      // ==========================================
      if (newArticlesData.length > 0) {
        const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.SHEET_ARTICLES);
        const lastRow = sheet.getLastRow();
        
        // データがある次の行から、データ件数分の範囲を指定して一括書き込み
        sheet.getRange(lastRow + 1, 1, newArticlesData.length, newArticlesData[0].length).setValues(newArticlesData);
        Logger.log(`合計 ${newArticlesData.length} 件の記事を保存しました。`);
      } else {
        Logger.log("新しい記事はありませんでした。");
      }
    }
    
    /**
     * Webアプリケーションとして公開した場合のAPIエンドポイント
     * @param {Object} e - リクエストパラメータ
     * @returns {TextOutput} JSON形式の記事データ
     */
    function doGet(e) {
      const limit = Number(e.parameter.limit || 100);
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.SHEET_ARTICLES);
      const lastRow = sheet.getLastRow();
    
      // データが存在しない場合
      if (lastRow <= 1) {
        return ContentService.createTextOutput(JSON.stringify([]))
          .setMimeType(ContentService.MimeType.JSON);
      }
    
      // 1行目のヘッダー情報を取得
      const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
      // パフォーマンス改善:必要な件数分だけをシートの後ろから取得する
      // データ行数は (lastRow - 1)。要求limit件と実データ件数のうち、小さい方を採用。
      const numRowsToFetch = Math.min(limit, lastRow - 1);
      const startRow = lastRow - numRowsToFetch + 1; // 取得開始行
    
      const values = sheet.getRange(startRow, 1, numRowsToFetch, sheet.getLastColumn()).getValues();
      const data = [];
    
      // 取得した2次元配列をJSONオブジェクトの配列に変換
      for (let i = 0; i < values.length; i++) {
        const row = {};
        headers.forEach((header, index) => {
          row[header] = values[i][index];
        });
        data.push(row);
      }
    
      return ContentService.createTextOutput(JSON.stringify(data))
        .setMimeType(ContentService.MimeType.JSON);
    }

    どうやって使うの?

    スプレッドシートに2つタブを作り以下の名前にします
    feeds
    articles

    feedsタブの1行目を以下の4項目にします
    source
    source_category
    url
    enabled

    あとは以下のように取得したいRSSなどを記入します

    画像

    articlesタブの1行目を以下の6項目にします
    collected_at
    source
    source_category
    title
    url
    published_at

    ここは取得した情報が自動で記入されるシートになるので
    あとは空欄にしておきます

    画像

    あとは
    拡張機能➡Apps Script
    コード.gsにコードを貼り付け
    保存
    プルダウンメニューからcollectRSSを選択
    実行
    これでarticlesタブに取得した記事が記入されます

    画像

    おまけとして最後に
    デプロイするとJSON形式の記事データに変換するコードが入っています
    不要な場合はそのセクションは削除してください

    宣伝

    みなさんはnoteの有料記事についてどう思いますか?
    私はあれは実績があり信頼を得た一部の書き手のみに与えられた一つの到達点だと思っています
    私のように実績も信頼もない者が設定するようなものではないとも思っています
    でもせっかくある程度の時間をかけてアプリ開発をしたし、なんらかの収益を得たいとは思っています
    つまり何が言いたいかと言うと私のやっていることや私の記事に何らかの価値を見出した奇特な方がおられましたら以下の私がbooklistlistという名前でamazon上に設定しているページに何か欲しい物が載っていた場合は以下のリンク経由で買っていただけると嬉しいなということです
    売上の一部が私の収益になります
    私は有料記事を設定するよりもこの形態による収益化を好みます

    あなたへのおすすめ