<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>ota2000</title><description>ota2000 のブログ。データエンジニアリング、GCP、Terraform、Astro などについて。</description><link>https://ota2000.com/</link><language>ja</language><item><title>保育園の通知メールを LINE に自動転送する</title><link>https://ota2000.com/blog/gmail-line-notify/</link><guid isPermaLink="true">https://ota2000.com/blog/gmail-line-notify/</guid><description>Gmail API Watch + Cloud Run functions + LINE Messaging API で、保育園の通知を LINE グループに転送する仕組みを作った。Web ポータルから画像や PDF も取得して LINE に直接届くようにした</description><pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate><content:encoded>息子の通う保育園では専用の連絡システムを使っていて、登園・降園の通知や日々の連絡が指定したメールアドレスに届く。便利なんだけど、メールだと気づかないことがある。妻と共有したいこともあり、LINE グループに転送する仕組みを作った。

## アーキテクチャ

```mermaid
flowchart TD
    A[&quot;保育園連絡システム&quot;] --&gt;|メール送信| B[&quot;Gmail&quot;]
    B --&gt;|Watch Push 通知| C[&quot;Cloud Pub/Sub&quot;]
    C --&gt;|トリガー| D[&quot;Cloud Run functions&quot;]
    D --&gt;|Gmail API で本文取得| B
    D --&gt;|ログイン &amp; スクレイピング| F[&quot;Web ポータル&quot;]
    D --&gt;|画像・PDF アップロード| G[&quot;Cloud Storage&quot;]
    D --&gt;|メッセージ送信| E[&quot;LINE グループ&quot;]
    G -.-&gt;|署名付きURL| E
```

