OpenClaw に Google スライドとスプレッドシートを API で直接編集させる — 仕事の文脈を持ったまま Discord から頼む

ブログ

AI に資料を頼むと、できあがったファイルが届きます。開いて直して、また渡して……の往復になりがちです。資料のためだけに別の AI を呼べば、仕事の中身を一から説明し直し。しかも、パソコンの前にいないと話が進みません。

私はこれを、いつも仕事を頼んでいる AI に、共有した Google スライドや表をそのまま直してもらう形に変えました。頼む先は、アプリ開発や動画づくりを任せている AI エージェント「OpenClaw」です。ChatGPT のように答えを返すだけでなく、ファイルを開いて直すところまで自分でやる AI です。前半は使ってみた体験を、後半は仕組みと手順を書きます。

2コマ漫画のスライド。1コマ目は斜面を滑る選手と、下でスマホを構えて撮るコーチ。2コマ目は2人でスマホの画面を見ながら「ここ、もう少し膝を曲げよう」と話し、上では次の選手が「まだかな…」と待っている。下に「困りごと: 撮っている間、撮る人はその1本に付きっきり。1人がコーチングを受けている間、次の子は滑れない」

AI が作ったスライドの1枚です。イラストは AI が描いたもので、実在の人物ではありません。

実例1:白紙のスライドが、その夜のうちに23枚に

なぜ資料が要ったか

スキー(モーグル)の練習を録画するアプリを、AI と作っていました。いまの練習では、撮る人がカメラに付きっきりになります。1人がコーチから教わっている間、次の子は滑れません。

チームの保護者がそれぞれ持っているスマホやタブレットを出し合って、現地で回せるようにしたい。そのために、使う人と「何に困っていて、どう変わるのか」をそろえる資料が必要になりました。

どう進んだか

Discord(LINE のようなチャットアプリ)で送った最初の一言はこうです。

手を加えたいから、Google スライドで一緒に編集できる?

白紙のスライドを1つ作って共有したのが 19:29 です。そこからは、こんな流れでした。

  1. 困りごとの漫画は、私が中身を指定しました。「1コマ目は滑る選手と、下で撮るコーチ。2コマ目はスマホを見ながら話す2人と、上で待つ次の選手」。AI はそのとおりに描いて、スライドに置きました(上の1枚です)。
  2. 改善案の漫画は、タブレットを持つコーチ、三脚に立てたスマホ、そしてもう滑り出している次の選手、という絵になりました。
  3. 機材のつなぎ方は、私が案を出しました。全部で7通り(ルータなし3・Wi-Fi ルータ3・衛星インターネット1)です。AI はこれを1通りずつ、1枚のスライドにしました。
  4. 図は、短い一言で作り直せます。「Wi-Fi ルータ版の図を、まず1つ」と頼むと、2分で1枚。それを見て「コースの背景とリフトも入れて」と返すと、5分で描き直しが届きました。

22:50 には、23枚のスライドが並んでいました。

2コマ漫画のスライド。三脚のスマホが選手を撮り、コーチと選手がタブレットを見ながら話している。上では次の選手がもう滑り出している

人物や機材の絵は1つずつ切り抜いて置いてあり、文字もスライドの図形で入っています。なので、気になるところは自分で絵を動かしたり、セリフを書き換えたりできます。途中で私が直し始めても、AI が描き直すたびに私の手直しが消える、ということはありませんでした。

仕事の流れを知っていたから描けたこと

このアプリには、Android の端末をまとめ役にして、ほかの機械をつなぐ機能があります。そして、スキー場のコースの上は電波が弱い。AI はアプリを一緒に作ってきたので、この2つを知っていました。

だから、つなぎ方の図を描くとき、まとめ役の Wi-Fi が届く範囲をオレンジで囲み、リフトの上の選手には「届かない」と付けています。資料のために、この前提を改めて説明してはいません。なお、届くかどうかは AI の見立てで、距離を測ったわけではありません。

つなぎ方の図のスライド。オレンジで囲んだ「コース下」の範囲に、タブレットを持つコーチ(テザリングの親機)・三脚の録画機・選手のスマホが点線でつながっている。右の「リフトの上」の枠には、リフトに乗る選手と「リフトの上は Wi-Fi が届かない。見るには先にダウンロードが必要」の注意書き

