🦕

DenoとGitHub ActionsでZennの最新記事をGitHub Profileに掲載して自動でCommitまでやる

10 min read

最近、Zennの記事をけっこう書いています。

https://zenn.dev/kawarimidoll

そこで、Zennでの活動状況を他のプラットフォームでもアピールしたいと思いました。
今回はGitHubのプロフィールページ(つまり、自分のユーザー名と同名のリポジトリのREADME.md)にZennの最新記事を載せたいと思います。

タイトル通り、Deno 🦕 を使っていきます。

RSSでZennの最新記事を取得する

まず、Zennの最新記事を取得します。
以前、DenoでZennの記事を取得するDenoモジュールを作成しました。

https://zenn.dev/kawarimidoll/articles/3d51924dc595b6

これを使ってもよいのですが、勉強を兼ねて別の手段を使います。

ZennではRSSフィードが提供されているので、ここから記事を取得してみます。

DenoのRSSモジュールを使います。

https://deno.land/x/rss@0.3.6

とりあえずシンプルな例をやってみましょう。
rssフィードを取得し、deserializeFeedで整形します。
取得に使っているKyはこちらの記事で紹介しています。

https://zenn.dev/kawarimidoll/articles/13c3f75f6f22d6
update_profile.ts
import { deserializeFeed, ky } from "./deps.ts";

const { feed, feedType } = await deserializeFeed(
  await ky("https://zenn.dev/kawarimidoll/feed").text(),
);

console.log(feedType);
console.log(feed);

実行します。
実行に使っているVelociraptorはこちらの記事で紹介しています。

https://zenn.dev/kawarimidoll/articles/1c48c097020cbc
velociraptor.ymlの設定

最初のrssの取得以外で必要な権限も追加してあります

velociraptor.yml
allow:
  - write=./assets/zenn.png,README.md
  - read=README.md
  - net=zenn.dev,res.cloudinary.com

scripts:
  update_profile:
    cmd: update_profile.ts
  lint: deno lint
  fmt: deno fmt
  pre-commit:
    cmd:
      - vr lint
      - vr fmt
    gitHook: pre-commit
