月次の売上シートを Gemini に渡して、カテゴリごとの傾向を書き出してもらったことがあります。返ってきた文章は流暢でしたが、カテゴリの割り振りが実際の表とずれていました。
最初はプロンプトを疑いました。指示を細かくしても、モデルを変えても、ずれ方が変わるだけで直りません。おかしいと思って、渡していた文字列そのものを目で追ったところ、原因はモデルより手前にありました。表は、Gemini に届く前の抽出の時点で、すでに壊れていました。
個人開発でスプレッドシートを扱っていると、この形の表は嫌になるほど出てきます。人が読む前提で作られた表だからです。ここでは、その表が抽出時に何を失うかを実際に数えて、失わない形へ直すところまでを Python で組みます。
抽出した時点で、行の半分が所属を失っています
題材として、手元の売上集計に近い形の表を用意しました。カテゴリ列は同じ値が続く範囲を縦に結合してあり、月ごとの見出しが1行目、その下に販売数と売上が並ぶ2段構成です。表の途中に区切りの空行があり、最後に合計行が付いています。よくある形だと思います。
これを openpyxl でそのまま読み出し、CSV に落とすと、こうなりました。
カテゴリ,商品,2026年7月,,2026年8月,
,,販売数,売上,販売数,売上
壁紙,四季の壁紙,1200,84000,1310,91700
,浮世絵壁紙,940,65800,1005,70350
,夜景壁紙,610,42700,588,41160
癒し,焚き火の音,430,30100,402,28140
,波の音,380,26600,411,28770
,,,,,
ツール,単位変換,220,15400,198,13860
,方位磁針,160,11200,175,12250
合計,,3940,275800,4089,286230同じスクリプトの中で数えた結果が次のとおりです。
| 指標 | 素の抽出 |
|---|---|
| データ行 | 8 行 |
| カテゴリが空になった行 | 4 行(データ行の 50%) |
| 同名の列見出し | 販売数 ×2 / 売上 ×2 |
| ヘッダが占める行数 | 2 行 |
結合セルの値は、範囲の左上のセルにしか入っていません。残りのセルは空です。画面では「壁紙」が3行分に見えていても、読み出せば3行のうち2行は空になります。浮世絵壁紙も夜景壁紙も、どのカテゴリのものか分からない行として渡っていました。
見出しも同じです。1行目の「2026年7月」は C 列にしかなく、D 列は空。2行目には販売数と売上が2組ずつ並びます。この2行を別々の行として渡すと、販売数という列名が2つある表になります。どちらが7月かは、モデルの側では判断できません。
Docs の表でも似たことが起きますが、崩れ方も直し方も違います。そちらは表を渡したはずが、行と列の対応が消えていたで扱っています。
結合セルは、読み出した直後に展開しておきます
直し方はそれほど難しくありません。結合範囲の情報はファイルに残っているので、読み出した直後に、範囲内の全セルへ左上の値を配ります。
from openpyxl import load_workbook
def expand_merges(ws):
"""結合範囲の全セルに左上の値を配り、素の二次元配列にして返す"""
grid = [[c.value for c in row] for row in ws.iter_rows()]
for rng in ws.merged_cells.ranges:
top = grid[rng.min_row - 1][rng.min_col - 1]
for r in range(rng.min_row - 1, rng.max_row):
for c in range(rng.min_col - 1, rng.max_col):
grid[r][c] = top
return grid
ws = load_workbook("sales.xlsx").active
grid = expand_merges(ws)ws.merged_cells.ranges は縦の結合も横の結合も同じ形で返してきます。カテゴリ列の縦結合と、月見出しの横結合を、同じループで処理できるのはここが理由です。
この段階で grid を眺めると、カテゴリが空だった4行に値が入っています。ただし見出しはまだ2行のままです。
2行ヘッダは、列ごとに縦へ連結して1行に畳みます
見出しを1行にするときに悩むのが、上下で同じ語が繰り返される場合です。B 列は「商品」が縦に結合されているため、展開後は上下とも「商品」になります。これを機械的に連結すると「商品 / 商品」という列名ができてしまいます。
重複を落としてから連結し、それでも同名になった列には連番を振る、という順序にしました。
def flatten_header(grid, depth):
"""先頭 depth 行を1行のヘッダへ畳み、残りを本体として返す"""
head = grid[:depth]
names, seen = [], {}
for col in range(len(grid[0])):
parts = [str(head[r][col]).strip() for r in range(depth)
if head[r][col] not in (None, "")]
uniq = []
for p in parts:
if p not in uniq:
uniq.append(p)
name = " / ".join(uniq) or f"col{col + 1}"
seen[name] = seen.get(name, 0) + 1
if seen[name] > 1:
name = f"{name}#{seen[name]}"
names.append(name)
return names, grid[depth:]
def drop_blank(rows):
return [r for r in rows if any(v not in (None, "") for v in r)]depth を引数にしているのは、3段の見出しを持つ表が実際にあるからです。私は自動判定を一度試して、やめました。データの1行目が文字列だと見出しと区別がつかず、静かに1行食われる事故のほうが痛かったためです。表ごとに 2 なり 3 なりを明示して渡すほうが、後から読んでも意味が通ります。
ここまでを通した結果が次の CSV です。
カテゴリ,商品,2026年7月 / 販売数,2026年7月 / 売上,2026年8月 / 販売数,2026年8月 / 売上
壁紙,四季の壁紙,1200,84000,1310,91700
壁紙,浮世絵壁紙,940,65800,1005,70350
壁紙,夜景壁紙,610,42700,588,41160
癒し,焚き火の音,430,30100,402,28140
癒し,波の音,380,26600,411,28770
ツール,単位変換,220,15400,198,13860
ツール,方位磁針,160,11200,175,12250
合計,,3940,275800,4089,286230カテゴリの空欄は 4 行から 0 行へ、同名の列見出しは 2 組から 0 組になりました。空行も落ちています。
合計行を残したまま渡すと、数字がそのまま倍になります
平坦化が済んで安心したところに、まだ問題が残っていました。最終行の合計です。
整形後の表で、7月の販売数の列を素直に足すとこうなります。
| 足し方 | 7月の販売数の合計 |
|---|---|
| 全行をそのまま足す | 7,880 |
| 合計行を除いて足す | 3,940 |
ちょうど倍です。合計行が、他の行と同じ見た目のデータ行として並んでいるためです。人が見れば「合計」という文字で分かりますが、その語は日本語版のシートでしか使われません。英語のシートなら Total、私の手元には小計だけ入った表もあります。文字列で判定する方法は、いずれ取りこぼします。
数値の関係で見るほうが確実でした。ある行のある列の値が、同じ列の他の全行の和と一致するなら、その行は集計行の可能性が高いという判定です。
def looks_like_total(rows, col):
"""列 col について、他の全行の和と一致する行の index を返す"""
hits = []
column = [r[col] for r in rows]
for i, r in enumerate(rows):
others = sum(x for j, x in enumerate(column) if j != i)
if r[col] == others:
hits.append(i)
return hits
hits = looks_like_total(body, 2) # 2 = 2026年7月 / 販売数
clean = [r for i, r in enumerate(body) if i not in hits]この表に当てると hits は最終行だけを拾い、除外後の合計は 3,940 になりました。判定に使う列は、数値が入っていて欠損の少ない列を1つ選べば十分です。複数の列で一致を要求すると、行数の少ない表で取りこぼしが増えました。
なお、この判定は「全体の1行だけが他の和と一致する」場面を想定しています。カテゴリごとの小計が各ブロックの末尾に入る表では、小計が拾えず、総合計が小計の分まで含んだ和と一致しなくなります。そういう表は、集計列を持たない生データの形でエクスポートし直すほうが早いと考えています。
整形しても、文字数は減りません
ここが、やる前の予想と逆だった点です。無駄な空欄が埋まって空行が消えるのだから、渡す文字列は短くなるだろうと思っていました。実際に数えると、こうでした。
| 形式 | 文字数 |
|---|---|
| 素の抽出(CSV) | 281 |
| 平坦化後(CSV) | 302 |
21 文字、率にして 7.5% 増えています。カテゴリを埋め直した分と、列名に月を足した分が、空行と空欄を削った分を上回りました。
つまりこの前処理は、入力を軽くするための工夫ではありません。増えた 21 文字は、8 行のうち 4 行に所属を返し、2 組の同名列を区別するために払っています。この交換は、私自身の用途では毎回引き合いました。逆に言えば、行数が数万に及ぶ表でこれをそのまま貼るのは、量の面で無理があります。その規模なら、要約させる前に集計を済ませてから渡すほうが素直です。
整形済みの表を渡す
前処理が済んだら、渡し方は素直で構いません。整形後の CSV を本文に入れ、構造化出力で受け取る形にしています。
from google import genai
from google.genai import types
from pydantic import BaseModel
class RowInsight(BaseModel):
category: str
product: str
delta_units: int
note: str
client = genai.Client(api_key="YOUR_API_KEY")
config = types.GenerateContentConfig(
system_instruction=(
"入力は1行目がヘッダの CSV です。"
"列名の / の左は対象月を表します。合計行は除外済みです。"
),
response_mime_type="application/json",
response_schema=list[RowInsight],
)
response = client.models.generate_content(
model="gemini-3.7-flash",
contents=f"次の表から、前月比の増減が大きい行を抜き出してください。\n\n{csv_text}",
config=config,
)
rows = response.parsedsystem_instruction に「合計行は除外済みです」と書いているのは、モデルへの説明であると同時に、自分への注記でもあります。この一文と実際の前処理がずれたときに気づけるように、同じ言葉で残しています。
サンプリング系のパラメータ(temperature など)は 2026 年 8 月に非推奨となったため、この構成には入れていません。手元で config.model_dump(exclude_none=True) を確認すると、送出されるキーは response_mime_type・response_schema・system_instruction の3つだけになります。実際に何が送られるかを目で見ておくと、後から設定を足したときの差分が追いやすくなります。
前処理と API 呼び出しを一本のパイプラインにまとめる話は、Google Sheets API × Gemini API でつくるデータ処理パイプラインのほうが詳しいです。Sheets 側の機能をどこまで使い、どこから自前のコードに残すかという線引きについては、Sheets canvas へ移せるのは入口までで、実行境界は Apps Script に残りますで判断の材料を整理しています。
次にやること
お使いのシートを1枚選び、expand_merges を通す前と後で、キーになる列の空欄が何行あるかだけ数えてみてください。0 のままなら前処理は要りません。1 行でも減るなら、その行数がそのまま、これまでモデルに判断させていた曖昧さの量です。