本記事は「しずかなニュースの仕組み ― 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)で行います。まずは「正確に貯める」ことを優先し、要約やフィルタは次の段階に回します。処理の流れは以下のとおりです。
RSSリストの有効なフィードを走査- 各RSS/Atomを取得・パース(タイトル、URL、公開日、概要など)
- 重複排除(媒体名+URL+公開日のハッシュ)
- 日付を正規化(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分間隔に設定
*/
まとめ
- RSSリスト+記事一覧の二枚構成で基盤を固める。
- 取得はGASで安定化し、まずは正確に蓄積する。
- 公開は自前要約+出典リンクで、しずかなニュースの仕組みとして届ける。
この積み上げをベースに、次回以降は要約・フィルタリング(処理)と提示設計(出力)を具体化していきます。