❯ vr update_profile
RSS 2.0
{
  "xmlns:dc": "http://purl.org/dc/elements/1.1/",
  "xmlns:content": "http://purl.org/rss/1.0/modules/content/",
  "xmlns:atom": "http://www.w3.org/2005/Atom",
  version: "2.0",
  channel: {
    title: "kawarimidollさんのフィード",
    description: "Zennのkawarimidollさん(@kawarimidoll)のフィード",
    link: "https://zenn.dev/kawarimidoll",
    image: {
      url: "https://zenn.dev/images/logo-only-dark.png",
      title: "kawarimidollさんのフィード",
      link: "https://zenn.dev/kawarimidoll"
    },
    generator: "zenn.dev",
    lastBuildDate: 2021-06-29T05:58:52.000Z,
    href: "https://zenn.dev/kawarimidoll/feed",
    rel: "self",
    type: "application/rss+xml",
    "atom:link": {
      href: "https://zenn.dev/kawarimidoll/feed",
      rel: "self",
      type: "application/rss+xml"
    },
    language: "ja",
    items: [
      {
        title: "DenoとGraphQLでGitHubの草をターミナルに表示する",
        description: "GitHubの草が何となく好きです。どれくらい活動しているかが可視化され、自信やモチベーションになる気がします。\nしかし普段ターミナルに生息しているので、わざわざWebページを開くのは不便です。\nせっ...",
        link: "https://zenn.dev/kawarimidoll/articles/1679844a116395",
        ispermalink: "true",
        guid: "https://zenn.dev/kawarimidoll/articles/1679844a116395",
        pubDate: 2021-06-29T02:39:47.000Z,
        url: "https://res.cloudinary.com/dlhzyuewr/image/upload/s--Nl_Q3RZo--/co_rgb:222%2Cg_south_west%2Cl_text:n...",
        length: "0",
        type: "image/png",
        enclosure: [Object],
        "dc:creator": "kawarimidoll"
      },
      {
        title: "Denoと...
(略)

ふむ。 https://zenn.dev/kawarimidoll/feed の内容をシリアライズしてくれていますね。

メインの記事の内容はfeed.channel.itemsに入っています。こちらを取り出してみましょう。
普通に取り出そうとすると型チェックで止められてしまうため、いろいろimportして型ガード処理も追加します。

update_profile.ts
- import { deserializeFeed, ky } from "./deps.ts";
+ import { deserializeFeed, ky, Feed, FeedType, RSS1, RSS2 } from "./deps.ts";

  const { feed, feedType } = await deserializeFeed(
    await ky("https://zenn.dev/kawarimidoll/feed").text(),
  );

- console.log(feedType);
- console.log(feed);

+ const isRss2 = (
+   feed: Feed | RSS1 | RSS2,
+   feedType: FeedType.Atom | FeedType.Rss1 | FeedType.Rss2,
+ ): feed is RSS2 => feed && feedType === FeedType.Rss2;

+ if (!isRss2(feed, feedType)) {
+   throw new Error("Invalid feed type");
+ }

+ const item = feed.channel?.items[0];
+ if (!item) {
+   throw new Error("Item not found");
+ }
+ console.log(item);

これで実行します。

❯ vr update_profile
{
  title: "DenoとGraphQLでGitHubの草をターミナルに表示する",
  description: "GitHubの草が何となく好きです。どれくらい活動しているかが可視化され、自信やモチベーションになる気がします。\nしかし普段ターミナルに生息しているので、わざわざWebページを開くのは不便です。\nせっ...",
  link: "https://zenn.dev/kawarimidoll/articles/1679844a116395",
  ispermalink: "true",
  guid: "https://zenn.dev/kawarimidoll/articles/1679844a116395",
  pubDate: 2021-06-29T02:39:47.000Z,
  url: "https://res.cloudinary.com/dlhzyuewr/image/upload/s--Nl_Q3RZo--/co_rgb:222%2Cg_south_west%2Cl_text:n...",
  length: "0",
  type: "image/png",
  enclosure: {
    url: "https://res.cloudinary.com/dlhzyuewr/image/upload/s--Nl_Q3RZo--/co_rgb:222%2Cg_south_west%2Cl_text:n...",
    length: "0",
    type: "image/png"
  },
  "dc:creator": "kawarimidoll"
}

最新記事の情報を取得できました。

記事のOGP画像をダウンロードする

取得できた記事のタイトルとリンクがあれば最新記事の表示はできるのですが、Zennはcloudinaryで動的に生成されるOGP画像が提供されているので、これを使って画像リンクとして掲載したいと思います。

このOGP画像のURLをREADMEに直接書く方法でも実現できますが、今回はいろいろ勉強したいので、downloadモジュールを使ってリポジトリ内に画像をダウンロードする手段を採ります。

https://deno.land/x/download@v1.0.1

先程の取得結果では、画像URLはitem.urlで取得できそうなのですが、Itemの型定義にurlがないらしく、アクセスしようとするとlspに怒られてしまいました。
item.enclosure.urlにも同様の内容が入っているので、これを使用します。

update_profile.ts
// importにdownloadを追加する必要あり
// itemの定義まで同じ、その後に追記

const { link, enclosure } = item;
if (!link) {
  throw new Error("Link not found");
}
const url = enclosure?.url;
if (!url) {
  throw new Error("URL not found");
}

console.log({ link, url });
const file = "zenn.png";
const dir = "./assets";
await download(url, { file, dir });

実行します。保存先ディレクトリは事前に作っておきましょう。

❯ mkdir assets
mkdir: created directory 'assets'

❯ vr update_profile    
{
  link: "https://zenn.dev/kawarimidoll/articles/1679844a116395",
  url: "https://res.cloudinary.com/dlhzyuewr/image/upload/s--Nl_Q3RZo--/co_rgb:222%2Cg_south_west%2Cl_text:n..."
}

❯ ls assets
zenn.png

rssで取得した画像をダウンロードすることができました。

READMEを更新する

画像とリンクが手に入ったので、これをREADMEに反映させます。
READMEの書き換え位置の特定として、<!-- zenn-article-link-next-line -->というコメントの次の行にZenn記事へのリンクがあるとして、これを最新記事のものに置換します。
URLの正規表現は以下のものを使います。

"https?:\/\/[-_.!~*'()a-zA-Z0-9;\/?:\@&=+\$,%#]+"

rssから取得できたlinkを使ってreplaceを行います。

update_profile.ts
// await download(url, { file, dir }); まで同じ

const readme = "./README.md";
const text = await Deno.readTextFile(readme);

const urlStr = "https?:\/\/[-_.!~*'()a-zA-Z0-9;\/?:\@&=+\$,%#]+";
const replaceFlg = "<!-- zenn-article-link-next-line -->";
const regex = new RegExp(`(${replaceFlg}\n.*)${urlStr}`);

await Deno.writeTextFile(readme, text.replace(regex, `$1${link}`));

これを実行すると、URLが更新されます。

README.md
  ## Latest Zenn article
  <p align="center">
  <!-- zenn-article-link-next-line -->
- <a href="https://zenn.dev/kawarimidoll/articles/old_article"><img alt="Zenn" src="assets/zenn.png"></a>
+ <a href="https://zenn.dev/kawarimidoll/articles/1679844a116395"><img alt="Zenn" src="assets/zenn.png"></a>
  </p>

なお、レイアウト調整のため、markdown内にhtmlをそのまま書いていますが、markdown形式[![Zenn](assets/zenn.png)](https://zenn.dev/kawarimidoll/articles/xxxxx)でも同じ結果になります。

自動で更新してCommitする

以上の作業をGitHub Actionsを使用して自動で行い、Commitまでやります。
Profileのリポジトリに、これまで作ったファイルを配置します。

https://github.com/kawarimidoll/kawarimidoll

ではworkflowの定義を追加しましょう。
定期実行やVelociraptorのアクションを使える点に関しては、以前宣伝ツイート用のアクションを作った記事で紹介していますのでご覧ください。

https://zenn.dev/kawarimidoll/articles/ba1f36ec701403
.github/workflows/update_profile.yml
name: Update profile
on:
  # 03:20UTC -> 12:20JST
  schedule:
    - cron: "20 3 * * *"

jobs:
  update:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v2
      - uses: denoland/setup-deno@v1
      - uses: jurassiscripts/setup-velociraptor@v1
      - run: VR_HOOKS=false vr update_profile
      - name: Git commit
        run: |
          git config user.name github-actions[bot]
          git config user.email 41898282+github-actions[bot]@users.noreply.github.com
          if [ -n "$(git status --porcelain)" ]
          then git commit -aqm 'update profile' && git push origin
          else echo 'nothing to commit, working tree clean'
          fi

最後のところでコミットユーザーをgithub-actions[bot]に設定しています。
これでGitHubのコミットグラフ( https://github.com/kawarimidoll/kawarimidoll/commits/master )にbotが表示されます。
コミット権限のあるトークンを使って自分でコミットしたように見せかけても良いですが、github-actionsを明示しておいたほうが自動化されていることがわかりやすくて良い気がします。

また、変更されたファイルがない場合にgit commitしようとするとエラーコードが返され、ワークフローが失敗したとみなされてアラートが飛んでくるため、git status --porcelainを使って更新有無を判定しています。

なお、ワークフロー実行中にvrコマンドが実行されることにより、(定義されている場合は)Velociraptorのgit-hookが設定されます。
これが後のCommitを止める可能性があるため、実行の際にVR_HOOKS=false環境変数を設定しておくと安全です。

https://velociraptor.run/docs/git-hooks/#skipping-hooks

ということで、これでZennの最新記事をGitHubに載せることができました。

現在どんな感じになっているかは実際のプロフィールページをご覧ください。

https://github.com/kawarimidoll

おわりに

Denoを使ってGitHubのページを更新してみました。
まあ 手動だったらこんな宣伝しない のですが、自動化するのは楽しいものです。
今後もいろんなことをDenoで自動実行したいと思います。

今回のコードはプロフィールリポジトリで動いています。

https://github.com/kawarimidoll/kawarimidoll

参考

https://qiita.com/Seraimu/items/405864f7ac3c1bc352cb
https://github.com/JasonEtco/readme-box
https://qiita.com/nagimaruxxx/items/c2f186a2df5e32233122