Apple ScriptとGemini 2.0 Flash Experimentalを使って、スクリーンショットをmarkdownにしてクリップボードにコピーする
What
LLMの速度向上が著しいので、前からやってみたかった、「スクショを撮ってmarkdownでコピペ」を実装してみる。WebのテーブルなどをスクショしてGitHubにmarkdownでコピペできたらハッピーなはず。
なおこの筆者、Apple Scriptは未経験である。
ざっと以下の流れ。外部APIにはLLMのAPIを呼ぶ中間APIを作る。
環境
- MacBook Air M3 15インチ
- 15インチはいいぞー
- OS: Sonoma 14.6
Apple Scriptを開いてみる
ずぶの素人なのでApple Scriptを開いてみる。
/System/Applications/Utilities
にある「スクリプトエディタ」を開く。Spotlightでも開ける。
真白にぞ。🗻
LLMに書いてもらう
やりたいことをプロンプトにする。
Apple Scriptで以下のような機能を実装したい。
- 何らかのショートカットキーで起動
- スクリーンショット選択(範囲指定)
- スクリーンショットをBase64 encoding
- Base64をpayloadにしてバックエンドAPIにpost
- エンドポイントは仮置きしてください。
- Backendはmarkdownテキストを返します。
- markdownをclipboardにコピー
- 結果を何らかの形で通知
- エラーの場合も。
これをArcブラウザで作ったLLMトリオに投下する。ChatGPTとClaudeは課金勢なり。
どれか選ぶ
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
実行してみる
APIエンドポイントは仮置きなのでエラーになるが、試してみる。
再生ボタンから。
画面収録を許可。
エラーになる...
どうでも良いが下記が面白かった。
修正
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エラーのところまで来た。
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");
}
-
あとでHono.js on Cloudflare Workersでも作ってみたが、Cloudflare Workersの方が速度が速い。簡単なAuthも付けやすいので、Cloudflare Workersの方がおすすめかも。 ↩︎
エラー
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
成功したらしい。愉快な音も出た。
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
をつけてリダイレクトを追う必要があるのだった。
curlのところを
set curlCommand to "curl -L -H \"Content-Type: application/json\" -d " & quoted form of jsonData & " " & apiURL
としてあげれば良い。
やった!ハロワ!!
自前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)
}
ラッコさん🦦で検証。
ラッコさんをスクショするとラッコさんがdecodeされたので正しそう。
Geminiを呼ぶ
どのLLM providerにするかは今回は本質的な問題ではないので、最も実装が楽なAI Studio経由のGeminiで実装。
キーの取り方は一般教養ということで省略。
まずは画像ではなく単純な生成を動かす。
取得したキーは、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;
}
関数を分けたので単体でテストできる。
🐶
画像をGeminiに渡す
あたりを参考。
結果は下記。プロンプトは粗いもので、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
を使っている。
試す
これをスクショしてみる。
結果は下記。
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 |
なかなかの精度。「あっぱれ」もらえそうだ。
ショートカットの割り当て
できたApple Scriptをショートカットで呼べるようにする。
Automatorでクイックアクションに登録
色々方法はありそうだが、MacBookに最初から入っているAutomator
を使ってみる。これも、「いやどうも初めまして、かねてからお顔は拝見しておりましたが」である。
「Apple Scriptを実行」。作ったscriptを貼って保存。
キーボードショートカット
Spotlightなどで設定から「キーボードショートカット」を開く。
「サービス > 一般」に作ったクイックアクションがあるのでキーボードを割り当てる。
以下のように割り当てた。
これでショートカットキーで呼べるようになった⌨️
結び🍙
Apple Scriptは初見プレイだったが、LLMトリオを使って雰囲気で書けた。
Apple Scriptで難しい処理も、慣れた言語で一つAPIを置いておいて処理を委譲してしまえば良さそう。
エラー処理や認証などしていないが個人用ならこれでよかろう。
以上、深夜のお勉強メモ。ここまで読んでしまった奇特なあなたは落合博満の出塁率だけ覚えて帰ってください。
追記📝
スクショが大きいと100002エラーなるエラーが出た。
Base64のサイズが大きいっぽいので、以下の処理を追加。
-- 画像をリサイズ
do shell script "sips -Z 600 " & quoted form of tempPath
sipsはMacにデフォルトで入っている画像処理。知らなかった。
思ったより便利。Macのデフォツールを組み合わせているので使用感が良い。