Qiitaとはてなブログの記事のソース（Markdown）を取得するキーボードショートカット

## 背景

QiitaやはてなブログではMarkdownで記事がかけます。Markdownエディタ（Obsidian）をつかっているので、エディタの保管庫（Vault）に記事のソース（Markdown）を貼り付けておけると便利だろうなとおもうのです。

Markdownは軽量なので、エディタの保管庫にデータを持っていても軽快に動きます。エディタの検索機能で過去に書いた記事が検索できると、自分用ナレッジデータベースとしてはとても便利になりそうです。

なのですが、ソースMarkdownを取得するのに、手を動かさなければならなくて、面倒臭いなあとおもっていました。面倒くさいことはやらなくなります。抜け漏れが出てくると、データベースとしてはイマイチになってしまいます。

そこで、キーボードショートカットで、さっくりとソースMarkdownを取得できるようなスクリプトを書きました。

### 環境

ここで書いていることは、下記のバージョンで実施しました。

- Alfred 4.6.1
- VS Code 1.63.2
- macOS Monterey 12.1
- MacBook Pro (13-inch, 2020, Four Thunderbolt 3 Ports)

## 概要

### 操作の概要

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/12fc274a-3f51-1e16-3b5d-339319cdda9d.png" width=80%>
↓ `option + command + E`を押すと
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/7f82e2a5-b659-c218-8dd4-7f37a2c14b21.png" width=80%>
といったふうに`[記事タイトル][記事URL]`と記事のソースMarkdownを、クリップボードに取得します。

### スクリプトの概要

- Alfredを利用して、`option + command + E`で起動します
- 選択した部分が`Qiita`なら、`curl`コマンドでソースを取得します（シェルスクリプト）
- 選択した部分が`はてなブログ`なら、はてなブログのAPIを叩いてソースを取得します（Pythonスクリプト）

## 詳細

### Alfred Workflowの構成

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/18e83f99-a66b-a176-8455-88f3433a2707.png" width=80%>

4つのブロックで構成しています。

