🍎

スクリーンショットをmarkdownに変換してクリップボードに貼り付けるAIツールを作る(あるいは、Apple Script事始め)

2024/12/15に公開

はじめに

Google、OpenAIの12月の発表で目立ったのがRealtimeでした。いま見ているディスプレイに対して即時回答する、AIと一緒にゲームする、視覚の代わりにする...

これらがこのタイミングで実現した主たる要因は、LLMの応答速度の飛躍的向上でしょう。

筆者も速度が原因で眠らせていたアイデアがいくつかあるので、今回はその一つ、スクリーンショットをmarkdownとしてコピペする機能をApple Scriptで作ってみました。

なお、この記事は下記のScrapの再構成です。試行錯誤の過程を辿りたい場合は参考にしてください。

🦂 本記事のスコープ

  • Apple ScriptとGeminiを使い、スクリーンショットの内容をmarkdownにして、クリップボードに貼り付けるアプリを作成します。
  • したがって、Windowsは対象外です。
  • 筆者はApple Scriptの作成は今回が初めて。粗い部分があります。

🌳 環境

  • MacBook Air M3
  • MacOS:Sonoma 14.6

⚾️ 完成イメージ

たとえば、落合博満の成績テーブルをスクショすると...

https://ja.wikipedia.org/wiki/落合博満#詳細情報

以下のmarkdownがクリップボードに貼り付けされます。

年度 球団 試合 打席 打数 得点 安打 二塁打 三塁打 本塁打 塁打 打点 盗塁 盗塁死 犠打 犠飛 四球 敬遠 死球 三振 併殺打 打率 出塁率 長打率 OPS
1979 36 69 64 7 15 3 1 2 26 7 1 0 0 0 4 0 1 12 2 .234 .290 .406 .696
1980 57 188 166 28 47 7 0 15 99 32 1 0 2 2 17 0 1 23 5 .283 .349 .596 .946
1981 127 502 423 69 138 19 3 33 262 90 6 3 1 4 68 1 6 55 17 .326 .423 .619 1.043
1982 ロッテ 128 552 462 86 150 32 1 32 280 99 8 2 0 4 81 6 5 58 11 .325 .428 .606 1.034
1983 119 497 428 79 142 22 1 25 241 75 6 5 0 3 64 5 2 52 14 .332 .419 .563 .982
1984 129 562 456 89 143 17 3 33 265 94 8 1 0 4 98 8 4 83 14 .314 .436 .581 1.017
1985 130 568 460 118 169 24 1 52 351 146 5 1 0 4 101 26 3 40 16 .367 .481 .763 1.244
1986 123 522 417 98 150 11 0 50 311 116 5 1 0 1 101 19 3 59 15 .360 .487 [注12] .746 1.232
1987 125 519 432 83 143 33 0 28 260 85 1 4 0 4 81 10 2 51 10 .331 .435 .602 1.037
1988 130 557 450 82 132 31 1 32 261 95 3 4 0 6 98 13 3 70 11 .293 .418 .580 .998
1989 130 559 476 78 153 23 1 40 298 116 4 3 1 6 75 7 1 69 11 .321 .410 .626 1.036
1990 中日 131 570 458 93 133 19 1 34 256 102 3 3 0 8 100 17 4 87 7 .290 .416 .559 .975
1991 112 478 374 80 127 17 0 37 255 91 4 2 0 5 95 16 4 55 9 .340 .473 .682 1.155
1992 116 481 384 58 112 22 1 22 202 71 2 3 0 6 88 8 3 74 12 .292 .422 .526 .948
1993 119 504 396 64 113 19 0 17 183 65 1 2 0 8 96 14 4 69 13 .285 .423 .462 .885
1994 129 540 447 53 125 19 0 15 189 68 0 0 0 6 81 4 6 56 13 .280 .393 .423 .815
1995 巨人 117 483 399 64 124 15 1 17 192 65 1 0 0 8 73 2 3 87 17 .311 .414 .481 .895
1996 106 448 376 60 113 18 0 21 194 86 3 0 0 2 67 3 3 53 11 .301 .408 .516 .924
1997 113 466 397 35 104 14 0 3 127 43 3 0 0 5 61 1 3 60 16 .262 .361 .320 .680
1998 日本ハム 59 192 162 11 38 6 0 2 50 18 0 1 0 2 26 0 2 22 12 .235 .344 .309 .652
通算:20年 2236 9257 7627 1335 2371 371 15 510 4302 1564 65 35 4 88 1475 160 63 1135 236 .311 .423 .564 .987