何が決まったか

スライドで構成を話し合った流れで、その夜のうちに実際に試しました。つなぎ方は、タブレットのテザリング(タブレットの電波をほかの機械に分ける機能)です。画面を消したままの iPhone が、手で操作しなくても、23秒でつながりました。

選手は、自分のランの動画を受け取りページからスマホに落としておき、リフトの上で見る。この形に決まりました。

自分の仕事なら、お客さんや仲間に段取りを説明する資料を、同じように頼めます。

実例2:表に一言書くだけで、見直し案が隣に並ぶ

なぜ表が要ったか

小学生向けのクイズ動画で、なぞなぞの章を作っていました。動画の作り(2人のかけあいでヒントを出す形)はもう決まっていて、問題の良し悪しを決める基準もありました。

  • 答えを聞いた瞬間に、子どもも大人も「あー!」となる
  • 子どもがふだん使う言葉だけを使う
  • ひねりが1つある

どう進んだか

  1. AI が、手持ちの問題40問を表に書きました。「使う」「外す」「保留」で行を色分けしてあります。
  2. 私は、気になる問題の横に一言だけ書きました。
  3. AI はそれを読んで、右の列に見直し案を26問足しました。見直し後の問題と答えに加えて、なぜそう直したかも並んでいます。
スプレッドシートの画面。番号 N01〜N25 の行に、判定(使う・外す・保留・ひねり無し)、種類、対象、問題、答えの列が並ぶ。判定が「外す」の行は薄い赤、「保留」は薄い黄、「ひねり無し」は薄い灰色に塗られている

実際の行は、たとえばこうです。

元の問題 私の一言 AI の見直し案
「くるま」が「る」をおとしちゃった。何になった? る が何かわからない 「たいこ」の中にかくれている魚は、なーんだ?
「カメ」のまん中に「モ」を入れると、空をとぶ鳥になるよ。なーんだ? 単純すぎてなぞなぞじゃない ゾウはゾウでも、ものをひやすゾウは、なーんだ?
問題リストの拡大。左に元の問題、中央に私の一言、右に見直し後の問題・答え・対象・見直しのわけが並ぶ。見直した行は青く塗られている

仕事の流れを知っていたから出来たこと

動画のほかの章で使った題材(理科の章のこん虫、漢字の章で出した言葉)は、AI が自分で避けて見直し案を作りました。

何が決まったか

表の上で、私の一言から直し方が決まっていきました。たとえば、字をとる・字をかえる問題は、答えが意味のある言葉になる形に直す、といった具合です。

自分の仕事なら、見積もりの候補や SNS 投稿のネタの一覧を AI に書いてもらい、横に「これは堅すぎる」と一言書く、という使い方になります。

何がうれしいか

  • ファイルの往復が消える 届いたファイルを開いて、直して、送り返す。この手間がありません。AI も私も、同じファイルを直接触ります
  • 説明し直さなくていい 仕事の流れを知っている AI なので、「さっきの話をスライドにして」で通じます
  • 出先から一言で頼める スマホの Discord から「さっきの表、ここ直して」と送るだけです
  • Office も手元のパソコンも要らない PowerPoint も Excel も使いません。AI は私のパソコンとは別の、つけっぱなしのマシンで動いているので、私のパソコンを開いておく必要もありません

公式の製品とどう選ぶか

Anthropic(Claude を作っている会社)からも、似たことができる製品が出ています。

  • Claude Cowork Anthropic のサーバーで動き、スマホのアプリからも頼めます
  • Claude for Google Workspace(ベータ版) Google のファイルの横に Claude を出す拡張機能です。見るのは開いているファイルだけなので、そのファイルの外のこと(仕事の流れや、これまでの話)は知りません。頼むたびに説明が要ります

この記事の形を始めるのに要るものも、正直に書いておきます。

  • OpenClaw 自体は、無料で公開されているソフトです(MIT ライセンス)
  • 動かすには、つけっぱなしにしておくマシンと、AI のサービスの契約が要ります
  • 設定には、コマンドを打つ知識が要ります(後半がその手順です)

