Open16

Apple ScriptとGemini 2.0 Flash Experimentalを使って、スクリーンショットをmarkdownにしてクリップボードにコピーする

hosaka313hosaka313

What

LLMの速度向上が著しいので、前からやってみたかった、「スクショを撮ってmarkdownでコピペ」を実装してみる。WebのテーブルなどをスクショしてGitHubにmarkdownでコピペできたらハッピーなはず。

なおこの筆者、Apple Scriptは未経験である。

ざっと以下の流れ。外部APIにはLLMのAPIを呼ぶ中間APIを作る。

環境

  • MacBook Air M3 15インチ
    • 15インチはいいぞー
  • OS: Sonoma 14.6
hosaka313hosaka313

Apple Scriptを開いてみる

ずぶの素人なのでApple Scriptを開いてみる。

/System/Applications/Utilitiesにある「スクリプトエディタ」を開く。Spotlightでも開ける。

真白にぞ。🗻

hosaka313hosaka313

LLMに書いてもらう

やりたいことをプロンプトにする。

Apple Scriptで以下のような機能を実装したい。

- 何らかのショートカットキーで起動
- スクリーンショット選択(範囲指定)
- スクリーンショットをBase64 encoding
- Base64をpayloadにしてバックエンドAPIにpost
  - エンドポイントは仮置きしてください。
  - Backendはmarkdownテキストを返します。
- markdownをclipboardにコピー
- 結果を何らかの形で通知
  - エラーの場合も。

これをArcブラウザで作ったLLMトリオに投下する。ChatGPTとClaudeは課金勢なり。

hosaka313hosaka313

どれか選ぶ

Apple Scriptにおけるベストプラクティスなど知る由もないので、見た感じ読みやすそうなものにする。

try
	-- 一時ファイルパスの設定
	set tempPath to (path to temporary items as text) & "temp_screenshot.png"
	
	-- スクリーンショット取得(選択範囲指定モード)
	do shell script "screencapture -i " & quoted form of POSIX path of tempPath
	
	-- スクリーンショットが取得されたか確認
	tell application "System Events"
		if not (exists file tempPath) then
			error "スクリーンショットの取得に失敗しました。選択範囲の指定をキャンセルした可能性があります。"
		end if
	end tell
	
	-- 画像をbase64エンコード
	set base64Data to do shell script "base64 " & quoted form of POSIX path of tempPath
	
	-- JSONデータの作成
	set jsonData to "{\"image\":\"" & base64Data & "\"}"
	
	-- APIエンドポイント設定
	set apiURL to "https://api.example.com/convert/markdown"
	
	-- curlでPOSTリクエスト実行、JSONレスポンスをjqでmarkdownテキストを抽出
	set curlCommand to "curl -X POST -H \"Content-Type: application/json\" -d " & quoted form of jsonData & " " & apiURL & " | jq -r .markdown"
	
	-- リクエスト実行と結果取得
	set response to ""
	try
		set response to do shell script curlCommand
	on error errorMessage
		error "APIリクエストに失敗しました。: " & errorMessage
	end try
	
	-- レスポンスが空でないかチェック
	if response is "" then
		error "APIからのレスポンスが空です。"
	end if
	
	-- レスポンスをクリップボードにコピー
	set the clipboard to response
	
	-- 一時ファイルの削除
	do shell script "rm " & quoted form of POSIX path of tempPath
	
	-- 成功通知
	display notification "Markdownをクリップボードにコピーしました。" with title "変換成功" sound name "Glass"
	
on error errorMessage
	-- エラー通知
	display notification "エラーが発生しました: " & errorMessage with title "エラー" sound name "Basso"
end try
hosaka313hosaka313

修正

o1に直してもらう。

