この記事は5107文字約13分で読めます

貧乏人はつらいです。

Table of Contents

皆さん、どうやって技術ネタ、キャッチアップしてますか?

皆さんはどうやって日々日進月歩な技術ネタをキャッチアップしてますか?

私はよく企業や個人が書いている技術ブログから情報を得ることが多いです。本当に技術ブログって手軽なのにすごい勉強になりますよね。

皆さん、どうやってブログ記事を通知してますか?

ブログ記事確認はもちろん定期的にブログに訪問するのが一番ですが、なかなか時間の取れない中でそれは酷なので何かしら皆さん工夫していると思います。

ブログの更新にあわせてTwitterを更新してくれる企業様であれば、Twitterのフォローをすればいいかもしれませんが、必ずしもそうでもないかもしれませんし、Twitterのフォローには技術以外の話題も飛び交うので、集中して記事を確認することも難しいかもしれません。

そういったときに役立つのがRSSです。RSSとはRich Site Summaryの略で、ニュースやブログなど各種のウェブサイトの更新情報を配信するための仕組みやXMLフォーマットのことです。

RSSの更新を定期的に取得し、記事更新を教えてくれるRSSリーダーは皆さんお世話になっている人も多いのではないでしょうか?

私もGoogle Chromeに拡張としてRSSリーダーを入れていた時期もありました。

問題点

RSSリーダーを使って技術ブログの更新を検知する方法はおそらくデファクトスタンダードだと思いますが、個人的にちょっと問題点がありました。

それは、通勤時間の時間をうまく使ってキャッチアップするのが面倒ということです。

  • 携帯にPCと同じRSSを登録するのがめんどくさい
  • RSSリーダーを開かない
    • 電車に乗っているとTwitterやSlackを開いている時間がほぼ全て
    • Kindleで読書するのも細かく乗り換えがあって中断が多く発生するためストレス
  • (RSSリーダーによって違うのかもしれませんが)タイトルを見て中身を判断するのが難しい

このような悩みがあるため、私はSlackの**/feed**機能を使ってRSSを購読してました。

が、しかしこれもまたもや問題点。Slackの無料ワークスペースには、Appsが10個までしか登録できないのです。(/feedもAppsを消費します)

Slackには他にもAppsをいくつか作って入れているため、実際登録できるRSSは5個くらいになってしまいちょっと心もとない感じになってしまいました。

IFTTTはどうなの?

ちょっと詳しい人だと「じゃあIFTTT」はどうなんです?という意見が聞こえてきそうですが結果的にこちらも不採用。

理由は上記とほぼ同じで、無料版だと設定できる数に制限があるためこちらもあえなく不採用。

というより、お金出せよって声が聞こえてきますね。

じゃあ作ろっか

ということで、作ります。

要求は次の通りのことを満たす必要があります。

  • 無制限にRSSを登録できること
  • 更新がある場合のみSlackに投稿すること
  • SlackもAppsを消費しないこと(Custom Integration)
  • できれば内容を要約したものや、OGP画像も一緒に投稿して記事の選別に役立てられる付加機能を作ること

feedparser

今回は時間もない中だったのでサクッとPythonで作っていきます。

RSSの購読にはfeedparserを使うと便利です。

RSS2.0だけでなく、Atomや古いRSSの形式でも難なく読み込んでくれます。

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)

Entry Itemへのアクセスはイテレーターになっているので取り出しもかんたんです。

RSSのEntry Itemの取り出しはこれで進めます。本当にかんたんでありがたい。

さらに便利なのはpublished_parsedという項目がEntry Itemから取れます。

こちら、RSSのpublished_dateをdatetimeオブジェクトにパースしてくれます。

おかげで、フォーマット差分をあまり意識することなく、更新差分チェック実装ができました。

ステート管理

RSSには記事の作成日付(Publish Date)があり、RSSの取得のたびに差分チェックとして活用することができます。

なので、以前取得した記事のPublish Dateを記憶して、更新があった場合のみ記事を取得するようにしたいのですが、それには何かしらのDB、もしくはデータ保存する仕組みが必要となります。

今回は無料という縛りがあるため、当初はGitHubのレポジトリ上にステートファイルをコミットするようにしようとも思ったのですが、コミットが伸び過ぎてしまうのは色々問題なのでやはりDBを使いたいです。

HarperDB

HarperDBは、データ管理を容易にすることに重点を置いた分散型データベースで、ジョインを含むNoSQLとSQLをサポートしています。

NoSQLでSQLがかけるのは便利ですね!!

日本ではあまり聞きませんが、dev.toとかだとちょこちょこ話題に上がっております。

こちらのHarperDB、HarperDB Cloud Instanceというマネージドサービスも提供されており、インスタンスタイプを選ぶだけで、手軽にHarperDBを使うことができるようになっております。

harperdb

img

え?でもお高いんじゃない?そんな声が聞こえてきますね。

なんと、今だけかもしれませんがHarperDB Cloud Instanceの一番最小のInstance構成だと無料で使うことができます!これは嬉しいですね。

NameValue
RAM0.5GB
DISK1GB
VERSION3.0.0
IOPS3000

正直今回の使い方ではこのレベルで十分です。

Python上での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,)

test = db.search_by_hash(HARPERDB_SCHEMA, "last_published", [name], get_attributes=["time"])
for t in test:
    print(t["time"])

このようにNoSQLライクにHash Attributeを使って検索する感じで実装できます。もちろんValue引きも可能です。(遅くなるのかは不明だがNoSQLなら全走査になりそうなので多分遅い)