選び方の目安はこうです。ChatGPT などにときどき文章を頼む程度なら、まず公式の製品で十分です。仕事の流れごと AI に任せ始めていて、資料のたびに説明し直すのが面倒になってきた人には、この記事の形が効きます。

自分もやってみたい人は、この先の後半へどうぞ。どう設定したか、どこでつまずいたかを書いています。


ここから先は、仕組みと手順の話です(エンジニア向け)。

はじめに(エンジニア向け)

Linux マシンに常駐させている OpenClaw のエージェントに、Google スライドと Google スプレッドシートを API で直接編集 してもらっています。API は、プログラムからサービスを操作するための窓口です。Office も GUI も使いません。

この記事では、実際に動かしているスクリプトから要点を抜き出して、仕組みと、つまずいた所をまとめます。最後に Claude Cowork / Claude for Google Workspace との違いにも触れます。

1. 全体の構成

全体の構成図。私(Discord)が OpenClaw のエージェント(Linux 上で常駐)に頼み、エージェントは Python スクリプトを書いて実行する。Google の認証は2つに分かれ、利用者の OAuth トークン(drive スコープ)で Slides API + Drive API、サービスアカウント(spreadsheets スコープ)で Sheets API を使う。エージェントは手元の物(生成した絵・切り抜き、コード、実機)も使う

エージェントは Discord で依頼を受けると、その場で Python のスクリプトを書いて実行します。Google への認証は2通りあり、スライドは私(利用者)の OAuth トークン、スプレッドシートはサービスアカウントで触っています。OAuth トークンは、本人がログインして「このプログラムに任せてよい」と許可した証明です。サービスアカウントは、人ではなくプログラムが使うために作る Google のアカウントです。2通りにした理由は「なぜ認証が2通りなのか」に書きます。

2. 準備

API を有効にする

Google Cloud のプロジェクト(API の利用設定や鍵をまとめておく単位)で、使う API を有効にします。

有効にするのは、認証に使う OAuth クライアントやサービスアカウントが属しているプロジェクト です。私は Drive 用に作っていた OAuth クライアントのプロジェクトで Slides API を有効にしておらず、最初の呼び出しで止まりました。

認証を用意する

下の表の「スコープ」は、その認証で触ってよい範囲の指定です。リフレッシュトークンは、期限の短いアクセス用のトークンを取り直すために保存しておくトークンです。

用途 認証 スコープ
スライド(画像の一時アップロードを含む) 利用者の OAuth 2.0 トークン(リフレッシュトークンを保存して使う) https://www.googleapis.com/auth/drive
スプレッドシート サービスアカウントの鍵 https://www.googleapis.com/auth/spreadsheets

鍵とトークンは、エージェントの作業場所の中の専用ディレクトリに、権限 600 のファイルとして置いています。記事のコードではパスを token.json / service-account.json に置き換えています。

ファイルを編集者で共有する

サービスアカウントは、メールアドレスのような ID を持つ「アカウント」です。スプレッドシートの共有ダイアログでその ID を 編集者 として追加すると、そのファイルだけを API で触れるようになります。共有されていないファイルは見えません。

スライドは利用者本人のトークンで触るので、本人が編集できるファイルならそのまま触れます。

3. スライド: 画像を入れ、部品と文字を分けて置く

画像はいったん Drive に置き、リンク共有してから createImage

Slides API の createImage は、画像のバイト列ではなく URL を受け取ります。しかもその URL は公開されている必要があります1。手元の Linux で作った PNG を入れるには、どこかに一時的に公開する必要があります。

そこで、次の順にしています。

  1. PNG を Drive にアップロードする
  2. 「リンクを知っている全員が閲覧可」の権限を付ける
  3. createImage に Drive の URL を渡す(Slides 側が取り込んだ時点でコピーを持つ1)
  4. 権限を外し、ファイルをゴミ箱に移す(finally で実行するので、途中で失敗しても共有は戻る)

