1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RSSの記事をGoogleスプレッドシートに集める GASで重複を防ぎ 朝夕の収集を設定する

1
Last updated at Posted at 2026-11-01

気になる分野の記事を一覧にしたい。でも、新着のたびに通知を受けたり、同じ記事を何度も追加したりするのは避けたい。欲しかったのは、朝と夕方にまとめて読み、必要な記事を選べる一覧でした。

そこで、公開RSS/AtomをGoogle Apps Scriptで取得し、Googleスプレッドシートへ追記する構成を作りました。この記事では、新しいシートと1配信元から試せるコードを使い、RSSの取得、URLによる重複防止、朝夕のトリガー設定までを順に紹介します。

元の運用版では、通常収集の手動実行1回で400記事を追加できました。一方で、取得失敗や上限による未取得もありました。掲載用サンプルの確認範囲と運用版の実測を分け、動いた部分だけでなく、使う前に知っておきたい制限も説明します。

今回作るもの

流れは次のとおりです。

公開RSS/Atom → Apps Script → Googleスプレッドシート → 人が選ぶ・読む

  • Sources:取得先、分類、優先度、前回の結果を管理
  • Articles:タイトル、URL、公開日時、概要、RSSに含まれる本文を保存
  • 時間主導トリガー:日本時間の6時・18時付近に収集

FeedlyのAPIやログインを使う構成ではありません。Feedlyなどで管理している購読情報を参考に、配信元が公開しているRSS/AtomのURLを登録します。購読フォルダを後から追加しても、こちらの一覧へ自動同期はされません。

記事ごとのZapierアクションを実行しないため、この収集自体ではZapierのタスク枠を使いません。ただし、Apps Scriptにも実行時間や外部アクセス等の割当があります。「無料で無制限に取得できる」という意味ではありません。割当の公式説明

最初は新しいシートと1配信元で試す

既存データを守るため、再現するときは新しいスプレッドシートを用意してください。以下のサンプルは、実機で使った処理から個人のシートIDや購読情報を取り除き、テスト対象を最初の1配信元へ変更したものです。

掲載用サンプルはローカルの構文・補助関数テストまで確認しています。本文後半の400件という実測値は元の運用版の結果であり、掲載用サンプルをGoogle側で再実行した結果ではありません。

Sourcesを用意する

Sourcesというシートを作り、次のタブ区切りのヘッダーをA1へ貼り付けます。列名と順序はコードと一致させてください。

Enabled	Priority	Folder	Source	FeedURL	SiteURL	SourceType	LastCheckedAt	LastSuccessAt	FetchStatus	LastError	LastAdded

2行目には、最初のテスト用に1つだけ登録します。

  • A列 Enabled:チェックボックスを挿入してオン
  • B列 Priority:1(小さい数字を先に処理)
  • C列 Folder:任意の分類名。例:技術
  • D列 Source:配信元名
  • E列 FeedURL:配信元が案内している公開RSS/Atom URL
  • F列 SiteURL:配信元サイトのURL
  • G列 SourceType:rss
  • H〜L列:空欄。実行時に記録されます

URLには、パスワード、トークン、非公開の通知先情報を入れません。自分のブラウザで見えるページのURLが、RSSのURLとは限らない点にも注意してください。

Articlesを用意する

Articlesというシートを作り、次のヘッダーをA1へ貼り付けます。

CapturedAt	PublishedAt	Category	Title	URL	Source	Author	Excerpt	Tags	ArticleID	SelectedCategory	Importance_1to5	SelectionReason	ReviewStatus	Body	ContentStatus	FeedURL

SelectedCategory、Importance_1to5、SelectionReasonは後から記事を選別するための予約列です。このコードでは埋めません。収集日時と記事の公開日時も別の列にしています。

RSSの項目を整える

RSS 2.0とAtomでは、タイトル、URL、日付、概要などの位置や名前が異なります。parseFeed_で記事単位の共通オブジェクトへそろえ、plain_で概要やcontentのHTMLを簡易的に除去します。XMLの解析にはApps ScriptのXmlServiceを使います。XmlService公式説明

