841-biborokuWebフロントエンドの備忘録

AstroでRSSフィードから記事一覧を取得してCloudflare Pagesで表示してみる

自身のnoteに書いた記事をRSSフィードを利用してAstroに一覧表示するための手順をまとめた記事です。

※RSSを配信しているサービスの利用規約やルールをご確認ください。

設定と完成したコード

設定

  • Astro 4.7.1
  • Astroの出力モードhybrid
  • Cloudflare Pagesの互換性フラグにnodejs_compatを設定
  • XMLデータをJSON形式に変換するパッケージnpm i fast-xml-parser -D

このコードでできること

  • 複数のRSSフィードから記事を取得して日付が新しい順に並び替えて10件表示
  • RSS 2.0、Atom形式
  • 日付をYYYY.MM.DD形式

src/libs/fetchAndProcessRSS.ts

import { XMLParser } from "fast-xml-parser";

// 記事のインターフェースを定義
interface Article {
  title: string;
  link: string;
  thumb: string | null;
  description: string;
  pubDate: string;
}

// RSSフィードを取得してJSONデータに変換する関数
export async function fetchRSS(url: string): Promise<any> {
  try {
    const response = await fetch(url); // URLからRSSフィードをフェッチ
    const xmlData = await response.text(); // フェッチしたデータをテキストとして取得
    const parser = new XMLParser({
      ignoreAttributes: false, // 属性を無視しない設定
    });
    const jsonData = parser.parse(xmlData); // XMLデータをJSONに変換
    return jsonData;
  } catch (error) {
    console.error("Error fetching or parsing RSS feed:", error); // エラーが発生した場合にコンソールに表示
    throw error; // エラーをスロー
  }
}

// 日付をYYYY.MM.DD形式にフォーマットする関数
function formatDate(date: Date): string {
  const year = date.getFullYear();
  const month = (date.getMonth() + 1).toString().padStart(2, "0");
  const day = date.getDate().toString().padStart(2, "0");
  return `${year}.${month}.${day}`;
}

// アイテムからサムネイルURLを抽出する関数
function extractThumb(item: any): string | null {
  if (item.enclosure && item.enclosure.url) {
    return item.enclosure.url;
  } else if (item["media:thumbnail"]) {
    return item["media:thumbnail"];
  } else if (Array.isArray(item.link)) {
    const link = item.link.find(
      (l: any) => l.rel === "enclosure" || l.type?.startsWith("image")
    );
    return link ? link.href : null;
  }
  return null;
}

// RSSアイテムをArticleインターフェースにパースする関数
function parseRSSItem(item: any, isAtom: boolean = false): Article {
  return {
    title: item.title,
    link: isAtom ? item.link?.href || item.link : item.link,
    thumb: extractThumb(item),
    description: item.description || item.summary || item.content,
    pubDate: formatDate(
      new Date(item.pubDate || item.updated || item.published)
    ),
  };
}

// RSS 2.0形式のJSONデータをArticle配列にパースする関数
function parseRSS2(jsonData: any): Article[] {
  const items = Array.isArray(jsonData.rss.channel.item)
    ? jsonData.rss.channel.item
    : [jsonData.rss.channel.item];
  return items.map((item: any) => parseRSSItem(item));
}

// Atom形式のJSONデータをArticle配列にパースする関数
function parseAtom(jsonData: any): Article[] {
  const entries = Array.isArray(jsonData.feed.entry)
    ? jsonData.feed.entry
    : [jsonData.feed.entry];
  return entries.map((entry: any) => parseRSSItem(entry, true));
}