全体のフロー構成図は以下のとおりです。GeminiAPIのところは、画像解析ができる任意のAIで構いません。

🔧 準備: Google AI StudioからAPIキーを取得

どのLLMプロバイダーを使うかは自由ですが、リクエスト制限に達しない簡単なユースケースでは、Google AI StudioのGeminiが最適解と思います。無料で、画像認識も速度もトップクラスです。

https://aistudio.google.com/apikey

ただし、無料の分、諸々の制限付きなので、目的に合わせて選択してください。

手順は簡単ですので、説明は省略します。

🦦 バックエンドAPIを作る

Apple Scriptで細かいことをしようとするとややこしいので、Gemini APIの前に中間のバックエンドAPIを立てます。このバックエンドの役割は以下です。

  • Base64をApple Scriptから受け取る
  • Gemini APIにリクエスト
  • 回答をparse
  • Apple Scriptに文字列で返す

今回のアプリで変更が起きやすいのはGemini APIへのリクエストなので、その部分をバックエンドに切り離しておくと、クライアントサイドのApple Scriptの修正をしなくて済みます。

回答を文字列で返すのもApple Scriptの負荷を減らすためですが、デメリットとして、エラー処理がうまくできません。本記事ではエラーメッセージもそのままクリップボードに貼り付けされる仕様です。[1]

ScrapではGoogle Apps Scriptを使って作りましたが、この記事では、Hono.js on Cloudflare Workersでサクッと作ります。

🔥 Hono.jsの作成

まずプロジェクトを作成します。
pnpm create hono@latest screenshot-to-markdown

続いてメインコードを追加。

src/index.ts
import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { convertToMarkdown } from './convertToMarkdown'
import { HTTPException } from 'hono/http-exception'

interface Secrets {
  Bindings: {
    BEARER_AUTH: string,
    GEMINI_API_KEY: string
  }
}

const app = new Hono<Secrets>()

app.use(logger())
app.use(secureHeaders())
app.use(bearerAuth({
  verifyToken: async (token, c) => {
    if (!c.env.BEARER_AUTH) {
      console.error('BEARER_AUTH is not set')
      return false
    }
    return token === c.env.BEARER_AUTH
  },
}))

app.post('/', async (c) => {
  if (c.env.GEMINI_API_KEY === undefined) {
    throw new Error('GEMINI_API_KEY is not set')
  }
  const { image: base64 } = await c.req.json()
  const markdown = await convertToMarkdown(base64, c.env.GEMINI_API_KEY)
  return c.text(markdown)
})

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    switch (err.status) {
      case 400:
        return c.text("Bad Request", 400)
      case 401:
        return c.text("Unauthorized", 401)
      default:
        return c.text("Internal Server Error", 500)
    }
  }
  console.error(err)
  return c.text("Internal Server Error", 500)
})

export default app

以下がGemini APIへのアクセス。ササっと作るためライブラリは使わず、戻り値もキャストしてしまっています。

モデルには最新のgemini-2.0-flash-expを使っています。日本語OCRもかなりの精度です。

src/convertToMarkdown.ts
export async function convertToMarkdown(base64: string, apiKey: string): Promise<string> {
  const SYSTEM_PROMPT = `あなたはスクリーンショットをmarkdownに変換するアシスタントです。以下に注意して変換してください。

- 画像内の文字を読み取ってmarkdownに変換してください。その際、必要に応じてmarkdown tableやlist、boldなどの記法を使い、なるべく画像内の文字を忠実に再現するように努めてください。
  - 特にテーブルやリストでうまく表現できる場合は積極的に使ってください。
- 画像に文字がない場合は、空文字("")を返してください。
- 回答形式は、変換したmarkdownのみとしてください。前置きや解説は不要です。`

  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`;

  const payload = {
    "contents": {
      "role": "USER",
      "parts": [
        {
          "inlineData": {
            "data": base64,
            "mimeType": "image/png"
          }
        },
        {
          "text": "スクリーンショットをmarkdownに文字起こししてください。"
        }
      ]
    },
    "system_instruction": {
      "parts": [
        {
          "text": SYSTEM_PROMPT,
        }
      ]
    },
    "generation_config": {
      "temperature": 0,
    }
  };

  const response = await fetch(endpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch: ${response.statusText}`);
  }
  const json = await response.json() as {
    candidates: {
      content: {
        parts: {
          text: string;
        }[];
      };
    }[];
  };
  const output = (json.candidates && json.candidates.length > 0 &&
    json.candidates[0].content && json.candidates[0].content.parts &&
    json.candidates[0].content.parts.length > 0)
    ? json.candidates[0].content.parts[0].text
    : "";

  console.log(output);

  return output;
}

