RSSからデータ取得 ― しずかなニュースの仕組みの開発記録

小さなプロダクト

本記事は「しずかなニュースの仕組み ― WordPress自動更新の実験」シリーズの一環として、ニュース記事を自動収集するためにRSSを情報取得の基盤に据える方針と、その実装の考え方をまとめます。


GPTではなくRSSを使う理由

ニュースの自動収集と要約という観点では、まずGPT APIを直接使う方法が思い浮かびます。しかし、前回までの検討どおり、現状では検索や記事収集までをGPT API単体に任せるのは難しい状況です。そこで当面は、RSSを情報取得の基盤とします。RSSは古くからある仕組みですが、今も多くのニュースサイトが提供しており、一定のフォーマットで記事を取得できる利点があります。


RSSをそのまま公開しない

RSSに含まれるタイトルや要約をそのまま再配信することはしません。目指すのは「転載」ではなく、再構成(加工)です。

  • RSS → スプレッドシートに保存
  • GPTで要約やフィルタを実行
  • 独自の視点で整理し、出典リンクを明示して公開

この流れにより、著作権やマナーを守りつつ、“しずかな”体験に整えられると考えています。


スプレッドシート構成

土台は次の2枚構成です。

  • RSSリスト:収集対象フィードの台帳
  • 記事一覧:取得した記事の蓄積先

この2枚を基盤に、収集(入力)→加工(処理)→提示(出力)の流れを組み立てます。


RSSリストの作成

RSSリストは事前にGPTに候補を挙げさせ、全文を無料で読めるニュースサイトを選定しました。会員限定や途中で遮られる媒体は除外します。各行には次の情報を持たせます。

  • 有効/無効(収集ON/OFF)
  • 媒体名、RSS URL、カテゴリ
  • 優先度(抽出や並び替えに使用)
  • 最終取得時刻(運用確認用)

これにより、「読もうとしたのに途中で遮られる」ストレスを避け、しずかなニュースの仕組みに近づけます。


GASでRSSを取得する

記事一覧への書き込みはGoogle Apps Script(GAS)で行います。まずは「正確に貯める」ことを優先し、要約やフィルタは次の段階に回します。処理の流れは以下のとおりです。

  1. RSSリストの有効なフィードを走査
  2. 各RSS/Atomを取得・パース(タイトル、URL、公開日、概要など)
  3. 重複排除(媒体名+URL+公開日のハッシュ)
  4. 日付を正規化(ISOと日本表示)して記事一覧に保存

記事一覧の主な列:

  • 取得時刻、媒体名、タイトル、URL
  • 公開日(表示用)、公開日(ISO)
  • 概要(description/summary由来)
  • GUID/ID、重複判定用ハッシュ

コードサンプル(GAS)

下記コードはWordPressで崩れないよう、<pre><code>形式で掲載します。

/**
/******************************************************
 * しずかなニュースサイト:RSS取得スクリプト(GAS)
 * - 「RSSリスト」→ 収集対象フィード台帳
 * - 「記事一覧」→ 記事データの蓄積先
 * - RSS/Atom 両対応、重複排除、日付正規化
 * - 時間主導トリガーで fetchAllFeeds() を定期実行
 ******************************************************/

const SHEET_FEEDS    = 'RSS';
const SHEET_ARTICLES = '記事一覧';

/**
 * エントリーポイント:
 * RSSリストを走査して記事を取得し、記事一覧に追記(重複はスキップ)
 */