実際のスクリプト(add_slides.py)では、自前の小さなモジュールでアクセストークンを取り、urllib で REST を直接叩いています。ここでは、google-auth でトークンを取る形に置き換えて要点だけ抜きました。

import json, sys, urllib.request, uuid
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request

creds = Credentials.from_authorized_user_file(
    "token.json", ["https://www.googleapis.com/auth/drive"])


def api(method, url, body=None, raw=None, ctype="application/json"):
    if not creds.valid:
        creds.refresh(Request())
    data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
    req = urllib.request.Request(url, data=data, method=method, headers={
        "Authorization": "Bearer " + creds.token, "Content-Type": ctype})
    with urllib.request.urlopen(req, timeout=180) as r:
        return json.loads(r.read() or b"{}")


def upload_png(path):
    b = uuid.uuid4().hex
    meta = json.dumps({"name": "_tmp_slide_image_" + path.split("/")[-1]}).encode()
    data = (f"--{b}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n".encode() + meta +
            f"\r\n--{b}\r\nContent-Type: image/png\r\n\r\n".encode() + open(path, "rb").read() +
            f"\r\n--{b}--".encode())
    r = api("POST", "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id",
            raw=data, ctype=f"multipart/related; boundary={b}")
    return r["id"]


def cleanup(fid):
    try:
        perms = api("GET", f"https://www.googleapis.com/drive/v3/files/{fid}/permissions?fields=permissions(id,type)")
        for p in perms.get("permissions", []):
            if p["type"] == "anyone":
                api("DELETE", f"https://www.googleapis.com/drive/v3/files/{fid}/permissions/{p['id']}")
        api("PATCH", f"https://www.googleapis.com/drive/v3/files/{fid}?fields=id,trashed", {"trashed": True})
    except Exception as e:  # 消し損ねたら ID を出して手で消せるようにする
        print("CLEANUP FAILED", fid, e)


PID, PAGE, PNG = sys.argv[1], sys.argv[2], sys.argv[3]   # プレゼンID・スライドのページID・画像
EMU = 914400                                              # 1インチ = 914400 EMU
fid = upload_png(PNG)
try:
    api("POST", f"https://www.googleapis.com/drive/v3/files/{fid}/permissions",
        {"role": "reader", "type": "anyone"})
    api("POST", f"https://slides.googleapis.com/v1/presentations/{PID}:batchUpdate", {"requests": [
        {"createImage": {
            "url": f"https://drive.google.com/uc?export=view&id={fid}",
            "elementProperties": {
                "pageObjectId": PAGE,
                "size": {"width": {"magnitude": 2 * EMU, "unit": "EMU"},
                         "height": {"magnitude": 2 * EMU, "unit": "EMU"}},
                "transform": {"scaleX": 1, "scaleY": 1, "translateX": 1 * EMU,
                              "translateY": 1 * EMU, "unit": "EMU"}}}}]})
finally:
    cleanup(fid)   # 失敗しても共有を戻してゴミ箱へ

公開リンクを付けている時間は、共有を付けてから、すべての batchUpdate(重なり順の調整などを含む)と後始末が終わるまでの数秒〜数十秒です。それでも「一瞬でも公開したくない画像」には向かない方法なので、入れる絵は公開されても困らないものに限っています。

非公開のままの Drive 画像を入れられないかを確かめるために、試し用のプレゼンテーションを作って2種類の URL で createImage を呼び、終わったら試しのファイルを両方ゴミ箱に移すスクリプト(test_private_image.py)も書きました。最終的に本番のスクリプトはリンク共有する方式になっています。

部品は透過 PNG、文字はスライドの図形

生成した2コマ漫画をそのまま1枚の画像として貼ると、人が「この人物を少し右へ」「この台詞を直したい」と思っても手を出せません。そこで、

  • 人物・三脚・スマホなどは、1つずつ 切り抜いて透過 PNG(背景を透明にした画像)にして createImage で置く
  • 吹き出しの台詞・見出し・説明は スライドの図形(テキストボックス・角丸四角形) として置く

と分けています。切り抜きには、画像の背景を自動で消すツール rembg のアニメ調向けモデル isnet-anime を使っています。次は cutout.py を1枚分の関数に簡略化した例です(元は複数の部品をまとめて処理し、座標を元画像の大きさに合わせて拡大しています)。

