astro-rssでのRSSの改善とモジュール化

RSS改善

Astro.jsではrss生成を以下のように行える

import rss, { pagesGlobToRssItems } from '@astrojs/rss';

export async function GET(context) {
  return rss({
    title: 'Astro学習者 | ブログ',
    description: 'Astroを学ぶ旅',
    site: context.site,
    items: await pagesGlobToRssItems(import.meta.glob('./**/*.md')),
    customData: `<language>ja-jp</language>`,
  });
}

課題

rowicyの本サイトでも公式のコードに倣い以下のようにしてRSSを提供していた

import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import siteInfo from '@/data/siteInfo';

export async function GET(context) {
  const blogs = await getCollection('blog');
  return rss({
    title: siteInfo.appName,
    description: siteInfo.description,
    site: context.site,
    items: blogs.map(blog => ({
      title: blog.data.title,
      pubDate: blog.data.pubDate,
      description: blog.data.description,
      customData: blog.data.customData,
      link: `/blog/${blog.slug}/`,
    })),
    customData: `<language>ja-jp</language>`,
  });
}

※ 独自のフロントマターによりpagesGlobToRssItemsは使えないのでitems内をそれぞれ設定

これでRSS情報は取得できるようになっているのだが、個人的にFeedlyを使うようになって本サイトがどう表示されるか試した時に

【1.】サムネイル画像が表示されない
【2.】サイトを購読追加する際に、RSS候補が出てこない ( URL入力で候補がでてくるが ‘https://www.rowicy.com’ 入力時にはでてこない )

ことがわかったので年末年始の空き時間でこの改善をした

RSS比較

今回ははてなブログを参考にRSSに要素を追加した

  • rowicyのRSS
<rss version="2.0" data-google-analytics-opt-out="">
    <channel>
        <title>Rowicy</title>
        <description>RowicyのWebサイトです。</description>
        <link>https://www.rowicy.com/</link>
        <language>ja-jp</language>
        
        <item>
            <title>includeで引数に応じて出力の分岐をする方法</title>
            <link>https://www.rowicy.com/blog/ejs-include-argument/</link>
            <guid isPermaLink="true">https://www.rowicy.com/blog/ejs-include-argument/</guid>
            <description>EJSのincludeで引数に応じて出力の分岐を行う方法をご紹介します。</description>
            <pubDate>Tue, 14 Mar 2023 00:00:00 GMT</pubDate>
        </item>
        ...
  • はてなブログのRSS
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Sample Blog Title</title>
    <link>https://example.com/</link>
    <description>This is a sample RSS feed description.</description>
    <lastBuildDate>Tue, 01 Jan 2025 12:00:00 +0000</lastBuildDate>
    <docs>https://example.com/rss-spec</docs>
    <generator>Sample::RSS::Generator</generator>

    <item>
      <title>Sample Article Title</title>
      <link>https://example.com/articles/sample-article</link>
      <description>
        This is a sample article description.
        It can contain plain text or HTML content.
      </description>
      <pubDate>Tue, 01 Jan 2025 10:00:00 +0000</pubDate>
      <guid isPermaLink="false">sample://entry/1234567890</guid>
      <category>Technology</category>
      <category>Web</category>
      <category>Sample</category>
      <enclosure
        url="https://example.com/images/sample-image.png"
        type="image/png"
        length="0"
      />
    </item>
	...

比較してみてitem要素に以下が追加できそうだ

  • category: タグ
  • enclosure: サムネイル画像

要素追加

Astroのソースコードから

公式ドキュメントではこの要素の言及がなかったが, astro-rssではv2.4.0からcategory, enclosure, author, comments, sourceなどに対応していたので

公式のテストコードに沿って以下のように追加した

  const blogs = await getCollection('blog');
  return rss({
    title: siteInfo.appName,
    description: siteInfo.description,
    site: context.site,
    items: blogs.map(blog => ({
      title: blog.data.title,
      pubDate: blog.data.pubDate,
      description: blog.data.description,
      customData: blog.data.customData,
      link: `/blog/${blog.slug}/`,
+       categories: blog.data.tags,
+       enclosure: {
+        url: `/og/${blog.slug}.png`,
+        type: 'image/png',
+        length: 0,
+      },
      
    })),
    customData: `<language>ja-jp</language>`,
  });

v4.0.5からはenclosure.lengthは0も設定できるようになった。これはRSS Advisory Boardにてバイトサイズ不明の時に0を設定するようになっている

RSS仕様的には設定はしたほうがよいのだろうが、バイトを計算するコード実装は少し面倒なので後回し
またGoogleのクローラーがこの値を重視しているのかは不明(RSSは壊れてないかだけみてリンク先内容を重視しているのではと推測)

enclosureを記事内画像に

OGP画像をenclosureに設定するだけではフィード一覧が同じ顔ぶれのサムネだけになってしまう

記事の第一印象を伝えるために、記事内画像挿入があればそれをサムネイルにしたい

ということで記事から画像リンクを抜き取る関数も用意した

function extractImageUrl(body: string) {
  if (!body) return null;
  const relativeImages = [];
  const absoluteImages = [];
  const imgTagRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
  const mdImageRegex = /!\[^\](^\)*\]\(([^)]+)\)/g;

  const imgMatches = [...body.matchAll(imgTagRegex)];
  const mdMatches = [...body.matchAll(mdImageRegex)];
  const allUrls = [
    ...imgMatches.map(match => match[1]),
    ...mdMatches.map(match => match[1]),
  ];

  if (allUrls.length === 0) return null;

  for (const url of allUrls) {
    const isRelative = url.startsWith('/');
    let fullUrl = url;
    const ext = fullUrl.split('?')[0].split('.').pop()?.toLowerCase();
    let type;

    switch (ext) {
      case 'jpg':
      case 'jpeg':
        type = 'image/jpeg';
        break;
      case 'png':
        type = 'image/png';
        break;
      case 'gif':
        type = 'image/gif';
        break;
      case 'webp':
        type = 'image/webp';
        break;
      case 'svg':
        type = 'image/svg+xml';
        break;
      default:
        continue;
    }

    const imageObj = {
      url: fullUrl,
      type: type,
      length: 0,
    };

    if (isRelative) {
      relativeImages.push(imageObj);
    } else {
      absoluteImages.push(imageObj);
    }
  }

  const images = [...relativeImages, ...absoluteImages];
  return images.length > 0 ? images : null;
}