try
	set tempFolder to do shell script "echo $TMPDIR"
	set tempPath to tempFolder & "temp_screenshot.png"
	
	-- スクリーンショット取得(選択範囲指定モード)
	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エンコード(標準入力から読み込み)
	set base64Data to do shell script "base64 < " & quoted form of tempPath
	
	-- JSONデータの作成
	set jsonData to "{\"image\":\"" & base64Data & "\"}"
	
	-- APIエンドポイント設定 (例)
	set apiURL to "https://api.example.com/convert/markdown"
	
	-- curlでPOSTリクエスト実行、JSONレスポンスをjqでmarkdownテキストを抽出
	-- ※ Macにjqがインストールされている必要があります: brew install jq
	set curlCommand to "curl -X POST -H \"Content-Type: application/json\" -d " & quoted form of jsonData & " " & apiURL & " | jq -r .markdown"
	
	-- リクエスト実行と結果取得
	set response to ""
	try
		set response to do shell script curlCommand
	on error errorMessage
		error "APIリクエストに失敗しました。: " & errorMessage
	end try
	
	-- レスポンスが空でないかチェック
	if response is "" then
		error "APIからのレスポンスが空です。"
	end if
	
	-- レスポンスをクリップボードにコピー
	set the clipboard to response
	
	-- 一時ファイルの削除
	do shell script "rm -f " & quoted form of tempPath
	
	-- 成功通知
	display notification "Markdownをクリップボードにコピーしました。" with title "変換成功" sound name "Glass"
	
on error errorMessage
	-- エラー通知
	display notification "エラーが発生しました: " & errorMessage with title "エラー" sound name "Basso"
end try

これでAPIエラーのところまで来た。

hosaka313hosaka313

API部分の作成

何でも良いが、手っ取り早く作りたいので、Google Apps Scriptにした。[1]

Apple Scriptから直接GeminiやOpenAIのAPIを読んでも良いが、なるべくApple Scriptの処理は軽くしたいので、間に自前APIを挟む。

function doPost(e) {
  console.log(e.postData.contents)
  return ContentService.createTextOutput("hello World");
}
脚注
  1. あとでHono.js on Cloudflare Workersでも作ってみたが、Cloudflare Workersの方が速度が速い。簡単なAuthも付けやすいので、Cloudflare Workersの方がおすすめかも。 ↩︎

hosaka313hosaka313

エラー

jqがない。

が、自前APIを挟んで戻り値は文字列で扱うことにしたので、不要。

以下のようになった。

try
	set tempFolder to do shell script "echo $TMPDIR"
	set tempPath to tempFolder & "temp_screenshot.png"
	
	-- スクリーンショット取得(選択範囲指定モード)
	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エンコード(標準入力から読み込み)
	set base64Data to do shell script "base64 < " & quoted form of tempPath
	
	-- JSONデータの作成
	set jsonData to "{\"image\":\"" & base64Data & "\"}"
	
	-- APIエンドポイント設定
	set apiURL to "https://script.google.com/macros/s/<deployしたエンドポイント>/exec"
	
	-- curlでPOSTリクエストを実行
	set curlCommand to "curl -X POST -H \"Content-Type: application/json\" -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

成功したらしい。愉快な音も出た。

hosaka313hosaka313

Apps Scriptはリダイレクトオプションがいる。

クリップボードに"hello world"があると思いきや、下記だった。

<HTML>
<HEAD>
<TITLE>Moved Temporarily</TITLE>
</HEAD>
<BODY BGCOLOR="#FFFFFF" TEXT="#000000">
<!-- GSE Default Error -->
<H1>Moved Temporarily</H1>
The document has moved <A HREF="https://script.googleusercontent.com/macros/echo?user_content_key...">here</A>.
</BODY>
</HTML>

Apple Scriptはずぶの素人でもApps Scriptに関しては通暁しているので、お馴染みのエラーだ。
Apps Scriptは-Lをつけてリダイレクトを追う必要があるのだった。

https://qiita.com/cajonito/items/9e66ef60831d51105bc0

curlのところを

	set curlCommand to "curl -L -H \"Content-Type: application/json\" -d " & quoted form of jsonData & " " & apiURL

としてあげれば良い。

やった!ハロワ!!

hosaka313hosaka313

自前APIを書く

あとは、Apple ScriptからBase64を受け取って、LLMに投げ、Apple Scriptに文字列を返すAPIを作ればアガリ、である。

段階的に、まずはもらったbase64をそのまま返す、

function doPost(e) {
  const jsonData = JSON.parse(e.postData.contents);  
  const base64Data = jsonData.image;
  
  return ContentService
    .createTextOutput(base64Data)
}

ラッコさん🦦で検証。

https://rakko.tools/tools/71/

ラッコさんをスクショするとラッコさんがdecodeされたので正しそう。

hosaka313hosaka313

Geminiを呼ぶ

どのLLM providerにするかは今回は本質的な問題ではないので、最も実装が楽なAI Studio経由のGeminiで実装。

