tag:crieit.net,2005:https://crieit.net/tags/RSS/feed 「RSS」の記事 - Crieit Crieitでタグ「RSS」に投稿された最近の記事 2021-06-27T09:05:46+09:00 https://crieit.net/tags/RSS/feed tag:crieit.net,2005:PublicArticle/17450 2021-06-27T09:05:46+09:00 2021-06-27T09:05:46+09:00 https://crieit.net/posts/RSS-Slack スーパー完全無料でRSSをSlackに投稿できるやつを作った <h2 id="皆さん、どうやって技術ネタ、キャッチアップしてますか?"><a href="#%E7%9A%86%E3%81%95%E3%82%93%E3%80%81%E3%81%A9%E3%81%86%E3%82%84%E3%81%A3%E3%81%A6%E6%8A%80%E8%A1%93%E3%83%8D%E3%82%BF%E3%80%81%E3%82%AD%E3%83%A3%E3%83%83%E3%83%81%E3%82%A2%E3%83%83%E3%83%97%E3%81%97%E3%81%A6%E3%81%BE%E3%81%99%E3%81%8B%EF%BC%9F">皆さん、どうやって技術ネタ、キャッチアップしてますか?</a></h2> <p>皆さんはどうやって日々日進月歩な技術ネタをキャッチアップしてますか?</p> <p>私はよく企業や個人が書いている技術ブログから情報を得ることが多いです。本当に技術ブログって手軽なのにすごい勉強になりますよね。</p> <h2 id="皆さん、どうやってブログ記事を通知してますか?"><a href="#%E7%9A%86%E3%81%95%E3%82%93%E3%80%81%E3%81%A9%E3%81%86%E3%82%84%E3%81%A3%E3%81%A6%E3%83%96%E3%83%AD%E3%82%B0%E8%A8%98%E4%BA%8B%E3%82%92%E9%80%9A%E7%9F%A5%E3%81%97%E3%81%A6%E3%81%BE%E3%81%99%E3%81%8B%EF%BC%9F">皆さん、どうやってブログ記事を通知してますか?</a></h2> <p>ブログ記事確認はもちろん定期的にブログに訪問するのが一番ですが、なかなか時間の取れない中でそれは酷なので何かしら皆さん工夫していると思います。</p> <p>ブログの更新にあわせてTwitterを更新してくれる企業様であれば、Twitterのフォローをすればいいかもしれませんが、必ずしもそうでもないかもしれませんし、Twitterのフォローには技術以外の話題も飛び交うので、集中して記事を確認することも難しいかもしれません。</p> <p>そういったときに役立つのがRSSです。RSSとは<strong>R</strong>ich <strong>S</strong>ite <strong>S</strong>ummaryの略で、ニュースやブログなど各種のウェブサイトの更新情報を配信するための仕組みやXMLフォーマットのことです。</p> <p>RSSの更新を定期的に取得し、記事更新を教えてくれるRSSリーダーは皆さんお世話になっている人も多いのではないでしょうか?</p> <p>私もGoogle Chromeに拡張としてRSSリーダーを入れていた時期もありました。</p> <h2 id="問題点"><a href="#%E5%95%8F%E9%A1%8C%E7%82%B9">問題点</a></h2> <p>RSSリーダーを使って技術ブログの更新を検知する方法はおそらくデファクトスタンダードだと思いますが、個人的にちょっと問題点がありました。</p> <p>それは、<strong>通勤時間の時間をうまく使ってキャッチアップするのが面倒ということです。</strong></p> <ul> <li>携帯にPCと同じRSSを登録するのがめんどくさい</li> <li>RSSリーダーを開かない <ul> <li>電車に乗っているとTwitterやSlackを開いている時間がほぼ全て</li> <li>Kindleで読書するのも細かく乗り換えがあって中断が多く発生するためストレス</li> </ul></li> <li>(RSSリーダーによって違うのかもしれませんが)タイトルを見て中身を判断するのが難しい</li> </ul> <p>このような悩みがあるため、私は<strong>Slack</strong>の<strong>/feed</strong>機能を使ってRSSを購読してました。</p> <p>が、しかしこれもまたもや問題点。Slackの無料ワークスペースには、Appsが10個までしか登録できないのです。(/feedもAppsを消費します)</p> <p>Slackには他にもAppsをいくつか作って入れているため、実際登録できるRSSは5個くらいになってしまいちょっと心もとない感じになってしまいました。</p> <h3 id="IFTTTはどうなの?"><a href="#IFTTT%E3%81%AF%E3%81%A9%E3%81%86%E3%81%AA%E3%81%AE%EF%BC%9F">IFTTTはどうなの?</a></h3> <p>ちょっと詳しい人だと「じゃあIFTTT」はどうなんです?という意見が聞こえてきそうですが結果的にこちらも不採用。</p> <p>理由は上記とほぼ同じで、無料版だと設定できる数に制限があるためこちらもあえなく不採用。</p> <p>というより、お金出せよって声が聞こえてきますね。</p> <h2 id="じゃあ作ろっか"><a href="#%E3%81%98%E3%82%83%E3%81%82%E4%BD%9C%E3%82%8D%E3%81%A3%E3%81%8B">じゃあ作ろっか</a></h2> <p>ということで、作ります。</p> <p>要求は次の通りのことを満たす必要があります。</p> <ul> <li>無制限にRSSを登録できること</li> <li>更新がある場合のみSlackに投稿すること</li> <li>SlackもAppsを消費しないこと(Custom Integration)</li> <li>できれば内容を要約したものや、OGP画像も一緒に投稿して記事の選別に役立てられる付加機能を作ること</li> </ul> <h2 id="feedparser"><a href="#feedparser">feedparser</a></h2> <p>今回は時間もない中だったのでサクッとPythonで作っていきます。</p> <p>RSSの購読には<a target="_blank" rel="nofollow noopener" href="https://pythonhosted.org/feedparser/">feedparser</a>を使うと便利です。</p> <p>RSS2.0だけでなく、Atomや古いRSSの形式でも難なく読み込んでくれます。</p> <pre><code class="python">import feedparser entries = feedparser.parse('http://feedparser.org/docs/examples/rss20.xml') for e in entries: print(e.title) print(e.link) print(e.summary) </code></pre> <p>Entry Itemへのアクセスはイテレーターになっているので取り出しもかんたんです。</p> <p>RSSのEntry Itemの取り出しはこれで進めます。本当にかんたんでありがたい。</p> <p>さらに便利なのは<strong>published_parsed</strong>という項目がEntry Itemから取れます。</p> <p>こちら、RSSのpublished_dateをdatetimeオブジェクトにパースしてくれます。</p> <p>おかげで、フォーマット差分をあまり意識することなく、更新差分チェック実装ができました。</p> <h2 id="ステート管理"><a href="#%E3%82%B9%E3%83%86%E3%83%BC%E3%83%88%E7%AE%A1%E7%90%86">ステート管理</a></h2> <p>RSSには記事の作成日付(Publish Date)があり、RSSの取得のたびに差分チェックとして活用することができます。</p> <p>なので、以前取得した記事のPublish Dateを記憶して、更新があった場合のみ記事を取得するようにしたいのですが、それには何かしらのDB、もしくはデータ保存する仕組みが必要となります。</p> <p>今回は無料という縛りがあるため、当初はGitHubのレポジトリ上にステートファイルをコミットするようにしようとも思ったのですが、コミットが伸び過ぎてしまうのは色々問題なのでやはりDBを使いたいです。</p> <h3 id="HarperDB"><a href="#HarperDB">HarperDB</a></h3> <p>HarperDBは、データ管理を容易にすることに重点を置いた分散型データベースで、ジョインを含むNoSQLとSQLをサポートしています。</p> <p>NoSQLでSQLがかけるのは便利ですね!!</p> <p>日本ではあまり聞きませんが、<a target="_blank" rel="nofollow noopener" href="https://dev.to/">dev.to</a>とかだとちょこちょこ話題に上がっております。</p> <p>こちらのHarperDB、HarperDB Cloud Instanceというマネージドサービスも提供されており、インスタンスタイプを選ぶだけで、手軽にHarperDBを使うことができるようになっております。</p> <p><img src="https://i.imgur.com/CA1sLCU.png" alt="harperdb" /></p> <p><img src="https://i.imgur.com/48qXVQw.png" alt="img" /></p> <p>え?でもお高いんじゃない?そんな声が聞こえてきますね。</p> <p>なんと、今だけかもしれませんがHarperDB Cloud Instanceの一番最小のInstance構成だと無料で使うことができます!これは嬉しいですね。</p> <div class="table-responsive"><table> <thead> <tr> <th>Name</th> <th>Value</th> </tr> </thead> <tbody> <tr> <td>RAM</td> <td>0.5GB</td> </tr> <tr> <td>DISK</td> <td>1GB</td> </tr> <tr> <td>VERSION</td> <td>3.0.0</td> </tr> <tr> <td>IOPS</td> <td>3000</td> </tr> </tbody> </table></div> <p>正直今回の使い方ではこのレベルで十分です。</p> <p>Python上でのHarperDB操作も<a target="_blank" rel="nofollow noopener" href="https://pypi.org/project/harperdb/">専用のライブラリ</a>が用意されているためかんたんに実装できます。</p> <pre><code class="python">HARPERDB_URL = os.getenv("HARPERDB_URL") HARPERDB_USERNAME = os.getenv("HARPERDB_USERNAME") HARPERDB_PASSWORD = os.getenv("HARPERDB_PASSWORD") HARPERDB_SCHEMA = os.getenv("HARPERDB_SCHEMA", "prd") FILEPATH = "entry.csv" db = harperdb.HarperDB( url=HARPERDB_URL, username=HARPERDB_USERNAME, password=HARPERDB_PASSWORD,) test = db.search_by_hash(HARPERDB_SCHEMA, "last_published", [name], get_attributes=["time"]) for t in test: print(t["time"]) </code></pre> <p>このようにNoSQLライクにHash Attributeを使って検索する感じで実装できます。もちろんValue引きも可能です。(遅くなるのかは不明だがNoSQLなら全走査になりそうなので多分遅い)</p> <p>UpdateやInsertも同様な感じで実施できます。</p> <pre><code class="python">ef insert_last_published(name: str): db.insert(HARPERDB_SCHEMA, "last_published", [{"name": name, "time": 123456789}]) return 123456789 def update_last_published(name: str, time: int): result = db.update(HARPERDB_SCHEMA, "last_published", [{"name": name, "time": time}]) return result </code></pre> <p>また、便利だなと思ったのはやはりSQLでの走査です。</p> <pre><code class="python">def get_entry_urls(): return [{"name": x["name"], "url": x["url"], "icon": x["icon"]} for x in db.sql(f"select * from {HARPERDB_SCHEMA}.entry_urls")] </code></pre> <p>といった具合にテーブルの*Selectやジョインなんかも書くことができます。テーブル全体をなめたいとき、これは楽でいいですね。</p> <p>また、CSV load機能もあり、CSVをHarperDBに食わせることもできちゃったりします。</p> <p>今回はこちらの機能はRSSのEntryURL登録機能として便利に使用させていただきました。</p> <pre><code class="python">import os import harperdb HARPERDB_URL = os.getenv("HARPERDB_URL") HARPERDB_USERNAME = os.getenv("HARPERDB_USERNAME") HARPERDB_PASSWORD = os.getenv("HARPERDB_PASSWORD") HARPERDB_SCHEMA = os.getenv("HARPERDB_SCHEMA", "prd") FILEPATH = "entry.csv" db = harperdb.HarperDB( url=HARPERDB_URL, username=HARPERDB_USERNAME, password=HARPERDB_PASSWORD,) db.csv_data_load(HARPERDB_SCHEMA, "entry_urls", FILEPATH, action="upsert") </code></pre> <p>無料開発で一番ネックになるのがDBですが、正直これだけで大概のアプリは作れてしまうのではないでしょうか?</p> <h2 id="OGP画像を得るには?"><a href="#OGP%E7%94%BB%E5%83%8F%E3%82%92%E5%BE%97%E3%82%8B%E3%81%AB%E3%81%AF%EF%BC%9F">OGP画像を得るには?</a></h2> <p>OGPとは<strong>O</strong>pen <strong>G</strong>raph <strong>P</strong>rotocolの略で、TwitterやFacebookにURLリンクを貼り付けると出てくるあれです。</p> <p><img src="https://i.imgur.com/4LAaL3b.png" alt="img" /></p> <p>実際OGP作成を実装された方ならわかりますが、OGPはHTMLのHeaderに決まりきったmetaタグを記載して表現しております。</p> <pre><code class="html"><meta property="og:type" content="article" data-react-helmet="true"> <meta property="og:url" content="https://blog.tubone-project24.xyz/2021/01/01/mqtt-nenga" data-react-helmet="true"> <meta property="og:title" content="MQTTと電子ペーパーを使って年賀状を作る" data-react-helmet="true"> <meta property="og:description" content="年賀書きたくないマン Table of Contents 一年の計は元旦にあり 注意 年末年始はやってみようBOX MQTT React Hooks Tailwind CSS 電子ペーパー やらないことにしようBOX アーキテクチャー 辛かったこと Hooks…" data-react-helmet="true"> <meta property="og:image" content="https://i.imgur.com/tmkmoVA.png" data-react-helmet="true"> <meta name="twitter:title" content="MQTTと電子ペーパーを使って年賀状を作る" data-react-helmet="true"> <meta name="twitter:description" content="年賀書きたくないマン Table of Contents 一年の計は元旦にあり 注意 年末年始はやってみようBOX MQTT React Hooks Tailwind CSS 電子ペーパー やらないことにしようBOX アーキテクチャー 辛かったこと Hooks…" data-react-helmet="true"> <meta name="twitter:image" content="https://i.imgur.com/tmkmoVA.png" data-react-helmet="true"> </code></pre> <p>Slackのattachmentsに入れる画像はOGPのImageから取るようにします。</p> <h3 id="opengraph-py3"><a href="#opengraph-py3">opengraph-py3</a></h3> <p>PythonでOGPを解析するなら<a target="_blank" rel="nofollow noopener" href="https://pypi.org/project/opengraph_py3/">opengraph</a>ライブラリが便利です。ただし、</p> <pre><code>pip install opengraph </code></pre> <p>でインストールするとPython2用のライブラリがインストールされてしまいまともに動かないので、</p> <pre><code>pip install opengraph_py3 </code></pre> <p>でインストールするようにします。</p> <p>使い方もかんたんで、<strong>opengraph_py3.OpenGraph</strong>でインスタンスを作ってあげれば、<strong>ogp["image"]</strong>にOGPイメージURLが保存されます。</p> <p>一点注意としてopengraphは裏でBeautifulSoapが動いているようで、Headerのないページに対してOGPを取得しようとするとAttributeErrorが出てしまうので例外処理を入れております。</p> <p>本家にPR出すか迷いましたが、2017年から更新がないので骨折り損になりそうなので、やめておきます。</p> <pre><code class="python">import opengraph_py3 def get_ogp_image(link: str): try: ogp = opengraph_py3.OpenGraph(url=link) if ogp.is_valid(): return ogp["image"] else: return "" except AttributeError as e: logger.debug(f"No Head contents: {e}") return "" </code></pre> <h2 id="Favicon"><a href="#Favicon">Favicon</a></h2> <p>できれば、Slack投稿するときに技術ブログのアイコンをブログごとに変えたいなと思ったので、Faviconを取る実装も入れます。</p> <p>Pythonにはfavicon取るためのライブラリ<a target="_blank" rel="nofollow noopener" href="https://pypi.org/project/favicon/">favicon</a>があります。</p> <p>使い方も超かんたんで、<strong>favicon.get</strong>で取得したオブジェクトの配列0番目が一番大きなfaviconなのでそれを取るだけです。</p> <pre><code class="python">import favicon def get_favicon(link): icons = favicon.get(link) if len(icons) == 0: return "" else: return icons[0].url </code></pre> <h2 id="キーワード抽出"><a href="#%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E6%8A%BD%E5%87%BA">キーワード抽出</a></h2> <p>さて、今回の醍醐味のキーワード抽出ですがこちらもかんたんに実装できます。</p> <p><a target="_blank" rel="nofollow noopener" href="http://gensen.dl.itc.u-tokyo.ac.jp/pytermextract/">pytermextract</a>という専門用語抽出ツールと形態素解析ライブラリ<a target="_blank" rel="nofollow noopener" href="https://mocobeta.github.io/janome/">janome</a>を組み合わせることでかんたんに実現できます。</p> <p>janomeは本当に便利で、特にCIに乗っけてぐるぐるしたい人にはmecabをインストールする必要も辞書をコンパイルする必要もなく、pipで一発入れれば使えるので重宝しています。</p> <p>pytermextractはPyPI登録されているライブラリではないのでインストールは公式サイトから落としたZIPを展開しsetup.pyから行います。</p> <p>また、janomeもpipでインストールします。</p> <pre><code class="shell">unzip pytermextract-0_01.zip cd pytermextract-0_01 python setup.py install pip install janome </code></pre> <p>まずは、キーワード抽出したいテキストをjanomeのTokenizerにかけて、結果を頻出度から単名詞の左右の連接情報スコア(LR)を算出し、</p> <p>重要度スコアとしてはじき出す、という仕組みらしいです。とは言っても私にはよくわからなったのでサンプルコード丸パクリです。</p> <p>得られる結果は<strong>{"単語": スコア}</strong>となってますので、こちらをスコア順にリバースソートして上位6位を取得する形にしました。</p> <p>しょうもない知識ですが、janomeのTokenizerインスタンス作るところは処理コストがちょっと高いので、リファクタでモジュールトップレベルでの宣言にしてます。</p> <pre><code class="python">from janome.tokenizer import Tokenizer import termextract.janome import termextract.core t = Tokenizer() def extract_keyword(text): tokenize_text = t.tokenize(text) frequency = termextract.janome.cmp_noun_dict(tokenize_text) lr = termextract.core.score_lr( frequency, ignore_words=termextract.janome.IGNORE_WORDS, lr_mode=1, average_rate=1) term_imp = termextract.core.term_importance(frequency, lr) score_sorted_term_imp = sorted(term_imp.items(), key=lambda x: x[1], reverse=True) logger.debug(f"keywords: {score_sorted_term_imp}") return score_sorted_term_imp[:6] </code></pre> <h3 id="RSSのSummaryTextでは精度がでない、そりゃそうじゃ。"><a href="#RSS%E3%81%AESummaryText%E3%81%A7%E3%81%AF%E7%B2%BE%E5%BA%A6%E3%81%8C%E3%81%A7%E3%81%AA%E3%81%84%E3%80%81%E3%81%9D%E3%82%8A%E3%82%83%E3%81%9D%E3%81%86%E3%81%98%E3%82%83%E3%80%82">RSSのSummaryTextでは精度がでない、そりゃそうじゃ。</a></h3> <p>見出し通りですが、当初はfeedparserから取得できるEntry ItemのSummaryをpytermextractに食わせてましたが、SummaryTextが短すぎて全く期待する動作になりませんでしたので、BeautifulSoupを使って、実際の記事の本文を取得しpytermextractに食わせる実装に変更しました。</p> <pre><code class="python">from bs4 import BeautifulSoup import urllib.request as req def extract_html_text(url): res = req.urlopen(url) soup = BeautifulSoup(res, "html.parser") p_tag_list = soup.find_all("p") return " ".join([p.get_text() for p in p_tag_list]) </code></pre> <p>本文はpタグと判断しfind_allするちんけな実装です。ごめんなさい。</p> <h2 id="Slack投稿"><a href="#Slack%E6%8A%95%E7%A8%BF">Slack投稿</a></h2> <p>いよいよSlack投稿部分の作成です。</p> <p>Slack投稿はCustomIntegrationのIncoming Webhookで作ります。</p> <p>なので、<a target="_blank" rel="nofollow noopener" href="https://api.slack.com/reference/messaging/attachments">Slack attachment</a>が使えます。</p> <p>特質したことはないのですが、OGP画像はimage_urlに、faviconはauthor_imageにキーワードはfieldsに入れてます。</p> <h2 id="GitHub Actions化"><a href="#GitHub+Actions%E5%8C%96">GitHub Actions化</a></h2> <p>最後にGitHub Actionsに載せて、定期実行させます。</p> <p>その前にの<a href="#harperdb">#harperdb</a>でも書いたとおり、RSS追加時のHarperDBへのEntry追加の定義を書いていきます。</p> <p>特定のファイルに更新があった場合のみ動くGitHub Actionsを作る場合は、 on_pushなどの条件にpathsを入れることで実現できます。これだけです。</p> <pre><code class="yml">on: push: branches: - main paths: - "entry.csv" pull_request: branches: - main paths: - "entry.csv" </code></pre> <p>また、定期実行にはschedule cronが便利です。</p> <pre><code class="yml">on: push: branches: - main pull_request: branches: - main schedule: - cron: "*/30 * * * *" </code></pre> <h2 id="完成"><a href="#%E5%AE%8C%E6%88%90">完成</a></h2> <p>ということでできました。</p> <p><img src="https://i.imgur.com/Ip4IaYs.png" alt="img" /></p> <p>entry.csvに書いたRSS feedを30分ごとに確認しにいき、前回よりpublish_dateの更新があったばあいはOGP, favicon, キーワード付きでSlack投稿します。</p> <p>レポジトリはこちらです。</p> <p><a target="_blank" rel="nofollow noopener" href="https://github.com/tubone24/tech_blog_spider">https://github.com/tubone24/tech_blog_spider</a></p> <p>ForkするとGitHubA ctionsがうまく発火しないっぽいので、もし利用する際はgit cloneして自身のレポジトリに再Pushして使っていただければと思います。</p> <h2 id="結論"><a href="#%E7%B5%90%E8%AB%96">結論</a></h2> <p>HarperDBを使って何でもつくれそうな予感がするこの頃です。</p> tubone24 tag:crieit.net,2005:PublicArticle/16407 2020-12-22T16:15:41+09:00 2020-12-22T16:15:41+09:00 https://crieit.net/posts/hugo-react-dev Hugo で React + TypeScript を利用してサクッとウェブサイトに RSS リーダーを追加する <p>この記事は <a target="_blank" rel="nofollow noopener" href="https://qiita.com/advent-calendar/2020/static-site-generator">Static Site Generator Advent Calendar 2020</a> 22日目の記事です。</p> <h1 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h1> <p>Hugo のウェブサイトに組み込む RSS リーダーを TypeScript で開発してみたいと思い調査したところ、Hugo の最新版には <a target="_blank" rel="nofollow noopener" href="https://github.com/evanw/esbuild">ESBuild</a> が組み込まれていて、<strong>非常に手厚く JavaScript の開発環境がサポートされていることが分かりました。</strong> 本記事では紹介していませんが <a target="_blank" rel="nofollow noopener" href="https://gohugo.io/hugo-pipes/babel/">Babel</a> も利用できるようです。</p> <p>また、NPM パッケージも利用できるため、普段のウェブ開発と同様の流れで開発ができ、各種ライブラリを用いた開発も非常に楽でした。<br /> 今回は Hugo で JavaScript 開発する方法を RSS リーダーの開発を例に上げ、そこで得た知見についても交える形で記事として残しておくことにしました。</p> <p><strong>ちなみに本記事内容は Hugo で JavaScript 開発する方法に焦点を絞ったものなのですが、ウェブサイトに RSS リーダーを組み込むことに焦点を絞って見たい方は <a href="#(%E4%BD%99%E8%AB%87)-rss-%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92-hugo-%E3%81%AE-data-templates-%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B"><code>RSS リーダーを Hugo の Data Templates で実装する</code></a> から見ていただくことをオススメします。</strong></p> <h1 id="Hugo で JavaScript (React + TypeScript) の開発環境を整える"><a href="#Hugo+%E3%81%A7+JavaScript+%28React+%2B+TypeScript%29+%E3%81%AE%E9%96%8B%E7%99%BA%E7%92%B0%E5%A2%83%E3%82%92%E6%95%B4%E3%81%88%E3%82%8B">Hugo で JavaScript (React + TypeScript) の開発環境を整える</a></h1> <p>まず、<strong>TypeScript のビルドは ESBuild に任せることができるため何も行う必要はありません。</strong> そのため React 開発用パッケージのインストールのみ行えば大丈夫です。</p> <p>Hugo プロジェクトのルートディレクトリで下記コマンドを実行し、<code>package.json</code> を作成してから、React の開発に必要なパッケージをインストールします。</p> <pre><code class="bash">npm init -y npm install --save react react-dom </code></pre> <p>無事パッケージのインストールが完了したら、早速 TSX ファイルを <code>assets/js/App.tsx</code> に作成してしまいます。</p> <pre><code class="javascript">// assets/js/App.tsx import * as React from "react"; import * as ReactDOM from "react-dom"; function App() { return ( <> Hello React! </> ); } ReactDOM.render( <App />, document.getElementById("react") ); </code></pre> <p>上記のコードを見てもらえば分かる通り、レンダリング先に <code>id</code> が <code>react</code> の DOM ノードを指定しています。そのため Hugo 側で該当する DOM ノードを用意する必要があります。その際の HTML テンプレートは下記になります。</p> <pre><code class="html"><!-- ... --> <!-- 利用するリソースを指定する --> <span>{</span><span>{</span> with resources.Get "js/App.tsx" <span>}</span><span>}</span> <!-- id が react の div 要素を用意する --> <div id="react"></div> <!-- TSX を ESBuild でビルドする際の Hugo のオプションを指定する --> <span>{</span><span>{</span> $options := dict "targetPath" "js/app.js" "minify" true "defines" (dict "process.env.NODE_ENV" "\"development\"") <span>}</span><span>}</span> <!-- TSX のビルドを Hugo のオプションで指定した内容で実行する --> <span>{</span><span>{</span> $js := resources.Get . | js.Build $options <span>}</span><span>}</span> <!-- 一応 SRI を有効化した状態でビルドした JS を読み込む --> <span>{</span><span>{</span> $secureJS := $js | resources.Fingerprint "sha512" <span>}</span><span>}</span> <script src="<span>{</span><span>{</span> $secureJS.Permalink <span>}</span><span>}</span>" integrity="<span>{</span><span>{</span> $secureJS.Data.Integrity <span>}</span><span>}</span>"></script> <span>{</span><span>{</span> end <span>}</span><span>}</span> <!-- ... --> </code></pre> <p>ちなみに <code>$options</code> で指定している ESBuild でビルド時に指定可能なオプションは <a target="_blank" rel="nofollow noopener" href="https://gohugo.io/hugo-pipes/js/">Hugo の公式ページ</a> に記載されています。</p> <p>上記 HTML の記述を RSS リーダーを埋め込みたいページに追加します。<br /> この状態で該当ページにアクセスすると下記のような表示が確認できるはずです。</p> <p><img src="https://i.gyazo.com/7e196a2a52f492771deb5dd6913bbe60.png" alt="Hello React! と画面に表示される" /><br /> <strong>App.tsx で定義した内容が画面に表示される</strong></p> <p>これで React + TypeScript の開発環境が整いました。</p> <h1 id="RSS リーダーを実装する"><a href="#RSS+%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B">RSS リーダーを実装する</a></h1> <p>あとは一般的な Web フロントエンド開発の流れで RSS リーダーの開発を進めていくだけです。</p> <h2 id="ウェブサイトで読み込みたい RSS フィードを準備する"><a href="#%E3%82%A6%E3%82%A7%E3%83%96%E3%82%B5%E3%82%A4%E3%83%88%E3%81%A7%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%9F%E3%81%84+RSS+%E3%83%95%E3%82%A3%E3%83%BC%E3%83%89%E3%82%92%E6%BA%96%E5%82%99%E3%81%99%E3%82%8B">ウェブサイトで読み込みたい RSS フィードを準備する</a></h2> <p>RSS フィードを利用する際は必ず提供しているサービスの利用規約をご確認ください。<br /> <a target="_blank" rel="nofollow noopener" href="https://qiita.com/terms">Qiita</a> 及び <a target="_blank" rel="nofollow noopener" href="https://zenn.dev/terms">Zenn</a> については個人利用かつ自分の情報のみを扱う範囲内であれば利用が許可されているように見受けられました。</p> <p>下準備としてウェブサイトで読み込みたい RSS フィードを事前にダウンロードするためのバッチを作成します。バッチは NPM を利用して作成していきます。<strong>NPM を導入したので Hugo で利用する簡易なバッチは JavaScript でサクッと作成していきます。</strong></p> <p>まずはスクリプト作成の際に必要となるパッケージを事前にいくつかインストールします。</p> <pre><code class="bash"># html をテキスト変換にするパッケージと RSS フィードのパーサーをインストールする npm i -D --save html-to-text rss-parser </code></pre> <p>実際のコードは下記になります。ファイル名末尾が <code>.mjs</code> なのは <a target="_blank" rel="nofollow noopener" href="https://dev.to/mikeesto/top-level-await-in-node-2jad">Top-Level Await</a> を使用したいからです。</p> <pre><code class="javascript">// scripts/update-rss.mjs import { writeFileSync } from 'fs'; import pkg from 'html-to-text'; const { htmlToText } = pkg; import Parser from 'rss-parser'; const parser = new Parser(); // 自ブログで読み込みたい RSS フィードの情報を設定する const rssFeed = { Zenn: { rss_url: 'https://zenn.dev/nikaera/feed', profile_url: 'https://zenn.dev/nikaera', }, Qiita: { rss_url: 'https://qiita.com/nikaera/feed.atom', profile_url: 'https://qiita.com/nikaera', } } try { const jsonFeed = {} // RSS フィード内の description を 73字で切り取り末尾に ... を付与する関数 const spliceContent = (content) => `${htmlToText(content).slice(0, 73)}...` // rssFeed 変数で定義されてる情報を繰り返し処理する for (const [site, info] of Object.entries(rssFeed)) { // RSS フィードの URL から必要な情報を取得する const feed = await parser.parseURL(info.rss_url); // RSS フィードに登録されている項目で必要な情報のみを取得する const items = feed.items.map((i) => { return { title: i.title, content: spliceContent(i.content), url: i.link, date: i.pubDate } }) // 取得内容は jsonFeed に格納する const { rss_url, profile_url } = info jsonFeed[site] = { rss_url, profile_url, items }; } // 最後に jsonFeed に格納された内容を JSON 文字列として static/rss.json に出力する writeFileSync('./static/rss.json', JSON.stringify(jsonFeed)); } catch(err) { console.error(err); } </code></pre> <p>次に <code>package.json</code> の <code>scripts</code> に登録してコマンドとして実行可能にします。</p> <pre><code class="json">{ "scripts": { "update-rss": "node ./scripts/update-rss.mjs" } } </code></pre> <p>これで <code>npm run update-rss</code> を実行すれば自ブログで表示する際に用いる JSON ファイルとして RSS フィードの内容を <code>static/rss.json</code> に出力できます。また、JSON ファイルは <code>static</code> フォルダに出力しているため <code>http://localhost:1313/rss.json</code> でアクセスできます。</p> <p><img src="https://i.gyazo.com/508ba87c41f1c1e410b89ff1bb56be4e.png" alt="npm run update-rss を実行して出力した rss.json" /><br /> <strong>npm run update-rss を実行して出力した rss.json</strong></p> <p><img src="https://i.gyazo.com/9b7ebeedce1cb69b6b3ab8acacb0b1d1.png" alt="npm run update-rss を実行して出力した rss.json にブラウザからアクセスする" /><br /> <strong><code>http://localhost:1313/rss.json</code> にアクセスして出力した rss.json が参照可能なことを確認する</strong></p> <h2 id="RSS リーダーを React + TypeScript で実装する"><a href="#RSS+%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92+React+%2B+TypeScript+%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B">RSS リーダーを React + TypeScript で実装する</a></h2> <p>準備が整ったので、早速 RSS リーダーを作成していきます。</p> <p>下記は Hugo のテーマの 1つである <a target="_blank" rel="nofollow noopener" href="https://themes.gohugo.io/hugo-papermod/">hugo-PaperMod</a> の <code>archives</code> テンプレートを利用してページに埋め込むことを想定した RSS リーダーのコードです。</p> <pre><code class="typescript">// assets/js/Rss.tsx import React, { useMemo, useState } from 'react' import * as superagent from 'superagent'; const Rss = (props) => { const [feed, setFeed] = useState({}); const { name } = props; useMemo(() => { (async () => { try { const res = await superagent.get('/rss.json'); setFeed(res.body[name]); } catch (err) { console.error(err); } })() }, [name]); if (!("items" in feed)) return null return ( <div className="archive-month"> <h3 className="archive-month-header"> <a href={feed.profile_url} target="_blank" rel="noopener noreferrer">{name}</a> - <a href={feed.rss_url} target="_blank" rel="noopener noreferrer">RSS</a> </h3> <div className="archive-posts"> {feed.items.map((item) => { return <div className="archive-entry" key={item.url}> <h3 className="archive-entry-title">{item.title}</h3> <div className="archive-meta">{item.date} - {item.content}</div> <a className="entry-link" href={item.url} target="_blank" rel="noopener noreferrer">&nbsp;</a> </div> })} </div> </div> ) } export default Rss </code></pre> <p>次に <code>assets/js/App.tsx</code> で <code>assets/js/Rss.tsx</code> を読み込み画面に表示できるよう改修します。</p> <pre><code class="javascript">// assets/js/App.tsx import Rss from './Rss'; import * as React from "react"; import * as ReactDOM from "react-dom"; function App() { return ( <> <div class="archive-year"> <h2 class="archive-year-header"> Tech 🦾 </h2> <Rss name="Zenn" /> <Rss name="Qiita" /> </div> </> ); } ReactDOM.render( <App />, document.getElementById("react") ); </code></pre> <p>これで RSS リーダーを埋め込んだページを閲覧すると下記のような画面が表示されるはずです。</p> <p><img src="https://i.gyazo.com/0a6b8923d141ae70f5e298637f5acc69.png" alt="hugo-PaperMod で archives テンプレートを用いて RSS リーダーを表示する" /><br /> <strong>hugo-PaperMod で <code>archives</code> テンプレートを用いて RSS リーダーを表示したときの画面</strong></p> <p>もし他の RSS フィードを追加したい場合は <code>scripts/update-rss.mjs</code> の <code>rssFeed</code> 変数に情報を追加して、<code>App.tsx</code> に <code><Rss name="<rssFeed 変数で定義した RSS Feed 名>" /></code> を定義することで対応できます。</p> <h1 id="RSS フィードの内容を自動で更新する"><a href="#RSS+%E3%83%95%E3%82%A3%E3%83%BC%E3%83%89%E3%81%AE%E5%86%85%E5%AE%B9%E3%82%92%E8%87%AA%E5%8B%95%E3%81%A7%E6%9B%B4%E6%96%B0%E3%81%99%E3%82%8B">RSS フィードの内容を自動で更新する</a></h1> <p><code>npm run update-rss</code> を手元で実行して <code>static/rss.json</code> を更新して公開すれば、最新の RSS フィードの内容をページに反映できる状態ですが、都度手動で更新するのは面倒な作業です。</p> <p>そこで今回は GitHub Actions の <code>schedule</code> を用いて <code>static/rss.json</code> の更新を自動化します。</p> <h2 id="GitHub Actions のワークフローファイルを作成する"><a href="#GitHub+Actions+%E3%81%AE%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B">GitHub Actions のワークフローファイルを作成する</a></h2> <p>実際のワークフローファイルは下記になります。<code>schedule</code> の項目で設定している内容がワークフローの実行スケジュールになります。今回は半日毎に更新が走るようにしました。</p> <pre><code class="yml"># .github/workflows/update-rss.yml name: update rss json file on: push: branches: - main # Set a branch name to trigger deployment schedule: - cron: '0 */12 * * *' # 今回は半日に 1回のタイミングで更新するようにした jobs: build: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 with: ref: main submodules: true # Fetch Hugo themes (true OR recursive) fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod - name: Use Node.js 14.10.1 uses: actions/setup-node@v1 with: node-version: 14.10.1 - name: Install dependencies run: npm install - name: Update RSS Feeds run: npm run update-rss - name: Commit files run: | git config --local user.email "[email protected]" git config --local user.name "GitHub Action" git add static/rss.json STATUS=$(git status -s) if [ -n "$STATUS" ]; then git commit -m "Update rss.json `date +'%Y-%m-%d %H:%M:%S'`" -a git push origin main fi </code></pre> <p>上記ワークフローファイルをプロジェクトに追加して、リモートリポジトリにプッシュした後は、ワークフローが実行されるタイミングを待ちます。</p> <p>無事にワークフローの実行が完了すると下記のようなコミットが追加されているはずです。</p> <p><img src="https://i.gyazo.com/ebb7cb2e64b13e4a1e1a592836f511f5.png" alt="GitHub Actions が JSON ファイルを更新してコミットしている" /><br /> <strong>GitHub Actions が JSON ファイルを更新してコミットしている</strong></p> <p><img src="https://i.gyazo.com/1a74399a3cf1053e0480a01590086fbe.png" alt="コミットの詳細を見ると正常に JSON ファイルが更新されていることを確認できる" /><br /> <strong>コミットの詳細を見ると正常に JSON ファイルが更新されていることが確認できる</strong></p> <p><img src="https://i.gyazo.com/bf86668d5fc32ca09b6d2cfcf71262ce.png" alt="コミット後 Hugo をビルド & デプロイするとページが更新されていることを確認できる" /><br /> <strong>コミット後 Hugo をビルド & デプロイするとページが更新されていることを確認できる</strong></p> <p>これで Zenn や Qiita 等に記事を書いた際に、都度手動で <code>static/rss.json</code> を更新してページに最新の内容を反映させる作業は必要なくなりました。</p> <h1 id="(余談) RSS リーダーを Hugo の Data Templates で実装する"><a href="#%28%E4%BD%99%E8%AB%87%29+RSS+%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92+Hugo+%E3%81%AE+Data+Templates+%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B">(余談) RSS リーダーを Hugo の Data Templates で実装する</a></h1> <p>ちなみに Hugo には <a target="_blank" rel="nofollow noopener" href="https://gohugo.io/templates/data-templates/">Data Templates</a> という仕組みがあり、これを用いることで実は JavaScript を利用しなくても HTML テンプレートで RSS リーダーを実現できるということを後から知りました。</p> <p>そこで最後に Data Template での RSS リーダーの実装方法について記載します。</p> <p>まずは、<code>scripts/update-rss.mjs</code> の内容を書き換えます。</p> <pre><code class="typescript">// scripts/update-rss.mjs import { writeFileSync } from 'fs'; import pkg from 'html-to-text'; const { htmlToText } = pkg; import Parser from 'rss-parser'; const parser = new Parser(); const rssFeed = { Zenn: { rss_url: 'https://zenn.dev/nikaera/feed', profile_url: 'https://zenn.dev/nikaera' }, Qiita: { rss_url: 'https://qiita.com/nikaera/feed.atom', profile_url: 'https://qiita.com/nikaera' } } try { const jsonFeed = {} const spliceContent = (content) => `${htmlToText(content).slice(0, 73)}...` for (const [site, info] of Object.entries(rssFeed)) { const feed = await parser.parseURL(info.rss_url); const items = feed.items.map((i) => { console.log(i); return { title: i.title, content: spliceContent(i.content), url: i.link, date: i.pubDate } }) const { rss_url, profile_url } = info jsonFeed[site] = { rss_url, profile_url, items }; /* 最終的な JSON ファイルの出力先は data フォルダとなり、RSS フィード毎に出力する 例: ./data/Qiita.json, ./data/Zenn.json, etc. */ writeFileSync(`./data/${site}.json`, JSON.stringify(jsonFeed[site])); } } catch(err) { console.error(err); } </code></pre> <p>上記を実行することで <code>data/Qiita.json</code> や <code>data/Zenn.json</code> にファイルが出力されます。</p> <p>Hugo の Data Template を用いると <code>data</code> フォルダ内に配置した <code>json</code>, <code>yaml</code>, <code>toml</code> 形式のファイルは Go の HTML テンプレートで読み込めるようになります。</p> <p>例えば、<strong><code>data/Qiita.json</code> に配置された JSON ファイルを読み込みたい場合は Go のテンプレートで <code>$Qiita := $.Site.Data.Qiita</code> のような記述でできます。</strong></p> <p>次に RSS リーダーを埋め込んでいたページを下記のように書き換えます。</p> <pre><code class="html"><!-- ... --> <!-- React 関連の記述を全て削除する --> <!-- <span>{</span><span>{</span> with resources.Get "js/App.tsx" <span>}</span><span>}</span> <div id="react"></div> <span>{</span><span>{</span> $options := dict "targetPath" "js/app.js" "minify" true "defines" (dict "process.env.NODE_ENV" "\"development\"") <span>}</span><span>}</span> <span>{</span><span>{</span> $js := resources.Get . | js.Build $options <span>}</span><span>}</span> <span>{</span><span>{</span> $secureJS := $js | resources.Fingerprint "sha512" <span>}</span><span>}</span> <script src="<span>{</span><span>{</span> $secureJS.Permalink <span>}</span><span>}</span>" integrity="<span>{</span><span>{</span> $secureJS.Data.Integrity <span>}</span><span>}</span>"></script> <span>{</span><span>{</span> end <span>}</span><span>}</span> --> <div class="archive-year"> <h2 class="archive-year-header"> Tech 🦾 </h2> <div class="archive-month"> <!-- data/Zenn.json の内容を読み込む --> <span>{</span><span>{</span> $Zenn := $.Site.Data.Zenn <span>}</span><span>}</span> <h3 class="archive-month-header"> <a href="<span>{</span><span>{</span> $Zenn.profile_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">Zenn</a> - <a href="<span>{</span><span>{</span> $Zenn.rss_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">RSS</a> </h3> <div class="archive-posts"> <!-- 配列で格納されている記事情報を繰り返し処理で取得する --> <span>{</span><span>{</span>- range $Zenn.items <span>}</span><span>}</span> <div class="archive-entry" key="<span>{</span><span>{</span> .url <span>}</span><span>}</span>"> <h3 class="archive-entry-title"><span>{</span><span>{</span> .title <span>}</span><span>}</span></h3> <div class="archive-meta"><span>{</span><span>{</span> .date <span>}</span><span>}</span> - <span>{</span><span>{</span> .content <span>}</span><span>}</span></div> <a class="entry-link" aria-label="<span>{</span><span>{</span> .content <span>}</span><span>}</span>" href="<span>{</span><span>{</span> .url <span>}</span><span>}</span>" target=" _blank" rel="noopener noreferrer"></a> </div> <span>{</span><span>{</span>- end <span>}</span><span>}</span> </div> </div> <div class="archive-month"> <!-- data/Qiita.json の内容を読み込む --> <span>{</span><span>{</span> $Qiita := $.Site.Data.Qiita <span>}</span><span>}</span> <h3 class="archive-month-header"> <a href="<span>{</span><span>{</span> $Qiita.profile_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">Qiita</a> - <a href="<span>{</span><span>{</span> $Qiita.rss_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">RSS</a> </h3> <div class="archive-posts"> <!-- 配列で格納されている記事情報を繰り返し処理で取得する --> <span>{</span><span>{</span>- range $Qiita.items <span>}</span><span>}</span> <div class="archive-entry" key="<span>{</span><span>{</span> .url <span>}</span><span>}</span>"> <h3 class="archive-entry-title"><span>{</span><span>{</span> .title <span>}</span><span>}</span></h3> <div class="archive-meta"><span>{</span><span>{</span> .date <span>}</span><span>}</span> - <span>{</span><span>{</span> .content <span>}</span><span>}</span></div> <a class="entry-link" aria-label="<span>{</span><span>{</span> .content <span>}</span><span>}</span>" href="<span>{</span><span>{</span> .url <span>}</span><span>}</span>" target=" _blank" rel="noopener noreferrer"></a> </div> <span>{</span><span>{</span>- end <span>}</span><span>}</span> </div> </div> </div> <!-- ... --> </code></pre> <p>また GitHub Actions のワークフローを用いて RSS フィードの情報を更新していた場合は、<code>.github/workflows/update-rss.yml</code> ファイルの更新も必要になります。</p> <pre><code class="yml"># .github/workflows/update-rss.yml name: update rss json file on: push: branches: - main # Set a branch name to trigger deployment schedule: - cron: '0 */12 * * *' jobs: build: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 with: ref: main submodules: true # Fetch Hugo themes (true OR recursive) fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod - name: Use Node.js 14.10.1 uses: actions/setup-node@v1 with: node-version: 14.10.1 - name: Install dependencies run: npm install - name: Update RSS Feeds run: npm run update-rss # Git で追加する内容を data フォルダに変更する # git add static/rss.json -> git add data/ - name: Commit files run: | git config --local user.email "[email protected]" git config --local user.name "GitHub Action" git add data/ STATUS=$(git status -s) if [ -n "$STATUS" ]; then git commit -m "Update data folder `date +'%Y-%m-%d %H:%M:%S'`" -a git push origin main fi </code></pre> <p>これで JavaScript で作成した RSS リーダーから、Hugo の Data Templates を用いて作成した RSS リーダーへ移行できました。</p> <h1 id="おわりに"><a href="#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB">おわりに</a></h1> <p>Hugo で React + TypeScript 開発を楽にできそうなことが分かり、テンションが上がってしまい、そのままのノリで実際に RSS リーダーを自ブログ向けに作成してみました。</p> <p>しかし、本記事内容で RSS リーダーを実装するのであれば、Hugo の Data Templates を利用することがベストなことに後から気づきました。ただ Hugo での JavaScript を用いた開発手法が理解でき勉強になったので結果ヨシとしました。</p> <p>Hugo での JavaScript 開発環境は相当充実していることが分かったので、また何かアイデアを思いついたら気軽に作って自ブログに取り込んでいきます。今はザックリ WebGL/WebVR とかで何か面白いもの作れそうだなと考えています。</p> <h1 id="参考リンク"><a href="#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF">参考リンク</a></h1> <ul> <li><a target="_blank" rel="nofollow noopener" href="https://esbuild.github.io/">esbuild - An extremely fast JavaScript bundler</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://gohugo.io/templates/data-templates/">Data Templates | Hugo</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://gohugo.io/functions/">Functions Quick Reference | Hugo</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://gohugo.io/hugo-pipes/js/">JavaScript Building | Hugo</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://reactjs.org/docs/hooks-intro.html">Introducing Hooks – React</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://github.com/rbren/rss-parser">rbren/rss-parser: A lightweight RSS parser, for Node and the browser</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://github.com/html-to-text/node-html-to-text">html-to-text/node-html-to-text: Advanced html to text converter</a></li> </ul> nikaera