取り急ぎ用意したような関数のため拡張性がないが, Markdownから画像リンク, imgタグを抜き出せる

enclosureはtype指定が必須であるため
リンクのURLから拡張子がわかる場合のみ候補に追加している

また, 自サイト内で提供している画像(public/image配下)をリスト内で優先にした

この関数を用いてenclosureは以下のようになる

      enclosure: extractImageUrl(blog.body)?.[0] || {
        url: `/og/${blog.slug}.png`,
        type: 'image/png',
        length: 0,
      },

これで 【1.】サムネイル画像が表示されない は解決

OGP画像は画像が取れない時に設定される

先ほどのextractImageUrlはenclosureオブジェクトのリストを返すのに最初の要素だけ使っているが
これは型で決まっているためである. これについての余談として、RSS2.0の追加仕様であるRSS Best Practices Profileのenclosure解説では

For best support in the widest number of aggregators, an item should not contain more than one enclosure.

幅広いRSS収集者のサポートをうけるために、itemはenclosureを2つ以上含まない方がよいです

とある一方で、item解説では

The preceding elements must not be present more than once in an item, with the exception of category.

itemの各要素はcategory除いて、1つのitemに複数回存在してはなりません

とあるため強制表現に矛盾がある

世の中の大半のRSS Readerは後者の仕様にしたがってenclosureは1つだけを読み込むことが多いだろう

他の要素は追加しないのか

itemの他要素としてcommentsauthor が設定できるが,

comments: コメント欄がない
author : 値にメールアドレスが必要だが、現メンバー全員がメアドを公開していない

ため設定はしなかった

フィードのモジュール化

RowicyではメンバーごとのRSSも用意している
https://www.rowicy.com/{MEMBER_NAME}/rss.xml

そのため, 内容を使い回しつつitemをメンバー名で絞り込む必要がある

src/lib/getFeed.tsでgetFeedを用意
メンバー名やタグ名でフィルタしてRSSOptionsを返すようにした

async function getFeed( siteUrl: string, maxItems?: number, filter?: { tag?: string; author?: string }) {

// ...
  const rssOptions: RSSOptions = {

    items: blogs.map(blog => ({
      title: blog.data.title,
      pubDate: new Date(blog.data.pubDate),
      description: blog.data.description,
      link: blog.data.externalUrl
        ? blog.data.externalUrl
        : `/blog/${blog.slug}/`,
      categories: blog.data.tags,
      enclosure: extractImageUrl(blog.body)?.[0] || {
        url: `/og/${blog.slug}.png`,
        type: 'image/png',
        length: 0,
      },
    })),
    customData: `<language>ja-jp</language>`,
  };
  return rssOptions;
}
export { getFeed };

詳しくはこちら

【2.】サイトを購読追加する際に、RSS候補が出てこない

については<link rel="alternate">の設置により解消した

<link rel="alternate">は「今見ているページとは別の代替ページが存在すること」を伝えるためのHTMLタグ
RSS他にも多言語対応ページやSPサイトがあればそれを記述する

これを設定し忘れていただけだった

まとめ

最終的にFeedlyで問題なく記事を取得できるようになった

他にもitemの時系列順を最新順にしたり外部記事ではURLを外部に変えるなどの細かい修正をしてPRに出した

今回の修正はRSSの仕様について情報を探すことが多く、時間のかかる作業だった

仕様の調査にはperplexityを使用したが、表面上の回答が多く、実際にソースを読んでいく中での発見の方が多かった

ドキュメント調査でもまだ自力で読んでいく必要があると感じたし、心理的にもAIの出力を確認しにサイトに行く癖がまだあるが、出力にない重要な情報を見つけるたびに「やっぱり」と思う

AIを用いての調査については別記事にいつか書くとして、今回はこんなところで

RiiiM

Author: RiiiM

Backend Developer

Share

Link is copied.