Secretの追加

  • BEARER_AUTH
  • GEMINI_API_KEY

をシークレットとして使っています。wranglerを使って以下で登録できます。

$npx wrangler secret put BEARER_AUTH
$npx wrangler secret put GEMINI_API_KEY

デプロイ。

$pnpm run deploy

これで完成です。

🍎 Apple Scriptの作成

🤖 Automatorの起動

今回、Apple Scriptを任意のショートカットキーで実行できるように、Automator経由で作成します。


「アプリケーション」フォルダ

クイックアクションから作成。

検索窓から「AppScriptを実行」を選択。

この後、ここにApple Scriptを貼り付けます。

🍏 Apple Script

Apple Scriptは以下のようになります。

最初の変数でバックエンドAPIのURL、Tokenをセットしてください。

try
	-- 変数設定
	set {tempFolder, tempPath, apiURL, token} to {¬
		(do shell script "echo $TMPDIR"), ¬
		((do shell script "echo $TMPDIR") & "temp_screenshot.png"), ¬
		"<バックエンドAPIのURL>", ¬
		"<登録したSecret Token>"}
	
	-- スクリーンショット取得(選択範囲指定モード)
	do shell script "screencapture -i " & quoted form of tempPath
	
	-- スクリーンショットが取得されたか確認
	set fileExists to do shell script "[ -f " & quoted form of tempPath & " ] && echo 'yes' || echo 'no'"
	if fileExists is "no" then
		error "スクリーンショットの取得に失敗しました。選択範囲の指定をキャンセルした可能性があります。"
	end if
	
	-- 画像をリサイズ(Base64のデータ量削減のため)
	do shell script "sips -Z 600 " & quoted form of tempPath
	
	-- 画像をbase64エンコード
	set base64Data to do shell script "base64 < " & quoted form of tempPath
	
	-- JSONデータの作成
	set jsonData to "{\"image\":\"" & base64Data & "\"}"
	
	-- curlでPOSTリクエストを実行
	set curlCommand to "curl -L -H \"Content-Type: application/json\" -H \"Authorization: Bearer " & token & "\" -d " & quoted form of jsonData & " " & apiURL
	
	-- リクエスト実行と結果取得
	set response to ""
	try
		set response to do shell script curlCommand
		
		-- レスポンスが空でないかチェック
		if response is "" then
			error "APIからのレスポンスが空です。"
		end if
		
		-- レスポンスをクリップボードにコピー
		set the clipboard to response
		
		-- 成功通知
		display notification "Markdownをクリップボードにコピーしました。" with title "変換成功" sound name "Glass"
		
	on error errorMessage
		error "APIリクエストに失敗しました。: " & errorMessage
	end try
	
	-- 一時ファイルの削除
	do shell script "rm -f " & quoted form of tempPath
	
on error errorMessage
	-- エラー通知
	display notification "エラーが発生しました: " & errorMessage with title "エラー" sound name "Basso"
end try

完成したらAutomatorに貼り付け、「⌘+S」で保存してください。

🍰 ショートカットの割り当て

Spotlightなどで「設定 > キーボードショートカット」を開き、「サービス > 一般」に作ったクイックアクションがあるのでキーボードを割り当てられます。

画面収録の許可

スクリプトエディタおよびスクショを撮るアプリに画面収録の許可が必要です。

プライバシーとセキュリティ」から許可します。

🧪 テスト

これで完成です。テストしてみましょう。

割り当てたショートカットキーでMacBook Air M3のスペック表をコピペしてみます。
https://www.apple.com/jp/shop/buy-mac/macbook-air/15インチ-m3

結果は下記でした。

