🐡

読書メモにKindleデスクトップのページ番号を自動入力する技術

に公開

読書メモのページ番号書きが苦行

僕は、MacのKindleデスクトップアプリで本を読みながら、Notionにメモを取っています。
メモには必ずページ番号も書くようにしているのですが、これが意外と面倒です。

三桁の数字を覚えようとしている間に、書き残そうと思ったことを忘れます。

そこで、画面上のKindleからページ番号を読み取り、Notionに自動で入力するショートカットキーを作りました

仕組みの概要

以下の3ステップで実現しています。

  1. screencaptureコマンドで画面を撮る
  2. ページ番号が表示されている位置を切り抜く
  3. AzureのOCRでページ番号のテキストを取得
  4. AppleScript(osascript)を使い、カーソル一に P{ページ番号} + Enter をsend

実装(Go言語)

必要な機能を最小限に絞ったコードがこちらです。

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"image"
	"image/png"
	"io"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"strings"
)

// Azure OCRのレスポンス構造体
type OCRResponse struct {
	Regions []struct {
		Lines []struct {
			Words []struct {
				Text string `json:"text"`
			} `json:"words"`
		} `json:"lines"`
	} `json:"regions"`
}

func main() {
	// 1. スクリーンショットの取得
	// -x フラグでシャッター音を消す
	fullFile := "full_screen.png"
	exec.Command("screencapture", "-x", fullFile).Run()

	// 画像の読み込み
	file, _ := os.Open(fullFile)
	img, _, _ := image.Decode(file)
	file.Close()

	// 2. 切り抜き
	// image.Rect(x0, y0, x1, y1)
	cropRect := image.Rect(1320, 2110, 1600, 2160)
	
	type subImager interface {
		SubImage(r image.Rectangle) image.Image
	}
	cropped := img.(subImager).SubImage(cropRect)

	// 切り抜いた画像を保存
	cropFile := "crop_page.png"
	outFile, _ := os.Create(cropFile)
	png.Encode(outFile, cropped)
	outFile.Close()

	// 3. Azure OCR APIの呼び出し
    // https://xxx.cognitiveservices.azure.com/ みたいなURL
	endpoint := os.Getenv("AZURE_OCR_ENDPOINT")
	apiKey := os.Getenv("AZURE_OCR_KEY")
	apiURL := endpoint + "/vision/v3.2/ocr?language=ja"

	imageData, _ := os.ReadFile(cropFile)
	req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(imageData))
	req.Header.Set("Content-Type", "application/octet-stream")
	req.Header.Set("Ocp-Apim-Subscription-Key", apiKey)

	client := &http.Client{}
	resp, _ := client.Do(req)
	defer resp.Body.Close()

	var result OCRResponse
	json.NewDecoder(resp.Body).Decode(&result)

	// OCR結果から全単語を結合
	var rawText string
	for _, region := range result.Regions {
		for _, line := range region.Lines {
			for _, word := range line.Words {
				rawText += word.Text
			}
		}
	}

	// ページ番号を抽出
	// Kindleの「317中の123ページ」といった表記から数字を抜き出す
	re := regexp.MustCompile(`(\d+)`)
	matches := re.FindStringSubmatch(rawText)
	if len(matches) < 2 {
		fmt.Println("ページ番号が見つかりませんでした")
		return
	}
	pageNumber := matches[0]

	// 4. AppleScriptでNotionにキー送信
	// P + ページ番号 + Enter を入力する
	script := fmt.Sprintf(`
tell application "System Events"
	keystroke "P%s"
	delay 0.1
	key code 36 -- Return (Enter)
end tell`, pageNumber)
	
	exec.Command("osascript", "-e", script).Run()
	fmt.Printf("P%s を入力しました\n", pageNumber)
}

Karabiner-Elementsでショートカットキーを割り当て

僕はOption+Jに割り当てています。またNotionのWeb版を使っているので、bundle_identifiersをChromeに絞っています。

