「今月、あのプロジェクトに何時間使ったんだっけ…?」

フリーランスで複数案件を並行していると、この一言が月末に必ずやってきますよね😅 稼働記録をつけているつもりでも、メモアプリとカレンダーとSlackの履歴に散らばっていて、請求書を作る前日に慌てて掘り起こす——そんな経験、心当たりありませんか?

今回は、Python+gspread+Googleスプレッドシートで稼働記録の入力から月次集計・請求額の算出までをまるっと自動化する方法を、コピペで動くコード付きで解説します。ローカルのExcelファイルではなくクラウド(スプレッドシート)を記録先にするのがポイントです。外出先のスマホからも同じ表を見られるので、工数管理が一気に現実的になります。

  • 難易度:★★☆(Python初〜中級者向け)
  • 所要時間:初回セットアップ約30分+コード写経
  • 必要なもの:Pythonの実行環境/Googleアカウント

なぜExcelではなくスプレッドシート×gspreadなのか

freelance developer desk laptop
freelance developer desk laptop / Photo by Daniil Komov via Pexels

工数管理をPythonで自動化する方法はいくつかありますが、記録先の選択で運用のラクさがかなり変わります。ざっくり比較するとこんな感じです。

  • ローカルのExcel(openpyxl):オフラインで速い。ただしPCを開かないと入力も閲覧もできない
  • CSV+自作ツール:軽いが、クライアントに共有しづらい
  • Googleスプレッドシート(gspread):スマホから入力・閲覧OK。クライアントへの共有もURL1本。Pythonからも読み書き自由

つまり「入力はスマホからでも手打ちでも、集計はPythonで一瞬」というハイブリッド運用ができるのが最大のメリットです。稼働記録って、結局「入力し続けられるか」がすべてなんですよね。

STEP1:サービスアカウントを作ってシートを共有する

gspreadからスプレッドシートを操作するには、サービスアカウント(プログラム用のGoogleアカウントのようなもの)を用意します。流れはこうです。

  1. Google Cloudでプロジェクトを作成
  2. 「Google Sheets API」と「Google Drive API」を有効化
  3. サービスアカウントを作成し、鍵(JSON)をダウンロード
  4. JSON内の client_email(〜@〜.iam.gserviceaccount.com)を確認
  5. 工数管理用スプレッドシートを作り、そのメールアドレスに編集者権限で共有

5番目の共有を忘れると、あとで APIError(403 PERMISSION_DENIED)が出ます。シートIDではなくシート名で open() した場合は SpreadsheetNotFound になります。いずれも原因は「共有し忘れ」なので、ここは必ず確認しておきましょう✅

ダウンロードしたJSONは service_account.json という名前でプロジェクト直下に置きます。Gitには絶対に入れないので、.gitignore への追記も忘れずに。

STEP2:gspreadをインストールして接続確認

pip install gspread google-auth pandas

シートは「稼働記録」という名前のワークシートを作り、1行目に見出しを入れておきます。

  • A列:日付
  • B列:案件名
  • C列:作業内容
  • D列:開始
  • E列:終了
  • F列:稼働時間

準備ができたら、接続できるか確かめます。

import gspread
from google.oauth2.service_account import Credentials

# Sheets と Drive の両方のスコープが必要
SCOPES = [
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/drive",
]
SHEET_KEY = "ここにスプレッドシートIDを貼る"  # URLの /d/ と /edit の間

def get_worksheet(name="稼働記録"):
    creds = Credentials.from_service_account_file("service_account.json", scopes=SCOPES)
    gc = gspread.authorize(creds)
    return gc.open_by_key(SHEET_KEY).worksheet(name)

if __name__ == "__main__":
    ws = get_worksheet()
    print("接続OK:", ws.title)
    for row in ws.get_all_records()[:3]:
        print(row)
スプレッドシートへの接続確認
スプレッドシートへの接続確認

ポイントをまとめるとこんな感じです。

  • get_all_records()1行目を見出しとして辞書のリストを返してくれる(pandasに渡しやすい)
  • スプレッドシートIDはURLの /d//edit の間の長い文字列
  • 関数化しておくと、後続スクリプトから使い回せる

STEP3:稼働1件をワンコマンドで記録する

次に、稼働を1行追記する関数を作ります。手入力の手間をどこまで削れるかが続けられるかの分かれ目なので、引数は最小限にします。

from datetime import datetime, timedelta
from connect_test import get_worksheet  # STEP2の関数を再利用