// 複数のRSSフィードURLを処理し、最新の10記事を返す関数
export async function fetchAndProcessRSS(
  rssURLs: string[]
): Promise<Article[]> {
  let articles: Article[] = [];

  await Promise.all(
    rssURLs.map(async (rssURL) => {
      try {
        const rssData = await fetchRSS(rssURL); // 各RSSフィードをフェッチ
        let fetchedArticles: Article[] = [];

        if (rssData.rss) {
          fetchedArticles = parseRSS2(rssData); // RSS 2.0形式を解析
        } else if (rssData.feed) {
          fetchedArticles = parseAtom(rssData); // Atom形式を解析
        } else {
          console.error(`Unknown feed format for ${rssURL}`, rssData); // 不明なフォーマットのフィード
        }

        articles.push(...fetchedArticles); // 取得した記事を追加
      } catch (error) {
        console.error(`Error processing RSS feed ${rssURL}:`, error); // エラー処理
      }
    })
  );

  // 記事を日付でソートし、最新の10記事を取得
  articles.sort(
    (a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()
  );
  articles = articles.slice(0, 10);

  return articles; // 最終的な記事リストを返す
}

src/pages/feed.astro

---
import Layout from "@/layouts/Layout.astro";
import { fetchAndProcessRSS } from "@/libs/fetchAndProcessRSS";
import { Image } from "astro:assets";
import noimage from "@/images/noimage.png";

// RSSフィードのURL 配列で複数RSSフィードURLの指定が可能
const rssURLs = [
  "https://note.com/xxx/rss"
];

// RSSフィードから記事を取得して統合
const articles = await fetchAndProcessRSS(rssURLs);
---
<Layout>
  <h1>Latest Articles</h1>
  <ul>
    {
      articles.map((article) => (
        <li>
          <a href={article.link}>
            {article.thumb ? (
              <img src={article.thumb} alt="" />
            ) : (
              <Image src={noimage.src} width={400} height={300} alt="" />
            )}
            <div class="info">
              <p class="date">{article.pubDate}</p>
              <p class="title">{article.title}</p>
            </div>
          </a>
        </li>
      ))
    }
  </ul>
</Layout>

😢つまづき⓵XMLデータをJSON形式に変換するパッケージでデプロイ失敗

はじめはxml2jsで試して作成していましたが、いざデプロイしようとしたらエラーでデプロイできませんでした。

エラー内容

02:15:37 [ERROR] [vite] x Build failed in 1.43s
[commonjsresolver] [plugin vite:resolve] Cannot bundle Node.js built-in "events" imported from "node_modules\xml2js\lib\parser.js". Consider disabling ssr.noExternal 
or remove the built-in dependency.

ChatGPTに聞いてみた

このエラーは、Viteビルド中に発生したものです。エラーメッセージによると、node_modules\xml2js\lib\parser.jsからNode.jsの組み込みモジュールである "events" をインポートしようとしているが、それがバンドルされていないため、エラーが発生しています。エラーの提案として、ssr.noExternalを無効にするか、組み込みの依存関係を削除することが挙げられています。

このエラーの理由として、ViteがデフォルトでNode.jsの組み込みモジュールをバンドルしないためです。これは、Viteがブラウザ向けのフロントエンドアプリケーションを効率的にビルドするための設計上の決定です。しかし、xml2jsライブラリがNode.jsの組み込みモジュールである "events" を使用しているため、このエラーが発生しています。

astro.config.mjsのviteに設定を追加したり試したのですが、うまくいきません・・

rss-parserも試してみたのですが、Cannot bundle Node.js built-in "http" imported from "node_modules\rss-parser\lib\parser.js"と同様のエラーが出たため断念しました。

最終的にはデプロイできたfast-xml-parserで落ち着きました。

😢つまづき②画像を取得するときにundefinedになる

はじめはnoteのRSSフィードの形式をもとに作っていたのですが、複数のRSSフィードからデータを取得するときに画像が取得できないケースに遭遇しました。

<enclosure url="https://xxx.xyz/xxxx.jpg" type="image/jpeg" length="0" />

取得しようと設定してもundefinedになります・・

parserするときに属性が削除されてしまいurlがなくなっていました。そこでXMLParserのignoreAttributesとattributeNamePrefixオプションの設定を追加したら取得できるようになりました🎉

const parser = new XMLParser({
  ignoreAttributes: false, // 属性を無視しない
  attributeNamePrefix: "", // 属性名のプレフィックスを削除
});

いろんなサービスのRSSフィードを見てみよう👀

※2024/6/11時点

note

iframe、RSSで、自分のサイトにnoteを表示する

<item>
  <title>記事タイトル</title>
  <media:thumbnail>画像URL</media:thumbnail>
  <description><![CDATA[ 本文 ]]></description>
  <note:creatorImage>クリエイター画像</note:creatorImage>
  <note:creatorName>クリエイター名</note:creatorName>
  <pubDate>Thu, 08 Sep 2022 01:47:28 +0900</pubDate>
  <link>記事URL</link>
  <guid>記事URL</guid>
</item>

Qiita

フィード機能(ホーム・タイムライン・トレンド)

<entry>
  <id>tag:qiita.com,2005:PublicArticle/記事ID?</id>
  <published>2024-06-11T15:46:46+09:00</published>
  <updated>2024-06-11T15:46:49+09:00</updated>
  <link rel="alternate" type="text/html" href="記事URL+パラメータ"/>
  <title>記事タイトル</title>
  <content type="html">本文</content>
  <author>
    <name>ユーザ名</name>
  </author>
</entry>

Zenn

ZennをRSSフィードで購読する

<item>
  <title><![CDATA[ 記事タイトル ]]></title>
  <description><![CDATA[ 本文 ]]></description>
  <link>記事URL</link>
  <guid isPermaLink="true">記事URL</guid>
  <pubDate>Wed, 12 Jun 2024 00:00:00 GMT</pubDate>
  <enclosure url="OGP画像URL" length="0" type="image/png"/>
  <dc:creator>ユーザ名</dc:creator>
</item>

はてなブックマーク

はてなブックマークフィード仕様

RSS 2.0、Atom形式の両方あります。

<item>
  <title>記事タイトル</title>
  <link>記事URL</link>
  <description>本文</description>
  <pubDate>Thu, 23 May 2024 11:57:41 +0900</pubDate>
  <guid isPermaLink="false">hatenablog://entry/ID?</guid>
  <enclosure url="画像URL" type="image/png" length="0" />
</item>
<entry>
  <title>記事タイトル</title>
  <link href="記事URL" />
  <id>hatenablog://entry/記事ID?</id>
  <published>2024-06-11T17:45:54+09:00</published>
  <updated>2024-06-11T17:45:54+09:00</updated>
  <summary type="html">要約</summary>
  <content type="html">本文</content>
  <category term="カテゴリ" label="カテゴリ" />
  <category term="カテゴリ" label="カテゴリ" />
  <link rel="enclosure" href="画像URL" type="image/png" length="0" />
  <author>
    <name>ユーザ名</name>
  </author>
</entry>