import numpy as np
from PIL import Image, ImageDraw
from rembg import new_session, remove

sess = new_session("isnet-anime")


def cut(src_path, box, erase=(), out_path="part.png"):
    """box の範囲を切り抜き、背景を消して余白を詰めた透過 PNG にする。
    erase は先に白で塗る範囲(吹き出しのしっぽなど、部品に入れたくないもの)"""
    im = Image.open(src_path).convert("RGB")
    crop = im.crop(box).copy()
    d = ImageDraw.Draw(crop)
    x0, y0 = box[0], box[1]
    for ex0, ey0, ex1, ey1 in erase:
        d.rectangle((ex0 - x0, ey0 - y0, ex1 - x0, ey1 - y0), fill=(255, 255, 255))
    res = remove(crop, session=sess, post_process_mask=True)
    alpha = np.array(res)[:, :, 3]
    ys, xs = np.where(alpha > 16)
    if len(xs):
        res = res.crop((xs.min(), ys.min(), xs.max() + 1, ys.max() + 1))
    res.save(out_path)

文字のほうは、図形を作って、文字を入れて、書体と揃えを指定します。次は add_slides.py の text_shape を簡略化した例です(元は塗り・枠線・縦の揃えも updateShapeProperties で指定しています)。updatePageElementAltText で代替テキストの「タイトル」に名前を付けておくと、人がスライドを開いたときにも何の部品か分かります。

def text_box(reqs, page, oid, x, y, w, h, text, pt=12):
    """インチ指定でテキストボックスを作るリクエストを reqs に足す"""
    reqs += [
        {"createShape": {"objectId": oid, "shapeType": "TEXT_BOX", "elementProperties": {
            "pageObjectId": page,
            "size": {"width": {"magnitude": int(w * EMU), "unit": "EMU"},
                     "height": {"magnitude": int(h * EMU), "unit": "EMU"}},
            "transform": {"scaleX": 1, "scaleY": 1, "translateX": int(x * EMU),
                          "translateY": int(y * EMU), "unit": "EMU"}}}},
        {"insertText": {"objectId": oid, "text": text}},
        {"updateTextStyle": {"objectId": oid, "textRange": {"type": "ALL"},
                             "style": {"fontFamily": "Noto Sans JP",
                                       "fontSize": {"magnitude": pt, "unit": "PT"}, "bold": True},
                             "fields": "fontFamily,fontSize,bold"}},
        {"updateParagraphStyle": {"objectId": oid, "textRange": {"type": "ALL"},
                                  "style": {"alignment": "CENTER"}, "fields": "alignment"}},
        {"updatePageElementAltText": {"objectId": oid, "title": "名前_" + text}},
    ]

図の点線(createLine)は、画像や図形より後から作ると絵の上に重なります。そこで、全部置き終えてから updatePageElementsZOrder の SEND_TO_BACK で線を一番奥に送っています。このリクエストは同じページの要素しか一度に扱えないので4、ページごとに1件ずつ作ります。

api("POST", f"https://slides.googleapis.com/v1/presentations/{PID}:batchUpdate", {"requests": [
    {"updatePageElementsZOrder": {"pageElementObjectIds": ids, "operation": "SEND_TO_BACK"}}
    for ids in lines_by_page.values()]})   # {ページID: [そのページの線のID, ...]}

4. スプレッドシート: 一括で書き、色分けし、フィルタを付ける

動画制作の仕事で、クイズの問題の在庫(40問)をスプレッドシートにまとめてもらいました。列は「番号・判定・種類・対象・問題・答え…」で、「判定」の値で行の色を変え、フィルタを付けています。

書き込みは、spreadsheets.batchUpdate(書式・構造)と spreadsheets.values.update(値)の組み合わせです。実際のスクリプト(sheet_write.py)から、在庫ファイルを読む部分を省いて要点を抜きました。

from google.oauth2 import service_account
from googleapiclient.discovery import build

SID = "スプレッドシートのID"
TAB = "在庫"
cred = service_account.Credentials.from_service_account_file(
    "service-account.json", scopes=["https://www.googleapis.com/auth/spreadsheets"])