def add_log(project, task, start, end):
    """稼働記録を1行追記する"""
    minutes = (end - start).total_seconds() / 60
    hours = round(minutes / 60, 2)  # 小数2桁の時間に変換

    ws = get_worksheet()
    ws.append_row([
        start.strftime("%Y-%m-%d"),
        project,
        task,
        start.strftime("%H:%M"),
        end.strftime("%H:%M"),
        hours,
    ], value_input_option="USER_ENTERED")
    return hours

if __name__ == "__main__":
    s = datetime(2026, 4, 10, 10, 0)
    e = datetime(2026, 4, 10, 12, 30)
    print(f"記録しました: {add_log('A社_在庫API', '認証まわり実装', s, e)}時間")

value_input_option="USER_ENTERED" を付けると、シートにブラウザから手入力したときと同じように、スプレッドシート側が日付や時刻として解釈してくれます。ここを省くと既定の RAW になり、「2026-04-10」や「10:00」が日付・時刻ではなくただの文字列としてセルに入ります。後で日付でフィルタしたいときに地味に困るので要注意です⚠️

STEP4:CLIタイマーで「開始」「終了」を打つだけにする

時刻を毎回手で書くのは続きません。そこで、startstop の2コマンドだけで完結するタイマーにします。状態はローカルのJSONに持たせるだけでOKです。

import json, os, sys
from datetime import datetime
from log_work import add_log

STATE = ".timer_state.json"

def start(project, task):
    if os.path.exists(STATE):
        print("⚠️ すでに計測中です。先に stop してください")
        return
    data = {
        "project": project,
        "task": task,
        "start": datetime.now().isoformat(timespec="seconds"),
    }
    with open(STATE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False)
    print(f"▶ 計測開始: {project} / {task}")

def stop():
    if not os.path.exists(STATE):
        print("計測中のタスクがありません")
        return
    with open(STATE, encoding="utf-8") as f:
        data = json.load(f)
    s = datetime.fromisoformat(data["start"])
    e = datetime.now()
    hours = add_log(data["project"], data["task"], s, e)
    os.remove(STATE)
    print(f"■ 計測終了: {hours}時間 をシートに記録しました")

if __name__ == "__main__":
    cmd = sys.argv[1]
    if cmd == "start":
        start(sys.argv[2], sys.argv[3])
    else:
        stop()

使い方はこれだけです。

  • python timer.py start "A社_在庫API" "設計レビュー"
  • 作業が終わったら python timer.py stop

「打刻を忘れる」問題は、エイリアス(alias ws="python ~/tools/timer.py")を切って、ターミナルを開いたら最初に叩く癖をつけるのがおすすめです。

STEP5:月次集計でプロジェクト別の工数と請求額を出す

ここからがPythonの本領発揮です。シートを丸ごと読んでpandasで集計し、案件別の稼働時間・請求額・予算消化率を一気に算出します。

import pandas as pd
from connect_test import get_worksheet

# 案件ごとの単価と月間契約時間
CONTRACTS = {
    "A社_在庫API": {"rate": 6000, "budget": 60},
    "B社_保守":     {"rate": 5000, "budget": 20},
    "自社サービス": {"rate": 0,    "budget": 30},
}

def monthly_report(year_month="2026-04"):
    df = pd.DataFrame(get_worksheet().get_all_records())
    df["日付"] = pd.to_datetime(df["日付"])
    df["稼働時間"] = pd.to_numeric(df["稼働時間"], errors="coerce").fillna(0)

    # 対象月だけ抽出
    target = df[df["日付"].dt.strftime("%Y-%m") == year_month]

    g = target.groupby("案件名")["稼働時間"].sum().reset_index()
    g["単価"]   = g["案件名"].map(lambda p: CONTRACTS.get(p, {}).get("rate", 0))
    g["請求額"] = (g["稼働時間"] * g["単価"]).astype(int)
    g["契約時間"] = g["案件名"].map(lambda p: CONTRACTS.get(p, {}).get("budget", 0))
    # 契約時間が0の案件(CONTRACTS未登録など)は 0除算で inf になるため 0 扱いにする
    g["消化率%"] = (g["稼働時間"] / g["契約時間"].where(g["契約時間"] > 0) * 100).round(1).fillna(0)
    return g.sort_values("請求額", ascending=False)

if __name__ == "__main__":
    rep = monthly_report("2026-04")
    print(rep.to_string(index=False))
    print(f"\n合計請求額: {rep['請求額'].sum():,} 円")
月次レポートの集計結果
月次レポートの集計結果

