Google Ads API: セットアップ・認証・読み取りと書き込み操作
プログラムによる Google Ads アクセスをセットアップするための技術的なウォークスルー — アーキテクチャから最初の API 呼び出しまで。MCC の設定、OAuth 2.0、デベロッパートークンのレベル、google-ads Python ライブラリ、GAQL 読み取りクエリ、validate_only 書き込みパターンについて解説します。
アーキテクチャ
1 回の API 呼び出しが動作するまでには、3 つの独立したエンティティが必要です。
| エンティティ | 目的 | 例 |
|---|---|---|
| **マネージャーアカウント(MCC)** | デベロッパートークンを発行する | `login_customer_id` |
| **Google Cloud プロジェクト** | 認証用の OAuth 2.0 クライアント | クライアント ID + シークレット |
| **Google Ads アカウント** | すべての API 操作の対象 | `customer_id` |
これらは同一のアカウントではありません。MCC は対象アカウントを所有していなくてもデベロッパートークンを所有できます — マネージャーとしてリンクされているだけで十分です。Google Cloud プロジェクトは、MCC とは異なる Google ID で管理しても構いません。対象アカウントは、キャンペーンが存在する広告主アカウントです。
この分離が重要な理由は、APIセンター(デベロッパートークンがプロビジョニングされる場所)がマネージャーアカウント内でしか利用できないためです。スタンドアロンの Google Ads アカウントは、管理者ユーザーがいても APIセンターにアクセスできません。
API 呼び出しのヘッダーは、これら 3 つの ID と OAuth ベアラートークンを組み立てます。
developer-token: <MCC APIセンターから取得>
login-customer-id: <MCC の数値 ID、ダッシュなし>
customer-id: <対象アカウントの数値 ID>
Authorization: Bearer <OAuth 2.0 アクセストークン>
前提条件
- セットアップが完了した Google Ads マネージャーアカウント
- 対象の広告アカウントがマネージャーアカウントにリンクされ、リンクが承認されていること
- Google Ads API が有効化された Google Cloud プロジェクト
- Python 3.9 以上と
google-adsライブラリ(pip install google-ads) - 1 回限りの OAuth 同意フロー用のブラウザ
ステップ 1: マネージャーアカウントとデベロッパートークン
APIセンターは、マネージャーアカウント内の ツールと設定 → 設定 → APIセンター にあります。APIセンターが表示されない場合、そのアカウントはマネージャーアカウントでないか、セットアップが不完全です。
APIセンターからデベロッパートークンをリクエストします。自身のアカウント用のトークンである場合は、アカウントタイプを Advertiser(代理店やサードパーティではありません)に設定してください。トークンはすぐに テストアクセス レベルで発行されます。
テストアクセスで許可される操作:
- リンクされた任意のアカウントに対する読み取り操作
- テストアカウントのみに対する書き込み操作
- 本番アカウントに対する書き込み操作は不可
テストアクセスで本番アカウントに書き込んだ場合のエラーは RESOURCE_NOT_FOUND であり、PERMISSION_DENIED ではありません。これは混乱を招きますが、リソースは実際に存在し、読み取りも可能だからです。このエラーは、API が現在のアクセスレベルではミューテーションをルーティングしないことを意味します。
ベーシックアクセスの申請
本番アカウントに書き込むには、同じ APIセンターのページからベーシックアクセスを申請します。申請フォームでは 12 の質問が出題されます。自身のアカウントでの内部利用の場合、関連する回答は次のとおりです。
| 質問 | 回答 |
|---|---|
| Q4: Google 担当者との関係 | なし(No) |
| Q6: ビジネスモデル | 自社のユースケースを説明(EC、リード獲得、内部レポートなど) |
| Q8: アクセス権限を持つユーザー | 内部ユーザー — 従業員のみ |
| Q9: サードパーティ開発ツール | いいえ(自社スクリプト/エージェント) |
| Q10: コンバージョントラッキング & リマーケティング API | いいえ(実際に使用する場合を除く) |
| Q11: キャンペーンタイプ | 検索、P-MAX、ショッピング(該当するもの) |
| Q12: 機能 | キャンペーン作成、キャンペーン管理、レポート |
Q7 では設計ドキュメント(PDF)が必要です。API アーキテクチャ、認証フロー、レート制限戦略、エラー処理、内部利用のみのユースケースを記載してください。事実に基づいて記述します — コンプライアンスチームは宣言されたユースケースとの整合性を審査します。
2026 年 6 月時点の処理期間: 約 3 営業日。ただし申請量が通常より多いとの注記があります。
ステップ 2: OAuth 2.0
Google Cloud プロジェクトのセットアップ
該当プロジェクトの Google Cloud コンソールで以下を実施します。
- Google Ads API を有効化(API とサービス → ライブラリ)
- OAuth 同意画面を設定:
- ユーザータイプ: Internal(Google Workspace 内のユーザーのみが認証可能 — 外部アプリ検証をスキップできます)
- アプリ名、サポートメール、デベロッパー連絡先メール
- データアクセスでスコープを追加:
https://www.googleapis.com/auth/adwords - OAuth クライアント ID を作成:
- アプリケーションタイプ: Desktop app
- クライアントシークレット JSON をダウンロード
Desktop app タイプはリダイレクト URI として http://localhost を使用します。これは正しい設定です — OAuth フローはブラウザを開き、Google が認証コード付きで localhost にリダイレクトし、ローカル HTTP サーバーがそれを受け取ります。
リフレッシュトークンの生成
127.0.0.1:0(ランダムポート)でローカル HTTP サーバーを起動し、認証 URL を表示し、1 つのリクエストを待って code パラメータを抽出し、トークンと交換する Python スクリプトです。
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from google_auth_oauthlib.flow import Flow
class CallbackHandler(BaseHTTPRequestHandler):
code_value = None
def do_GET(self):
qs = parse_qs(urlparse(self.path).query)
CallbackHandler.code_value = (qs.get('code') or [None])[0]
self.send_response(200)
self.end_headers()
self.wfile.write(b'OAuth complete.')
def log_message(self, format, *args):
return
server = HTTPServer(('127.0.0.1', 0), CallbackHandler)
redirect_uri = f'http://127.0.0.1:{server.server_port}/'
flow = Flow.from_client_secrets_file(
'client_secret.json',
scopes=['https://www.googleapis.com/auth/adwords'],
redirect_uri=redirect_uri
)
auth_url, _ = flow.authorization_url(
access_type='offline',
prompt='consent'
)
print(auth_url)
server.handle_request()
flow.fetch_token(code=CallbackHandler.code_value)
Path('google_ads_token.json').write_text(flow.credentials.to_json())
2 つのパラメータが重要です。
access_type='offline'— アクセストークンだけでなくリフレッシュトークンも返しますprompt='consent'— 以前に認証済みでも新しい同意画面を強制します。スコープが変更された場合に新しいリフレッシュトークンを取得するために必要です
リフレッシュトークンは、失効、ユーザーのパスワード変更、または 6 ヶ月間未使用でない限り永続的です。
既存クライアントでのスコープの落とし穴
OAuth クライアントが以前に他のスコープ(Gmail、Drive、Calendar)で認証されていた場合、Google は新旧のスコープの和集合を返します。google-auth-oauthlib ライブラリは、このスコープの不一致をデフォルトで拒否します。フローを読み込む前に環境変数を設定してください。
import os
os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'
または、認証 URL から include_granted_scopes=true を省略することで、明示的に渡されたスコープのみをリクエストするよう Google に指示します。
ステップ 3: `google-ads.yaml`
ライブラリは YAML ファイルから設定を読み取ります。
developer_token: <value>
client_id: <value>
client_secret: <value>
refresh_token: <value>
login_customer_id: 1911599764
use_proto_plus: true
セキュリティルール:
- ファイルパーミッション:
0600(オーナーの読み取り/書き込みのみ) - バージョン管理にコミットしない
- Git リポジトリの外部に保存する
- チームで使用する場合は、共有ファイルではなくシークレットマネージャー経由で配布する
login_customer_id は、ダッシュを含まない MCC の数値 ID です。どのマネージャーアカウントのデベロッパートークンを使用するかを API に指示します。対象の customer_id は設定ファイルではなく、呼び出し時に渡します。
use_proto_plus: true は protobuf-plus インターフェースを有効にします。現在の API バージョンでは必須です。
ステップ 4: Python クライアントの初期化
from google.ads.googleads.client import GoogleAdsClient
client = GoogleAdsClient.load_from_storage(
'/path/to/google-ads.yaml',
version='v22'
)
gs = client.get_service('GoogleAdsService')
version パラメータで API バージョンを明示的に指定します。google-ads ライブラリには複数のバージョンが並行してインストールされます(2026 年 6 月現在、v19 から v22 まで)。必ずバージョンを指定してください — デフォルトは遅れていたり、新しいフィールドをサポートしていない可能性があります。
接続確認:
cs = client.get_service('CustomerService')
customers = cs.list_accessible_customers()
ids = [r.split('/')[-1] for r in customers.resource_names]
# 出力例: ['1305475941', '3977581086', '1911599764']
ステップ 5: 読み取り操作(GAQL)
Google Ads は GAQL(Google Ads Query Language)という SQL ライクな構文を使用します。すべての読み取り操作は GoogleAdsService.search_stream() を経由します。このメソッドはページネーションを回避し、ほとんどのクエリで search() よりも推奨されます。
パフォーマンス指標付きキャンペーン
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.serving_status,
campaign.bidding_strategy_type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.ctr,
metrics.average_cpc
FROM campaign
ORDER BY campaign.id
キャンペーン予算
SELECT
campaign_budget.id,
campaign_budget.name,
campaign_budget.amount_micros,
campaign_budget.delivery_method,
campaign_budget.status,
campaign.id,
campaign.name
FROM campaign_budget
広告グループ
SELECT
ad_group.id,
ad_group.name,
ad_group.status,
ad_group.cpc_bid_micros,
campaign.id,
campaign.name
FROM ad_group
ORDER BY campaign.id, ad_group.id
キーワード(インプレッション順)
SELECT
ad_group_criterion.keyword.text,
ad_group_criterion.keyword.match_type,
ad_group_criterion.status,
ad_group_criterion.criterion_id,
ad_group.id,
ad_group.name,
campaign.id,
campaign.name,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM keyword_view
WHERE ad_group_criterion.type = 'KEYWORD'
ORDER BY metrics.impressions DESC
LIMIT 100
ポリシーステータス付き広告
SELECT
ad_group_ad.ad.id,
ad_group_ad.ad.name,
ad_group_ad.ad.type,
ad_group_ad.status,
ad_group_ad.policy_summary.approval_status,
ad_group.id,
ad_group.name,
campaign.id,
campaign.name
FROM ad_group_ad
コンバージョンアクション
SELECT
conversion_action.id,
conversion_action.name,
conversion_action.status,
conversion_action.type,
conversion_action.category,
conversion_action.include_in_conversions_metric,
conversion_action.counting_type
FROM conversion_action
Python 実行パターン
def gaql_query(client, customer_id, query):
gs = client.get_service('GoogleAdsService')
results = []
for batch in gs.search_stream(customer_id=customer_id, query=query):
for row in batch.results:
results.append(row)
return results
ステップ 6: `validate_only` を使用した書き込み操作
v22 API ではキーワード引数ではなくリクエストオブジェクトが必要です。すべてのミューテーションサービス(CampaignService、AdGroupService、AdGroupCriterionService、CampaignBudgetService)は同じパターンに従います。
from google.protobuf import field_mask_pb2
req = client.get_type('MutateCampaignsRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('CampaignOperation')
op.create.name = 'Campaign Name'
op.create.status = client.enums.CampaignStatusEnum.PAUSED
op.create.advertising_channel_type = client.enums.AdvertisingChannelTypeEnum.SEARCH
op.create.campaign_budget = 'customers/1305475941/campaignBudgets/123456789'
op.create.manual_cpc.enhanced_cpc_enabled = False
req.operations.append(op) # .append() を使用。.add() ではない
client.get_service('CampaignService').mutate_campaigns(request=req)
古い API バージョンとの主な違い:
validate_onlyはミューテーションメソッドのキーワード引数ではなく、リクエストオブジェクトのフィールドです- 操作は
req.operations.add(op)ではなくreq.operations.append(op)で追加します - リソースパスはヘルパーメソッドの戻り値ではなく、明示的な文字列です(
'customers/1305475941/campaigns/22479990461')
3 段階ミューテーションゲート
本番環境の安全性のため、ミューテーションは 3 つのゲートを通過します。
ゲート 1 — validate_only=True: API がリクエスト構造、必須フィールド、リソース参照を検証します。オブジェクトは作成されません。すべてのミューテーションで最初に実行してください。
ゲート 2 — 一時停止状態での作成: validate_only=False に設定し、status=PAUSED でオブジェクトを作成します。先に進む前に、読み取りクエリで結果を確認してください。
ゲート 3 — 本番有効化: status フィールドのみを ENABLED に更新する別の API 呼び出しです。これは明示的な承認を必要とし、作成時にまとめて行うべきではありません。
# ゲート 3: 作成済みの一時停止キャンペーンを有効化
req = client.get_type('MutateCampaignsRequest')
req.customer_id = '1305475941'
req.validate_only = False
op = client.get_type('CampaignOperation')
op.update.resource_name = 'customers/1305475941/campaigns/22479990461'
op.update.status = client.enums.CampaignStatusEnum.ENABLED
op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=['status']))
req.operations.append(op)
client.get_service('CampaignService').mutate_campaigns(request=req)
予算の更新
req = client.get_type('MutateCampaignBudgetsRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('CampaignBudgetOperation')
op.update.resource_name = 'customers/1305475941/campaignBudgets/123456789'
op.update.amount_micros = 1_000_000 # $1.00 / 日
op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=['amount_micros']))
req.operations.append(op)
client.get_service('CampaignBudgetService').mutate_campaign_budgets(request=req)
広告グループの入札単価更新
req = client.get_type('MutateAdGroupsRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('AdGroupOperation')
op.update.resource_name = 'customers/1305475941/adGroups/165827073530'
op.update.cpc_bid_micros = 250_000 # $0.25
op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=['cpc_bid_micros']))
req.operations.append(op)
client.get_service('AdGroupService').mutate_ad_groups(request=req)
キーワードの作成
req = client.get_type('MutateAdGroupCriteriaRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('AdGroupCriterionOperation')
op.create.ad_group = 'customers/1305475941/adGroups/165827073530'
op.create.status = client.enums.AdGroupCriterionStatusEnum.PAUSED
op.create.keyword.text = 'example keyword'
op.create.keyword.match_type = client.enums.KeywordMatchTypeEnum.EXACT
req.operations.append(op)
client.get_service('AdGroupCriterionService').mutate_ad_group_criteria(request=req)
エラー処理
from google.ads.googleads.errors import GoogleAdsException
try:
response = service.mutate_campaigns(request=req)
except GoogleAdsException as ex:
for error in ex.failure.errors:
print(f'{error.error_code}: {error.message}')
一般的なエラーコードとその意味:
| エラーコード | 原因 |
|---|---|
| `RESOURCE_NOT_FOUND` | 本番アカウントに対するテストアクセストークン(想定内)または実際にリソースが存在しない |
| `REQUIRED` | 必須フィールドの欠落(例: キャンペーン作成時の入札戦略) |
| `UNRECOGNIZED_FIELD` | 異なる API バージョンのフィールドまたは誤ったフィールド名 |
| `INVALID_ARGUMENT` | フィールド値がバリデーションに失敗(例: 負の予算) |
| `PERMISSION_DENIED` | OAuth ユーザーが対象アカウントへのアクセス権を持っていない |
API アクセスレベル
Google Ads API のアクセスはバイナリ(許可/拒否)ではなく、段階的です。
| レベル | プロビジョニング | 読み取り(本番) | 書き込み(本番) | 書き込み(テストアカウント) | 1 日の操作上限 |
|---|---|---|---|---|---|
| **テストアクセス** | MCC APIセンターから即時 | ✅ | ❌ | ✅ | 15,000 |
| **ベーシックアクセス** | 申請 + コンプライアンス審査 | ✅ | ✅ | ✅ | 15,000 |
| **スタンダードアクセス** | 支出閾値 + 審査 | ✅ | ✅ | ✅ | 無制限 |
テストアクセスは開発に十分です。認証、GAQL クエリ、validate_only テスト、Google Ads テストアカウントに対する完全な読み取り/書き込みが可能です。ベーシックアクセスへの移行は、支出額ではなくコンプライアンス審査によって制限されます。
スタンダードアクセスでは 1 日の操作上限が撤廃されます。トークンを通じて管理された支出の実績、または別途の申請が必要です。
テストアカウント
テストアカウントは、請求のない無料の Google Ads アカウントです。任意のミューテーションを受け付けます。作成方法: MCC で アカウント → + → 新しいアカウントを作成 → テストアカウント に移動します。MCC にリンクし、テストアクセスでの開発中は対象の customer_id として使用します。
アクセスレベルを跨いだ `validate_only`
validate_only=True はすべてのアクセスレベルで動作しますが、テストアクセスでは本番アカウントに対する validate_only ミューテーションも RESOURCE_NOT_FOUND で拒否されます。これは validate_only の制限ではなく、validate_only フラグの有無に関わらず、すべての書き込み操作に適用されるアクセス制御です。
コンバージョントラッキング: API 読み取りと機能ステータス
Google Ads の UI では、コンバージョンアクションが存在して有効であっても、「コンバージョントラッキングを設定してください」という推奨が表示される場合があります。API はコンバージョンアクションの存在を確認できますが(タイプ WEBPAGE、カテゴリ PURCHASE、ステータス ENABLED、include_in_conversions_metric: true)、これはアクションオブジェクトが存在することを証明するに過ぎません。ウェブサイトが対応する AW-.../label イベントを発行していること、Google タグが読み込まれていること、コンバージョンシグナルが受信されていることは証明されません。
コンバージョントラッキングを監査する際は、API チェック(コンバージョンアクションのメタデータ)とブラウザチェック(ネットワークタブ、タグの読み込み、イベントの発火)を分離してください。API は設定の信頼できる情報源(source of truth)であり、ブラウザは実行の信頼できる情報源です。
バックエンド収益アトリビューション のパターンがここでも当てはまります。クライアントサイドのコンバージョンイベントが信頼できない場合は、フォールバックとして Google Ads API の ConversionUploadService を介したサーバーサイドまたはオフラインのコンバージョンアップロードを計画してください。
API バージョニング
google-ads Python ライブラリには複数の API バージョンが同梱されています。2026 年 6 月現在、v19 から v22 までが利用可能です。各バージョンで GAQL フィールドやサービスメソッドの追加、非推奨化、削除が行われます。
バージョン選択のルール:
load_from_storage(version='v22')でバージョンを明示的に指定してください。デフォルトは古い可能性があります。- アップグレード前に Google Ads API リリースノート でフィールドの非推奨化を確認してください。
search_streamメソッドは v6 以降で利用可能で、推奨される読み取りパスです。- protobuf-plus(
use_proto_plus: true)は v12 以降で必須です。 - ミューテーションのリクエストオブジェクトパターン(
mutate_campaigns(request=req))は v17 以降でキーワード引数スタイルを置き換えました。
ローカルセキュリティ
すべての認証情報ファイルはバージョン管理の対象外にする必要があります。
~/.hermes/
google-ads.yaml # 0600 — 開発トークン、OAuth クライアント、リフレッシュトークン
google_ads_token.json # 0600 — OAuth トークン、adwords スコープ
google_client_secret.json # 0600 — OAuth クライアント ID + シークレット
作業リポジトリには .gitignore を追加してください。
google-ads.yaml
*_token*.json
*_secret*.json
期待される構造を文書化するために、プレースホルダー値を持つテンプレートファイルはコミットできます。
# google-ads.yaml.template — これをコミットし、ローカルで値を入力
developer_token: INSERT_DEV_TOKEN
client_id: INSERT_CLIENT_ID
client_secret: INSERT_CLIENT_SECRET
refresh_token: INSERT_REFRESH_TOKEN
login_customer_id: INSERT_MCC_ID
use_proto_plus: true
一般的な操作の必須フィールド
| 操作 | 必須フィールド |
|---|---|
| キャンペーン作成(検索) | `name`、`status`、`advertising_channel_type`、`campaign_budget`、`manual_cpc` または入札戦略 |
| 広告グループ作成(検索) | `name`、`status`、`type_`、`cpc_bid_micros`、`campaign` |
| キーワード作成 | `ad_group`、`status`、`keyword.text`、`keyword.match_type` |
| キャンペーンステータス更新 | `resource_name`、`status`、`update_mask`(paths: `['status']`) |
| 予算更新 | `resource_name`、`amount_micros`、`update_mask`(paths: `['amount_micros']`) |
必須フィールドが欠落している場合は、フィールド名とともに REQUIRED が返されます。更新操作で update_mask が欠落している場合、エラーは返されず、フィールドは更新されずに暗黙的に無視されます。
完全なテストスクリプト
すべてのエンティティタイプを読み取り、validate_only 書き込みテストを実行する単一のスクリプトです。
# google_ads_api_test.py — 全エンティティ読み取り + validate_only 書き込みテスト
import warnings
warnings.filterwarnings('ignore')
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
from google.protobuf import field_mask_pb2
CFG = '/path/to/google-ads.yaml'
CID = '1305475941'
c = GoogleAdsClient.load_from_storage(CFG, version='v22')
gs = c.get_service('GoogleAdsService')
def gaql(query, label):
print(f'\n=== {label} ===')
n = 0
for batch in gs.search_stream(customer_id=CID, query=query):
for row in batch.results:
n += 1
print(f'{n} rows')
# Read all entities
gaql("SELECT customer.id, customer.descriptive_name FROM customer WHERE customer.id = ...", "CUSTOMER")
gaql("SELECT campaign.id, campaign.name, campaign.status, ... FROM campaign", "CAMPAIGNS")
gaql("SELECT campaign_budget.id, campaign_budget.name, ... FROM campaign_budget", "BUDGETS")
gaql("SELECT ad_group.id, ad_group.name, ... FROM ad_group", "AD GROUPS")
gaql("SELECT ad_group_criterion.keyword.text, ... FROM keyword_view WHERE ...", "KEYWORDS")
gaql("SELECT ad_group_ad.ad.id, ... FROM ad_group_ad", "ADS")
gaql("SELECT conversion_action.id, ... FROM conversion_action", "CONVERSIONS")
# Write tests
def test_write(name, fn):
print(f'\n--- {name} ---')
try:
fn()
print('PASS')
except GoogleAdsException as e:
for err in e.failure.errors:
print(f'{err.error_code}: {err.message}')
def test_create_campaign():
req = c.get_type('MutateCampaignsRequest')
req.customer_id = CID
req.validate_only = True
op = c.get_type('CampaignOperation')
op.create.name = 'TEST_CAMPAIGN'
op.create.status = c.enums.CampaignStatusEnum.PAUSED
op.create.advertising_channel_type = c.enums.AdvertisingChannelTypeEnum.SEARCH
op.create.campaign_budget = f'customers/{CID}/campaignBudgets/123456789'
op.create.manual_cpc.enhanced_cpc_enabled = False
req.operations.append(op)
c.get_service('CampaignService').mutate_campaigns(request=req)
test_write('Create Campaign (validate_only)', test_create_campaign)
print('\nDone.')
完全な実行可能バージョンは、広告運用リポジトリの Google Ads API operations skill にあります。
参考資料
- Google Ads API ドキュメント
- google-ads Python ライブラリ
- GAQL リファレンス
- デスクトップアプリ向け OAuth 2.0
- Building a Project-Specific AI Assistant via Telegram — 同様の OAuth パターンを異なる Google スコープに適用
- Reference Architecture for a Thread-Based AI Operations Layer — API 自動化がプラグインされる運用モデル
- Solo Operations at Scale: Managing Dozens of Projects with a Small Team — 大規模運用において API 自動化が手動 UI 作業を置き換える理由