sh = build("sheets", "v4", credentials=cred, cache_discovery=False)

rows = [["番号", "判定", "種類", "問題", "答え"]]  # + 実データ(ここでは省略)
gid = 0                                           # 先頭のシート
n, m = len(rows), len(rows[0])

# 1) シート名と見出し行の固定
sh.spreadsheets().batchUpdate(spreadsheetId=SID, body={"requests": [
    {"updateSheetProperties": {
        "properties": {"sheetId": gid, "title": TAB, "gridProperties": {"frozenRowCount": 1}},
        "fields": "title,gridProperties.frozenRowCount"}}]}).execute()

# 2) 値を一括で書く(RAW: 数式として解釈させない)
sh.spreadsheets().values().update(spreadsheetId=SID, range=f"'{TAB}'!A1",
                                  valueInputOption="RAW", body={"values": rows}).execute()

# 3) 列幅・見出しの書式・折り返し・フィルタ・判定での色分け
rng = {"sheetId": gid, "startRowIndex": 0, "endRowIndex": n, "startColumnIndex": 0, "endColumnIndex": m}
widths = [60, 80, 110, 380, 150]   # px
reqs = [{"updateDimensionProperties": {
            "range": {"sheetId": gid, "dimension": "COLUMNS", "startIndex": i, "endIndex": i + 1},
            "properties": {"pixelSize": w}, "fields": "pixelSize"}} for i, w in enumerate(widths)]
reqs += [
    {"repeatCell": {"range": {"sheetId": gid, "startRowIndex": 0, "endRowIndex": 1},
                    "cell": {"userEnteredFormat": {"textFormat": {"bold": True},
                                                   "backgroundColor": {"red": 0.9, "green": 0.9, "blue": 0.9}}},
                    "fields": "userEnteredFormat(textFormat,backgroundColor)"}},
    {"repeatCell": {"range": rng,
                    "cell": {"userEnteredFormat": {"wrapStrategy": "WRAP", "verticalAlignment": "TOP"}},
                    "fields": "userEnteredFormat(wrapStrategy,verticalAlignment)"}},
    {"setBasicFilter": {"filter": {"range": rng}}},
]
colors = {"外す": (0.96, 0.8, 0.8), "保留": (1, 0.95, 0.75), "ひねり無し": (0.93, 0.93, 0.93)}
for label, (r, g, b) in colors.items():
    reqs.append({"addConditionalFormatRule": {"index": 0, "rule": {
        "ranges": [dict(rng, startRowIndex=1)],
        "booleanRule": {
            "condition": {"type": "CUSTOM_FORMULA", "values": [{"userEnteredValue": f'=$B2="{label}"'}]},
            "format": {"backgroundColor": {"red": r, "green": g, "blue": b}}}}}})
sh.spreadsheets().batchUpdate(spreadsheetId=SID, body={"requests": reqs}).execute()

# 4) 読み直して、書いた行数と中身を確かめる
back = sh.spreadsheets().values().get(spreadsheetId=SID, range=f"'{TAB}'!A1:E{n}").execute()["values"]
assert len(back) == n, len(back)

使っているリクエストは repeatCell、setBasicFilter、addConditionalFormatRule です。色は行ごとに塗るのではなく 条件付き書式 にしているので、人があとで判定を書き換えても色が追いかけます。

見た目は PDF に書き出して、画像で確かめる

API の応答が成功でも、列幅が狭くて文字が潰れている、色が思った行に付いていない、ということは起こります。そこでエージェントは書き終えたあと、シートを PDF に書き出し、pdftoppm で PNG にして、その画像を自分で見て確かめます。

この手順はその場で書いて実行していたもので、スクリプトとしては残っていません。記事用に書き直した例です。Drive API の files.export で PDF にし、pdftoppm(poppler-utils)で画像にします。サービスアカウントで書き出すには Drive の読み取りスコープ(drive.readonly)を足します。書き出せる大きさは 10 MB までです5。

import subprocess
from google.oauth2 import service_account
from googleapiclient.discovery import build