M3 M3 M3
8コアCPU 8コアCPU 8コアCPU
10コアGPU 10コアGPU 10コアGPU
16GBユニファイドメモリ 16GBユニファイドメモリ 24GBユニファイドメモリ
256GB SSDストレージ¹ 512GB SSDストレージ¹ 512GB SSDストレージ¹
16コアNeural Engine 16コアNeural Engine 16コアNeural Engine
True Tone搭載15.3インチLiquid Retinaディスプレイ¹ True Tone搭載15.3インチLiquid Retinaディスプレイ¹ True Tone搭載15.3インチLiquid Retinaディスプレイ¹
1080p FaceTime HDカメラ 1080p FaceTime HDカメラ 1080p FaceTime HDカメラ
MagSafe 3充電ポート MagSafe 3充電ポート MagSafe 3充電ポート
Thunderbolt/USB 4ポート×2 Thunderbolt/USB 4ポート×2 Thunderbolt/USB 4ポート×2
最大2台の外部ディスプレイに対応(ノートブックを閉じた状態) 最大2台の外部ディスプレイに対応(ノートブックを閉じた状態) 最大2台の外部ディスプレイに対応(ノートブックを閉じた状態)
Touch ID搭載Magic Keyboard Touch ID搭載Magic Keyboard Touch ID搭載Magic Keyboard
感圧タッチトラックパッド 感圧タッチトラックパッド 感圧タッチトラックパッド
デュアルUSB-Cポート搭載35Wコンパクト電源アダプタ デュアルUSB-Cポート搭載35Wコンパクト電源アダプタ デュアルUSB-Cポート搭載35Wコンパクト電源アダプタ
 Apple Intelligenceのために設計⁴  Apple Intelligenceのために設計⁴  Apple Intelligenceのために設計⁴

良いですね。

コピペできないApple Musicの曲目も、文字起こしできます。

ちなみにこれはラトル/ベルリンフィル

Symphony No. 7 in E Minor
作曲者: グスタフ・マーラー

1. I. Langsam (Adagio) - Allegro risoluto, ma non troppo  + 21:33
2. II. Nachtmusik I. Allegro moderato 14:48
3. III. Scherzo. Schattenhaft - Trio 10:21
4. IV. Nachtmusik II. Andante amoroso 12:05
5. V. Rondo-Finale. Tempo I (Allegro ordinario) - Tempo II (Allegro moderato ma energico) 17:21

テーブルにしてもらえるともっと良かったのですが、バックエンドのプロンプトの調整でなんとかなるでしょう。

注意点として、画面収録を許可していないアプリのスクショだと空のレスポンスが返ってきます。この辺りのきめ細かいエラー処理をしたい場合は、バックエンドAPIの戻り値をerrorなどの項目を持つJSONにしてあげる必要があります。

🍙 結び

少し前までは、日本語のOCRの精度の問題とレスポンス速度がネックになっていたのですが、gemini-2.0-flash-expだともう気にならないレベルです。近い将来、ローカルLLMが搭載されれば、もっとやりやすくなるでしょう。

構成もバックエンドAPIを置いてあげることで、Apple Scriptの部分は最小限で済み、好きな言語、ホスティングサービスで開発できました。

Apple Scriptは全くの初心者でしたが、LLMを使えば個人用では十分な精度で返してくれました。ぜひお試しください。

🧀 おまけ: ダイアログを使って任意のプロンプトを送る

もう少し汎用にしたい場合、ダイアログを使ってユーザーの入力も受け付けられます。

ファイルサイズの都合でガビガビですが、アルバムのジャケットをスクショし、「何が写っていますか?」と入力しています。

結果は、以下の通りでした。

アンドラーシュ・シフによるJ.S.バッハの「ゴルトベルク変奏曲」のアルバムカバーです。カバーは、赤い背景に青い線で三角形が描かれています。

実装は難しくなく、

	display dialog "AIへの指示を入力してください:" default answer ""
	set userInput to text returned of the result

	-- JSONデータの作成
	set jsonData to "{\"image\":\"" & base64Data & "\", \"prompt\":\"" & userInput & "\"}"

とダイアログを出し、JSONに追加、バックエンドで受け取ってGeminiに渡すだけです。

脚注
  1. Apple Script側でJSONをparseしたい場合、jqやPython、Rubyなどを使って行います。jqはinstallが必要、Python, Rubyはデフォルトで入っていますが、懸念要素が増えます。 ↩︎

Discussion