Gmail API の [Push 通知](https://developers.google.com/workspace/gmail/api/guides/push?hl=ja)を使う。メールが届くと Gmail が Pub/Sub にメッセージを発行し、Cloud Run functions が起動してメールを取得、LINE に転送する。ポーリングではなくイベント駆動なのでほぼリアルタイム。

保育園からのメールには2種類ある。

- **URL 付きメール** — メール本文には「ログインURL」だけ記載されていて、実際の連絡内容（テキスト、写真、PDF）は Web ポータルにログインしないと見られない
- **URL なしメール** — 登園・降園通知など、メール本文に内容が直接書かれているもの

URL 付きメールの場合、Cloud Run functions が Web ポータルに自動ログインしてメッセージを取得し、画像は LINE の image メッセージ、PDF は Flex Message のボタンとして送信する。

## アプリケーション構成

### `main.py` — エントリポイント

**`handle_gmail_notification`** (Pub/Sub トリガー)
1. Gmail から Pub/Sub 経由で「メールが届いた」通知を受け取る
2. 保育園からの未読メールを Gmail API で検索
3. メール本文にポータルの URL が含まれるか判定
4. URL あり → ポータルへログインしてコンテンツ取得 → 画像・PDF は GCS へアップロード → LINE に送信
5. URL なし → メール本文をそのまま LINE に送信
6. メールを既読にする

ポータルへのアクセスが失敗した場合はフォールバックとしてメール本文をそのまま送信する。

**`renew_watch`** (HTTP トリガー、Cloud Scheduler から6日ごと呼び出し)
- Gmail の Push 通知登録（`watch()`）を更新する（有効期限7日のため）

### Web ポータルからのコンテンツ取得

`requests.Session` でログインしてセッション Cookie を維持し、メッセージ詳細ページを BeautifulSoup でパースする。タイトル・本文・添付ファイル（画像や PDF）のリンクを抽出し、添付はセッションを使ってダウンロードする。

セッション切れやログイン失敗はレスポンス内容から検出し、例外を投げてフォールバックに回す。

```python
@dataclasses.dataclass
class Message:
    title: str
    body: str
    attachments: list[dict]  # [{&quot;filename&quot;: str, &quot;url&quot;: str}]
```

### `gmail_client.py` — Gmail API ラッパー

- OAuth2 refresh token で認証
- Google が refresh token をローテーションした場合、新しいトークンを自動で Secret Manager に書き戻す
- `get_unread_messages()` — 指定条件の未読メール取得
- `mark_as_read()` / `watch()` — 既読化・Push 通知登録

### `line_client.py` — LINE Messaging API

LINE Push API で3種類のメッセージを送信する。

- **テキスト** — タイトル + 本文 + 元URL
- **画像** — `image` メッセージタイプ（トーク画面には Pillow で生成した 240px のサムネイル、タップでオリジナル画像を表示）
- **PDF** — Flex Message のボタン UI（タップで GCS 署名付き URL を開く）

1回の push リクエストで最大5件のメッセージを送信でき、超える場合は自動で分割する。

## インフラ構成

Terraform で管理している。主なリソースは以下。

- **Cloud Run functions** × 2（通知処理 + watch 更新）
- **Cloud Pub/Sub** — Gmail からの Push 通知受け口
- **Cloud Scheduler** — 6日ごとの watch 再登録
- **Secret Manager** — OAuth トークン、LINE トークン、ポータルのログイン情報
- **Cloud Storage** — ポータルから取得した画像・PDF の保存（30日で自動削除）

画像と PDF は GCS に保存し、7日間有効の署名付き URL を生成して LINE に送る。バケット自体は非公開で、URL を知らない限りアクセスできない。

Cloud Run functions のサービスアカウントには以下の権限が必要。

- `roles/secretmanager.secretAccessor` — シークレット読み取り
- `roles/secretmanager.secretVersionManager` — refresh token の書き戻し
- `roles/storage.objectCreator` — GCS への書き込み
- `roles/iam.serviceAccountTokenCreator` — 署名付き URL 生成

## Gmail watch() の7日制限

Gmail API の `watch()` は最大7日で期限切れになる。放っておくと通知が止まる。Cloud Scheduler で6日ごとに HTTP トリガーの `renew_watch` を呼び出して自動更新している。

```hcl
resource &quot;google_cloud_scheduler_job&quot; &quot;renew_watch&quot; {
  name      = &quot;renew-gmail-watch&quot;
  schedule  = &quot;0 0 */6 * *&quot;
  time_zone = &quot;Asia/Tokyo&quot;

  http_target {
    uri         = google_cloudfunctions2_function.renew_watch.url
    http_method = &quot;POST&quot;
    oidc_token {
      service_account_email = google_service_account.function.email
    }
  }
}
```

## OAuth refresh token のローテーション対応

Google は OAuth2 の refresh token を予告なくローテーションすることがある。`creds.refresh()` で新しい access token を取得する際、refresh token も新しいものへ置き換わる場合がある。古い refresh token は無効化されるため、新しいトークンを保存しないと認証が壊れる。

実際にこの問題で通知停止を経験しており、`gmail_client.py` でトークンの変更を検知して Secret Manager へ自動で書き戻すようにした。

```python
creds.refresh(Request())

if creds.refresh_token and creds.refresh_token != original_refresh_token:
    self._update_refresh_token_secret(creds.refresh_token)
```

## 使ってみて

保育園から通知が来ると数秒で LINE に届く。以前はメール本文の先頭200文字がテキストで届くだけだったが、今は連絡の全文が読め、写真もそのまま表示される。PDF の行事予定表もワンタップで開ける。

妻と同じグループに入れているので、登園・降園の確認や保育園からの連絡共有がスムーズになった。</content:encoded></item><item><title>LINEヤフー広告 検索広告の dlt ソースを作った</title><link>https://ota2000.com/blog/dlt-yahoo-ads-search/</link><guid isPermaLink="true">https://ota2000.com/blog/dlt-yahoo-ads-search/</guid><description>LINEヤフー広告 検索広告（Yahoo Ads Search / SS）のデータを BigQuery などに転送する dlt ソースを実装した。MCC 対応、動的フィールド取得、動的型変換など</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>[dlt-community-sources](https://github.com/ota2000/dlt-community-sources) に LINEヤフー広告検索広告（旧 Yahoo!検索広告 / SS）のソースを追加した。[v0.7.1](https://github.com/ota2000/dlt-community-sources/releases/tag/v0.7.1) でリリース済み。

## なぜ dlt か

Yahoo!広告のデータを BigQuery に入れたかった。キャンペーンやターゲティング設定などのエンティティデータも含めて。

自前でスクリプトを書くと、ページネーション、リトライ、スキーマ管理、差分取得あたりを毎回実装することになる。[dlt](https://dlthub.com/) はそのへんを引き受けてくれるので、source（API からデータを取ってくる部分）の実装に集中できる。

dlt で source を書くと付いてくるもの。

- BigQuery、Snowflake、DuckDB、Postgres など 30 以上のデスティネーション。source 側はデスティネーションを意識しなくていい
- スキーマ進化。API にフィールドが追加されれば dlt が自動でカラムを追加する
- `dlt.sources.incremental` による差分取得。レポートの attribution window にも乗せやすい
- JSON の nested 構造を自動展開してテーブルに分割してくれる
- PyPI パッケージとして配布できる。`dlt init` 方式（ソースコードをプロジェクトにコピーする方式）と違って、バージョン管理やアップデートが普通の `pip install` で回る

## 使い方

```bash
pip install dlt-community-sources[yahoo-ads-search]
```

```python
import dlt
from dlt_community_sources.yahoo_ads_search import yahoo_ads_search_source

source = yahoo_ads_search_source(
    client_id=&quot;YOUR_CLIENT_ID&quot;,
    client_secret=&quot;YOUR_CLIENT_SECRET&quot;,
    refresh_token=&quot;YOUR_REFRESH_TOKEN&quot;,
    base_account_id=&quot;YOUR_MCC_ID&quot;,
    account_id=&quot;YOUR_ACCOUNT_ID&quot;,
)

pipeline = dlt.pipeline(
    pipeline_name=&quot;yahoo_ads&quot;,
    destination=&quot;bigquery&quot;,  # or &quot;duckdb&quot;, &quot;snowflake&quot;, etc.
    dataset_name=&quot;yahoo_ads&quot;,
)
pipeline.run(source)
```

41 エンティティリソースと 1 レポートリソース（22 レポートタイプ）。

## 認証

Yahoo!広告 API は OAuth 2.0 Authorization Code Grant を使う。

1. [Yahoo! Ads Developer Center](https://ads-developers.yahoo.co.jp/) でアプリを登録して `client_id` / `client_secret` を取得
2. ブラウザで OAuth 認可フローを実行して Authorization Code を取得
3. Authorization Code → `refresh_token` を取得

`refresh_token` は 4 週間未使用で失効する。日次でパイプラインを回していれば延長され続けるので実質期限なし。ローテーションもないので Secret Manager への書き戻しも不要。認証まわりの運用は他の広告媒体と比べてシンプル。

source 関数に `client_id`、`client_secret`、`refresh_token` を渡すと、内部で `access_token` を自動取得する。`access_token` の有効期限は 1 時間だが、毎回 refresh するので意識する必要はない。

## MCC 対応

Yahoo!広告は MCC（マイクライアントセンター）で複数の広告アカウントを束ねる構造になっている。

source は 1 アカウント = 1 呼び出しの設計にしている。`base_account_id` に MCC の ID を、`account_id` に広告アカウントの ID を渡す。`base_account_id` は `x-z-base-account-id` ヘッダーにセットされる。

複数アカウントを取りたい場合は、`discover_accounts` ヘルパーで配下アカウントを取得してループする。パイプラインをアカウントごとに分けることで、incremental のカーソルがアカウント間で混ざらない。

```python
from dlt_community_sources.yahoo_ads_common import (
    discover_accounts,
    make_client,
    refresh_access_token,
)

tokens = refresh_access_token(client_id, client_secret, refresh_token)
client = make_client(tokens[&quot;access_token&quot;], base_account_id)
accounts = discover_accounts(client, &quot;https://ads-search.yahooapis.jp/api/v19&quot;)

for account_id in accounts:
    pipeline = dlt.pipeline(
        pipeline_name=f&quot;yahoo_ads_{account_id}&quot;,
        destination=&quot;bigquery&quot;,
        dataset_name=&quot;yahoo_ads&quot;,
    )
    source = yahoo_ads_search_source(
        base_account_id=base_account_id,
        account_id=account_id,
    )
    pipeline.run(source)
```

`discover_accounts` は SERVING ステータスのアカウントだけ返す。ENDED アカウントのデータが必要なら `account_id` を明示指定する。

## 動的フィールド取得

レポートのフィールドはハードコードしていない。実行時に `getReportFields` API を呼んで、そのレポートタイプで使えるフィールドの一覧を取得する。

```python
# 内部でやっていること
body = {&quot;reportType&quot;: &quot;CAMPAIGN&quot;}
data = post_rpc(client, f&quot;{base_url}/ReportDefinitionService/getReportFields&quot;, body)
fields = [f[&quot;fieldName&quot;] for f in data[&quot;rval&quot;][&quot;fields&quot;]]
```

API バージョンが上がってフィールドが増減しても、ソースのコードを変える必要がない。ここは割と気に入っている。

### impossibleCombinationFields の自動解決

Yahoo!広告のレポート API には「同時に指定できないフィールドの組み合わせ」がある。`getReportFields` のレスポンスに `impossibleCombinationFields` として入っている。

全フィールドをそのまま指定するとエラーになる。そこで貪欲法でコンフリクトの多いフィールドから順に除外して、最大の互換フィールドセットを組み立てている。CAMPAIGN レポートだと 68 フィールド中 60 が同時取得できる。除外されるのは `CONVERSION_NAME` や `OBJECT_OF_CONVERSION_TRACKING` 等のセグメント系。

個別にフィールドを指定したければ `report_fields` パラメータで上書きできる。

## 動的型変換

レポートの CSV は全部文字列で返ってくる。型変換もハードコードではなく `getReportFields` の `fieldType` を見ている。

```python
# getReportFields のレスポンスにこういう情報が入っている
{&quot;fieldName&quot;: &quot;COST&quot;, &quot;fieldType&quot;: &quot;LONG&quot;}      # → int
{&quot;fieldName&quot;: &quot;CLICK_RATE&quot;, &quot;fieldType&quot;: &quot;DOUBLE&quot;}  # → Decimal
{&quot;fieldName&quot;: &quot;CAMPAIGN_NAME&quot;, &quot;fieldType&quot;: &quot;STRING&quot;}  # → そのまま
```

新しい数値フィールドが追加されても型が合う。金額系は `Decimal` で精度を保持している。

## CSV カラム名のマッピング

地味にハマったポイント。Yahoo!広告のレポート CSV は、ヘッダーが API フィールド名（`ACCOUNT_ID`）ではなく表示名（`Account ID`）で返ってくる。

内部では `reportLanguage=EN` 固定にして、`getReportFields` の `displayFieldNameEn` から表示名→フィールド名のマッピングを作り、ダウンロード後にカラム名を変換している。

## サービスごとの body_style

これもハマった。Yahoo!広告の API はサービスによってリクエストボディの構造が違う。

- `CampaignService/get`: `accountId` + `startIndex` + `numberResults`（普通のページネーション）
- `BalanceService/get`: `accountIds` 配列（ページネーションなし）
- `CustomizerAttributeService/get`: `accountId` のみ（ページネーションパラメータを渡すと 400）
- `AccountLinkService/get`: 空ボディ

ドキュメントからは読み取れなかったので、実際に叩いて 400 のレスポンスボディから判別した。41 リソース分。

## 実装の判断

### rest_api を使わなかった理由

dlt の宣言的 `rest_api` は REST API 向けに設計されている。Yahoo!広告の API は全エンドポイントが POST RPC スタイルなので合わない。レポート取得も非同期 3 ステップ（定義作成 → ポーリング → CSV ダウンロード）。カスタム `@dlt.resource` で書いた。

### write_disposition の選択

マスタデータ（campaigns、ad_groups 等）は `merge`。`replace` だと毎回全件置換で、データ量が増えると遅い。レポートも `merge` + `dlt.sources.incremental` で差分更新しつつ、attribution window 分だけ遡って再取得する。

campaign_targets や balance のようなスナップショット系は `replace`。毎回入れ替える方が正しい。

### ENDED アカウントの扱い

`discover_accounts` は SERVING のアカウントだけ返す。ENDED でも API 的にはデータを取れるが、配信実績がないとレポートは空になる。ENDED アカウントのデータが必要なら `account_id` で明示指定する。

## 疎通確認

複数の MCC と配下アカウントで動作確認済み。エンティティとレポートの両方で DuckDB へのロードまで通っている。

## 今後

YDA（ディスプレイ広告）は SS と共通コード（`yahoo_ads_common`）を共有する設計にしてある。YDA のデータが使えるようになったら [PR #9](https://github.com/ota2000/dlt-community-sources/pull/9) で追加する。</content:encoded></item><item><title>Claude Code のスキルに使えるオープンライセンスのガイドラインを探した</title><link>https://ota2000.com/blog/data-engineering-guidelines-as-skills/</link><guid isPermaLink="true">https://ota2000.com/blog/data-engineering-guidelines-as-skills/</guid><description>改変・再配布が許可されたデータエンジニアリング系のガイドラインを調べ、Claude Code のスキルとして BI as Code や dbt 開発のワークフローに組み込んでいる話</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>[前回の記事](/blog/dashboard-design-guidebook-as-skill/)で、デジタル庁のダッシュボードガイドブックを Claude Code のスキルにした話を書いた。「他にもスキルにできるガイドラインはないのか」と聞かれたので、調べた結果を書く。

## ガイドラインをスキルにする動機

ダッシュボードの YAML を書いたり dbt モデルを設計したりするとき、Claude Code はコードの書き方は知っているが、設計判断の根拠を持っていない。「このチャートは棒グラフと折れ線のどちらにするか」「このモデルの粒度はどうすべきか」といった判断は、人間がレビューで都度指摘するか、スキルに判断基準を入れておく必要がある。

世の中にはデータエンジニアリング領域のガイドラインが数多く公開されている。問題は、それらをスキルに組み込めるかどうか。スキルに入れるにはガイドラインの内容を要約・加工する必要があるので、ライセンスが改変を許可していないと使えない。

## 調べたガイドラインとライセンス

| ガイドライン | ライセンス | 改変 | 条件 |
|---|---|---|---|
| デジタル庁 ダッシュボードガイドブック | PDL1.0 | OK | 出典記載 + 加工した旨の明記 |
| IBCS (International Business Communication Standards) | CC BY-SA 4.0 | OK | 出典記載 + 同じライセンスで共有 |
| dbt Best Practices (docs.getdbt.com) | Apache 2.0 | OK | ライセンス表記 |
| ODCS (Open Data Contract Standard) | Apache 2.0 | OK | ライセンス表記 |

一方、著名だがライセンス上そのまま使えないものもある。Kimball Group のディメンショナルモデリング（All rights reserved）、Stephen Few のダッシュボード設計原則（書籍著作権）、DAMA-DMBOK（著作権保護、一部図表のみ CC BY-ND）など。どれも優れた内容だが、スキルに要約・加工して組み込むことはライセンス上できない。概念を参考にして自分の言葉で書く分には問題ないが、「ガイドラインをそのまま変換する」というアプローチとは区別している。

上の4つがオープンライセンスで改変可能なのは、それぞれのメンテナが意図的にそうしているから。デジタル庁は公共データの利活用を促進するため、IBCS Association はレポート表記の普及を目的として CC BY-SA を選択している。dbt Labs と Bitol は OSS コミュニティとしてドキュメントも Apache 2.0 で公開している。こうしたライセンス選択のおかげでスキル化が可能になっている。

## スキル化したガイドライン

### 1. デジタル庁 ダッシュボードガイドブック → チャート設計判断

[前回の記事](/blog/dashboard-design-guidebook-as-skill/)で詳しく書いたので省略するが、チャートタイプの選択、Do&apos;s/Don&apos;ts、カラーパレットの設計判断をスキルにしている。Lightdash の YAML を書くときに自動で読み込まれて、「円グラフより棒グラフの方が正確に伝えられる」といった判断を Claude が自分でやってくれるようになった。

### 2. IBCS → レポート・ダッシュボードの表記統一

[IBCS](https://www.ibcs.com/ibcs-standards-1-2/) はビジネスレポートの表記を標準化するための国際規格。CC BY-SA 4.0 で公開されていて、ISO 標準化も進行中（ISO/AWI 24896）。

核になるのは SUCCESS の7原則（SAY・UNIFY・CONDENSE・CHECK・EXPRESS・SIMPLIFY・STRUCTURE）で、レポートの設計からセマンティックな表記ルールまで体系化している。詳しくは公式サイトを参照してほしい。

デジタル庁のガイドブックと重なる部分もあるが、IBCS の方がより体系的で、特に表記の統一（UNIFY）と情報密度（CONDENSE）はデジタル庁ガイドブックにはない観点。

スキルに入れる際は、デジタル庁ガイドブックのスキルと役割を分けた。デジタル庁の方は「個々のチャートをどう作るか」、IBCS の方は「ダッシュボード全体をどう構成するか」という棲み分け。

### 3. dbt Best Practices → モデリング規約

[dbt Labs の公式ベストプラクティス](https://docs.getdbt.com/best-practices)は Apache 2.0 で公開されている。内容は大きく3つ。

**スタイルガイド**:
- snake_case でフィールド命名、略語を使わない
- `stg_`、`fct_`、`dim_` のプレフィックスでモデルの役割を明示
- 主キーは `&lt;object&gt;_id` 形式（`account_id` など）

**プロジェクト構造**:
- source-conformed（外部システムの形）から business-conformed（ビジネスの形）へのアークを作る
- staging → intermediate → marts のレイヤリング

**テスト**:
- 全モデルの主キーに unique + not_null テスト必須
- sqlfluff 等のリンターでスタイルを自動チェック

自分のチームでは dbt のモデリング規約を独自にまとめたスキルを持っているが、dbt Labs の公式ガイドと照らし合わせて足りない部分を補完した。特にフィールド命名規則とプレフィックスの標準は、公式の記述をベースにした方がチーム内の合意を取りやすい。実際、Claude が `customer_id` ではなく `cust_id` のような略語を出してくることがあったが、スキルに命名規則を入れてからは出なくなった。

### 4. ODCS → データ契約の定義

[Open Data Contract Standard](https://github.com/bitol-io/open-data-contract-standard)（ODCS）は Linux Foundation の Bitol プロジェクトが策定するオープン標準。Apache 2.0 ライセンス。

データ契約とは、データの提供者と利用者の間で「このデータはこのスキーマで、この鮮度で、この品質で提供する」という合意を形式化したもの。ODCS は YAML でこれを記述する。

```yaml
schema:
  - name: orders
    logicalType: object
    physicalType: table
    description: &quot;注文データ&quot;
    properties:
      - name: order_id
        logicalType: string
        primaryKey: true
        required: true
      - name: order_date
        logicalType: date
        required: true
```

スキルとしては、dbt のソース定義やテスト設計と組み合わせている。「このソースにはどんなテストが必要か」を Claude に考えさせるとき、ODCS の考え方（スキーマ、鮮度、ボリューム、セマンティクス）がフレームワークとして機能する。

ODCS の仕様をそのまま全部入れているわけではなく、dbt の `sources.yml` と対応する部分を抜き出して、「データ契約的に考えるとこのソースにはどんなテストが必要か」を判断できるようにしている。

## スキル化のパターン

4つのガイドラインをスキルにしてみて、共通するパターンが見えてきた。

### 原文をそのまま入れない

前回の記事でも書いたが、PDF やドキュメントをそのまま突っ込んでもコンテキストが長すぎて効かない。「コード生成に使える部分」だけを抽出する。

### ツール固有のマッピングを足す

ガイドラインはツール非依存で書かれている。「時間変化には折れ線グラフ」は正しいものの、それが Lightdash の `chartType: cartesian` の `type: line` だとは書いていない。dbt の `stg_` プレフィックスも、自分のプロジェクトでどのディレクトリに置くかまで公式ガイドには書いていない。このマッピングを足す作業の効果が一番大きい。

### 自チームの文脈を足す

汎用的なガイドラインには選択肢が複数並んでいる。IBCS のカラーパレット、dbt のプロジェクト構造のバリエーション、ODCS の品質レベルの定義など。全部入れると Claude が迷うので、自チームで採用しているものだけに絞る。

### 設計判断と実装手順を分ける

1つのスキルに全部入れると膨れる。「なぜそうするか（設計判断）」と「どう書くか（実装手順）」は別スキルにした方が、Claude Code の自動選択も効きやすい。

```
設計判断（WHY / WHAT）  → *-design-principles, *-patterns
実装手順（HOW）          → *-guide
制約・規約（MUST）       → rules/
```

## ガイドラインの探し方

「スキルにできるガイドラインは他にないか」と探すとき、以下の基準で見ている。

1. **ライセンス**: CC BY / CC BY-SA / Apache 2.0 / MIT など、改変・再配布が明示的に許可されているもの
2. **具体性**: Do&apos;s/Don&apos;ts やチェックリストなど、判断基準が明確に書かれているもの。抽象的な原則だけだと Claude の判断に使えない
3. **自分のワークフローとの接点**: コード生成やレビューの場面で参照できるもの。組織設計やプロセス改善の話はスキルに向かない

この3つを満たすものは意外と少ない。著名なガイドラインでもライセンスが All rights reserved だったり、内容が抽象的すぎたりする。今のところ上記の4つが自分の業務に合っている。

## やってみて

既に体系化されたガイドラインをスキルの素材にすると、自分でゼロから判断基準を書くより早い。dbt の命名規則なら公式ガイドの記述をベースにすればチーム内で「なぜそうするのか」の説明も不要になる。

ガイドラインをそのまま入れても効かないので「自チームの文脈に合わせて削る・足す」作業は必要だが、この作業自体がガイドラインの理解を深めるプロセスにもなる。PDF を読んで終わりにするより定着する。

今回調べてみて、オープンライセンスで公開されている良質なガイドラインは思ったより少なかった。逆に言えば、上記4つはライセンス・具体性・実務との接点の3つを満たす貴重なドキュメント。公開してくれているメンテナに感謝しつつ、ライセンス条件を守って活用していきたい。

---

本記事で言及したガイドラインの出典は以下のとおり。

- 「ダッシュボードデザインの実践ガイドブック」（デジタル庁）https://www.digital.go.jp/resources/dashboard-guidebook — PDL1.0 に基づき内容を要約・加工して利用
- IBCS Standards 1.2（IBCS Association）https://www.ibcs.com/ibcs-standards-1-2/ — CC BY-SA 4.0 に基づき内容を要約・加工して利用
- dbt Best Practices（dbt Labs）https://docs.getdbt.com/best-practices — Apache License 2.0
- Open Data Contract Standard（Bitol / Linux Foundation）https://github.com/bitol-io/open-data-contract-standard — Apache License 2.0</content:encoded></item><item><title>Claude Code で git index.lock が消えない問題と原因の特定</title><link>https://ota2000.com/blog/claude-code-git-index-lock/</link><guid isPermaLink="true">https://ota2000.com/blog/claude-code-git-index-lock/</guid><description>IDE のせいだと思ったら Claude Code 自身の statusline が原因だった話</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Claude Code を使っていると `git add` や `git commit` が `index.lock` で失敗し続ける問題にハマった。初歩的だけど原因の特定に手間取ったので記録しておく。

## 症状

Claude Code のセッション中に git コマンドを実行すると、ほぼ毎回これが出る。

```
fatal: Unable to create &apos;.git/index.lock&apos;: File exists.
```

`rm -f .git/index.lock` で消しても数秒で再作成される。

## 最初に疑ったもの（ハズレ）

Cursor（VS Code 系 IDE）の `git.autorefresh` や `git.autofetch` が定期的に `git status` を叩いているのでは、と推測した。設定を `false` に変更し、Cursor を再起動してみたが解消しなかった。

拡張機能（GitHub Pull Requests 等）も疑って無効化したが変わらず。

## 犯人の特定

lock ファイルを消した直後にプロセスを確認する方法で特定できた。

```bash
rm -f .git/index.lock
# 再作成されるのを待って
ps aux | grep git
```

出力。

```
ota2000  18331  /opt/homebrew/opt/git/libexec/git-core/git status --porcelain=2
```

このプロセスの親を辿ると、Claude Code の `statusline.js` だった。Claude Code はターミナル下部にブランチ名と変更状態を表示するために、`git status --porcelain` を定期的に実行している。この実行時に `index.lock` が作られ、他の git コマンドと競合していた。

## 解決

`GIT_OPTIONAL_LOCKS=0` という環境変数を設定すると、git は read-only な操作（`git status` 等）で lock ファイルを作らなくなる。

`~/.claude/statusline.js` を修正して、git コマンド実行時にこの環境変数を渡すようにした。

```javascript
// Before
execSync(&apos;git status --porcelain&apos;, { encoding: &apos;utf8&apos; });

// After
const gitEnv = { ...process.env, GIT_OPTIONAL_LOCKS: &apos;0&apos; };
execSync(&apos;git status --porcelain&apos;, { encoding: &apos;utf8&apos;, env: gitEnv });
```

加えて `~/.claude/settings.json` の `env` にも追加しておいた。

```json
{
  &quot;env&quot;: {
    &quot;GIT_OPTIONAL_LOCKS&quot;: &quot;0&quot;
  }
}
```

これで lock ファイルは再作成されなくなった。

## 振り返り

IDE を疑って設定を変えたり再起動したりで 30 分くらい無駄にした。最初から `ps aux | grep git` していれば一瞬だった。`index.lock` が消えないときはプロセスを見る。これに尽きる。

`GIT_OPTIONAL_LOCKS=0` は read-only な git 操作の lock を抑制する公式の仕組みで、[git のドキュメント](https://git-scm.com/docs/git#Documentation/git.txt-codeGITOPTIONALLOCKScode)にも記載がある。statusline のようなバックグラウンドで `git status` を叩く仕組みには最初から入れておくべきだろう。</content:encoded></item><item><title>デジタル庁のダッシュボードガイドブックを Claude Code のスキルにした</title><link>https://ota2000.com/blog/dashboard-design-guidebook-as-skill/</link><guid isPermaLink="true">https://ota2000.com/blog/dashboard-design-guidebook-as-skill/</guid><description>デジタル庁が公開したダッシュボードデザインの実践ガイドブックを、BI as Code のワークフローで活用できるよう Claude Code のスキルに変換した</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>デジタル庁が「[ダッシュボードデザインの実践ガイドブック](https://www.digital.go.jp/resources/dashboard-guidebook)」をリニューアルした。このツイートで知った。

&lt;blockquote class=&quot;twitter-tweet&quot; data-conversation=&quot;none&quot; data-cards=&quot;hidden&quot;&gt;&lt;p lang=&quot;ja&quot; dir=&quot;ltr&quot;&gt;＜ぜひ拡散のほどを！＞&lt;br&gt;デジタル庁より公表している「ダッシュボードのテンプレート」をリニューアルしました。誰でも、より簡単にキレイなダッシュボードが作れるようになります。&lt;br&gt;&lt;br&gt;・グラフの種類を大幅に増えました&lt;br&gt;・カラーパレットが7タイプから選べます&lt;br&gt;・活用事例がどんどん増えています&lt;/p&gt;&amp;mdash; hikaru / 樫田光 (@hik0107) &lt;a href=&quot;https://twitter.com/hik0107/status/2038867496179298442?ref_src=twsrc%5Etfw&quot;&gt;March 31, 2026&lt;/a&gt;&lt;/blockquote&gt;

行政向けの資料だけど、民間でもそのまま使える内容だった。チャートの選び方、Do&apos;s/Don&apos;ts、カラーパレットまで、ダッシュボードを作るときに必要な判断基準が一通り揃っている。

業務では Lightdash で BI as Code をやっている。ダッシュボードやチャートを YAML で定義して Git 管理し、CI/CD でデプロイする運用。YAML を書く部分は Claude Code のスキル（コンテキストとして自動で読み込まれるドキュメント）で半自動化しているが、「どのチャートタイプを選ぶか」「何を載せるか」の設計判断はスキルでカバーできていなかった。

ガイドブックの内容がちょうどこの穴を埋めてくれるので、スキルに変換した。

## ガイドブックの概要

59ページの PDF で、以下の構成になっている。

1. **はじめに** — ダッシュボードの定義と類型（提示型 vs 探索型）
2. **要件の整理** — 5W1H フレームワーク、制約条件の確認
3. **プロトタイピング** — 載せるべき情報の選定原則、レイアウトの考え方
4. **情報表現のポイント** — グラフの種類と選び方、カラーパレット、グラフ設計の原則、Do&apos;s/Don&apos;ts
5. **実装** — チェックリスト、アクセシビリティ

「情報表現のポイント」の章が特に良くて、Do&apos;s/Don&apos;ts が具体的な図解つきで書かれている。「棒グラフの原点は0にする」「色のみで分類を識別しない」「タイトルにデータ種別を表記する」など、わかっているつもりでも雑になりがちなポイントが並んでいる。

## ガイドブックの中身で使えるところ

スキルに入れるにあたって、59ページの中から BI as Code のワークフローで実際に使える部分を抜き出した。いくつか紹介する。

### ダッシュボードの類型

ダッシュボードは「提示型」と「探索型」の2つに分かれる、という整理がまず冒頭にある。

|          | 提示型       | 探索型             |
|----------|-------------|-------------------|
| 目的     | 概況の把握   | 詳細の分析         |
| 前提知識 | 一般的な知識 | 特定のドメイン知識 |
| 操作     | 単純な操作   | 複雑な操作         |

自分のチームで作っているダッシュボードはほぼ提示型。「見る人が素早く状況を把握して、異常に気づいて、行動の必要性を判断する」のが目的。これを言語化してスキルに入れておくと、Claude が勝手にフィルターを大量に生やしたり、探索型寄りの設計にしたりすることが減る。

### チャート選択ガイドライン

「伝えたい情報」からグラフの種類を逆引きするテーブルがある。スキルへ組み込む際、Lightdash の `chartType` とのマッピングを追加した。

| 伝えたい情報           | グラフ            | Lightdash chartType |
|----------------------|------------------|-------------------|
| 時間変化・傾向         | 折れ線グラフ      | `cartesian` (line) |
| 数量比較               | 棒グラフ          | `cartesian` (bar)  |
| 構成比                 | 円グラフ/ドーナツ | `pie`              |
| KPI 単値               | 指標              | `big_number`       |

特に円グラフについて「**多くの場合、棒グラフの方がデータを正確に伝えられる**」と明記されているのが良い。これがスキルに入っていると、Claude が安易に円グラフを提案しなくなる。

### Do&apos;s / Don&apos;ts

10項目ある中から、自分が刺さったものをいくつか。

- **タイトルにデータ種別を表記する** —「国産自動車」ではなく「国産自動車の出荷台数（月次推移）」。タイトルだけでグラフの中身がわかるようにする
- **グラフと凡例を隣接させる** — 凡例が離れていたり、順番がグラフと逆だったりすると誤読の原因になる
- **グラフに使用する色数を絞る** — 目安は 1〜5色。注目すべき系列を明確にする

わかっていても、チャートの YAML を書いているとつい雑になる。スキルに入れておくことで、レビュー時にも「ガイドブックの Do&apos;s/Don&apos;ts に照らしてどうか」という観点が入る。

## スキルの構成

もともと Lightdash の YAML テンプレートや実装手順をまとめたスキルがあった。デザイン原則を同じスキルに足すとサイズが膨れるので、別スキルに分けた。

```
設計判断（WHY / WHAT）  → lightdash-design-principles（今回追加）
実装手順（HOW）          → lightdash-guide（既存）
制約・規約（MUST）       → rules/lightdash/（既存）
```

スキルの `description` にキーワードを書いておくと、Claude Code が指示内容に応じて自動で読み込むスキルを選んでくれる。「ダッシュボード設計」「チャート選択」みたいな話をするとデザイン原則スキルが読み込まれ、YAML を書き始めると実装ガイドスキルに切り替わる。

## 59ページをスキルに落とすときの取捨選択

PDF をそのままスキルに突っ込んでも効かない。コンテキストが長すぎると Claude は重要な部分を拾えなくなるし、自分のワークフローに合わない記述が混ざっていると判断がブレる。59ページを約280行のスキルに絞り込むにあたって、いくつか判断基準があった。

### 「コードで表現できるか」で切る

スキルに入れる意味があるのは、Claude が YAML を書くときに参照できる情報。「棒グラフの原点は0にする」は YAML の `min: 0` に直結するから入れる。「PowerPoint でプロトタイプを作る」はコード生成と関係ないから入れない。

同じ理由で、アクセシビリティの詳細（スクリーンリーダー対応など）も外した。Lightdash の YAML レベルでは制御できない領域なので、スキルに書いても Claude が実行できない。

### ツール固有のマッピングを足す

ガイドブックの記述はツール非依存。「時間変化を見せたいなら折れ線グラフ」とは書いてあるが、それが Lightdash の `chartType: cartesian` の `type: line` に対応するとは書いていない。

この対応関係を足さないと、Claude は原則を理解しても正しい YAML を出力できない。逆に言えば、マッピングさえ足せばガイドブックの知識をそのまま YAML 生成に活かせる。ここの効果が一番大きかった。

### 自チームの文脈を足す

ガイドブックは汎用的に書かれているので、「提示型と探索型がある」という説明はあっても「うちのチームはどっちか」は書いていない。スキルには「主に提示型」と明記した。これがないと Claude は毎回どちらの方針で設計すべきか迷う。

カラーパレットも同じで、ガイドブックには7タイプのパレットが載っているが、全部入れると Claude がどれを使うか迷う。実際に使うパレットだけに絞った。

### 捨てたものの基準

まとめると、以下に該当するものは外した。

- コード生成に関係しないプロセスの説明（プロトタイピングの進め方、ステークホルダーとの合意形成など）
- 使っている BI ツールでは制御できない領域
- 複数の選択肢が並列で書かれていて、自チームの文脈を足さないと判断できないもの（そのまま入れると迷いの原因になる）

## 利用規約

ガイドブックは[公共データ利用規約（PDL1.0）](https://www.digital.go.jp/copyright-policy)の下で公開されている。CC BY 4.0 と互換性があり、出典を記載すれば自由に利用・加工・商用利用が可能。スキルファイルには出典と加工した旨を記載している。

## やってみて

Do&apos;s/Don&apos;ts が図解つきで書かれていて、そのまま実務に持ち込める内容だった。

スキルにしたことで、ダッシュボードの YAML を書くときに設計原則が勝手にコンテキストへ入るようになった。Claude が円グラフを出してきたときに「スキルに『棒グラフの方が正確に伝えられる』と書いてあるので棒グラフにします」と自分で判断を修正してくれる、みたいなことが実際に起きている。

ガイドブック自体は BI ツールに依存しない内容なので、Lightdash 以外でも参考になるはずだ。

---

出典：「ダッシュボードデザインの実践ガイドブック」（デジタル庁）(2026年3月31日版) https://www.digital.go.jp/resources/dashboard-guidebook PDL1.0 に基づき、内容を要約・加工して利用。</content:encoded></item><item><title>dlt-community-sources v0.6.0: rest_api 移行とサプライチェーンセキュリティ</title><link>https://ota2000.com/blog/dlt-community-sources-v060/</link><guid isPermaLink="true">https://ota2000.com/blog/dlt-community-sources-v060/</guid><description>全ソースを dlt の宣言的 rest_api に移行し、SLSA Provenance やDependency Review などサプライチェーンセキュリティを強化した</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>[dlt-community-sources](https://github.com/ota2000/dlt-community-sources) の [v0.6.0](https://github.com/ota2000/dlt-community-sources/releases/tag/v0.6.0) をリリースした。アーキテクチャを大きく変えたので書いておく。

## Breaking Changes

2つの破壊的変更がある。

- 全ソースのカスタム `client.py` クラスを廃止した。`AppStoreConnectClient`、`NextDNSClient`、`TwilioClient` を直接インポートしていた場合は動かなくなる
- NextDNS の `_profile_id` フィールドを `_profiles_id` にリネームした。dlt の `include_from_parent` の命名規則に合わせた

## rest_api への全面移行

v0.5.0 まで、各ソースは独自の HTTP クライアントクラス（`client.py`）で API を叩いていた。v0.6.0 では dlt の宣言的 `rest_api` ソースに移行した。

App Store Connect、NextDNS、Twilio の3ソースすべてが対象。標準的な REST エンドポイントは `RESTAPIConfig` の辞書で定義し、`rest_api_resources()` へ渡す形になった。

```python
config: RESTAPIConfig = {
    &quot;client&quot;: {
        &quot;base_url&quot;: &quot;https://api.example.com/v1/&quot;,
        &quot;auth&quot;: MyAuth(),
    },
    &quot;resources&quot;: [
        {
            &quot;name&quot;: &quot;apps&quot;,
            &quot;endpoint&quot;: {&quot;path&quot;: &quot;apps&quot;},
        },
        {
            &quot;name&quot;: &quot;builds&quot;,
            &quot;endpoint&quot;: {
                &quot;path&quot;: &quot;builds&quot;,
                &quot;params&quot;: {&quot;filter[app]&quot;: &quot;{resources.apps.id}&quot;},
            },
            &quot;include_from_parent&quot;: [&quot;id&quot;],
        },
    ],
}
```

カスタムの `@dlt.resource` 関数が残っているのは、`rest_api` では対応できないケースだけ。

- App Store Connect の Sales/Finance/Analytics Reports（TSV / gzip バイナリのダウンロード）
- NextDNS の Series リソース（レスポンスの時系列データを平坦化する変換が必要）
- Twilio の一部リソース（RFC 2822 形式の日付を ISO 8601 に変換する必要がある）

### なぜ移行したか

カスタムクライアントの問題点はいくつかあった。

- ページネーション、リトライ、認証をソースごとに自前で実装していた
- テストもクライアント単体のモックテストになりがちで、実際のパイプライン動作を検証しにくかった
- ソースを追加するたびに似たようなクライアントコードを書く必要があった

`rest_api` に移行すると、エンドポイントの定義が辞書になるので追加・変更が楽になる。ページネーションやリトライは dlt 側が面倒を見てくれる。テストも `_rest_api_config()` が正しい辞書を返すかを検証するだけでよくなった。

### 認証まわりの工夫

App Store Connect は JWT 認証で、トークンの有効期限が20分と短い。`rest_api` の認証は `BearerTokenAuth` を拡張して、リクエストごとに JWT を再生成する仕組みにした。

```python
@configspec
class AppStoreConnectAuth(BearerTokenAuth):
    key_id: str = None
    issuer_id: str = None
    private_key: str = None

    def __call__(self, request):
        self.token = self._generate_jwt()
        return super().__call__(request)
```

`@configspec` デコレータとクラスレベルのフィールド宣言が必要で、これがないと dlt の `rest_api` 設定バリデーションを通らなかった。

### HTTP クライアントの統一

カスタムリソースでは `requests.Session` を直接使っていたが、dlt の `dlt.sources.helpers.requests.Client` に置き換えた。429/5xx の自動リトライと指数バックオフが入っているので、`rest_api` 側のリソースと同じリトライ挙動になる。

```python
from dlt.sources.helpers import requests as req

def _make_client(auth) -&gt; req.Client:
    client = req.Client()
    client.session.auth = auth
    return client
```

## サプライチェーンセキュリティの強化

PyPI パッケージを公開しているので、サプライチェーン攻撃への対策を入れた。

### SLSA Provenance

publish ワークフローに SLSA Provenance attestation を追加した。パッケージがどのコミットから、どの CI 環境でビルドされたかを暗号的に証明できる。`pip install` 時に `--require-hashes` を使えば検証も可能。

### Dependency Review

PR に対して [dependency-review-action](https://github.com/actions/dependency-review-action) を走らせている。新しい依存関係に既知の脆弱性がないかを CI でチェックする。

### その他

- `CODEOWNERS` ファイルの追加
- ブランチ保護（レビュー必須、ステータスチェック必須）
- Dependabot の脆弱性アラートとセキュリティアップデートの有効化
- プライベート脆弱性報告の有効化

## AI ルールとスキルの整備

開発体験の改善として、AI アシスタント向けのルールとスキルも整備した。

`.ai/rules.md` を唯一の原本として管理している。同期スクリプトが Claude Code（`CLAUDE.md`）、Cursor（`.cursor/rules/`）、Gemini CLI（`GEMINI.md`）、Codex CLI（`AGENTS.md`）向けのファイルを自動生成するので、メンテするのは `.ai/rules.md` だけで済む。

スキルとしては `add-source`（新しいソースの追加手順）と `add-resource`（既存ソースへのリソース追加手順）を用意している。v0.6.0 に合わせて `rest_api` ファーストのワークフローに書き直した。

さらに [dltHub AI workbench](https://dlthub.com/docs/reference/ai-assistance) も統合した。`dlt ai init` と `dlt ai toolkit install` で dlt エコシステムのコンテキスト（rest-api-pipeline のスキル群）が入る。プロジェクト固有のルールが上書きする二層構造にしてある。</content:encoded></item><item><title>Google Analytics MCP で Claude Code から GA4 データを引き出す</title><link>https://ota2000.com/blog/ga4-mcp-claude-code/</link><guid isPermaLink="true">https://ota2000.com/blog/ga4-mcp-claude-code/</guid><description>Google Analytics MCP サーバーを Claude Code に接続し、GA4 のレポートをターミナルから取得できるようにした。スキル化して /ga-report 一発で呼べるようにした</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Google が公開した [Google Analytics MCP サーバー](https://github.com/googleanalytics/google-analytics-mcp) を Claude Code に接続して、ターミナルから GA4 のデータを取得できるようにした。ついでにスキルも作って `/ga-report` 一発でレポートが出るようにした。

## Google Analytics MCP とは

MCP（Model Context Protocol）は、AI エージェントが外部ツールと連携するためのプロトコル。Google Analytics MCP サーバーはこのプロトコルを実装していて、GA4 の Admin API と Data API を AI エージェントから呼び出せるようにする。

使えるツールは7つ。

| ツール | 用途 |
|---|---|
| `get_account_summaries` | アカウント・プロパティ一覧 |
| `get_property_details` | プロパティの詳細情報 |
| `get_custom_dimensions_and_metrics` | カスタムディメンション・指標 |
| `list_google_ads_links` | Google Ads 連携情報 |
| `list_property_annotations` | アノテーション一覧 |
| `run_report` | レポート実行（過去データ） |
| `run_realtime_report` | リアルタイムレポート |

すべて読み取り専用で、`analytics.readonly` スコープで動作する。

## セットアップ

### GCP の準備

GA4 の Admin API と Data API を有効化する。

```bash
gcloud services enable analyticsadmin.googleapis.com analyticsdata.googleapis.com \
  --project=YOUR_PROJECT_ID
```

### OAuth クライアントの作成

[GCP コンソール](https://console.cloud.google.com/apis/credentials)で OAuth クライアントを作成する。

1. OAuth 同意画面を設定（外部、テストユーザーに自分を追加）
2. 認証情報 → OAuth クライアント ID → デスクトップアプリ
3. JSON をダウンロード

### 認証

```bash
gcloud auth application-default login \
  --scopes=https://www.googleapis.com/auth/analytics.readonly,https://www.googleapis.com/auth/cloud-platform \
  --client-id-file=YOUR_CLIENT_JSON_FILE
```

### analytics-mcp のインストール

公式は `pipx run` を推奨しているが、初回のダウンロードが遅くて MCP サーバーの起動タイムアウトに引っかかった。事前にインストールしておくのが確実。

```bash
pipx install analytics-mcp
```

### Claude Code への接続

`~/.claude.json` の `mcpServers` に追加する。

```json
{
  &quot;mcpServers&quot;: {
    &quot;analytics-mcp&quot;: {
      &quot;type&quot;: &quot;stdio&quot;,
      &quot;command&quot;: &quot;/Users/you/.local/bin/analytics-mcp&quot;,
      &quot;args&quot;: [],
      &quot;env&quot;: {
        &quot;GOOGLE_APPLICATION_CREDENTIALS&quot;: &quot;/Users/you/.config/gcloud/application_default_credentials.json&quot;,
        &quot;GOOGLE_PROJECT_ID&quot;: &quot;your-project-id&quot;
      }
    }
  }
}
```

Claude Code を再起動して `/mcp` で `analytics-mcp` が表示されれば接続完了。

## 使ってみる

アカウント一覧の取得。

```
&gt; GA4 のアカウント一覧を見せて

アカウント: ota2000
  プロパティ: ota2000.com (properties/530272485)
```

リアルタイムレポート。

```
&gt; 今アクティブなユーザーは？

日本から2名がアクティブです。
```

過去データのレポート。ディメンションと指標を組み合わせて自由にクエリできる。

```
&gt; 今日のページ別 PV を教えて

| ページ | PV | ユーザー | セッション |
|---|---|---|---|
| / | 15 | 3 | 3 |
| /blog/dlt-community-sources/ | 1 | 1 | 1 |
```

デバイス別、流入元、ブラウザ、地域、エンゲージメント（直帰率・滞在時間）なども取得できる。

## スキルを作る

毎回「ページ別 PV を見せて」と打つのは面倒なので、Claude Code のスキルとして定義した。`.claude/skills/ga-report/SKILL.md` にプロパティ ID やデフォルトのレポート定義を書いておく。

```yaml
---
name: ga-report
description: GA4 レポート取得。ota2000.com のアクセス状況をレポートする。
---
```

スキルの中身には、引数なしで呼ばれた場合に並列実行するレポートを定義した。

1. リアルタイムのアクティブユーザー数
2. 過去7日間のページ別 PV
3. 過去7日間の日別 PV
4. 過去7日間の流入元
5. 過去7日間のデバイス別

`/ga-report` と打つだけで、これらが一括で返ってくる。期間指定（`/ga-report 30d`）や詳細モード（`/ga-report detail`）にも対応させた。

## スキルをプロジェクトローカルに置く理由

Claude Code のスキルは `~/.claude/skills/`（グローバル）と `.claude/skills/`（プロジェクト）の2箇所に置ける。GA4 のプロパティ ID やブログの文体ルールは ota2000.com 固有なので、プロジェクトローカルに置いた。git で差分が追えるし、他のプロジェクトに影響しない。</content:encoded></item><item><title>dlt-community-sources を公開した</title><link>https://ota2000.com/blog/dlt-community-sources/</link><guid isPermaLink="true">https://ota2000.com/blog/dlt-community-sources/</guid><description>dlt 用のデータソースを extras で管理する PyPI パッケージを作った</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>dlt 用のデータソースを集めたパッケージ [dlt-community-sources](https://github.com/ota2000/dlt-community-sources) を PyPI に公開した。

## 何ができるか

今のところ App Store Connect API に対応している。

```bash
pip install dlt-community-sources[app-store-connect]
```

```python
from dlt_community_sources.app_store_connect import app_store_connect_source

source = app_store_connect_source(
    key_id=&quot;YOUR_KEY_ID&quot;,
    issuer_id=&quot;YOUR_ISSUER_ID&quot;,
    private_key=open(&quot;AuthKey_XXXXX.p8&quot;).read(),
    vendor_number=&quot;YOUR_VENDOR_NUMBER&quot;,
)
pipeline.run(source)
```

apps、builds、TestFlight、サブスクリプション、Sales/Finance/Analytics Reports など15リソース。デスティネーションは BigQuery、Snowflake、DuckDB など dlt が対応しているものなら何でもいい。

## なぜ作ったか

dlt の公式 verified sources があるが、更新頻度が落ちている。App Store Connect のソースもなかった。dlt のソースはただの Python 関数なので、自分で書いて PyPI に公開した方が早い。

extras で管理しているので、今後ソースを増やすときもリポジトリを量産せずに済む。

```toml
[project.optional-dependencies]
app-store-connect = [&quot;PyJWT[crypto]&gt;=2.8.0&quot;]
# 今後追加するソースもここに足すだけ
```

## 主な機能

- incremental loading（Sales Reports は日次、Finance Reports は月次）
- JWT トークンの自動リフレッシュ
- レート制限のリトライ（429 で指数バックオフ）
- 権限不足のリソースは 403 でスキップ（パイプライン全体は落ちない）

## 今後

当面は自分が業務で必要になったソースを足していく。ソースの追加は `dlt_community_sources/` の下にディレクトリを作って `@dlt.resource` を書くだけなので、ほしいソースがある人はぜひ PR を送ってほしい。

- [GitHub](https://github.com/ota2000/dlt-community-sources)
- [PyPI](https://pypi.org/project/dlt-community-sources/)</content:encoded></item><item><title>Claude Code から Gemini CLI を呼び出してレビューさせる</title><link>https://ota2000.com/blog/gemini-cli-review-from-claude-code/</link><guid isPermaLink="true">https://ota2000.com/blog/gemini-cli-review-from-claude-code/</guid><description>Google AI Pro の契約を活かして、Claude Code のスキルから Gemini CLI にコードレビューを委任する仕組みを作った</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Claude Code でコードを書いて、セルフレビューもさせている。が、これは自分で書いたコードを自分でレビューしているのと同じで、どうしても甘くなる。別のモデルにも見せたい。

最近 Codex CLI のレビューが良いという話を聞くが、ChatGPT Plus 以上の契約が必要で、自分は入っていない。Google AI Pro は契約しているので Gemini CLI を使うことにした。無料枠でも Google アカウントがあれば 1,000リクエスト/日使える。Pro なら日次上限がさらに高い。

## セットアップ

```bash
npm install -g @google/gemini-cli
```

API キーは [Google AI Studio](https://aistudio.google.com/apikey) で発行する。Claude Code の `~/.claude/settings.json` に環境変数として入れておく。

```json
{
  &quot;env&quot;: {
    &quot;GEMINI_API_KEY&quot;: &quot;your_api_key&quot;
  }
}
```

## スキルを作る

`~/.claude/skills/gemini-review/SKILL.md` を置く。

```markdown
---
name: gemini-review
description: &quot;Run code review using Gemini CLI. Use when: &apos;gemini review&apos;, &apos;Geminiでレビュー&apos;.&quot;
user_invocable: true
---

# Gemini Review

Gemini CLI を使ってコードレビューを実行する。
差分を取得して gemini -p に渡す。Gemini の出力はそのまま表示する。
```

スキルの中にコマンド例を書いておくと、Claude Code がそのまま実行してくれる。

```bash
git diff HEAD~1 | gemini -p &quot;You are an expert code reviewer. Review the following git diff. Focus on bugs, security issues, and logic errors only. Be concise. Output in Japanese.&quot;
```

`/gemini-review` とタイプするだけ。差分が Gemini に渡ってレビュー結果が返ってくる。

## 実際に使ってみた

[dlt-community-sources](https://github.com/ota2000/dlt-community-sources) の AI ルール同期スクリプトをレビューさせた。Claude のセルフレビューでは「指摘なし、LGTM」だったが、Gemini はこう返してきた。

&gt; `scripts/sync-ai-rules.sh`: `.ai/skills/*.md` のループにおいて、ファイルが一つも存在しない場合にシェル設定（`nullglob` 等）によってはリテラル文字列として処理される可能性があります

glob が空ディレクトリでマッチしないケースの指摘。今は問題ないが、スキルファイルを全部消したらスクリプトがコケる。`compgen -G` でガードを追加した。

Claude が自分のコードに LGTM を出した直後に Gemini が穴を見つけるのは、まさにこういうのを期待していた。

## 使い分け

Claude Code のセルフレビュー（`/review`）でバグやロジックエラーを潰してから、`/gemini-review` で別視点のチェックを入れる。同じモデルに何度見せても出てこないものが、モデルを変えると出てくることがある。

## おまけ：AI ルールを一箇所で管理する

このリポジトリでは `.ai/rules.md` だけを編集して、sync スクリプトで各ツール向けのファイルに配布している。

| ファイル | ツール |
|---|---|
| `CLAUDE.md` | Claude Code |
| `AGENTS.md` | Codex CLI |
| `GEMINI.md` | Gemini CLI |
| `.cursor/rules/` | Cursor |
| `.github/copilot-instructions.md` | GitHub Copilot |

Gemini CLI もスキルに対応しているので `.ai/skills/*.md` → `.gemini/skills/` にも同期している。コントリビューターがどのツールを使っていてもルールが同じになる。

- [dlt-community-sources](https://github.com/ota2000/dlt-community-sources)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)</content:encoded></item><item><title>Lightdash の PDF 配信を改善した</title><link>https://ota2000.com/blog/lightdash-pdf-contribution/</link><guid isPermaLink="true">https://ota2000.com/blog/lightdash-pdf-contribution/</guid><description>画像を PDF に埋め込むだけだった Scheduled Delivery の PDF を、ネイティブ PDF に置き換えた</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>業務で [Lightdash](https://github.com/lightdash/lightdash) を使っている。Lightdash は dbt プロジェクトに接続してセルフサービス分析を実現するオープンソースの BI ツール。

Scheduled Delivery という機能があり、ダッシュボードやチャートを定期的に Slack やメールに配信できる。この機能で生成される PDF の品質に不満があったので、改善するコードを書いた。[0.2673.0](https://github.com/lightdash/lightdash/compare/0.2672.0...0.2673.0) でリリースされ、[コントリビューター](https://github.com/lightdash/lightdash/pull/21443)にも追加してもらった。

## 何が問題だったか

Scheduled Delivery には CSV、XLSX、Image、Google Sheets の4つのフォーマットがある。Image フォーマットを選ぶと「Also include image as PDF attachment」というオプションが出て、画像に加えて PDF も添付できる。

この PDF の生成方法に問題があった。`pdf-lib` というライブラリを使って、スクリーンショットの PNG 画像をそのまま PDF に埋め込んでいた。

```typescript
const pdfDoc = await PDFDocument.create();
const pngImage = await pdfDoc.embedPng(buffer);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage);
const pdfBytes = await pdfDoc.save();
```

これだと以下の問題がある。

- テキストが選択できない（ラスター画像なので）
- リンクがクリックできない
- 拡大するとぼやける（ベクターではなくピクセルデータ）
- 検索ができない
- CJK フォントが正しくレンダリングされない（日本語が崩れる）
- スクリーンショットの解像度に依存するので粗い

画像を PDF コンテナで包んだだけなので、PDF である利点がほぼない。メールで受け取った人がダッシュボードの数値をコピーしたり、チャートのリンクを辿ったりできない。日本語環境ではフォントの問題もあって、そもそも読みづらかった。

## 既存のスクリーンショット生成の構成

コードを読んで、スクリーンショットがどう撮られているか調べた。

Lightdash のアーキテクチャでは、バックエンド API、スケジューラーワーカー、ヘッドレスブラウザがそれぞれ別のコンテナとして動いている。

| サービス | 役割 |
|---|---|
| Backend API | Express.js の REST サーバー |
| Scheduler Worker | Graphile Worker によるバックグラウンドジョブ処理 |
| Headless Browser | ヘッドレス Chromium コンテナ（スクリーンショット・PDF 生成） |

スクリーンショットの流れはこうなっている。

1. スケジューラーワーカーがジョブをキューから取り出す
2. `UnfurlService` が Playwright 経由でヘッドレス Chromium に CDP（Chrome DevTools Protocol）接続する
3. ダッシュボードの minimal ページ（サイドバー等を除いたレンダリング専用ページ）を開く
4. フロントエンドが全タイルのレンダリング完了を検知し、`#lightdash-ready-indicator` という hidden div を DOM に挿入する
5. Playwright が `page.waitForSelector(&apos;#lightdash-ready-indicator&apos;)` でこの要素を検出する
6. `page.screenshot()` でキャプチャする

ここで気づくのは、Playwright とヘッドレス Chromium の接続が既にあること。`page.screenshot()` の代わりに `page.pdf()` を呼べば、同じブラウザセッションから PDF を生成できる。

## なぜ page.pdf() か

PDF 生成にはいろいろなアプローチがある。サーバーサイドで HTML を PDF に変換するライブラリ（Puppeteer、wkhtmltopdf、WeasyPrint など）を使う方法もある。Playwright の `page.pdf()` がベストだとは限らない。

ただ、今回のケースでは Playwright が最も合理的だった。

- ヘッドレス Chromium のサイドカーコンテナが既に動いている
- Playwright の CDP 接続が既にある
- スクリーンショットと同じブラウザセッションを使える
- 新しいライブラリやインフラが不要
- むしろ `pdf-lib` を削除できる（依存が減る）

`page.pdf()` は Chromium の印刷機能を使う。ブラウザがレンダリングした DOM がそのまま PDF になる。テキスト選択やリンクが効き、ベクター描画なので拡大しても潰れない。

## page.pdf() のクリッピング問題

`page.pdf()` を呼ぶだけなら簡単だが、1つ問題がある。

`page.screenshot()` には `clip` オプションがあり、ページの特定領域だけをキャプチャできる。Lightdash ではダッシュボードのコンテンツ領域（グリッドコンテナ）だけを切り出すのに使われている。

```typescript
// screenshot の場合 — clip で領域指定できる
imageBuffer = await page.locator(finalSelector).screenshot({
    path,
    animations: &apos;disabled&apos;,
    timeout: RESPONSE_TIMEOUT_MS,
});
```

`page.pdf()` にはこの `clip` オプションがない。何もしないとページ全体が PDF になり、ヘッダーや余白が含まれてしまう。ダッシュボードのコンテンツ領域だけを切り出すには別の方法が必要だった。

### コンテンツサイズの測定

まず `page.evaluate()` でブラウザ内の DOM を調べ、コンテンツの実際の高さを測定する。ダッシュボードのグリッドコンテナは CSS 上の高さがコンテンツより大きいことがあるため、子要素の `getBoundingClientRect()` を1つずつ調べて最大の bottom を取る。

```typescript
const contentBottom = await page.evaluate((sel: string) =&gt; {
    const container = document.querySelector(sel);
    if (!container) return 800;
    let maxBottom = 0;
    for (const child of Array.from(container.children)) {
        const rect = child.getBoundingClientRect();
        if (rect.height &gt; 0 &amp;&amp; rect.bottom &gt; maxBottom)
            maxBottom = rect.bottom;
    }
    return Math.ceil(maxBottom || 800);
}, finalSelector);
```

### PDF 生成

測定したサイズを `page.pdf()` の `width` / `height` に渡す。`margin` を全て 0 にし、`pageRanges: &apos;1&apos;` で1ページだけ出力する。`printBackground: true` でダッシュボードの背景色も含める。

```typescript
const pdfBuffer = await page.pdf({
    width: `${clip.width}px`,
    height: `${contentBottom + 10}px`,
    printBackground: true,
    pageRanges: &apos;1&apos;,
    margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
```

### PDF の MediaBox 書き換え

ページサイズをコンテンツに合わせても、Chromium の PDF レンダリングで微妙な余白が残る。そこで PDF のバイナリを直接操作し、MediaBox（表示領域を定義する矩形）を書き換える。

```typescript
return cropPdfToClip(Buffer.from(pdfBuffer), clip);
```

`cropPdfToClip` は PDF の incremental update（PDF spec §7.5.6）を使っている。これは PDF ファイルの末尾に変更を追記する仕組みで、元のバイト列を一切変更しない。PDF ビューアは末尾の更新を優先的に読むので、追記した MediaBox が適用される。

やっていることを簡単に説明する。

1. PDF バイナリから Page オブジェクトを探す（`/Type /Page` を持つオブジェクト）
2. 既存の MediaBox からページの高さを取得する
3. クリッピング領域を px から pt に変換する（PDF の座標系は 72pt/inch、ブラウザは 96px/inch）
4. 新しい MediaBox を計算する（PDF の座標系は左下原点で Y 軸が上向き）
5. 変更した Page オブジェクトと新しい xref テーブルを PDF の末尾に追記する

`pdf-lib` のようなライブラリを使わず、バッファ操作だけで実現している。これにより新しい依存を追加する必要がなく、結果として `pdf-lib` 自体も不要になり削除できた。

## PR の経緯

最初の PR（[#21143](https://github.com/lightdash/lightdash/pull/21143)）では、PDF 品質改善に加えて PDF 単体配信（画像なしで PDF だけ送る新しいフォーマット）も一緒に出していた。スケジューラーの enum 追加、フロントエンドのフォーマット選択 UI、各通知チャネルの対応と、スコープが大きかった。

メンテナーからフィードバックをもらった。

&gt; I think we can replace our pdf generation on our &quot;image&quot; export with your new method, so we can avoid this new &quot;delivery&quot; method entirely. This would simplify this PR

つまり、まず既存の「Image + PDF」フローの PDF 品質を改善するだけにして、PDF 単体配信は別 PR にしたいということ。変更を小さく段階的に入れる方針。

メンテナーが自分のブランチをベースに [#21438](https://github.com/lightdash/lightdash/pull/21438) を作った。`page.pdf()` と `cropPdfToClip` のコードはそのまま採用され、既存の「Image + PDF」フローへの組み込みに絞って整理された。`createImagePdf`（`pdf-lib` で画像を埋め込むメソッド）を `createBrowserPdf`（`page.pdf()` でネイティブ PDF を生成するメソッド）に置き換え、`pdf-lib` の依存を削除する内容。

&gt; Based on work by @OTA2000 in #21143 — thank you for the contribution! 🙏

#21438 は 0.2673.0 でリリースされた。自分の PR はクローズしたが、コア部分のコードが採用されてリリースまで到達した。

## 振り返り

最初の PR はスコープが大きすぎた。PDF 品質改善と PDF 単体配信を1つの PR に入れてしまい、メンテナーに分割してもらう形になった。分割後は品質改善の部分が数日でマージ・リリースされたので、最初から小さく切って出せばよかった。

自分の PR がクローズされたときは正直がっかりした。ただ、コードは使われているしリリースもされている。PR の番号が自分のものかどうかより、書いたコードがプロダクトに入ったかどうかのほうが大事だと思い直した。

## PDF 単体配信

品質改善とは別に、画像なしの PDF だけを送る機能も [#21457](https://github.com/lightdash/lightdash/pull/21457) で出している。#21438 で入った `page.pdf()` のインフラを使い、新しい `SchedulerFormat.PDF` フォーマットを追加するもの。こちらはレビュー待ち。</content:encoded></item><item><title>Claude Code の公式ベストプラクティスを試している</title><link>https://ota2000.com/blog/claude-code-best-practices/</link><guid isPermaLink="true">https://ota2000.com/blog/claude-code-best-practices/</guid><description>公式ドキュメントを読んでデータ基盤の業務で実践してみた記録</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Claude Code を日常的に使っている。[公式のベストプラクティス](https://code.claude.com/docs/en/best-practices)を読んで、自分の業務（dbt、BigQuery、Terraform あたり）で片っ端から試してみた。効いたものとそうでもなかったものがある。

## skills が一番よかった

`~/.claude/skills/{name}/SKILL.md` にファイルを置くと、Claude Code がスキルとして認識する。description へキーワードを書いておけば、該当する話題が出たとき勝手に読み込まれる。

自分は dbt の命名規則、CI/CD のデプロイフロー、レイヤリング方針、BigQuery の運用ルールあたりを書いた。「dbt モデル追加したい」と言うだけで自社の手順が入った状態で返ってくる。毎回説明しなくていい。

[公式のスキルオーサリングガイド](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)に「Claude が元々知らないことだけ書け」とある。「BigQuery はカラム型ストアで…」みたいなことは書いても意味がない。ディレクトリ構成、自動生成スクリプトの存在、やってはいけないこと。そういう自社固有の話だけ書く。description は三人称の英語で、`MUST activate when:` をつけると発火しやすくなる。

## CLAUDE.md は短いほうがいい

公式の表現を借りると「各行について『これを削除したら Claude が間違えるか？』と問う。No なら削除」。最初は何でも書いていたが、長くなるとルールが無視される。

今はこう分けている。

| 置き場所 | 中身 |
|---|---|
| CLAUDE.md | コミット規約、レビュー方針、言語設定 |
| skills | dbt パターン、BigQuery 運用、CI/CD フロー |
| hooks | lint、フォーマット |

hooks は CLAUDE.md と違って無視されない。SQL を編集したら sqlfmt、.tf を編集したら terraform fmt が走るようにした。公式の言い方だと「CLAUDE.md は advisory、hooks は deterministic」。

## /clear は地味に大事

dbt の作業をした後にそのまま Terraform の質問をすると、さっきのコンテキストが邪魔になる。公式が「kitchen sink session」と呼んでいるやつ。タスクが変わったら `/clear`。

## 検証手段を渡す

dbt モデルを書いたら `make dbt-test`、SQL なら `sqlfluff lint`。「作ったら検証しろ」を skills や CLAUDE.md に書いておけば勝手にやってくれる。hooks も併用すれば編集のたびに lint が走る。

## サブエージェントは調査に使う

調査でファイルを大量に読むとメインのコンテキストが埋まる。サブエージェントに投げるとサマリーだけ返ってくる。リポジトリが複数ある場合は並列で調査できるので便利。skills に `context: fork` と `agent: Explore` を書いておくと、スラッシュコマンドでサブエージェントに投げられる。

## そこそこ使えるもの

**Plan Mode** — 複数ファイルにまたがる変更のときだけ。単一ファイルの修正には要らない。

**CLI ツール** — `gh`、`gcloud`、`bq` を使わせると Web コンソールを開く回数が減る。BigQuery MCP サーバーも入れるとテーブルスキーマの確認がターミナルで済む。

**エージェント定義** — `~/.claude/agents/*.md` に7つ作った。ただしスキルと比べると毎回手動でスポーンしないといけないので、日常的に使うのはスキルのほう。エージェントは並列調査か大きめの委任に限る。

**セッション名** — `/rename` でつけておくと `claude --resume` で探しやすい。

**プラグイン** — pyright-lsp で Python の型チェック、gopls-lsp で Go の補完。dbt プラグインはドキュメント参照とコマンド実行をサポートしてくれる。

## あまり効かなかったもの

**auto memory** — 自動でメモリを書き出してくれる機能。データ基盤の構成は変更が多くて、古いメモリが残っていると変な前提で進むことがあった。今は手動で管理している。

**エージェントの大量作成** — 7つ作って頻繁に使うのは2-3個。スキルのほうが勝手に発火するから楽。

## 参考

- [Best Practices for Claude Code](https://code.claude.com/docs/en/best-practices)
- [Extend Claude with skills](https://code.claude.com/docs/en/skills)
- [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)</content:encoded></item><item><title>BigQuery へのデータ取り込みに dlt を選んだ理由と構成</title><link>https://ota2000.com/blog/dlt-bigquery-cloud-run-job/</link><guid isPermaLink="true">https://ota2000.com/blog/dlt-bigquery-cloud-run-job/</guid><description>Python 製のデータインジェストライブラリ dlt を Cloud Run Job で動かして BigQuery にデータを入れる構成について</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>外部サービスからのデータ取り込みを自前の Go サービスで書いていた。動いてはいるが、パイプラインが増えるたびにスキーマ管理や差分取り込みのロジックを毎回書くことになる。そろそろしんどい。Python 製の EL ツール dlt へ寄せることにした。

## dlt とは

[dlt](https://dlthub.com/) (data load tool) は Python 製のオープンソースな EL ライブラリ。データソースから BigQuery や Snowflake、DuckDB などへの取り込みを Python で書ける。

- スキーマ推論が自動。型の検出やネスト構造の展開もやってくれる
- incremental loading、スキーマ進化、state 管理が組み込み
- REST API、SQL DB、クラウドストレージなどソースは多い
- `pip install dlt[bigquery]` で入る。別途サーバーは要らない

## なぜ dlt にしたか

候補は Airbyte、Meltano (Singer)、今まで通り自前で書き続ける、あたり。

Airbyte はコネクタが豊富だが、セルフホストするならサーバーの管理が要る。Airbyte Cloud にすると従量課金がかかる。取り込み先が BigQuery だけなのにそこまでやるか、という話になった。

Meltano は Singer の tap/target エコシステムを使えるが、tap の品質にばらつきがある。メンテが止まっているものも多い。セルフホストの運用負荷もそれなりにある。

自前で書き続けるのは自由度が高い。ただ、スキーマ管理、state 管理、リトライを毎回自分で書くことになる。1つ2つならいいが、10本超えてくると厳しい。

dlt は pip で入る Python ライブラリなので、既存の環境にそのまま載る。state 管理やスキーマ進化が組み込みだから自分で書かなくていい。サーバーも不要。パイプラインを足すときのコストが低い、というのが決め手だった。

## 構成

Cloud Run Job + BigQuery にした。

```mermaid
flowchart LR
    A[&quot;Cloud Scheduler&quot;] --&gt; B[&quot;Cloud Run Job&lt;br&gt;(dlt)&quot;]
    C[&quot;GCS&lt;br&gt;(Object Finalize)&quot;] --&gt; D[&quot;Eventarc&quot;] --&gt; B
    B --&gt; E[&quot;BigQuery&quot;]
```

Cloud Run Job にしたのは、実行時だけリソースを使うのでコストが安いのと、スケジュール実行・手動実行・イベント駆動のいずれにも対応しているから。定期取り込みは Cloud Scheduler、GCS にファイルが置かれたら取り込むケースは Eventarc の Object Finalize トリガーで起動できる。もともと Go 向けの Cloud Run Job のコード生成（Dockerfile やデプロイ定義）の仕組みが社内にあったので、Python 向けを追加するだけで済んだのも大きかった。

Docker イメージに dlt とパイプラインコードを入れて Cloud Run Job に登録する。サービスアカウントには BigQuery Data Editor、Job User、Read Session User の3ロールが要る。

## 基本的なパイプライン

最小構成はこれだけで動く。

```python
import dlt

pipeline = dlt.pipeline(
    pipeline_name=&quot;my_pipeline&quot;,
    destination=&quot;bigquery&quot;,
    dataset_name=&quot;source_my_data&quot;,
)

data = [{&quot;id&quot;: 1, &quot;name&quot;: &quot;test&quot;}]
load_info = pipeline.run(data, table_name=&quot;my_table&quot;)
print(load_info)
```

`pipeline.run()` を呼ぶだけ。テーブルがなければ作られる。スキーマが変わればカラムも追加される。

実用的にはデータ取得を `@dlt.resource` で定義する。REST API から取る場合。

```python
import dlt
from dlt.sources.helpers import requests

@dlt.resource(write_disposition=&quot;append&quot;)
def events():
    response = requests.get(&quot;https://api.example.com/events&quot;)
    yield response.json()

pipeline = dlt.pipeline(
    pipeline_name=&quot;events_pipeline&quot;,
    destination=&quot;bigquery&quot;,
    dataset_name=&quot;source_events&quot;,
)
pipeline.run(events)
```

`write_disposition` で書き込みモードを指定する。`append` で追記、`replace` で全置換、`merge` で primary key ベースの upsert。

## state 管理

dlt は実行状態をデスティネーション側に自動保存する。`pipeline.run()` するとデータ本体に加えて管理テーブルが3つ作られる。

| テーブル | 役割 |
|---|---|
| `_dlt_loads` | ロード履歴。いつ、どのスキーマバージョンで読み込んだか |
| `_dlt_version` | スキーマバージョン管理。スキーマ定義の JSON を丸ごと持っている |
| `_dlt_pipeline_state` | パイプラインの状態。incremental loading のカーソル位置など |

これが地味にありがたい。Cloud Run Job は毎回クリーンなコンテナで起動するので、ローカルに状態を持てない。dlt は前回どこまで取り込んだかを BigQuery 上の state から復元して、続きから実行してくれる。

incremental loading と組み合わせるとこうなる。

```python
@dlt.resource(write_disposition=&quot;append&quot;)
def events(updated_at=dlt.sources.incremental(&quot;updated_at&quot;)):
    url = &quot;https://api.example.com/events&quot;
    params = {&quot;since&quot;: updated_at.last_value}
    response = requests.get(url, params=params)
    yield response.json()
```

`dlt.sources.incremental` に追跡対象のフィールドを渡すと、前回の最大値を `last_value` として保持してくれる。この値が `_dlt_pipeline_state` に保存される。

## 所感

書いてみると、自前で実装していたスキーマ管理や state 管理がそのまま dlt に置き換わる感じで楽だった。データ取得のロジックだけ書けばいい。

Python のジェネレータベースなのでテストも書きやすい。モックしたデータを yield するだけでいい。

正直、dlt はまだ発展途上なところもある。ドキュメントは増えてきているが、エッジケースで挙動がわからず結局ソースを読んだりはした。OSS なのでそれができるのは助かる。

Cloud Run Job との相性はいい。パイプラインを足すときは Python スクリプトを追加するだけ。</content:encoded></item><item><title>Google 公式の BigQuery MCP サーバーを試した</title><link>https://ota2000.com/blog/bigquery-mcp-server/</link><guid isPermaLink="true">https://ota2000.com/blog/bigquery-mcp-server/</guid><description>プレビュー版だが claude.ai / Claude Code から BigQuery を直接操作できるようになった</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Google Cloud から BigQuery のリモート MCP サーバーがプレビュー版で出た。ドキュメントには3月17日以降 BigQuery を有効にすると MCP サーバーも自動で有効になるとあるが、自分の環境では3月23日時点でも手動での有効化が必要だった。

プレビュー版なので本番で使うかは要判断だが、手元で試した。

## MCP とは

Model Context Protocol。Anthropic が策定したオープンプロトコルで、AI アプリケーションが外部ツールと連携するための仕組み。Claude Code、Gemini CLI、Cursor などが対応している。

Google Cloud はこの MCP のリモートサーバーを複数のサービスで出し始めた。BigQuery はそのうちの1つ。

## 使えるツール

BigQuery MCP サーバーが持っているのは5つ。

- `list_dataset_ids` — データセット一覧
- `get_dataset_info` — データセットの詳細
- `list_table_ids` — テーブル一覧
- `get_table_info` — テーブルのスキーマやメタデータ
- `execute_sql` — SQL の実行

テーブルの探索からクエリ実行まで一通りカバーしている。

## セットアップ

有効化は1コマンド。

```bash
gcloud beta services mcp enable bigquery.googleapis.com --project=PROJECT_ID
```

IAM ロールは管理者向けとユーザー向けで分かれている。

- 管理者（API の有効化時）
  - Service Usage 管理者
- ユーザー（MCP の利用時）
  - MCP ツールユーザー
  - BigQuery ジョブユーザー
  - BigQuery データ閲覧者

加えて、Google Cloud コンソールで OAuth クライアントを作成する必要がある。「API とサービス」→「認証情報」→「OAuth 2.0 クライアント ID」から、アプリケーションの種類は「ウェブ アプリケーション」を選択する。「承認済みのリダイレクト URI」に `https://claude.ai/api/mcp/auth_callback` を追加しておく。

claude.ai の設定 → カスタマイズ → コネクタの「コネクタを参照」から「Google Cloud BigQuery」を検索して追加する。OAuth クライアント ID とシークレットを入力し、Google アカウントで認証すれば使える。

この方法で追加すると claude.ai と Claude Code の両方で利用できる。

## 使ってみて

claude.ai のコネクタとして追加するので、ブラウザ上のチャットからもそのまま使える。「先週の乗車数を出して」と聞けば SQL を組んで実行し、結果を返してくれる。開発者でなくても claude.ai さえ使えればデータの確認や簡単な分析ができる。

Claude Code からも同じコネクタが使えるので、dbt のモデル定義を書いているときに「このテーブルのカラム何だっけ」と聞くと MCP 経由で BigQuery に問い合わせてくれる。Web コンソールを開いてテーブルを探す、という作業を日に何度もやっていたので、これがなくなるだけで体感が違う。

あわせて Model Armor（Google の AI セーフティ機能）も設定した。ドキュメントに記載があったので一緒に入れたが、プロンプトインジェクション対策として有効にしておいたほうがいい。IAM 周りも地味に設定項目が多いので、Terraform で管理するのがおすすめ。

プレビュー版なので仕様が変わる可能性はある。

## 参考

- [BigQuery リモート MCP サーバーを使用する](https://docs.cloud.google.com/bigquery/docs/use-bigquery-mcp?hl=ja)
- [BigQuery MCP ツールリファレンス](https://docs.cloud.google.com/bigquery/docs/reference/mcp?hl=ja)
- [Google Cloud MCP の概要](https://docs.cloud.google.com/mcp/overview)</content:encoded></item><item><title>Astro ブログに記事タイトル入りの OGP 画像を自動生成する</title><link>https://ota2000.com/blog/astro-ogp-image/</link><guid isPermaLink="true">https://ota2000.com/blog/astro-ogp-image/</guid><description>satori + resvg でビルド時に OGP 画像を生成し、SNS シェア時に記事タイトルが表示されるようにした</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>ブログ記事を X でシェアしたとき、OGP 画像が全記事共通のサイトロゴだった。記事タイトルが画像に入っていた方がクリックされやすいので、記事ごとに OGP 画像を自動生成するようにした。

## 仕組み

Astro の静的エンドポイントで、各記事に対応する `/blog/{id}/og.png` を生成する。

1. **satori** — React 風の JSX オブジェクトから SVG を生成
2. **@resvg/resvg-js** — SVG を PNG に変換

ビルド時にすべての記事分の PNG が生成される。ランタイムの処理は不要。

## セットアップ

```bash
pnpm add satori @resvg/resvg-js
```

## 画像生成エンドポイント

`src/pages/blog/[id]/og.png.ts` を作成。既存の `[id].astro` は `[id]/index.astro` に移動する。

```typescript
import type { APIRoute, GetStaticPaths } from &apos;astro&apos;;
import { getCollection } from &apos;astro:content&apos;;
import satori from &apos;satori&apos;;
import { Resvg } from &apos;@resvg/resvg-js&apos;;

export const getStaticPaths = (async () =&gt; {
  const posts = await getCollection(&apos;blog&apos;);
  return posts
    .filter((post) =&gt; !post.data.draft)
    .map((post) =&gt; ({
      params: { id: post.id },
      props: { title: post.data.title, date: post.data.date },
    }));
}) satisfies GetStaticPaths;

// ビルド時に1回だけ取得
const fontResponse = await fetch(
  &apos;https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@latest/japanese-700-normal.woff&apos;
);
const fontData = Buffer.from(await fontResponse.arrayBuffer());

export const GET: APIRoute = async ({ props }) =&gt; {
  const { title, date } = props;

  const svg = await satori(
    {
      type: &apos;div&apos;,
      props: {
        style: { /* レイアウト定義 */ },
        children: [
          { type: &apos;div&apos;, props: { children: title } },
          // アイコン、著者名、日付 ...
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: &apos;Noto Sans JP&apos;, data: fontData, weight: 700 }],
    },
  );

  const resvg = new Resvg(svg, { fitTo: { mode: &apos;width&apos;, value: 1200 } });
  const png = resvg.render().asPng();
  return new Response(Buffer.from(png), {
    headers: { &apos;Content-Type&apos;: &apos;image/png&apos; },
  });
};
```

## 日本語フォントの読み込み

satori は SVG を生成するためにフォントデータが必要。ローカルにフォントファイルを置く方法だと、Astro のビルド時に `process.cwd()` がビルド出力ディレクトリを指すためパスが壊れる。

CDN から `fetch` で取得する方式にした。トップレベル `await` で書けばビルド時に1回だけ取得される。

```typescript
const fontResponse = await fetch(
  &apos;https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@latest/japanese-700-normal.woff&apos;
);
const fontData = Buffer.from(await fontResponse.arrayBuffer());
```

## OGP meta タグの設定

Layout に `ogImage` prop を追加し、ブログ記事ページでは動的 URL を渡す。

```html
&lt;!-- Layout.astro --&gt;
&lt;meta property=&quot;og:image&quot; content={ogImage || &apos;https://ota2000.com/og-image.png&apos;} /&gt;
&lt;meta name=&quot;twitter:card&quot; content={ogImage ? &apos;summary_large_image&apos; : &apos;summary&apos;} /&gt;
```

```astro
&lt;!-- [id]/index.astro --&gt;
&lt;Layout ogImage={`https://ota2000.com/blog/${post.id}/og.png`}&gt;
```

`summary_large_image` にすると X で大きな画像カードとして表示される。

## アイコンを埋め込む

サイトのアバター画像を OGP に含めたかったので、`apple-touch-icon.png` を base64 エンコードして satori に渡している。

```typescript
const iconBase64 = `data:image/png;base64,${
  fs.readFileSync(&apos;public/apple-touch-icon.png&apos;).toString(&apos;base64&apos;)
}`;

// satori のレイアウト内で
{ type: &apos;img&apos;, props: { src: iconBase64, width: 48, height: 48 } }
```

## 結果

SNS でシェアしたときに記事タイトル + アイコンが大きく表示されるようになった。ビルド時間は 13 記事で約 7 秒増。記事を追加するたびに自動で OGP 画像が生成される。</content:encoded></item><item><title>電工2種を受ける理由</title><link>https://ota2000.com/blog/electrical-worker-license/</link><guid isPermaLink="true">https://ota2000.com/blog/electrical-worker-license/</guid><description>スマートホームと照明DIYのために電工2種の勉強を始めた</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>電工2種の試験に申し込んだ。動機は完全に実用。

## SwitchBot リレースイッチを付けたい

SwitchBot の[リレースイッチ1](https://amzn.to/4swvFKD)という製品がある。壁スイッチの裏に設置して、既存の照明をスマート化できる。物理スイッチはそのまま使えて、アプリやAlexaからも操作できるようになる。米国などでは以前から販売されていたが、最近ようやく日本でも買えるようになった。

ただし、壁スイッチの裏の配線をいじる必要がある。これは電気工事士の資格がないとやってはいけない。業者に頼めば数万円。自分でやれば製品代だけで済む。

## ダウンライトも替えたい

自宅の埋込式LEDダウンライトが切れたとき、業者に依頼するのが億劫。埋込式は器具ごと交換になるので電気工事が必要になる。

照明1つ替えるために業者を呼ぶのは面倒だし、「ちょっとした交換作業のためにわざわざ来てもらう」のも気が引ける。資格があれば自分のペースでやれる。

## 試験について

電工2種は年2回実施。筆記と実技がある。筆記はまぁなんとかなりそうだが、実技は実際にケーブルを剥いて器具に結線する。工具一式を揃えて練習が必要。

高専で電気系の学科にいたとはいえ、もう15年以上前の話。オームの法則くらいしか覚えていない。テキストを開いたら「複線図」という言葉が出てきて、全く記憶になかった。</content:encoded></item><item><title>Astro ブログで Mermaid 図を使えるようにする</title><link>https://ota2000.com/blog/astro-mermaid/</link><guid isPermaLink="true">https://ota2000.com/blog/astro-mermaid/</guid><description>Shiki の excludeLangs + クライアントサイド mermaid.js で Markdown 内に Mermaid 図を埋め込む</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>ブログ記事にアーキテクチャ図を入れたくなった。テキストのフロー図でもいいけど、Mermaid で書けると見やすい。Astro で Mermaid を使えるようにした。

## やりたいこと

Markdown のコードブロックに `mermaid` と書くだけで図がレンダリングされる状態にしたい。

````markdown
```mermaid
flowchart TD
    A --&gt; B --&gt; C
```
````

## 試した選択肢と結果

- **rehype-mermaid** — ビルド時に Playwright でブラウザを起動してレンダリング。Cloudflare Pages のビルド環境に Playwright のバイナリがなく使えなかった。`pre-mermaid` strategy でも `mermaid-isomorphic` が `playwright` を import しようとして失敗する
- **astro-mermaid** — Astro integration。静的ビルドで `mermaid` モジュールの解決に失敗した

結局、**追加パッケージなし**で実現できた。

## セットアップ

### 1. Shiki から mermaid を除外

Astro 5.5 で追加された `excludeLangs` を設定する。これがないとシンタックスハイライターが mermaid コードブロックを普通のコードとして処理してしまう。

```javascript
// astro.config.mjs
export default defineConfig({
  markdown: {
    syntaxHighlight: {
      type: &apos;shiki&apos;,
      excludeLangs: [&apos;mermaid&apos;],
    },
  },
});
```

これで mermaid コードブロックが `&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;` として出力される。

### 2. mermaid.js をクライアントサイドで読み込み

Layout の `&lt;/body&gt;` の前に追加。mermaid.js は `&lt;pre class=&quot;mermaid&quot;&gt;` を期待するので、Shiki が出力した `&lt;code class=&quot;language-mermaid&quot;&gt;` を変換してから初期化する。

```html
&lt;script is:inline type=&quot;module&quot;&gt;
  var mermaidBlocks = document.querySelectorAll(&apos;code.language-mermaid&apos;);
  if (mermaidBlocks.length &gt; 0) {
    mermaidBlocks.forEach(function(code) {
      var pre = code.parentElement;
      pre.className = &apos;mermaid&apos;;
      pre.textContent = code.textContent;
    });
    var { default: mermaid } = await import(
      &apos;https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs&apos;
    );
    mermaid.initialize({
      startOnLoad: true,
      theme: document.documentElement.dataset.theme === &apos;dark&apos;
        ? &apos;dark&apos; : &apos;default&apos;,
    });
  }
&lt;/script&gt;
```

mermaid を使っていないページでは `querySelectorAll` が空なので CDN への読み込みは発生しない。

## ハマったところ

### Cloudflare Pages で rehype-mermaid が動かない

rehype-mermaid は内部で `mermaid-isomorphic` → `playwright` に依存している。`pre-mermaid` strategy でも import 時点で `playwright` パッケージを要求するため、Cloudflare Pages では使えない。結局 rehype プラグインは不要だった。

### Shiki の出力形式

`excludeLangs` で除外しても、出力は `&lt;pre class=&quot;mermaid&quot;&gt;` ではなく `&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;` になる。mermaid.js はルート要素に `class=&quot;mermaid&quot;` を期待するため、クライアントサイドでの変換が必要。

## 結果

```mermaid
flowchart LR
    A[&quot;Markdown&quot;] --&gt; B[&quot;code.language-mermaid&quot;]
    B --&gt;|クライアントJS| C[&quot;pre.mermaid&quot;]
    C --&gt;|mermaid.js| D[&quot;SVG 図&quot;]
```

追加パッケージなし、Astro の設定1行 + Layout にスクリプト追加だけで動く。</content:encoded></item><item><title>息子の初サッカー観戦</title><link>https://ota2000.com/blog/kashiwa-mito-2026/</link><guid isPermaLink="true">https://ota2000.com/blog/kashiwa-mito-2026/</guid><description>2歳の息子と三協フロンテア柏スタジアムで J1百年構想リーグ EAST 第8節を観戦</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>3月22日、柏ファンの友人たちに誘われて、三協フロンテア柏スタジアムへ。J1百年構想リーグ EAST 第8節、柏レイソル vs 水戸ホーリーホック。自分は水戸出身なのでホーリーホックを応援している。完全アウェーだった。

2歳の息子を連れて行った。初めてのスポーツ観戦になる。

結果は 3-0 で柏の完勝。悔しいが、試合自体は楽しく観られた。

息子は試合の内容なんて当然わかっていない。でもスタジアムの雰囲気にはずっと反応していた。歓声、太鼓、周りの人が立ち上がる瞬間。ゴールが決まって盛り上がるたびに、つられて手を叩いていた。柏のゴールで喜んでいたのは複雑だけど。

途中で飽きて走り回るかと思ったが、意外と最後まで持った。

テレビとスタジアムはやっぱり別物だ。ピッチとの距離、応援の一体感、ゴールの瞬間の空気。息子にとっても良い体験だったはず。次はケーズデンキスタジアム水戸で、ホーム側で観たい。</content:encoded></item><item><title>Cloudflare Pages + Astro に Decap CMS を導入する</title><link>https://ota2000.com/blog/decap-cms-cloudflare-pages/</link><guid isPermaLink="true">https://ota2000.com/blog/decap-cms-cloudflare-pages/</guid><description>スマホからブログ記事を書けるようにした。OAuth プロキシを Pages Functions で実装。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>出先でスマホからブログ記事を書きたい。GitHub のモバイルアプリで Markdown を編集するのは辛い。

Decap CMS（旧 Netlify CMS）を入れた。ブラウザ上で記事を書いて、そのまま GitHub にコミットしてくれる。`/admin/` にアクセスするだけ。

## セットアップ

`public/admin/` に2ファイル置くだけ。

`index.html` で Decap CMS の JS を読み込む。

```html
&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;utf-8&quot; /&gt;
    &lt;meta name=&quot;robots&quot; content=&quot;noindex&quot; /&gt;
    &lt;title&gt;Content Manager&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;script src=&quot;https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js&quot;&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

`config.yml` でコレクションを定義する。

```yaml
backend:
  name: github
  repo: ota2000/ota2000.com
  branch: main
  base_url: https://ota2000.com
  auth_endpoint: api/auth

collections:
  - name: blog
    label: Blog
    folder: site/src/content/blog
    create: true
    slug: &quot;{{slug}}&quot;
    extension: md
    fields:
      - { label: Title, name: title, widget: string }
      - { label: Description, name: description, widget: string }
      - { label: Date, name: date, widget: datetime, format: &quot;YYYY-MM-DD&quot; }
      - { label: Tags, name: tags, widget: list }
      - { label: Body, name: body, widget: markdown }
```

## OAuth プロキシ

ここがハマった。Decap CMS の GitHub 認証は OAuth サーバーが必要。Netlify にホストしていれば自動で使えるが、Cloudflare Pages では自前で用意する必要がある。

PKCE 認証を試したが、Decap CMS の GitHub 向け PKCE は未完成（Issue #6597 がオープンのまま）。Netlify の OAuth プロキシにフォールバックして動かない。

結局 Cloudflare Pages Functions で OAuth プロキシを書いた。`/api/auth` と `/api/callback` の2つ。

`/api/auth` は GitHub の OAuth 認可画面にリダイレクトするだけ。

```javascript
export async function onRequest(context) {
  const clientId = context.env.GITHUB_OAUTH_CLIENT_ID;
  const scope = &apos;repo&apos;;
  const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&amp;scope=${scope}`;
  return Response.redirect(url, 302);
}
```

`/api/callback` がトークン交換と Decap CMS への受け渡しを担う。ポイントは2段階の `postMessage` ハンドシェイク。

```javascript
function renderBody(status, content) {
  return `&lt;script&gt;
    const receiveMessage = (message) =&gt; {
      window.opener.postMessage(
        &apos;authorization:github:${status}:${JSON.stringify(content)}&apos;,
        message.origin
      );
      window.removeEventListener(&quot;message&quot;, receiveMessage, false);
    }
    window.addEventListener(&quot;message&quot;, receiveMessage, false);
    window.opener.postMessage(&quot;authorizing:github&quot;, &quot;*&quot;);
  &lt;/script&gt;`;
}
```

最初に `authorizing:github` を親ウィンドウに送り、親が応答したらその `origin` を使ってトークンを返す。この2段階が必要だと気づくまでに時間がかかった。直接 `postMessage` でトークンを送っても Decap CMS は受け取ってくれない。

## 環境変数

GitHub OAuth App の Client ID と Client Secret を Cloudflare Pages の環境変数に設定する。Terraform で管理。

```hcl
GITHUB_OAUTH_CLIENT_ID = {
  type  = &quot;plain_text&quot;
  value = var.github_oauth_client_id
}
GITHUB_OAUTH_CLIENT_SECRET = {
  type  = &quot;plain_text&quot;
  value = var.github_oauth_client_secret
}
```

## 使い勝手

`ota2000.com/admin/` にアクセスして GitHub ログインすれば記事一覧が出る。新規作成・編集どちらも Markdown エディタで書ける。スマホからでも使える。保存すると GitHub にコミットされ、Cloudflare Pages が自動デプロイ。</content:encoded></item><item><title>Astro ブログに全文検索とタグ機能を追加する</title><link>https://ota2000.com/blog/astro-blog-search/</link><guid isPermaLink="true">https://ota2000.com/blog/astro-blog-search/</guid><description>静的 JSON インデックスによる全件検索とタグフィルタリングの実装</description><pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate><content:encoded>記事が増えてきたときに検索がないと困る。Algolia のような外部サービスを入れるほどの規模ではないので、ビルド時に JSON を生成してクライアントサイドで検索する方式にした。

## タグ

Content Collection のスキーマに `tags` を追加した。

```typescript
schema: z.object({
  title: z.string(),
  description: z.string(),
  date: z.coerce.date(),
  draft: z.boolean().default(false),
  tags: z.array(z.string()).default([]),
})
```

frontmatter に `tags: [&quot;Cloudflare&quot;, &quot;Terraform&quot;]` と書く。タグ別の一覧ページは `/blog/tag/[tag].astro` で動的に生成される。

ブログ一覧にもタグをピル状に並べて、クリックでフィルタできるようにした。

## 検索

ビルド時に全記事のメタデータを JSON として出力するエンドポイントを作った。

```typescript
// src/pages/blog/search.json.ts
export async function GET() {
  const posts = (await getCollection(&apos;blog&apos;))
    .filter((post) =&gt; !post.data.draft)
    .sort((a, b) =&gt; b.data.date.valueOf() - a.data.date.valueOf());

  const data = posts.map((post) =&gt; ({
    id: post.id,
    title: post.data.title,
    description: post.data.description,
    date: post.data.date.toISOString(),
    dateStr: post.data.date.toLocaleDateString(&apos;ja-JP&apos;),
    tags: post.data.tags,
  }));

  return new Response(JSON.stringify(data));
}
```

`/blog/search.json` に全記事のタイトル・説明文・タグが入る。クライアントでこれを fetch して部分一致検索する。

```javascript
const matched = posts.filter((p) =&gt;
  p.title.toLowerCase().includes(q) ||
  p.description.toLowerCase().includes(q) ||
  p.tags.some((t) =&gt; t.toLowerCase().includes(q))
);
```

JSON は初回の検索時に1回だけ取得してキャッシュする。検索中はページネーションの記事リストを隠して、検索結果だけ表示する。

## ページネーション

Astro の `paginate()` で10件ずつ分割している。

```astro
---
export const getStaticPaths = (async ({ paginate }) =&gt; {
  const posts = (await getCollection(&apos;blog&apos;))
    .filter((post) =&gt; !post.data.draft)
    .sort((a, b) =&gt; b.data.date.valueOf() - a.data.date.valueOf());
  return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;
---
```

今は記事が少ないのでページ分割は見えないが、10件を超えたら自動で出る。

全部静的ファイルで完結するので、外部サービスへの依存もランタイムのサーバーもない。</content:encoded></item><item><title>Astro でパスワード保護されたレジュメページを作る</title><link>https://ota2000.com/blog/astro-encrypted-resume/</link><guid isPermaLink="true">https://ota2000.com/blog/astro-encrypted-resume/</guid><description>ビルド時に AES-GCM で暗号化し、ブラウザで復号する静的サイト向けのアプローチ</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>自分のサイトにレジュメを置きたかった。必要なときに URL とパスワードを伝えるだけで見てもらえると楽だし、内容の更新もプッシュするだけで済む。

ただ、誰でも見られる状態にはしたくない。Astro は静的サイトジェネレーターなのでサーバーサイド認証はない。Cloudflare Access という手もあったが、閲覧者にメール認証を通してもらう必要がある。パスワード1つで済むほうがシンプルでいい。

## やったこと

ビルド時にレジュメの HTML を AES-GCM で暗号化して、静的 HTML に base64 文字列として埋め込む。閲覧者がパスワードを入力すると、ブラウザの Web Crypto API で復号して表示する。

ソースコードを開いても暗号化された文字列しか見えない。`noindex` も付けているので検索エンジンにも引っかからない。

## 暗号化

暗号化のユーティリティは `src/lib/encrypt.ts` に切り出した。

```typescript
export async function encrypt(plaintext: string, password: string) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  // PBKDF2 でパスワードから鍵を導出
  const key = await crypto.subtle.deriveKey(
    { name: &apos;PBKDF2&apos;, salt, iterations: 100000, hash: &apos;SHA-256&apos; },
    keyMaterial,
    { name: &apos;AES-GCM&apos;, length: 256 },
    false,
    [&apos;encrypt&apos;],
  );
  // AES-GCM で暗号化
  const ciphertext = await crypto.subtle.encrypt(
    { name: &apos;AES-GCM&apos;, iv },
    key,
    encoder.encode(plaintext),
  );
  // ...
}
```

Astro のフロントマターはビルド時に Node.js で実行されるので、ここで暗号化を呼べる。

```astro
---
const password = import.meta.env.RESUME_PASSWORD;
const content = fs.readFileSync(&apos;src/data/resume-content.html&apos;, &apos;utf-8&apos;);
const encrypted = await encrypt(content, password);
---
```

パスワードは環境変数 `RESUME_PASSWORD` で管理している。Cloudflare Pages に設定し、Terraform でも管理している。

## 復号

ブラウザ側では同じ PBKDF2 + AES-GCM のパラメータで復号する。パスワードの照合は SHA-256 ハッシュで先にやって、間違っていたらそこで弾く。

リロードのたびにパスワードを求められるのは面倒なので、`sessionStorage` に保持して自動復号するようにした。タブを閉じれば消える。

## 更新フロー

`src/data/resume-content.html` を編集してプッシュするだけ。ビルド時に勝手に暗号化される。経験年数も `new Date().getFullYear() - 2016` で動的に計算しているので、年が変わっても放置でいい。</content:encoded></item><item><title>mise でプロジェクトごとのツールバージョンを管理する</title><link>https://ota2000.com/blog/mise-tool-management/</link><guid isPermaLink="true">https://ota2000.com/blog/mise-tool-management/</guid><description>asdf から mise に移行した理由と基本的な使い方</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>開発ツールのバージョン管理に [mise](https://mise.jdx.dev/) を使っている。以前は asdf を使っていたが、mise に移行してから快適になった。

## mise とは

mise (旧 rtx) は asdf 互換のツールバージョンマネージャ。asdf のプラグインエコシステムをそのまま使えて、Rust 製で動作が速い。

## asdf との違い

- Rust 製なので shim の解決が速い
- `mise.toml` で設定でき、TOML 形式で読みやすい
- `mise run` でタスクランナーとしても使える
- `.env` 的な環境変数管理も内蔵している

## プロジェクトでの使い方

プロジェクトルートに `mise.toml` を置くだけ。

```toml
[tools]
terraform = &quot;1.14.7&quot;
node = &quot;22&quot;
python = &quot;3.12&quot;
```

チームメンバーが `mise install` を実行すれば、全員同じバージョンのツールを使える。

## 注意点

非インタラクティブシェル (CI 環境など) では shim の解決に失敗することがある。GitHub Actions なら `mise activate` を使うか、直接パスを指定すればよい。

asdf からの移行はスムーズだった。互換性の問題もほぼなし。一番の改善は速度で、シェル起動時のオーバーヘッドが体感できるレベルで減った。</content:encoded></item><item><title>Cloudflare Pages の環境変数を Terraform で管理する際のハマりどころ</title><link>https://ota2000.com/blog/cloudflare-pages-terraform-env/</link><guid isPermaLink="true">https://ota2000.com/blog/cloudflare-pages-terraform-env/</guid><description>Terraform provider v5 での env_vars 設定と secret_text の罠</description><pubDate>Mon, 16 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Cloudflare Pages のビルド環境変数を Terraform で管理しようとしてハマった話。

## provider v5 での書き方

Cloudflare Terraform provider v5 では、Pages プロジェクトの環境変数は `deployment_configs.env_vars` で設定する。v4 以前の `environment_variables` とは変わっている。

```hcl
resource &quot;cloudflare_pages_project&quot; &quot;site&quot; {
  # ...
  deployment_configs = {
    production = {
      env_vars = {
        MY_VAR = {
          type  = &quot;plain_text&quot;
          value = var.my_var
        }
      }
    }
  }
}
```

`type` には `plain_text` か `secret_text` を指定できる。

## secret_text が壊れている

`secret_text` を指定すると、Cloudflare API は読み取り時に値を返さない。セキュリティとしては正しい。ただ、Terraform にとっては致命的だった。

1. `terraform apply` で `secret_text` の値を設定する
2. 次の `terraform plan` で API から state を refresh する
3. API が値を返さないので、Terraform は state に空文字列を書き込む
4. 次の apply でその空文字列が Cloudflare に送信される

要するに、secret_text で設定した値は次の apply で消える。`terraform state show` では `(sensitive value)` と表示されていて一見正しく見えるが、中身は空文字列だった。

ダッシュボードで確認したら変数名だけあって値が空。ビルドは `RESUME_PASSWORD` が未設定でエラー。原因の特定に少し時間がかかった。

## 対処法

`plain_text` を使って、Terraform 変数側で `sensitive = true` にする。

```hcl
variable &quot;resume_password&quot; {
  type      = string
  sensitive = true
}
```

Terraform Cloud で Sensitive 変数として登録すれば、plan/apply のログには値が出ない。Cloudflare のダッシュボード上では「プレーンテキスト」と表示されるが、ビルド環境変数なので実用上は問題ない。

provider のバグとして報告されるべき挙動だが、現時点では `plain_text` + `sensitive` variable が確実な回避策になる。</content:encoded></item><item><title>個人ドメインのインフラを Terraform で管理する</title><link>https://ota2000.com/blog/cloudflare-terraform/</link><guid isPermaLink="true">https://ota2000.com/blog/cloudflare-terraform/</guid><description>Cloudflare の DNS、Pages、Email Routing を Terraform で IaC 化する</description><pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate><content:encoded>個人ドメイン ota2000.com のインフラを全て Terraform で管理している。

## 構成

- DNS: CNAME、MX、SPF、DKIM、DMARC
- Cloudflare Pages: GitHub 連携による自動デプロイ
- Email Routing: Gmail への転送ルール
- Bulk Redirects: 短縮 URL (`/s/github` など)
- Transform Rules: セキュリティヘッダー

## なぜ Terraform で管理するか

Cloudflare のダッシュボードでも設定はできる。ただ Terraform にしておくと便利な点がいくつかある。

- 変更履歴が Git に残る。誰が何をいつ変えたか明確
- PR ベースでレビューできる
- 同じ構成を別ドメインにも適用できる
- 手動変更があれば `terraform plan` で検知できる

## Cloudflare Provider v5 の注意点

Cloudflare の Terraform Provider v5 では構文が大きく変わった。

```hcl
# v4 (ブロック構文)
build_config {
  build_command = &quot;npm run build&quot;
}

# v5 (オブジェクト引数)
build_config = {
  build_command = &quot;npm run build&quot;
}
```

`rules` や `items` も同様にリスト形式に変わっている。v4 のサンプルコードをそのまま使うとエラーになるので注意。

## Backend

State 管理には Terraform Cloud (Free) を使っている。個人利用なら 500 リソースまで無料で、Web UI から plan/apply の結果も確認できる。

個人サイトでも IaC 化しておくと設定の見通しが良くなる。DNS レコードは手動管理だと何のために追加したか忘れがちなので、コードとして残しておく価値がある。</content:encoded></item><item><title>Astro + Cloudflare Pages で個人サイトを構築した</title><link>https://ota2000.com/blog/astro-cloudflare-pages/</link><guid isPermaLink="true">https://ota2000.com/blog/astro-cloudflare-pages/</guid><description>静的サイト生成と Cloudflare Pages の組み合わせが個人サイトにちょうど良い</description><pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate><content:encoded>個人サイトを Astro + Cloudflare Pages で作った。この構成が個人サイトに合っていると感じたので、選定理由を書いておく。

## なぜ Astro か

静的サイト生成に特化していて、ビルドが速い。出力もシンプル。Content Collections で Markdown のブログ記事を型安全に管理できるのが気に入っている。

React や Vue を使わなくてもいい。必要な箇所だけ Islands として差し込める。JavaScript をほぼ出力しないのでビルドサイズが小さく、個人ブログには十分すぎる。

## なぜ Cloudflare Pages か

無料枠が手厚い。リクエスト数は無制限で、ビルドも月 500 回まで。push するだけで自動デプロイされ、PR ごとにプレビュー URL が発行される。

DNS、Web Analytics、Transform Rules と同じ Cloudflare 内で完結するのも楽。管理画面を行き来しなくて済む。

## Content Collections (v6)

Astro v6 では Content Layer API に変わった。`glob` loader を使って Markdown を読み込む。

```typescript
import { defineCollection, z } from &apos;astro:content&apos;;
import { glob } from &apos;astro/loaders&apos;;

const blog = defineCollection({
  loader: glob({ pattern: &apos;**/*.md&apos;, base: &apos;./src/content/blog&apos; }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    draft: z.boolean().default(false),
  }),
});
```

## デプロイ

Cloudflare Pages の GitHub 連携を設定し、ビルドコマンドを `cd site &amp;&amp; npm install &amp;&amp; npm run build` にする。あとは main ブランチに push すれば自動でデプロイされる。

## コスト

ドメイン維持費以外は全部無料。Cloudflare Pages、Terraform Cloud (Free)、GitHub Actions、いずれも無料枠で収まっている。</content:encoded></item><item><title>Cloudflare Email Routing で独自ドメインメールを無料運用する</title><link>https://ota2000.com/blog/cloudflare-email-routing/</link><guid isPermaLink="true">https://ota2000.com/blog/cloudflare-email-routing/</guid><description>独自ドメインのメールアドレスを Gmail で送受信する設定</description><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><content:encoded>独自ドメイン `ota2000.com` のメールアドレスを、追加コストなしで Gmail から送受信できるようにした。

## 受信: Cloudflare Email Routing

Cloudflare Email Routing は無料のメール転送サービス。

```
ota2000@ota2000.com → 個人の Gmail アドレス
```

設定は MX レコードを Cloudflare のサーバーに向けるだけ。Terraform で書くとこうなる。

```hcl
resource &quot;cloudflare_email_routing_rule&quot; &quot;ota2000&quot; {
  zone_id = var.zone_id
  name    = &quot;ota2000@ota2000.com -&gt; Gmail&quot;
  enabled = true

  matchers = [{ type = &quot;literal&quot;, field = &quot;to&quot;, value = &quot;ota2000@ota2000.com&quot; }]
  actions  = [{ type = &quot;forward&quot;, value = [&quot;your-email@gmail.com&quot;] }]
}
```

## 送信: Resend + Gmail

受信は Cloudflare で転送できるが、送信には別の SMTP サーバーが要る。自分は [Resend](https://resend.com/) を使っている。

1. Resend でドメインを追加し、DKIM・SPF レコードを設定
2. Gmail の「アカウントとインポート」から「他のメールアドレスを追加」
3. SMTP サーバーに `smtp.resend.com` を指定

これで Gmail から `ota2000@ota2000.com` として送信できる。

## DNS レコード

メール関連で必要な DNS レコードは意外と多い。

- MX: Cloudflare Email Routing 用 x 3
- SPF: `v=spf1 include:_spf.mx.cloudflare.net ~all`
- DMARC: `v=DMARC1; p=none; ...`
- DKIM: Cloudflare 用 + Resend 用

Terraform で管理しておくと、各レコードが何のために存在するかコード上で分かる。

## コスト

全部無料。Cloudflare Email Routing は無料、Resend も月 3,000 通まで無料枠がある。個人利用なら十分。</content:encoded></item><item><title>Cloudflare Transform Rules でセキュリティヘッダーを設定する</title><link>https://ota2000.com/blog/security-headers-cloudflare/</link><guid isPermaLink="true">https://ota2000.com/blog/security-headers-cloudflare/</guid><description>Terraform で Cloudflare のセキュリティヘッダーを管理する</description><pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Cloudflare の Transform Rules を使って、全レスポンスにセキュリティヘッダーを追加した。コード変更なしでヘッダーを付与できるので、静的サイトとの相性がいい。

## 設定したヘッダー

| ヘッダー | 値 | 目的 |
|---------|-----|------|
| X-Content-Type-Options | nosniff | MIME タイプスニッフィングの防止 |
| X-Frame-Options | DENY | クリックジャッキング防止 |
| Referrer-Policy | strict-origin-when-cross-origin | リファラー情報の制御 |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | ブラウザ API の制限 |
| X-XSS-Protection | 1; mode=block | XSS フィルターの有効化 |

## Terraform での設定

Cloudflare Provider v5 では `cloudflare_ruleset` リソースを使う。

```hcl
resource &quot;cloudflare_ruleset&quot; &quot;security_headers&quot; {
  zone_id = var.zone_id
  name    = &quot;Security Headers&quot;
  kind    = &quot;zone&quot;
  phase   = &quot;http_response_headers_transform&quot;

  rules = [
    {
      action      = &quot;rewrite&quot;
      expression  = &quot;true&quot;
      description = &quot;Add security headers&quot;
      enabled     = true
      action_parameters = {
        headers = {
          &quot;X-Content-Type-Options&quot; = {
            operation = &quot;set&quot;
            value     = &quot;nosniff&quot;
          }
        }
      }
    },
  ]
}
```

`expression = &quot;true&quot;` で全リクエストに適用される。

## 確認

```bash
curl -sI https://ota2000.com | grep -i x-content-type
# x-content-type-options: nosniff
```

Transform Rules ならアプリケーションコードに手を入れずにヘッダーを追加できる。Terraform で管理しておけば設定内容も一目で分かる。</content:encoded></item></channel></rss>