ここが重要です。

  • 単価と契約時間はコード側に辞書で持つ(シートに書くと誤操作で壊れやすい)
  • pd.to_numeric(..., errors="coerce") で手入力の全角文字などを無害化
  • 契約時間が0の案件をそのまま割ると inf になり、STEP6でシートに書き戻す際にエラーの原因になるので0埋めしておく
  • 消化率を見れば「今月このままだと超過する」が月中でも分かる

この消化率、地味ですが効きます。月末に「20時間オーバーしてたけど請求できない」という事故を防げるんですよね。

STEP6:集計結果をシートに書き戻す

ターミナルで見るだけではもったいないので、「月次サマリ」ワークシートに書き戻しましょう。クライアントにそのまま共有できる状態になります。

from connect_test import get_worksheet
from monthly_report import monthly_report
import gspread

def write_summary(year_month="2026-04"):
    rep = monthly_report(year_month)
    try:
        ws = get_worksheet("月次サマリ")
    except gspread.WorksheetNotFound:
        ws = get_worksheet().spreadsheet.add_worksheet("月次サマリ", rows=100, cols=10)

    values = [[f"{year_month} 稼働レポート"]]
    values.append(rep.columns.tolist())
    values += rep.values.tolist()
    values.append(["合計", float(rep["稼働時間"].sum()), "", int(rep["請求額"].sum()), "", ""])

    ws.clear()
    ws.update(values, "A1", value_input_option="USER_ENTERED")
    ws.format("A2:F2", {"textFormat": {"bold": True}})
    print("月次サマリを更新しました")

if __name__ == "__main__":
    write_summary("2026-04")

ws.update() に二次元リストを渡すと1回のAPIリクエストでまとめて書き込みできます。1セルずつ update_cell() で回すのはNGです(理由は次で説明します)。なお update() の引数順は gspread 6系の update(values, range_name) を前提にしています(5系は逆順なので、バージョンを跨ぐ場合は values= / range_name= とキーワードで渡すのが安全です)。

APIのレート制限でコケないための2つのコツ

gspreadで最初にハマるのが APIError: [429] Quota exceeded です。Google Sheets APIには1分あたりのリクエスト数制限があるため、ループ内で1行ずつ書くとすぐ引っかかります。

# ❌ 悪い例:100回リクエストが飛ぶ
for row in rows:
    ws.append_row(row)

# ✅ 良い例:1回のリクエストでまとめて追記
ws.append_rows(rows, value_input_option="USER_ENTERED")

# ✅ 読み込みも同じ。都度 get ではなく一括で取得してメモリ上で処理する
records = ws.get_all_records()

覚えておきたいのはこの2点だけです。

  • 書き込みは append_rows / update でまとめる
  • 読み込みは最初に1回、あとはpandasで処理する

それでも429が出る場合は、リトライを挟むか、集計処理を1日1回のバッチ(cronやタスクスケジューラ)に寄せてしまうのが現実的です。

ここまでできたら試したい応用ネタ

  • 消化率が90%を超えたらLINEやSlackに通知(超過前に単価交渉の材料にできる)
  • 作業内容の文字列を集計して「実装/打ち合わせ/調査」の比率を可視化
  • 月次サマリをそのままCSV出力して、請求書テンプレートに流し込む
  • 案件名をシートのプルダウンにして入力ゆれを防止(表記ゆれは集計の天敵です)

特に最後の「表記ゆれ対策」は効果が大きいです。「A社_在庫API」と「A社 在庫API」が混ざると、groupbyで別案件として集計されてしまいます。入力側で潰しておきましょう。

まとめ

今回は、フリーランスエンジニアの稼働記録・工数管理をPythonとgspreadで自動化する方法を、サービスアカウントの準備からCLIタイマー、月次集計、シートへの書き戻しまで通しで解説しました。

  • 記録先をGoogleスプレッドシートにすると、スマホ入力とPython集計を両立できる
  • start / stop の2コマンドまで簡略化すると記録が続く
  • pandasで案件別の稼働時間・請求額・消化率を一発算出
  • APIリクエストは「まとめて読む・まとめて書く」が鉄則

工数管理は地味な作業ですが、数字が見えるようになると「その案件、本当に採算取れてる?」という判断が一瞬でできるようになります。月末の1〜2時間を取り戻すだけでも十分元が取れるはずです。

まずはSTEP2の接続確認だけでもやってみてください。シートに1行書き込めた瞬間、「むずかしそう」が「できそう」に変わりますよ😊 一緒に業務効率化を進めていきましょう!

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る
Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

もしも

Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

FastAPIでWebAPI開発を実践的に学ぶ

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。