1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

背景

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)

概要

操作の概要

↓ `option + command + E`を押すと といったふうに`[記事タイトル][記事URL]`と記事のソースMarkdownを、クリップボードに取得します。

スクリプトの概要

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

詳細

Alfred Workflowの構成

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

  1. トリガー(option + command + E
  2. スクリプトの実行
  3. 画面への出力(即時確認用)
  4. クリップボードへのコピー

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

トリガー

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

スクリプトの実行

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

Qiitaの場合

# 変数・定数をセット
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なら、シェルコマンドを走らせます
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でタイトルを抜き出す方法は、下記の記事を参考にさせていただきました(なかでも簡易な方法を使いました)

はてなブログの場合

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

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

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

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

#!/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

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

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

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

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

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

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

また、macOSのPythonは鬼門の方向ですので、macOS環境のPythonを参考にさせていただきました。

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

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

画面への出力

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

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

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

おわりに

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

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

ご参考

Obsidian|使いかたとコツ(目次)

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?