UpdateやInsertも同様な感じで実施できます。

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

また、便利だなと思ったのはやはりSQLでの走査です。

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")]

といった具合にテーブルの*Selectやジョインなんかも書くことができます。テーブル全体をなめたいとき、これは楽でいいですね。

また、CSV load機能もあり、CSVをHarperDBに食わせることもできちゃったりします。

今回はこちらの機能はRSSのEntryURL登録機能として便利に使用させていただきました。

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")

無料開発で一番ネックになるのがDBですが、正直これだけで大概のアプリは作れてしまうのではないでしょうか?

OGP画像を得るには?

OGPとはOpen Graph Protocolの略で、TwitterやFacebookにURLリンクを貼り付けると出てくるあれです。

img

実際OGP作成を実装された方ならわかりますが、OGPはHTMLのHeaderに決まりきったmetaタグを記載して表現しております。

<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">

Slackのattachmentsに入れる画像はOGPのImageから取るようにします。

opengraph-py3

PythonでOGPを解析するならopengraphライブラリが便利です。ただし、

pip install opengraph

でインストールするとPython2用のライブラリがインストールされてしまいまともに動かないので、

pip install opengraph_py3

でインストールするようにします。

使い方もかんたんで、opengraph_py3.OpenGraphでインスタンスを作ってあげれば、**ogp["image"]**にOGPイメージURLが保存されます。

一点注意としてopengraphは裏でBeautifulSoapが動いているようで、Headerのないページに対してOGPを取得しようとするとAttributeErrorが出てしまうので例外処理を入れております。

本家にPR出すか迷いましたが、2017年から更新がないので骨折り損になりそうなので、やめておきます。

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 ""

Favicon

できれば、Slack投稿するときに技術ブログのアイコンをブログごとに変えたいなと思ったので、Faviconを取る実装も入れます。

Pythonにはfavicon取るためのライブラリfaviconがあります。

使い方も超かんたんで、favicon.getで取得したオブジェクトの配列0番目が一番大きなfaviconなのでそれを取るだけです。

import favicon
def get_favicon(link):
    icons = favicon.get(link)
    if len(icons) == 0:
        return ""
    else:
        return icons[0].url

キーワード抽出

さて、今回の醍醐味のキーワード抽出ですがこちらもかんたんに実装できます。

pytermextractという専門用語抽出ツールと形態素解析ライブラリjanomeを組み合わせることでかんたんに実現できます。

janomeは本当に便利で、特にCIに乗っけてぐるぐるしたい人にはmecabをインストールする必要も辞書をコンパイルする必要もなく、pipで一発入れれば使えるので重宝しています。

pytermextractはPyPI登録されているライブラリではないのでインストールは公式サイトから落としたZIPを展開しsetup.pyから行います。

また、janomeもpipでインストールします。

unzip pytermextract-0_01.zip
cd pytermextract-0_01
python setup.py install

pip install janome

まずは、キーワード抽出したいテキストをjanomeのTokenizerにかけて、結果を頻出度から単名詞の左右の連接情報スコア(LR)を算出し、

重要度スコアとしてはじき出す、という仕組みらしいです。とは言っても私にはよくわからなったのでサンプルコード丸パクリです。

得られる結果は**{"単語": スコア}**となってますので、こちらをスコア順にリバースソートして上位6位を取得する形にしました。

しょうもない知識ですが、janomeのTokenizerインスタンス作るところは処理コストがちょっと高いので、リファクタでモジュールトップレベルでの宣言にしてます。

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]

RSSのSummaryTextでは精度がでない、そりゃそうじゃ。

見出し通りですが、当初はfeedparserから取得できるEntry ItemのSummaryをpytermextractに食わせてましたが、SummaryTextが短すぎて全く期待する動作になりませんでしたので、BeautifulSoupを使って、実際の記事の本文を取得しpytermextractに食わせる実装に変更しました。

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])

本文はpタグと判断しfind_allするちんけな実装です。ごめんなさい。

Slack投稿

いよいよSlack投稿部分の作成です。

Slack投稿はCustomIntegrationのIncoming Webhookで作ります。

なので、Slack attachmentが使えます。

特質したことはないのですが、OGP画像はimage_urlに、faviconはauthor_imageにキーワードはfieldsに入れてます。

GitHub Actions化

最後にGitHub Actionsに載せて、定期実行させます。

その前にの#harperdbでも書いたとおり、RSS追加時のHarperDBへのEntry追加の定義を書いていきます。

特定のファイルに更新があった場合のみ動くGitHub Actionsを作る場合は、 on_pushなどの条件にpathsを入れることで実現できます。これだけです。

on:
  push:
    branches:
      - main
    paths:
      - "entry.csv"
  pull_request:
    branches:
      - main
    paths:
      - "entry.csv"

また、定期実行にはschedule cronが便利です。

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  schedule:
    - cron: "*/30 * * * *"

完成

ということでできました。

img

entry.csvに書いたRSS feedを30分ごとに確認しにいき、前回よりpublish_dateの更新があったばあいはOGP, favicon, キーワード付きでSlack投稿します。

レポジトリはこちらです。

https://github.com/tubone24/tech_blog_spider

ForkするとGitHubA ctionsがうまく発火しないっぽいので、もし利用する際はgit cloneして自身のレポジトリに再Pushして使っていただければと思います。

結論

HarperDBを使って何でもつくれそうな予感がするこの頃です。

tubone24にラーメンを食べさせよう!

ぽちっとな↓

Buy me a ramen