キーの取り方は一般教養ということで省略。
https://aistudio.google.com/apikey

まずは画像ではなく単純な生成を動かす。

取得したキーは、API_KEYという雑な名前でApps Scriptのスクリプトプロパティにセット。

function doPost(e) {
  const jsonData = JSON.parse(e.postData.contents);  
  const base64Data = jsonData.image;
  
  // base64を元にMarkdown変換
  const markdown = convertToMarkdown(base64Data);
  
  return ContentService
    .createTextOutput(markdown)
    .setMimeType(ContentService.MimeType.TEXT);
}


function convertToMarkdown(base64){
  const apiKey = PropertiesService.getScriptProperties().getProperty("API_KEY")
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${apiKey}`;
  
  const payload = {
    "contents": [{
      "parts": [{
        "text": "「わん!」と言ってください"
      }]
    }]
  };
  
  const options = {
    "method": "post",
    "contentType": "application/json",
    "payload": JSON.stringify(payload)
  };
  
  const response = UrlFetchApp.fetch(endpoint, options);
  const json = JSON.parse(response.getContentText());

  const output = (json.candidates && json.candidates.length > 0) ? json.candidates[0].content.parts[0].text : "";
  console.log(output)
  
  return output;
}

関数を分けたので単体でテストできる。

🐶

hosaka313hosaka313

画像をGeminiに渡す

https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/image-understanding?hl=ja#gemini-send-multimodal-samples-image-Base64 image data

あたりを参考。

結果は下記。プロンプトは粗いもので、safetyなどは入れてない。

function doPost(e) {
  const jsonData = JSON.parse(e.postData.contents);
  const base64Data = jsonData.image;
  if (!base64Data) {
    return sendTextOutput("")
  }
  // base64を元にMarkdown変換
  const markdown = convertToMarkdown(base64Data);

  return sendTextOutput(markdown)
}

function sendTextOutput(content) {
  return ContentService
    .createTextOutput(content)
    .setMimeType(ContentService.MimeType.TEXT);
}

const SYSTEM_PROMPT = `あなたはスクリーンショットをmarkdownに変換するアシスタントです。以下に注意して変換してください。

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

function convertToMarkdown(base64) {
  const apiKey = PropertiesService.getScriptProperties().getProperty("API_KEY");
  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 options = {
    "method": "post",
    "contentType": "application/json",
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(endpoint, options);
  const json = JSON.parse(response.getContentText());

  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;
}

これを再デプロイする。

ノリがいいので、出たばっかりのgemini-2.0-flash-expを使っている。

hosaka313hosaka313

試す

これをスクショしてみる。

結果は下記。

Open 1分前に作成 0

**Apple Scriptを使ってスクリーンショットを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

なかなかの精度。「あっぱれ」もらえそうだ。

hosaka313hosaka313

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

できたApple Scriptをショートカットで呼べるようにする。

Automatorでクイックアクションに登録

色々方法はありそうだが、MacBookに最初から入っているAutomatorを使ってみる。これも、「いやどうも初めまして、かねてからお顔は拝見しておりましたが」である。

「Apple Scriptを実行」。作ったscriptを貼って保存。

キーボードショートカット

Spotlightなどで設定から「キーボードショートカット」を開く。

「サービス > 一般」に作ったクイックアクションがあるのでキーボードを割り当てる。

以下のように割り当てた。

これでショートカットキーで呼べるようになった⌨️

hosaka313hosaka313

結び🍙

Apple Scriptは初見プレイだったが、LLMトリオを使って雰囲気で書けた。

Apple Scriptで難しい処理も、慣れた言語で一つAPIを置いておいて処理を委譲してしまえば良さそう。
エラー処理や認証などしていないが個人用ならこれでよかろう。

以上、深夜のお勉強メモ。ここまで読んでしまった奇特なあなたは落合博満の出塁率だけ覚えて帰ってください。

hosaka313hosaka313

追記📝

スクショが大きいと100002エラーなるエラーが出た。

Base64のサイズが大きいっぽいので、以下の処理を追加。

	-- 画像をリサイズ
	do shell script "sips -Z 600 " & quoted form of tempPath

sipsはMacにデフォルトで入っている画像処理。知らなかった。

思ったより便利。Macのデフォツールを組み合わせているので使用感が良い。