function fetchAllFeeds() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const feedSheet    = ss.getSheetByName(SHEET_FEEDS);
  const articleSheet = ss.getSheetByName(SHEET_ARTICLES);
  if (!feedSheet)    throw new Error(`シートが見つからない: ${SHEET_FEEDS}`);
  if (!articleSheet) throw new Error(`シートが見つからない: ${SHEET_ARTICLES}`);

  const feedValues = feedSheet.getDataRange().getValues();
  const feedHeader = feedValues.shift(); // 先頭行=見出し
  const F = headerIndex(feedHeader);     // 見出し→列番号マップ

  // 既存ハッシュ(重複排除)の取得
  const existingHashes = new Set();
  if (articleSheet.getLastRow() > 1) {
    const hashCol = headerIndex(articleSheet.getRange(1,1,1,articleSheet.getLastColumn()).getValues()[0])['ハッシュ'];
    if (hashCol != null) {
      const hashValues = articleSheet.getRange(2, hashCol+1, articleSheet.getLastRow()-1, 1).getValues();
      hashValues.forEach(r => { const h = r[0]; if (h) existingHashes.add(String(h)); });
    }
  }

  const nowStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm');
  const append = [];
 
  // 各フィードを処理
  for (let i = 0; i < feedValues.length; i++) {
    
    const row = feedValues[i];
    Logger.log(row)
    const enabled = toBoolean(row[F['有効']]);
    if (!enabled) continue;

    const sourceName = str(row[F['媒体名']]);
    const feedUrl    = str(row[F['RSS URL']]);
    Logger.log(sourceName,feedUrl)
    if (!feedUrl) continue;

    try {
      const items = fetchFeedItems(feedUrl);
      items.forEach(it => {
        const iso  = it.isoDate || '';
        const hash = md5(`${sourceName}|${it.link}|${iso}`);
        if (existingHashes.has(hash)) return;

        append.push([
          nowStr,                    // 取得時刻
          sourceName,                // 媒体名
          it.title || '',            // タイトル
          it.link || '',             // URL
          it.jpDate || '',           // 公開日(yyyy/M/d)
          it.isoDate || '',          // 公開日(ISO8601)
          it.summary || '',          // 概要(description/summary由来)
          it.guidOrId || '',         // GUID/ID
          hash                       // ハッシュ(重複排除用)
        ]);
        existingHashes.add(hash);
      });

      // 最終取得時刻の更新(列があれば)
      if (F['最終取得日時'] != null) {
        feedSheet.getRange(2 + i, F['最終取得日時'] + 1).setValue(nowStr);
      }
    } catch (err) {
      console.error(`Feed取得失敗: ${sourceName} / ${feedUrl} / ${err}`);
    }
  }

  if (append.length) {
    // 記事一覧ヘッダを確認し、なければ作成
    if (articleSheet.getLastRow() === 0) {
      articleSheet.appendRow([
        '取得時刻','媒体名','タイトル','URL',
        '公開日','公開日(ISO)','概要','GUID/ID','ハッシュ'
      ]);
    } else if (articleSheet.getLastRow() === 1 && articleSheet.getLastColumn() === 1) {
      // まっさらなシート(1セルのみ)への対策
      articleSheet.getRange(1,1,1,9).setValues([[
        '取得時刻','媒体名','タイトル','URL',
        '公開日','公開日(ISO)','概要','GUID/ID','ハッシュ'
      ]]);
    }

    const startRow = articleSheet.getLastRow() + 1;
    articleSheet.getRange(startRow, 1, append.length, append[0].length).setValues(append);
  }
}

/**
 * 単一フィードを取得し、RSS/Atomを吸収して配列で返す
 */
function fetchFeedItems(feedUrl) {
  const res = UrlFetchApp.fetch(feedUrl, { muteHttpExceptions: true, followRedirects: true });
  const code = res.getResponseCode();
  if (code >= 400) throw new Error(`HTTP ${code}`);

  const xmlText = res.getContentText('UTF-8');
  const doc = XmlService.parse(xmlText);
  const root = doc.getRootElement();
  const local = localName(root);

  if (local === 'rss' || local === 'RDF') {
    return parseRss(root);
  }
  if (local === 'feed') {
    return parseAtom(root);
  }

  // ネームスペース付きの保険(localNameで拾えている想定だが念のため)
  const lname = localName(root).toLowerCase();
  if (lname.includes('rss') || lname.includes('rdf')) return parseRss(root);
  if (lname.includes('feed')) return parseAtom(root);

  throw new Error(`未対応のフィード形式: ${root.getName()}`);
}

/* ----------------------- RSS/Atom パーサ ----------------------- */

/** RSS 2.0 / RDF */
function parseRss(root) {
  // RSS2.0は <rss><channel><item>…、RDFは <rdf:RDF><item>…(channel無し もあり)
  const channel = getChildByLocalName(root, 'channel') || root;
  const items   = getChildrenByLocalName(channel, 'item');

  return items.map(item => {
    const title = getTextByLocalName(item, 'title');
    const link  = getTextByLocalName(item, 'link');
    const guid  = getTextByLocalName(item, 'guid') || '';
    const desc  = getTextByLocalName(item, 'description') || getTextByLocalName(item, 'content'); // 保険
    const pubRaw = getTextByLocalName(item, 'pubDate'); // RFC822想定

    const { isoDate, jpDate } = normalizeDate(pubRaw);

    return {
      title,
      link,
      summary: sanitize(desc),
      isoDate,
      jpDate,
      guidOrId: guid
    };
  });
}