1. [トリガー（`option + command + E`）](#トリガー)
1. [スクリプトの実行](#スクリプトの実行)
1. [画面への出力（即時確認用）](#画面への出力)
1. [クリップボードへのコピー](#クリップボードへのコピー)

以下にそれぞれのブロックについて補足します。

### トリガー

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/83a43d3d-2a2d-d826-4fd4-39f619f6a85f.png" width=80%>

- `Hotkey`にキーボードショートカットを登録します
- `Argument`を`Selection in macOS`に変更しています。選択した範囲が、次のブロックのクエリで使えます

### スクリプトの実行

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/92e94e53-59e0-1c70-d94a-f9212f1d7d64.png" width=80%>

- `Language`を`zsh`にしています。べつに`bash`でもいいですがなんとなく
- `with input as {query}`を選択しています。スクリプト内で`{query}`と書くと、macOSで選択した部分を使えます（今回の場合はURLを想定）

#### Qiitaの場合

```zsh
# 変数・定数をセット
url="{query}"
qiita="qiita.com"
hatena="hatenablog.com"
suffix=".md"

# はてブロの場合とQiitaの場合で処理をわけた
if [[ "$url" == *$hatena* ]]; then
  /usr/local/bin/python3.9 220117_copyHatenaSource.py $url

elif [[ "$url" == *$qiita* ]]; then
  curl -s $url | grep -o '<title>.*</title>' | sed 's#<title>\(.*\)</title>#\1#' | pbcopy
  title=`pbpaste`

  echo "[$title]($url)\n"
  curl "$url$suffix"

else
  exit

fi
```

- 選択した範囲のURLがはてなブログであるなら、`$url`（macOSでの選択範囲）を引数にして、Pythonスクリプトを起動します　→　Pythonスクリプトは後述します
- 選択した範囲のURLがQiitaなら、シェルコマンドを走らせます

```zsh
curl -s $url | grep -o '<title>.*</title>' | sed 's#<title>\(.*\)</title>#\1#' | pbcopy
title=`pbpaste`

echo "[$title]($url)\n"
curl "$url$suffix"
```

- まず1つ目の`curl`でタイトルを取得します
    - `<title>`タグで囲まれているところを`grep`で抽出し、`sed`でタグを消してます
    - 改行が含まれているとダメとか、`[]`が含まれているとダメとかありますが、ここではあまり細かいことは気にしません（それぐらいは手で直せばよいのではという考え）
- `[記事タイトル](記事URL)`を標準出力に吐いておきます
- Qiitaのばあいは、`記事URL.md`でMarkdownが取れます。2つ目の`curl`でMarkdownを取得しています

`curl`でタイトルを抜き出す方法は、下記の記事を参考にさせていただきました（なかでも簡易な方法を使いました）

- [HTMLコンテンツをcurlで取得して特定タグ内だけ抜き出す - Qiita](https://qiita.com/u1and0/items/2de79653096e08d9bc11)
- [ウェブページのタイトルをターミナルから取得する方法](https://gotohayato.com/content/112/)
- [Bash で HTML のタイトルを取得する - Sarchitect](http://sarchitect.net/18874)

#### はてなブログの場合

```zsh
/usr/local/bin/python3.9 220117_copyHatenaSource.py $url
```

のところで、Pythonスクリプトを起動しています（引数にはmacOSの選択範囲）。

スクリプトは、Alfred Workflowのフォルダに入れておきます（Workflowリストを右クリック > `Open in Finder...`が便利）。

スクリプトの内容は下記のとおりです。

```python
#!/usr/local/bin python3.9
# -*- coding: utf-8 -*-

import sys
import requests
import bs4
import re
import time

# コレクションURIを取得
def get_collection_uri(hatena_id, blog_id, password):
    service_doc_uri = "https://blog.hatena.ne.jp/{hatena_id:}/{blog_id:}/atom".format(hatena_id=hatena_id, blog_id=blog_id)
    res_service_doc = requests.get(url=service_doc_uri, auth=(hatena_id, password))
    if res_service_doc.ok:
        soup_servicedoc_xml = bs4.BeautifulSoup(res_service_doc.content, features="xml")
        collection_uri = soup_servicedoc_xml.collection.get("href")
        return collection_uri
    return False

# エントリIDを指定し、contentを取得
def get_entry_content_str(hatena_id, blog_id, password, entry_id):
    member_uri = "https://blog.hatena.ne.jp/{hatena_id}/{blog_id}/atom/entry/{entry_id}".format(hatena_id=hatena_id, blog_id=blog_id,entry_id=entry_id)
    res_member = requests.get(member_uri, auth=(hatena_id, password))
    if not res_member.ok:
        print("Failed: status_code: " + str(res_member.status_code))
        return False
    soup_response_xml = bs4.BeautifulSoup(res_member.content, features="xml")
    return soup_response_xml.find("content").string

# エントリIDを指定し、タイトルを取得
def get_entry_title_str(hatena_id, blog_id, password, entry_id):
    member_uri = "https://blog.hatena.ne.jp/{hatena_id}/{blog_id}/atom/entry/{entry_id}".format(hatena_id=hatena_id, blog_id=blog_id,entry_id=entry_id)
    res_member = requests.get(member_uri, auth=(hatena_id, password))
    if not res_member.ok:
        print("Failed: status_code: " + str(res_member.status_code))
        return False
    soup_response_xml = bs4.BeautifulSoup(res_member.content, features="xml")
    return soup_response_xml.find("title").string

# エントリIDを指定し、URL（link）を取得
def get_entry_link_str(hatena_id, blog_id, password, entry_id):
    member_uri = "https://blog.hatena.ne.jp/{hatena_id}/{blog_id}/atom/entry/{entry_id}".format(hatena_id=hatena_id, blog_id=blog_id,entry_id=entry_id)
    res_member = requests.get(member_uri, auth=(hatena_id, password))
    if not res_member.ok:
        print("Failed: status_code: " + str(res_member.status_code))
        return False
    soup_response_xml = bs4.BeautifulSoup(res_member.content, features="xml")
    return soup_response_xml.find("link", rel="alternate").get("href")


hatena_id="<hatena_id>"
blog_id="<blog_id>"
password="<password>"

collection_uri = get_collection_uri(hatena_id, blog_id, password)
entry_id_list = []

# 記事一覧を2回（過去記事を20件）取得し、エントリIDのリストを作成
# NOTE: したがって古い記事は取得できない
MAX_ITERATER_NUM = 2
for i in range(MAX_ITERATER_NUM):
    # Basic認証で記事一覧を取得
    res_collection = requests.get(collection_uri, auth=(hatena_id, password))
    if not res_collection.ok:
        print("faild")
        continue
    # Beatifulsoup4でDOM化
    soup_collectino_xml = bs4.BeautifulSoup(res_collection.content, features="xml")
    # entry elementのlistを取得
    entries = soup_collectino_xml.find_all("entry")
    # 下書きを無視
    pub_entry_list = list(filter(lambda e: e.find("app:draft").string != "yes", entries))
    # entry idを取得
    entry_id_list.extend([re.search(r"-(\d+)$", string=e.id.string).group(1) for e in pub_entry_list])
    
    # next
    link_next = soup_collectino_xml.find("link", rel="next")
    if not link_next:
        break
    collection_uri = link_next.get("href")
    if not collection_uri:
        break
    time.sleep(0.01)# 10ms

# 引数で指定したURLに合致するエントリがあれば、MD形式のリンクとソーステキストを出力
for entry_id in entry_id_list:
    # print (entry_id)
    link = get_entry_link_str(hatena_id, blog_id, password, entry_id)
    # print (entry_id, link, sys.argv[1])
    if link == sys.argv[1]:
        title   = get_entry_title_str(hatena_id, blog_id, password, entry_id)
        content = get_entry_content_str(hatena_id, blog_id, password, entry_id)
        print ("[" + title + "](" + link + ")\n" )
        print ("---\n")
        print ("# " + title + "\n")
        print (content)
        break
```

モジュールが必要なので、下記コマンドでインストールします。

```bash
$ python3.9 -m pip install requests
$ python3.9 -m pip install beautifulsoup4
```

処理の大まかな流れは下記のとおりです。

- コレクションURIを取得
- 記事一覧（最新10件）を取得し、そのなかからエントリIDを取得（10件）
- 次の10件の記事を取得し、エントリIDを取得（合計20件）
- 20件の記事のうち、macOSで選択したURLと一致する記事を探す
- もし見つかれば、ソースを取得して標準出力に吐く

あまり古い記事を探しに行かないという前提で、記事の取得は20件で止めています。もっと過去の記事を取得する場合は、個別に対応するつもりで書いています。また、パフォーマンス的にベストの方法ではないとおもうんですが、記事の取得件数を抑えているので、改善せずここで止めています。といったところはジェネラルなコードになっていないのでご留意ください。

はてなブログのAPIを叩く方法は、下記の記事を参考にさせていただきました。

- [はてなブログAtomPub - Hatena Developer Center](http://developer.hatena.ne.jp/ja/documents/blog/apis/atom)
- [はてなブログAPIで記事一覧を取得](https://cartman0.hatenablog.com/entry/2017/10/18/%E3%81%AF%E3%81%A6%E3%81%AA%E3%83%96%E3%83%AD%E3%82%B0API%E3%81%A7%E8%A8%98%E4%BA%8B%E4%B8%80%E8%A6%A7%E3%82%92%E5%8F%96%E5%BE%97)
- [はてなブログAPIで記事の取得と編集](https://cartman0.hatenablog.com/entry/2017/10/18/%E3%81%AF%E3%81%A6%E3%81%AA%E3%83%96%E3%83%AD%E3%82%B0API%E3%81%A7%E8%A8%98%E4%BA%8B%E3%81%AE%E5%8F%96%E5%BE%97%E3%81%A8%E7%B7%A8%E9%9B%86)

また、macOSのPythonは鬼門の方向ですので、[macOS環境のPython](https://www.python.jp/install/macos/index.html)を参考にさせていただきました。

> - macOSに入っているpythonを使おうとするな
> - Homebrewでインストールせよ
> - pip3でなくてpython3.9 -m pip xxxとせよ
> - venvなど仮想環境で動かせ

とのことです。仮想環境はつかいませんでしたが、モジュールのインストールには`$ python3.9 -m pip install xxx`をつかいました。

### 画面への出力

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/11cd30af-0fea-e4d0-097b-79078580e5ca.png" width=80%>

Alfredの`Large Type`というモジュールが便利なのでつかいました。スクリプトが標準出力に吐いたテキストを、画面に表示します。もともとはデバッグ用なのですけれども、さくっとプレビューできるのは便利なので、消さずに残しています。動作が安定して、ぜったい要らんわとなったら、消そうとおもいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/5d08c25b-2c9d-d7c7-f3dd-a985ed74023f.png" width=80%>


### クリップボードへのコピー

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/31163010-13ca-9047-ec6f-da60307327bd.png" width=80%>

標準出力に吐いたテキストを、クリップボードにコピーします。エディタ（Obsidian）にペーストすれば一連のタスクは終了です。

## おわりに

- Qiitaとはてなブログにしか対応していない
- 選択範囲が適切か否かなど、エラーハンドリングはしていない
- はてなブログは最近20件の記事からしか取得できない

などとおよそジェネリックとは言えないスクリプトですけれども、実運用上はそこそこいい感じかなとおもいます。「判断が複雑なところは人間がやって、単純作業はコンピュータがやる」の比率が、まあまあいいところに落ち着いたのではないかなあと。しばらく使ってみて、手に馴染むかどうか、たしかめてみたいとおもいます。

### ご参考

[Obsidian｜使いかたとコツ（目次）](https://qiita.com/hann-solo/items/ac8fa75394813e8adcf4)