取得した文字列が=などで始まる場合は、シートの数式として実行されないように先頭へアポストロフィを付けます。フィードの内容をそのままコードや命令として扱わないための措置です。

URLと配信元内IDで重複を防ぐ

一度取得したURLをセットに入れ、次の実行でも参照します。同じ実行の中で見つかったURLも、追加候補にした時点でセットへ入れます。

const seenUrls = new Set();
// 既存のArticlesからURLを読み込んでおく
// …
if (seenUrls.has(url) || seenIds.has(id)) {
  duplicates++;
  continue;
}
seenUrls.add(url);
seenIds.add(id);

比較にはURLのフラグメントと一部の追跡パラメータを取り除いた値を使い、保存するURLは配信元の元URLにしています。RSSのGUIDやAtomのIDも、配信元URLと組み合わせて比較します。

これは同一内容の記事を完全に判定する仕組みではありません。別ドメインへの転載、言語違い、別URLの同じニュースは残ります。また、同じURLの記事が更新されても、既存行を更新する機能はありません。まずは「同じURLを何度も追記しない」ことに範囲を絞りました。

Apps Scriptのコード

スプレッドシートの「拡張機能」からApps Scriptを開き、以下を貼り付けます。先頭のYOUR_SPREADSHEET_IDを、自分で作ったシートURLの/d/と/editの間のIDへ置き換えます。これは誤ったシートへ書き込まないための照合用です。Webアプリとしてのデプロイは不要です。