cred = service_account.Credentials.from_service_account_file(
    "service-account.json", scopes=["https://www.googleapis.com/auth/drive.readonly"])
dr = build("drive", "v3", credentials=cred, cache_discovery=False)
pdf = dr.files().export(fileId=SID, mimeType="application/pdf").execute()
open("sheet.pdf", "wb").write(pdf)
subprocess.run(["pdftoppm", "-png", "-r", "80", "sheet.pdf", "sheet"], check=True)  # sheet-1.png, ...

この画像が、上に載せたシートの画面です。

5. つまずいた所

人が編集している最中に、ページを丸ごと入れ直さない

スライドに部品を置いている途中で、私がブラウザで同じスライドを触り始めました。エージェントは最初「ページを消して作り直す」つもりでしたが、それをやると私の手直しが消えます。このときは丸ごとの入れ直しをやめて、折り返しと見出しだけを直しました。

その後、スライドを足すスクリプトには次の作りを入れています。

  • 作る要素の objectId を、実行ごとの ID 入りにする(<種類>_<16進6文字のランダム>_<連番>。例: img_3fa9c1_12)
  • 差し替えるページに 自分の接頭辞以外の要素 があれば、人が手を入れたとみなして古いページを消さず、新しい版をその前に入れるだけにする
  • 入れる位置はページ番号ではなく、見出しラベルの文字で探す(人がページを並べ替えても位置がずれない)
mine = spec.get("replace_owner_prefix", "")
foreign = [e["objectId"] for e in slides[pos].get("pageElements", [])
           if mine and mine not in e["objectId"]]
if foreign:
    print("old slide has other elements, keep it:", foreign[:5])
    old_slide = None   # 消さない

鍵で触れる範囲を絞る

  • スプレッドシート用のサービスアカウントは、編集者で共有されたファイルしか触れません。スコープも spreadsheets だけにしています
  • ただし、編集者はファイルの中身を自由に書き換えられます2。シートのタブを消す、全部のセルを消す、といった操作も API からできるので、壊されても困らない範囲だけを共有するようにしています
  • Drive 用の OAuth は、自作モジュールの既定を drive.file(そのアプリで作ったか開いたファイルだけ)にしています。人が作った既存のスライドを触るときだけ drive で取り直したトークンを使います
  • 一時的に公開した画像は finally で共有を外してゴミ箱へ移します。後始末が失敗したときは CLEANUP FAILED とファイル ID を出して、手で消せるようにしています

サービスアカウントでは新しいファイルを作れない

サービスアカウントは自分の Drive の保存容量を持っていません3。そのため、共有されたフォルダにも新しいファイルをアップロードできず、容量不足(storageQuotaExceeded、HTTP 403)で弾かれます。フォルダは作れました。既存のスプレッドシートを編集するだけなら問題ありません。

なぜ認証が2通りなのか

コードと作業メモから読み取れる範囲では、次のとおりです。

  • スライドは、画像を Drive に置く必要がある: createImage に渡す画像を、いったん Drive にアップロードしてリンク共有しています。上に書いたとおりサービスアカウントは新しいファイルを置けないので、保存容量を持つ利用者本人のトークンを使っています
  • スプレッドシートは、既存のファイルの編集だけで済む: 新しいファイルを作らないので、サービスアカウントで足ります。サービスアカウントは共有されたファイルしか見えないので、利用者の Drive 全体を触れるトークンより範囲を絞れます

もう1つ、時系列の事情もあります。スプレッドシート用のサービスアカウントは、それより前に「共有したシートだけ触れる形にしたい」という理由で用意してありました。スライドの作業では Drive に画像を置く処理を先に作っていたので、そのとき使っていた利用者のトークンをそのまま使った、というのが実際の経緯に近いと思います(ここは推測です)。

6. Claude Cowork・Claude for Google Workspace との違い

Anthropic からも、似たことができる製品が出ています。公式ヘルプの記述をもとに整理します。

