RSSHubで東京科学大学ニュースのルーターを作ってみた

## 背景

旧東京工業大学のウェブサイトには便利なRSS機能があるが、東京科学大学の新しいサイトにはその機能がなくなっている。機能を補完するためにさまざまな実現方法を調査した結果、最終的にRSSHubを使用することに決定した。

## RSSHubについて

https://github.com/DIYgod/RSSHub

簡単に言うと、RSSHubはウェブサイトからRSSリンクを生成するアプリである。既に多くのルーターが作成されているため、そのまま利用しても便利である。また、オープンソースプロジェクトであるため、自分でカスタムルーターを作成することも可能である。今回はオフィシャルパンフレットに従ってルーターを作ってみた。

## 環境構築

普通に、Node.jsをインストールして、pnpm iで構築できる。（ここでpnpmがなければ、先にnpm install pnpm）

## Namespaceの作成

[オフィシャルパンフレット](https://docs.rsshub.app/joinus/new-rss/start-code#creating-namespace)に従って、簡単にできると思う。
`name`は人が読むための名前であり、`url`はプロトコルなし（つまりhttpなどなし）のリンクである。また、説明が必要であれば、`description`に記載できる。
多言語対応もしており、（`zh`、`zh-TW`、`ja`）の言語で多言語ドキュメントを作成可能である。
```
import type { Namespace } from '@/types';

export const namespace: Namespace = {
    name: 'Institute of Science Tokyo',
    url: 'isct.ac.jp',
    lang: 'ja',
    description: `
:::tip
    支持通过category参数筛选新闻类别。详情请查看[指南](https://docs.rsshub.app/zh/guide/parameters#%E5%86%85%E5%AE%B9%E8%BF%87%E6%BB%A4)。
    You can filter news by category through the category parameter. For more information, please refer to the [guide](https://docs.rsshub.app/guide/parameters#filtering).
:::`,
    ja: {
        name: '東京科学大学',
    },
};
```

## Route本作成

### Routeの定義やRadarなど

この部分も[オフィシャルパンフレット](https://docs.rsshub.app/joinus/new-rss/start-code#creating-route)に従って作ればいい。
```
    path: '/news/:lang',
    categories: ['university'],
    example: '/isct/news/ja',
    parameters: { lang: 'language, could be ja or en' },
    features: {
        requireConfig: false,
        requirePuppeteer: false,
        antiCrawler: false,
        supportBT: false,
        supportPodcast: false,
        supportScihub: false,
    },
    radar: [
        {
            source: ['www.isct.ac.jp/:lang/news'],
            target: '/news/:lang',
        },
    ],
    name: 'News',
    maintainers: ['catyyy'],
```

### ウェブサイトの解析

ウェブサイト自体がAPIを通じてデータを取得している場合は、可能であればそのAPIを直接使用してデータを取得する方が良い。それができない場合は、HTMLデータを手動で解析しなければならない。最も簡単な方法は、F12を押して開発者ツールを開き、ネットワークオプションに切り替えてからページをリフレッシュすることです。これで、Fetch/XHRのセクションでウェブページのAPIリクエストを確認できる。
今回は運良く、東科大のニュースは[このAPI](https://www.isct.ac.jp/expansion/get_media_list_json.php?lang_cd=ja)を使って取得されているニュースデータで、分類も[このAPI](https://www.isct.ac.jp/expansion/get_tag_list_json.php?lang_cd=ja)から取得されている。

しかし、残念ながら上記のAPIの返す値は一般的なJSON形式ではなく、HTMLテキスト形式である。そのため、解析して利用可能な形式に変換する必要がある。この部分でかなりの時間を費やしてしまった。ここで、私はTSのentitiesにあるHTML解析関数decodeを使った。もしこれを見て役立つ人がいれば、非常に嬉しく思う。
```
handler: async (ctx) => {
        const { lang = 'ja' } = ctx.req.param();
        const mediaResponse = await ofetch(`https://www.isct.ac.jp/expansion/get_media_list_json.php?lang_cd=${lang}`);
        const tagResponse = await ofetch(`https://www.isct.ac.jp/expansion/get_tag_list_json.php?lang_cd=${lang}`);

        const mediaData = JSON.parse(decode(mediaResponse));
        const tagData = JSON.parse(decode(tagResponse));

        const itemsArray: MediaItem[] = Object.values(mediaData);
        const tagArray: TagItem[] = Object.values(tagData);

        const tagIdNameMapping: { [key: string]: string } = {};

        for (const item of Object.values(tagArray)) {
            tagIdNameMapping[item.TAG_ID] = item.TAG_NAME;
        }

        const items = itemsArray.map((item) => ({
            // タイトル
            title: item.TITLE,
            // リンク
            link: 'news/' + item.MEDIA_CD,
            // 本文
            description: item.META_DESCRIPTION,
            // リリース時間
            pubDate: parseDate(item.PUBLISH_DATE),
            // （オプション）作者
            // author: item.user.login,
            // （オプション）分類
            category: item.MEDIA_TYPES ? [tagIdNameMapping[Number.parseInt(item.MEDIA_TYPES.replaceAll('"', ''), 10)]] : [],
        }));
        return {
            // ウェブサイトのタイトル
            title: `ISCT News - ${lang}`,
            // ウェブサイトのリンク
            link: `https://www.isct.ac.jp/${lang}/news`,
            // 先に作成した内容
            item: items,
        };
    },
```

### 最後に作成できたファイル
```
import { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { decode } from 'entities';

interface MediaItem {
    ID: string;
    TITLE: string;
    PUBLISH_DATE: string;
    META_DESCRIPTION: string;
    MEDIA_CD: string;
    MEDIA_TYPES: string;
}

interface TagItem {
    TAG_ID: string;
    TAG_NAME: string;
}

export const route: Route = {
    path: '/news/:lang',
    categories: ['university'],
    example: '/isct/news/ja',
    parameters: { lang: 'language, could be ja or en' },
    features: {
        requireConfig: false,
        requirePuppeteer: false,
        antiCrawler: false,
        supportBT: false,
        supportPodcast: false,
        supportScihub: false,
    },
    radar: [
        {
            source: ['www.isct.ac.jp/:lang/news'],
            target: '/news/:lang',
        },
    ],
    name: 'News',
    maintainers: ['catyyy'],
    handler: async (ctx) => {
        const { lang = 'ja' } = ctx.req.param();
        const mediaResponse = await ofetch(`https://www.isct.ac.jp/expansion/get_media_list_json.php?lang_cd=${lang}`);
        const tagResponse = await ofetch(`https://www.isct.ac.jp/expansion/get_tag_list_json.php?lang_cd=${lang}`);

        const mediaData = JSON.parse(decode(mediaResponse));
        const tagData = JSON.parse(decode(tagResponse));

        const itemsArray: MediaItem[] = Object.values(mediaData);
        const tagArray: TagItem[] = Object.values(tagData);

        const tagIdNameMapping: { [key: string]: string } = {};

        for (const item of Object.values(tagArray)) {
            tagIdNameMapping[item.TAG_ID] = item.TAG_NAME;
        }

        const items = itemsArray.map((item) => ({
            // タイトル
            title: item.TITLE,
            // リンク
            link: 'news/' + item.MEDIA_CD,
            // 本文
            description: item.META_DESCRIPTION,
            // リリース時間
            pubDate: parseDate(item.PUBLISH_DATE),
            // （オプション）作者
            // author: item.user.login,
            // （オプション）分類
            category: item.MEDIA_TYPES ? [tagIdNameMapping[Number.parseInt(item.MEDIA_TYPES.replaceAll('"', ''), 10)]] : [],
        }));
        return {
            // ウェブサイトのタイトル
            title: `ISCT News - ${lang}`,
            // ウェブサイトのリンク
            link: `https://www.isct.ac.jp/${lang}/news`,
            // 先に作成した内容
            item: items,
        };
    },
};
```

## 提出
**提出する前に、必ず[Script Standard](https://docs.rsshub.app/joinus/advanced/script-standard)に従ってください。**
テストが完了した後は、pull requestを作成し、要求に従ってフォームに必要事項を記入すれば、通常は1日以内にmergeされるはずである。

ここで終わり、人生初めてOSSに貢献できて嬉しい。
もし興味があれば、ぜひ試してみてください。
**また、オフィシャルハンドブックには日本語がないようだので、もし何か質問があれば気軽に聞いてください。**