コード全文
/** @OnlyCurrentDoc */
// Article sample. Run only in a new spreadsheet you own. No credentials required.
const RSS_CONFIG = Object.freeze({sheetId:'YOUR_SPREADSHEET_ID', timezone:'Asia/Tokyo', maxMs:240000, maxRows:400, maxItemsPerFeed:30});
const ARTICLE_HEADERS = ['CapturedAt','PublishedAt','Category','Title','URL','Source','Author','Excerpt','Tags','ArticleID','SelectedCategory','Importance_1to5','SelectionReason','ReviewStatus','Body','ContentStatus','FeedURL'];
function testFirstFeed() { return collectRss_(true); }
function collectScheduled() { return collectRss_(false); }
function installTwiceDaily() {
  if (PropertiesService.getScriptProperties().getProperty('rss_test_passed') !== 'true') throw new Error('Run and verify testFirstFeed first.');
  const existing = ScriptApp.getProjectTriggers().filter(t=>t.getHandlerFunction()==='collectScheduled');
  if (existing.length) { console.log('Triggers already present: '+existing.length+'; no changes made.'); return; }
  [6,18].forEach(hour=>ScriptApp.newTrigger('collectScheduled').timeBased().inTimezone(RSS_CONFIG.timezone).atHour(hour).nearMinute(0).everyDays(1).create());
  console.log('Created 2 daily triggers at approximately 06:00 / 18:00 Asia/Tokyo (not exact-minute scheduling).');
}
function collectRss_(testMode) {
  const lock=LockService.getDocumentLock();
  if (!lock.tryLock(1000)) { console.log('Another run is active; skipped.'); return; }
  const started=Date.now();
  try {
    const ss=SpreadsheetApp.getActiveSpreadsheet();
    if (!ss || ss.getId()!==RSS_CONFIG.sheetId) throw new Error('Wrong bound spreadsheet.');
    const art=ss.getSheetByName('Articles'), sources=ss.getSheetByName('Sources');
    if (!art || !sources) throw new Error('Required sheets missing.');
    if (JSON.stringify(art.getRange(1,1,1,17).getValues()[0])!==JSON.stringify(ARTICLE_HEADERS)) throw new Error('Articles schema changed; stopped without writing.');
    const data=sources.getDataRange().getValues();
    const expected=['Enabled','Priority','Folder','Source','FeedURL','SiteURL','SourceType','LastCheckedAt','LastSuccessAt','FetchStatus','LastError','LastAdded'];
    if (JSON.stringify(data[0])!==JSON.stringify(expected)) throw new Error('Sources schema changed.');
    const seenUrls=new Set(),seenIds=new Set();
    if(art.getLastRow()>1) art.getRange(2,1,art.getLastRow()-1,17).getValues().forEach(r=>{if(r[4])seenUrls.add(normalizeUrl_(r[4])); if(r[9])seenIds.add(String(r[9]));});
    let jobs=data.slice(1).map((r,i)=>({r,row:i+2})).filter(j=>j.r[0]===true && /^https?:\/\//i.test(j.r[4]) && j.r[6]!=='feedly_generated_unverified' && !/^blocked_|^http_404/.test(String(j.r[9])));
    jobs.sort((a,b)=>Number(a.r[1])-Number(b.r[1]) || (Date.parse(a.r[7])||0)-(Date.parse(b.r[7])||0) || a.row-b.row);
    if(testMode) {
      jobs=jobs.slice(0,1); // Start with one public feed; at most two items.
    }
    let total=0,ok=0,failed=0,duplicates=0,checked=0;
    for(const job of jobs) {
      if(Date.now()-started>RSS_CONFIG.maxMs || total>=RSS_CONFIG.maxRows) break;
      const r=job.r,now=new Date(),stamp=now.toISOString();
      let status='',error='',added=0,success=r[8];
      try {
        // No cookies, credentials, paywall workarounds, or retries.
        const response=UrlFetchApp.fetch(String(r[4]),{muteHttpExceptions:true,followRedirects:true,validateHttpsCertificates:true});
        const code=response.getResponseCode();
        if(code!==200) {
          status=[401,403,429].includes(code)?'blocked_http_'+code:'http_'+code;
          error='HTTP '+code+'; no retry this run';
        } else {
          const xml=response.getContentText();
          if(xml.length>2000000) {status='too_large';error='Feed exceeds 2 MB processing limit';}
          else {
            let entries=parseFeed_(xml);
            entries.sort((a,b)=>(Date.parse(b.date)||0)-(Date.parse(a.date)||0));
            const last=Date.parse(r[8])||Date.now()-24*3600000;
            const since=Math.max(Date.now()-7*86400000,last-24*3600000);
            entries=entries.filter(e=>testMode || !e.date || !Number.isFinite(Date.parse(e.date)) || Date.parse(e.date)>=since).slice(0,testMode?2:RSS_CONFIG.maxItemsPerFeed);
            const pending=[];
            for(const e of entries) {
              if(total+pending.length>=RSS_CONFIG.maxRows) break;
              const url=normalizeUrl_(e.url),id=e.id?String(r[4])+'#'+e.id:url;
              if(!url || !e.title) continue;
              if(seenUrls.has(url)||seenIds.has(id)){duplicates++;continue;}
              const body=plain_(e.body).slice(0,45000),summary=plain_(e.summary).slice(0,4000);
              const cs=body?'rss_content_unverified_full':summary?'summary_only':'title_only';
              const row=[stamp,e.date||'',r[2],plain_(e.title),e.url,r[3],plain_(e.author),summary,e.tags.join(', '),id,'','','','pending',body,cs,r[4]].map(safeCell_);
              pending.push(row);seenUrls.add(url);seenIds.add(id);
            }
            if(pending.length) {
              const start=art.getLastRow()+1;
              if(start+pending.length-1>art.getMaxRows())art.insertRowsAfter(art.getMaxRows(),pending.length);
              art.getRange(start,1,pending.length,17).setValues(pending);
              SpreadsheetApp.flush();
              added=pending.length;total+=added;
            }
            status='ok';success=stamp;ok++;
          }
        }
      } catch(e) {status='error';error='Fetch/XML/write failed; inspect execution log. No automatic retry.';console.log('Source row '+job.row+': '+String(e.name||'Error'));}
      if(status!=='ok')failed++;
      sources.getRange(job.row,8,1,5).setValues([[stamp,success,status,error,added]]);
      checked++;
      // HTTP 429 stops the entire run. Marked source is skipped until manually reviewed.
      if(status==='blocked_http_429')break;
    }
    const result={mode:testMode?'test':'scheduled',checked,ok,failed,added:total,duplicates,remaining:jobs.length-checked,elapsedSeconds:Math.round((Date.now()-started)/1000)};
    PropertiesService.getScriptProperties().setProperty('rss_last_run',JSON.stringify(result));
    if(testMode && ok>0 && total>0) PropertiesService.getScriptProperties().setProperty('rss_test_passed','true');
    console.log(JSON.stringify(result));return result;
  } finally {lock.releaseLock();}
}
function parseFeed_(xml) {
  const root=XmlService.parse(xml).getRootElement();
  const name=root.getName().toLowerCase();
  let items;
  if(name==='feed') items=root.getChildren().filter(e=>e.getName()==='entry');
  else if(name==='rss') {const channel=root.getChildren().find(e=>e.getName()==='channel');items=channel?channel.getChildren().filter(e=>e.getName()==='item'):[];}
  else if(name==='rdf')items=root.getChildren().filter(e=>e.getName()==='item');
  else throw new Error('Not RSS/Atom');
  return items.map(item=>{
    const children=item.getChildren(),text=(...names)=>{const e=children.find(c=>names.includes(c.getName()));return e?e.getValue():'';};
    const links=children.filter(e=>e.getName()==='link');
    const alternate=links.find(e=>!e.getAttribute('rel')||e.getAttribute('rel').getValue()==='alternate')||links[0];
    const url=alternate?(alternate.getAttribute('href')?alternate.getAttribute('href').getValue():alternate.getValue()):'';
    return {title:text('title'),url,id:text('guid','id'),date:text('pubDate','published','updated','date'),summary:text('description','summary'),body:text('encoded','content'),author:text('creator','author'),tags:children.filter(e=>e.getName()==='category').map(e=>e.getAttribute('term')?e.getAttribute('term').getValue():e.getValue())};
  });
}
function plain_(value) {return String(value||'').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi,'').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi,'').replace(/<[^>]*>/g,' ').replace(/&nbsp;/g,' ').replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&#39;/g,"'").replace(/&#(\d+);/g,(_,n)=>Number(n)<=1114111?String.fromCodePoint(Number(n)):'').replace(/\s+/g,' ').trim();}
function normalizeUrl_(value) {return String(value||'').trim().replace(/#.*$/,'').replace(/([?&])(?:utm_[^=&]*|fbclid|gclid)=[^&]*&?/gi,'$1').replace(/\?&/g,'?').replace(/[?&]$/,'').replace(/^(https?:\/\/[^/]+)\/$/,'$1');}
function safeCell_(value) {const s=String(value==null?'':value);return /^[=+@-]/.test(s)?"'"+s:s;}

手動試験を2回行う

  1. testFirstFeedを実行します。最初の有効な配信元から最大2件だけ取得します。
  2. Googleの権限画面で、対象シートへのアクセスと外部サービス接続など、要求される内容を確認します。見慣れないアプリやコードの権限を無条件に承認しないでください。
  3. Articlesへ入ったタイトル、URL、公開日時、概要を配信元と照合します。実行ログの成功表示だけで判断しません。
  4. 同じ条件でもう一度実行し、同じ記事が増えないことを確認します。
  5. Sourcesの結果も見て、HTTPエラーや解析失敗を見落としていないか確認します。

サンプルの定期設定は、テストで少なくとも1件を取得した後にだけ実行できます。空のフィードは正常な場合もありますが、記事が保存される経路を確認するため、最初の試験には記事のある公開フィードを選びます。

UrlFetchAppはGoogle側から外部URLへアクセスします。手元のブラウザでは読めても、配信元のアクセス制御によって取得できないことがあります。UrlFetchApp公式説明

朝夕のトリガーを設定する

手動試験とシートの内容確認が済んだら、installTwiceDailyを実行します。

コードはAsia/Tokyoで6時・18時付近のトリガーを作ります。nearMinute(0)は厳密に00分に動く指定ではありません。公式には指定分の前後15分程度と説明されています。登録できたことと、予定どおり発火したことは別に確認します。ClockTriggerBuilder公式説明

同じcollectScheduledのトリガーが既にあれば、このサンプルは追加せず終了します。重複作成を避けるための簡易チェックです。既存の設定が2本とも正しい時刻になっているかまでは自動修復しないので、トリガー一覧を確認してください。

不要になった場合は、このサンプルが作ったcollectScheduledのトリガーだけを一覧から削除します。他のスクリプトや定期処理の設定は変更しません。

取得量と失敗を制御する

元の運用版と掲載サンプルでは、次の上限を設けています。

  • 1回の追加は最大400記事
  • 1配信元あたり最新30記事まで
  • 各配信元の処理を始める前に、経過240秒を確認して打ち切り
  • XML文字列が2,000,000文字を超えたら処理を見送る
  • 同時実行はDocument Lockで抑止

240秒は実行中のHTTP待ちを中断する仕組みではありません。待機中に時間が延びる可能性があり、Google側の上限内で必ず完了する保証はありません。

通常実行では、初回はおおむね過去48時間、次回以降は前回成功時刻から24時間重ねた範囲を対象にします。最大で過去7日までに制限しています。日付がない、または解析できない記事は除外せず残します。そのため、すべての行が新しい記事だとは限りません。

また、件数上限を超える大量配信や長期間の停止では取りこぼしがあり得ます。フィード自体から消えた記事まで回収できる仕組みでもありません。

401・403・429・404は記録し、以後の自動取得から外します。429はその回の収集全体も止めます。アクセス拒否を回避するために別の経路へ切り替えたり、自動で連続再試行したりはしません。原因を確認し、再開可能と判断したときだけ、対象行の状態を人が見直します。

その他のエラーは記録して次の配信元へ進みます。1つの配信元の失敗で、安全に取得できるほかの配信元まで止めないためです。

実機で確認できたこと

2026年11月1日時点の元の運用版の結果です。

試験 確認した結果
優先4配信元の少数試験 4件取得成功、失敗0、6記事追加
同じ少数試験の再実行 追加0、重複6件を除外
通常収集1回 87配信元確認、70成功、17失敗
通常収集の記事数 400記事追加、重複5件を除外
所要時間 167秒
未取得 400記事の上限に達し、残り3配信元は未取得

通常収集で追加した400件は、おおむね過去48時間の公開日時でした。既存1件と少数試験6件を含め、シート全体は407記事となり、その時点で保存URLの完全一致による重複は0件でした。

少数試験には古い記事も含まれるため、407件すべてを新着とは扱っていません。また、取得成功でも該当期間の新規記事が0件の配信元があります。

失敗はHTTP 404、403、501、503、サイズ上限、例外などでした。例外についてはHTTP・XML解析・書き込みのどこで失敗したかをすべて特定できておらず、原因を断定していません。

朝夕のトリガーは2本の登録を確認しましたが、この記事の検証時点では初回の定時発火はまだ確認していません。長期安定性や、すべての配信元での成功も未検証です。

RSSの本文と記事全文を分ける

取得状態は次の3種類にしています。

  • summary_only:概要だけがある
  • rss_content_unverified_full:RSSのcontent等はあるが、記事全文とは未確認
  • title_only:タイトルのみ

content:encodedやAtomのcontentがあっても、短い抜粋だけの場合があります。ラベルを見て「全文が読めた」と決めつけず、要約や推薦に使う前に実際の長さと内容を確認します。

HTMLは簡易的に文字列へ変換するため、表・コード・画像・書式を完全には保てません。各セルには長さの上限も設けています。元記事の代替として保管する用途には向きません。

記事本文の再配布や公開は、取得できることとは別の問題です。配信元の利用条件や著作権を確認し、この一覧は個人の閲覧・選別のために使う想定です。認証付きURLや個人専用のフィードを、公開サンプルへ混ぜないことも重要です。

次に改善したい点

今回は収集と重複防止までを確認しました。次の段階では、収集1回ごとの開始・終了・件数をまとめて記録し、完了した回だけを記事選別へ渡す仕組みを加えると、後続処理をつなぎやすくなります。

配信元ごとの更新日時だけでは、全体の収集が終わった証拠にはなりません。この完了記録と、収集後の自動要約は、この記事時点では未実装です。

小さく始めるなら、まず1配信元を手動で2回実行し、記事内容と重複防止を確認してから、配信元と定期実行を増やすのが分かりやすい進め方でした。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?