/** Atom 1.0 */
function parseAtom(root) {
  const entries = getChildrenByLocalName(root, 'entry');

  return entries.map(entry => {
    const title = getTextByLocalName(entry, 'title');

    // <link rel="alternate" type="text/html" href="..."> を優先
    const links = getChildrenByLocalName(entry, 'link');
    let link = '';
    for (const el of links) {
      const rel  = (el.getAttribute('rel')  && el.getAttribute('rel').getValue())  || 'alternate';
      const type = (el.getAttribute('type') && el.getAttribute('type').getValue()) || '';
      const href =  el.getAttribute('href') ? el.getAttribute('href').getValue()   : '';
      if (rel === 'alternate' && (!type || type.indexOf('html') !== -1) && href) {
        link = href; break;
      }
    }
    if (!link && links.length) {
      const href = links[0].getAttribute('href');
      link = href ? href.getValue() : '';
    }

    const id   = getTextByLocalName(entry, 'id') || '';
    const sum  = getTextByLocalName(entry, 'summary') || getTextByLocalName(entry, 'content') || '';
    const when = getTextByLocalName(entry, 'updated') || getTextByLocalName(entry, 'published') || '';

    const { isoDate, jpDate } = normalizeDate(when);

    return {
      title,
      link,
      summary: sanitize(sum),
      isoDate,
      jpDate,
      guidOrId: id
    };
  });
}

/* ----------------------- ヘルパ群 ----------------------- */

/** 見出し行 → 列インデックス(0始まり) */
function headerIndex(headerRow) {
  const map = {};
  headerRow.forEach((h, i) => map[String(h).trim()] = i);
  return map;
}

function str(v) { return (v == null) ? '' : String(v).trim(); }

function toBoolean(v) {
  if (typeof v === 'boolean') return v;
  const s = String(v).toLowerCase();
  return s === 'true' || s === '1' || s === 'yes' || s === 'on';
}

function sanitize(s) {
  if (!s) return '';
  return String(s).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
}

/** MD5ハッシュ(重複排除キー) */
function md5(s) {
  const bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, s, Utilities.Charset.UTF_8);
  return bytes.map(b => ('0' + (b & 0xff).toString(16)).slice(-2)).join('');
}

/** 日付正規化:RFC822/ISO8601 の文字列 → { isoDate, jpDate } */
function normalizeDate(raw) {
  if (!raw) return { isoDate: '', jpDate: '' };
  const d = new Date(raw);
  if (isNaN(d.getTime())) return { isoDate: '', jpDate: '' };
  return {
    isoDate: d.toISOString(),
    jpDate : Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy/M/d')
  };
}

/* ------- XmlService の localName ベースでNS差異を吸収する ------- */

function localName(el) {
  // 例: {http://www.w3.org/2005/Atom}feed の localName は 'feed'
  return el.getName ? el.getName().getLocalName ? el.getName().getLocalName() : el.getName() : '';
}

function getChildByLocalName(el, name) {
  const kids = el.getChildren();
  for (let i = 0; i < kids.length; i++) {
    if (localName(kids[i]) === name) return kids[i];
  }
  return null;
}

function getChildrenByLocalName(el, name) {
  const out = [];
  const kids = el.getChildren();
  for (let i = 0; i < kids.length; i++) {
    if (localName(kids[i]) === name) out.push(kids[i]);
  }
  return out;
}

function getTextByLocalName(el, name) {
  const c = getChildByLocalName(el, name);
  return c ? c.getText() : '';
}

/* ----------------------- 便利:メニュー&手動テスト ----------------------- */

/** スプレッドシートを開いたときにメニューを追加(任意) */
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('ニュース収集')
    .addItem('フィード取得(手動実行)', 'fetchAllFeeds')
    .addToUi();
}

/**
 * 初回セットアップ手順(メモ)
 * 1) シートを用意
 *   - RSSリスト:見出し例「有効|媒体名|RSS URL|カテゴリ|優先度|最終取得時刻」
 *   - 記事一覧:空でOK(実行時に見出しを自動作成)
 * 2) fetchAllFeeds() を手動実行して動作確認
 * 3) トリガー(時間主導)で fetchAllFeeds を 15〜60分間隔に設定
 */

まとめ

  1. RSSリスト+記事一覧の二枚構成で基盤を固める。
  2. 取得はGASで安定化し、まずは正確に蓄積する。
  3. 公開は自前要約+出典リンクで、しずかなニュースの仕組みとして届ける。

この積み上げをベースに、次回以降は要約・フィルタリング(処理)提示設計(出力)を具体化していきます。