Claude Cowork6

  • タスクは クラウドで動きます。デスクトップアプリを閉じても、PC がスリープしても動き続けます
  • あるデバイスで始めた作業を別のデバイスで続けられ、Web やモバイルからも使えます
  • 手元のファイルには、Claude Desktop アプリ経由で、接続したフォルダの中だけ読み書きできます。Linux 版の Claude Desktop はベータです
  • 有料プラン(Pro / Max / Ultra / Team / Enterprise)で使えます

Claude for Google Workspace7

  • Google ドキュメント・スプレッドシート・スライドにサイドバーを足す拡張で、ベータ です
  • 見るのは 開いているファイルの関係する部分と、選んでいる範囲 です。有効にしたコネクタを除き、Drive の他のファイル・メール・カレンダーは見えません
  • Google Workspace Marketplace から入れます

どちらも、Google のファイルを AI に編集させるだけなら十分です。私が OpenClaw でやっている形との差は、次の2点だと考えています。

  1. 仕事全体の文脈を持っていること。エージェントは、録画アプリの開発の経緯や、クイズ動画の在庫の判定基準を、それまでの Discord のやり取りと作業メモから知っています。スライドを開いた時点で「何のためのスライドか」「前回どこまで直したか」が分かっているので、ファイルの中身だけを見る拡張とは頼み方が変わります
  2. 自分の Linux マシンにある物を使えること。生成した絵を rembg で切り抜く、手元のリポジトリのコードから構成を読む、PDF を pdftoppm で画像にして目で確かめる、といった処理を、同じマシンの上で続けて行えます

逆に、公式の製品のほうが良い所もあります。鍵やトークンを自分で管理しなくてよく、Cowork はスマホから頼んでも PC が寝ていても進みます(手元のファイルを使うタスクは、デスクトップアプリを開いたままにするよう案内されています6)。私の構成でも Discord から頼めますが、それは常駐させている Linux マシンが動いていることが前提です。

まとめ

  • スライドは利用者の OAuth(drive スコープ)で、画像は「Drive に置く → リンク共有 → createImage → 共有を外してゴミ箱」の順に入れます
  • 部品は透過 PNG、文字はスライドの図形に分けると、人があとから直せます
  • スプレッドシートはサービスアカウント(spreadsheets スコープ、共有されたファイルだけ)で、値は values.update、書式・フィルタ・条件付き書式は batchUpdate でまとめて送ります
  • 見た目は PDF → pdftoppm → 画像で確かめます
  • 人が触っている最中のページは消さない、触れる範囲は鍵とスコープで絞る、が大事でした
  1. 出典: Slides API リファレンス CreateImageRequest。「The provided URL must be publicly accessible and up to 2 KB in length.」「The image is fetched once at insertion time and a copy is stored for display inside the presentation.」。画像は 50 MB 未満・25 メガピクセル以下・PNG / JPEG / GIF のいずれか、とも書かれています。 ↩↩

  2. 出典: Drive API: Roles and permissions。役割ごとにできる操作(閲覧・コメント・編集など)が表にまとまっています。 ↩

  3. 出典: Drive API: Resolve errors(storageQuotaExceeded の項)と Shared drives overview。「Service accounts don’t have storage quota and can’t own any files.」とあります。私の環境でも、共有されたマイドライブのフォルダへのアップロードが容量なしで弾かれました。 ↩

  4. 出典: UpdatePageElementsZOrderRequest。「All the page elements must be on the same page and must not be grouped.」 ↩

  5. 出典: Drive API: files.export。「the exported content is limited to 10 MB」。 ↩

  6. 出典: Get started with Claude Cowork。「Cowork runs your tasks in the cloud.」「Sessions keep running even when the desktop app is closed or your computer is asleep.」「Claude can only read and write files in folders you’ve connected」。対応プラットフォームとして「Claude Desktop for macOS, Windows, and Linux (beta)」。 ↩↩

  7. 出典: Use Claude in Google Docs, Sheets, and Slides。「Claude for Google Workspace adds a Claude sidebar to Google Docs, Google Sheets, and Google Slides.」「Claude for Google Workspace is in beta for Pro, Max, Ultra, Team, and Enterprise plans.」。有効にしたコネクタ以外では「cannot see other files in your Drive, your email, or your calendar」。 ↩

参考リンク

コメント

タイトルとURLをコピーしました