はてな API を Python から利用する

Mozilla のブックマークサービス Pocket のブラウザ拡張機能が使用できなくなってしまった。そもそも Pocket 公式のブラウザ拡張のページ[^pocket-chrome] そのものが消えてしまっている。おそらく Google Chrome 127 から拡張機能の Manifest V2 が使用できなくなった[^manifestv3] ことに伴い、現時点で Manifest V3 に対応する気がない Mozilla はブラウザ拡張を非公開にしてしまったのだろう。

[^pocket-chrome]: https://getpocket.com/apps/link/pocket-chrome ※ 2025年3月時点で 404 Not Found

[^manifestv3]: [「Google Chrome」で多数の拡張機能が利用不能に--新仕様「Manifest V3」の影響を考察 - ZDNET Japan](https://japan.zdnet.com/article/35230444/)

:::note warn
(2025.05.23 追記) Pocket のサービス終了が発表...
[Pocket is Saying Goodbye: What You Need to Know | Pocket Help](https://support.mozilla.org/en-US/kb/future-of-pocket)
:::

ブラウザ拡張はかなり使っていたので困るが、待っていても対応される気がしないので、ひとまず暫定的にでもブックマークサービスを「はてなブックマーク」へ移行することにした。

データ移行するにあたり Pocket データを CSV エクスポートするのは簡単にできた。[^pocket-export] あとははてなブックマークにインポートするだけで終わり、と思ったらなんとインポート機能が死んでいる。[^hatena-import]

[^pocket-export]: [Pocket: エクスポート](https://getpocket.com/export)

[^hatena-import]: [はてなブックマークのインポート機能を、スパム対策のため一時的に停止します - はてなブックマーク開発ブログ](https://bookmark.hatenastaff.com/entry/2017/11/22/172350)

仕方ないので API を使って移行するかと思ったのだが、調べたところはてな API は OAuth **1.0** だった。なんてこった。心が折れそうになったが、Python でゴニョゴニョしていたらなんだかんだで動いたのでここにメモしておく。

## 環境
- Python 3.13
- oauthlib 3.2.2

Jupyter Notebook で作業した。

## アクセストークンを入手するまで
以下にいろいろと Python コードを書いているが、この公式ドキュメントに従ってやっているだけではある。

https://developer.hatena.ne.jp/ja/documents/auth/apis/oauth/consumer

### アプリケーションを作る
割愛。

### 初期設定
使うライブラリを読み込む。

``` python
from urllib.request import Request, urlopen
from urllib.parse import parse_qs, quote, urlencode
from urllib.error import HTTPError
from oauthlib.oauth1 import Client
```

ConsumerKey, ConsumerSecret, Scope を指定する。

今回は「ブックマークを追加する」のに `write_private` 権限が必要であった。

``` python
HATENA_OAUTH_CLIENT_KEY = "..."
HATENA_OAUTH_CLIENT_SECRET = "..."
HATENA_OAUTH_SCOPE = ["write_private"]
```

### リクエストトークンを取得する
``` python
client = Client(
    client_key=HATENA_OAUTH_CLIENT_KEY,
    client_secret=HATENA_OAUTH_CLIENT_SECRET,
    callback_uri="oob",
)
uri, headers, body = client.sign(
    uri="https://www.hatena.com/oauth/initiate",
    http_method="POST",
    body=f"scope={",".join(HATENA_OAUTH_SCOPE)}",
    headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
    req = Request(uri, body.encode(), headers=headers)
    with urlopen(req) as res:
        results = parse_qs(res.read().decode())
        request_token = results["oauth_token"][0]
        request_token_secret = results["oauth_token_secret"][0]
except HTTPError as exc:
    print(f"ERROR: {exc.read().decode()}")
    raise
```

### Verifier を取得する
``` python
print(f"https://www.hatena.ne.jp/oauth/authorize?oauth_token={quote(request_token)}")
```

ここで組み立てた URL に Web ブラウザでアクセスする。権限リクエストを許可する画面が表示される。許可すると「以下のコードをアプリケーションに入力して下さい。」とコードが表示されるので、変数 `verifier` にいれる。

``` python
verifier = "..."
```

### アクセストークンを取得する
``` python
client = Client(
    client_key=HATENA_OAUTH_CLIENT_KEY,
    client_secret=HATENA_OAUTH_CLIENT_SECRET,
    resource_owner_key=request_token,
    resource_owner_secret=request_token_secret,
    verifier=verifier,
)
uri, headers, _ = client.sign("https://www.hatena.com/oauth/token")
try:
    req = Request(uri, headers=headers)
    with urlopen(req) as res:
        results = parse_qs(res.read().decode())
        access_token = results["oauth_token"][0]
        access_token_secret = results["oauth_token_secret"][0]
except HTTPError as exc:
    print(f"ERROR: {exc.read().decode()}")
    raise
```

ようやくアクセストークンが取得できた。

## ブックマークを追加する
公式ドキュメントによると、ブックマークを追加するには `url` だけ指定すればいいらしい。

https://developer.hatena.ne.jp/ja/documents/bookmark/apis/rest/bookmark

こんな感じでブックマークを追加できた。

``` python
import json


def add_bookmark(url: str):
    client = Client(
        client_key=HATENA_OAUTH_CLIENT_KEY,
        client_secret=HATENA_OAUTH_CLIENT_SECRET,
        resource_owner_key=access_token,
        resource_owner_secret=access_token_secret,
    )
    params = {"url": url, "private": True}
    uri, headers, _ = client.sign(
        uri=f"https://bookmark.hatenaapis.com/rest/1/my/bookmark?{urlencode(params)}",
        http_method="POST",
    )
    try:
        req = Request(uri, method="POST", headers=headers)
        with urlopen(req) as res:
            return json.loads(res.read())
    except HTTPError as exc:
        print(url)
        body = json.loads(exc.read())
        print(f"ERROR: {body}")
        raise
```

``` python
add_bookmark("https://qiita.com/hoto17296/")
```

あとは Pocket からエクスポートしてきた CSV を読み込んでぐるぐる回せば終わり。