complex_modificationsのJSONは以下の通りです。

{
  "description": "NotionでOption+Jを押した時にページ番号を自動入力",
  "manipulators": [
    {
      "conditions": [
        {
          "bundle_identifiers": [
            "^com\\.google\\.Chrome$"
          ],
          "type": "frontmost_application_if"
        }
      ],
      "from": {
        "key_code": "j",
        "modifiers": {
          "mandatory": [
            "left_option"
          ],
          "optional": [
            "any"
          ]
        }
      },
      "to": [
        {
          "shell_command": "/Users/riku/Dev/kindle-cursor/kindle-page-num >> /tmp/kindle-page-num.log 2>&1"
        }
      ],
      "type": "basic"
    }
  ]
}

補足 cropする位置

僕は 4K ディスプレイを3分割し

  • 中央:Kindle
  • 右:Notion

に配置しています。
この3分割は手作業ではなくRaycastを使っているので、Windowは必ずこの位置に来ます。
ちなみにNotionが左ではなく右な理由は、日本語は左寄せなので右に置いたほうが視線の移動が少ないからです。

この配置においては、ページ番号が表示される位置が image.Rect(1320, 2110, 1600, 2160) になります。
これは人それぞれだと思います。
cropするとこういう感じの画像になります。

IME ON/OFF問題が発生

実際に使ってみると、IMEがONのときに「p123」のように全角になってしまう事がわかりました。
これを防ぐために、Pの前に英数キー(key code 102)を送るようにしたら、
それはそれで勝手にIMEがOFFになって腹立つという別の問題も発生しました。

そこで、キーイベントを送る前に「現在のIMEがONかOFFか」を判定し、ONなら「英数キー」を叩いてIMEをオフにし、ページ番号入力後、かなキー(key code 104)でもとに戻す処理を入れました。

IMEの判定をGoからやるのは結構難しかったので、Objective-Cでコマンドを作り、それをGoから呼び出す形にしました。

ime-check.m (Objective-C)

#import <Foundation/Foundation.h>
#import <Carbon/Carbon.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TISInputSourceRef source = TISCopyCurrentKeyboardInputSource();
        if (source == NULL) {
            return 1;
        }

        // 入力ソースがASCII入力可能かどうか(英字かどうか)を取得
        CFBooleanRef isASCIICapable = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsASCIICapable);
        
        if (CFBooleanGetValue(isASCIICapable)) {
            printf("off\n");
        } else {
            printf("on\n");
        }
        CFRelease(source);
    }
    return 0;
}

Makefile

all: kindle-page-num ime-check

kindle-page-num: main.go
	go build -o kindle-page-num main.go

ime-check: ime-check.m
	clang -framework Foundation -framework Carbon -o ime-check ime-check.m

Go

func isIMEOn() bool {
	// 自作のime-checkバイナリを実行
	out, err := exec.Command("./ime-check").Output()
	if err != nil {
		return false
	}
	// "on" が返ってきたらIMEが有効
	return strings.TrimSpace(string(out)) == "on"
}

// キー送信処理
func sendKeys(page string) {
	var script string
	if isIMEOn() {
		script = buildScriptWithIME(page)
	} else {
		script = buildScriptSimple(page)
	}
	exec.Command("osascript", "-e", script).Run()
}

// IMEがONの場合:英数キー(102)を叩いてから入力し、最後にかなキー(104)で戻す
func buildScriptWithIME(page string) string {
	return fmt.Sprintf(`
tell application "System Events"
	key code 102 -- 英数キー
	delay 0.1
	keystroke "P%s"
	key code 36
	delay 0.1
	key code 104 -- かなキー
end tell`, page)
}

// IMEがOFFの場合:そのまま入力
func buildScriptSimple(page string) string {
	return fmt.Sprintf(`
tell application "System Events"
	keystroke "P%s"
	key code 36
end tell`, page)
}

Discussion