Databricks上でPages の Draft を API を使わずに自動で公開する方法

目次

はじめに

Databricks の Unity Catalog Pages は、社内の用語・略語・KPI などの定義を一元管理できる機能です。データ基盤チームが用語集を整備し、各ドメインの担当者がレビューを経て公開(Publish)する、という運用は多くの組織で発生します。

しかし、この「Draft(下書き)→ Published(公開)」への切り替え作業には、現時点(2026年時点)で公式APIが提供されていません。UI上のPublishボタンをクリックする以外に手段がなく、対象が数百〜数千件規模になると、一件ずつ手作業でクリックし続けることになります。

本記事では、この課題に対してブラウザ自動化ツール「Playwright」を使い、既存のログイン済みブラウザに接続する方式で、API不要のままPublish作業を自動化した検証結果を共有します。実際の検証環境で複数件のDraftを一括Publishし、全件成功を確認しています。


概要

なぜAPIが使えないのか

Unity Catalog Pagesは2026年時点でBeta機能であり、Publish操作を含む一部の操作はREST APIとして公開されていません。そのため、通常であればCLIやSDKで完結させたい作業も、UI操作に依存せざるを得ない状況でした。

採用したアプローチ:ブラウザ自動化(Playwright)

Playwrightは、Node.js/Python等からブラウザを自動操作できるツールです。今回は以下の方式を採用しました。

  1. Playwright付属のブラウザは使わない。代わりに、普段業務で使っているシステムのGoogle Chromeを「デバッグモード」で起動し、Playwrightからconnect_over_cdp(Chrome DevTools Protocol経由の接続)でそのブラウザに後乗りする。
  2. 対象ブラウザで人間が手動でSSO/MFAログインを済ませておく。
  3. Playwrightは、ログイン済みのセッションを使って「Draft一覧ページへ遷移 → 各PageのURLを開く → Publishボタンをクリック」を機械的に繰り返す。

この方式のポイントは、認証情報や自動ログイン処理を一切自作していないことです。ログインは常に人間が行い、Playwrightは「その後のクリック作業だけ」を代行します。これにより、パスワードやトークンをコード上で扱う必要がなく、監査ログ上も実行者本人のアカウントでの操作として記録されます。

全体の処理フロー

① Playwrightインストール(pip3 install playwright)
② デバッグ可能なChromeを起動(--remote-debugging-port + 専用プロファイル)
③ そのChromeで対象ワークスペースへ手動ログイン
④ Playwrightから接続できるか確認(check_connection.py)
⑤ Draft一覧を取得できるか確認(list_draft_pages.py)
⑥ 取得した一覧を人がレビュー(自動化しない)
⑦ レビュー済みの対象を一括Publish(publish_all_drafts.py)
⑧ 結果ログ(publish_log.jsonl)で成功/失敗・所要時間を確認

「⑥ 人によるレビュー」を明確に工程として独立させている点が今回の設計上の要点です。自動化するのはあくまで「公開する」というボタン操作のみであり、「公開して良いかどうか」の判断は自動化の対象外としています。以下、各ステップの詳細とコードを示します。


① Playwrightのインストール

Pythonライブラリ本体のみをインストールします。通常セットで行うplaywright install(Chromium等のバンドルブラウザのダウンロード)は今回不要です。理由は②で説明する通り、Playwright付属のブラウザは起動せず、普段ログインに使っているシステムのChromeへ「後から接続」する方式を採るためです。

  • コード:インストールコマンド pip3 install playwright

② デバッグ可能なChromeの起動

Chrome バージョン136以降、通常利用しているデフォルトのプロファイルでは、--remote-debugging-portを指定してもポートが実際には開かない仕様になっています。これはマルウェア対策としてのセキュリティ強化であり、独立した専用プロファイル(--user-data-dirで明示指定したもの)を使う必要があります。強制終了(kill -9相当)を使うと、Cookie等が永続化される前にプロセスが落ちてしまい、次回起動時にログイン状態が引き継がれない原因になるため、必ず正常終了させることがポイントです。

  • コード:Chrome起動コマンド
# 既存のChromeを正常終了させる(強制終了は使わない)
osascript -e 'quit app "Google Chrome"'
sleep 2

# 専用プロファイルでデバッグモード起動
open -a "Google Chrome" --args \
    --remote-debugging-port=9222 \
    --user-data-dir="$HOME/chrome-automation-profile"

起動確認は以下で行います。正常時はlsofにプロセスが表示され、curlはBrowserやwebSocketDebuggerUrl等を含むJSONを返します。

  • コード:起動確認コマンド sleep 2 lsof -i :9222 curl <http://localhost:9222/json/version>

③ 対象ワークスペースへの手動ログイン

②で起動したChromeウィンドウ上で、対象のDatabricksワークスペースのURL(例:https://<your-workspace>.cloud.databricks.com/...)を開き、SSO/MFAでログインし、Pages画面まで到達させます。

  • 新規プロファイルのため、初回はログイン情報が引き継がれません。
  • ログイン画面に「このデバイスを記憶する」等のチェックボックスがあれば、必ずチェックを入れておきます。
  • パスキー認証(QRコードのみの画面)で進まない場合は、「Try another way/別の方法を確認」等の選択肢がないか探します。

④ Playwright接続確認

PlaywrightがこのChromeへ接続でき、ログイン済みのタブを認識できるかを確認します。connect_over_cdpが、Playwrightの新規ブラウザ起動用の関数ではなく「既存の起動済みブラウザへ接続する」ための関数である点がこの方式の核になります。

  • コード:check_connection.py
# check_connection.py
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp("http://localhost:9222")
    context = browser.contexts[0]

    print(f"開いているタブ数:{len(context.pages)}")
    for i, page in enumerate(context.pages):
        print(f"[{i}]{page.title()} -{page.url}")

対象ワークスペースのタブがタブ一覧に表示されればOKです。

⑤ Draft一覧の取得確認

Publishはまだ実行せず、Draft状態のPage一覧が正しく取得できるかのみを確認します。ここでの注意点は、一覧画面が<table>タグではなくrole="row"属性を持つdivベースの構造で実装されていることです。table tbody trのようなセレクタでは0件になります。[role='row']でヘッダー行(Name/Domain/State等を含む行)を検出し、その直後に続くDraft/Published行のみを対象にすることで、正しく一覧を取得できます。

・コード: list_draft_pages.py


from playwright.sync_api import sync_playwright

DRAFT_LIST_URL = "https://<your-workspace>.cloud.databricks.com/search/discover?q=showPages%3Atrue+state%3ADRAFT"

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp("http://localhost:9222")
    context = browser.contexts[0]
    page = context.pages[0]

    page.goto(DRAFT_LIST_URL)
    page.wait_for_timeout(3000)

    rows = page.locator("[role='row']")
    count = rows.count()
    print(f"role='row' の総数:{count}")

    # ヘッダー行を探す
    header_index = None
    for i in range(count):
        text = rows.nth(i).inner_text()
        if "Name" in text and "Domain" in text and "State" in text:
            header_index = i
            break

    print(f"ヘッダー行のインデックス:{header_index}")

    # ヘッダー以降の行からページ情報を取得
    page_infos = []
    if header_index is not None:
        for i in range(header_index + 1, count):
            text = rows.nth(i).inner_text()
            if "Draft" in text or "Published" in text:
                lines = text.split("\n")
                name = lines[0] if lines else ""
                link = rows.nth(i).locator("a").first
                href = link.get_attribute("href") if link.count() > 0 else None
                page_infos.append({"name": name, "href": href})
            else:
                break

    print(f"\n=== 取得結果:{len(page_infos)}件 ===")
    for info in page_infos:
        print(info)

⑥ 内容確認(人によるレビュー)

⑤で取得した一覧について、以下を目視で確認します。このステップは自動化しません。

  • 説明文が整備されているか
  • ドメイン・類義語に誤りがないか
  • 名前の重複がないか
  • 機密情報の混入がないか

レビューが完了した対象のみを、次のPublish実行の対象とします。

⑦ 一括Publish実行

レビュー完了後、以下のスクリプトで対象のDraftを順次Publishします。get_draft_page_listは⑤と同じロジックで一覧を再取得し、publish_oneが1件ごとにページ遷移→Publishボタンのクリックを行います。処理全体をtry/exceptで囲むことで、途中で1件失敗しても後続の処理を止めずに進められるようにし、1件ごとに成功/失敗と所要時間をpublish_log.jsonlへ記録します。

  • コード:publish_all_drafts.py
# publish_all_drafts.py
from playwright.sync_api import sync_playwright
import json, time
from datetime import datetime

BASE_URL = "https://<your-workspace>.cloud.databricks.com"
DRAFT_LIST_URL = f"{BASE_URL}/search/discover?q=showPages%3Atrue+state%3ADRAFT"
LOG_FILE = "publish_log.jsonl"


def append_log(entry):
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")


def get_draft_page_list(page):
    page.goto(DRAFT_LIST_URL)
    page.wait_for_selector("text=Draft", timeout=15000)
    page.wait_for_timeout(4000)

    rows = page.locator("[role='row']")
    count = rows.count()

    header_index = None
    for i in range(count):
        text = rows.nth(i).inner_text()
        if "Name" in text and "Domain" in text and "State" in text:
            header_index = i
            break

    page_infos = []
    if header_index is not None:
        for i in range(header_index + 1, count):
            text = rows.nth(i).inner_text()
            lines = text.split("\n")
            first_line = lines[0] if lines else ""
            if ("Draft" in text or "Published" in text) and first_line not in ("Name", "Domain", "#"):
                link = rows.nth(i).locator("a").first
                href = link.get_attribute("href") if link.count() > 0 else None
                if href:
                    full_url = href if href.startswith("http") else f"{BASE_URL}{href}"
                    page_infos.append({"name": first_line, "url": full_url})

    return page_infos


def publish_one(page, page_info):
    page.goto(page_info["url"])
    publish_button = page.get_by_role("button", name="Publish")
    publish_button.wait_for(state="visible", timeout=10000)
    publish_button.click()
    time.sleep(2)


def format_duration(seconds):
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    if h > 0:
        return f"{h}時間{m}分{s}秒"
    if m > 0:
        return f"{m}分{s}秒"
    return f"{s}秒"


def main():
    overall_start = time.time()

    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp("http://localhost:9222")
        context = browser.contexts[0]
        page = context.pages[0]

        list_start = time.time()
        draft_pages = get_draft_page_list(page)
        list_elapsed = time.time() - list_start
        print(f"対象件数:{len(draft_pages)}件(一覧取得:{format_duration(list_elapsed)})")

        if len(draft_pages) == 0:
            print("対象がありません。処理を終了します。")
            return

        success_count = 0
        fail_count = 0
        durations = []

        print("\n=== 全件のPublishを開始します ===")
        for i, info in enumerate(draft_pages, 1):
            item_start = time.time()
            try:
                print(f"[{i}/{len(draft_pages)}] 処理中:{info['name']}")
                publish_one(page, info)
                item_elapsed = time.time() - item_start
                durations.append(item_elapsed)
                success_count += 1
                print(f"  ✅ 成功({format_duration(item_elapsed)})")
                append_log({
                    "name": info["name"],
                    "url": info["url"],
                    "result": "success",
                    "duration_seconds": round(item_elapsed, 2),
                    "timestamp": datetime.now().isoformat(),
                })
            except Exception as e:
                item_elapsed = time.time() - item_start
                durations.append(item_elapsed)
                fail_count += 1
                print(f"  ❌ 失敗({format_duration(item_elapsed)}):{e}")
                append_log({
                    "name": info["name"],
                    "url": info["url"],
                    "result": "failure",
                    "error": str(e),
                    "duration_seconds": round(item_elapsed, 2),
                    "timestamp": datetime.now().isoformat(),
                })

            elapsed_so_far = time.time() - overall_start
            avg_per_item = sum(durations) / len(durations)
            remaining_items = len(draft_pages) - i
            estimated_remaining = avg_per_item * remaining_items
            print(f"  経過:{format_duration(elapsed_so_far)} / "
                  f"平均:{avg_per_item:.1f}秒/件 / "
                  f"残り見込み:{format_duration(estimated_remaining)}")

        overall_elapsed = time.time() - overall_start
        print(f"\n=== 結果 ===")
        print(f"成功:{success_count}件 / 失敗:{fail_count}件")
        print(f"合計所要時間:{format_duration(overall_elapsed)}")
        if durations:
            print(f"1件あたり平均:{sum(durations)/len(durations):.1f}秒")
            print(f"1件あたり最短:{min(durations):.1f}秒")
            print(f"1件あたり最長:{max(durations):.1f}秒")

        append_log({
            "type": "summary",
            "total": len(draft_pages),
            "success": success_count,
            "failure": fail_count,
            "overall_duration_seconds": round(overall_elapsed, 2),
            "avg_duration_seconds": round(sum(durations)/len(durations), 2) if durations else None,
            "timestamp": datetime.now().isoformat(),
        })


if __name__ == "__main__":
    main()

⑧ 結果ログの確認

実行後、publish_log.jsonlを確認します。1行ごとに1件分の実行結果(成功/失敗・所要時間・タイムスタンプ)がJSON形式で追記されており、最後の行には全体のサマリー(合計件数・成功数・失敗数・平均所要時間)が記録されます。このログをもとに、失敗した項目だけを再実行したり、規模を広げた際の所要時間を見積もったりできます。

  • コード:ログ確認コマンド cat publish_log.jsonl

実験結果

つまずいたポイントと対処

検証の過程で、Databricks固有ではない、ブラウザ・OS側の仕様変更に起因する問題にいくつか遭遇しました。

事象原因対処
デバッグ用ポートへの接続が失敗するChrome バージョン136以降、通常利用のデフォルトプロファイルでは--remote-debugging-portを指定してもポートが実際には開かない仕様変更があった--user-data-dirで専用プロファイルを明示指定して起動することで回避
新規プロファイルでのログイン時、パスキー認証(QRコード)から進まないパスキー要求の可否はDatabricks側ではなく、Googleアカウント側の「デバイス信頼」判定に依存する。実績のない新規ブラウザは最も厳格な認証を要求される「Try another way/別の方法を確認」等、QRコード以外の認証手段を選択する
Chromeを毎回起動するたびに再ログインが必要強制終了(kill -9相当)によりCookie等が永続化される前にプロセスが落ちていたOS標準の正常終了コマンドに切り替える

いずれも「Playwrightの使い方」自体の問題ではなく、Chromeのセキュリティ強化やOSの権限設計に起因するものでした。ドキュメント化しておくことで、他の担当者が同じ問題に当たった際の解決時間を大きく短縮できます。

実行結果

環境構築と手動ログインの完了後、レビュー済みの複数件のDraftを対象に一括Publishスクリプトを実行しました。

項目結果
対象件数18件
成功18件
失敗0件
合計所要時間1分6秒
1件あたり平均3.3秒
1件あたり最短3.1秒
1件あたり最長3.8秒

18件全てが成功し、失敗は0件でした。1件あたりの所要時間は平均3.3秒で、そのほとんどはクリック後の反映待ち(スクリプト内に設けた固定の待機時間)によるものであり、実際のページ遷移・クリック操作自体は1秒未満で完了しています。

この速度がそのまま維持されると仮定すると、件数ごとの概算所要時間は次のようになります。

  • 100件:約5.5分
  • 1,000件:約55分
  • 2,000件:約1時間50分

ただし、今回の検証は合計実行時間が1分程度と短く、長時間の連続実行時に発生しうるセッション切れ(再MFA要求)や、サーバー側のレート制限による遅延・失敗については未検証です。数百〜数千件規模での実運用を見据える場合は、まず100件程度のバッチで安定性を確認するステップを挟むことを推奨します。


結論

Unity Catalog PagesにPublish用の公式APIが存在しない状況でも、Playwrightによるブラウザ自動化と「人間がログイン済みのブラウザに後乗りする」方式を組み合わせることで、公開作業を実用的な速度・成功率で自動化できることを確認しました。今回の検証規模は小規模ではありますが、全件成功という結果は、この方式が技術的に成立することを示しています。

一方で、今回の検証で得られた知見は以下の3点に集約されます。

  1. 障害の大半はDatabricks固有ではなく、Chrome・OSレベルのセキュリティ仕様変更に起因していた。 特にChrome 136以降のリモートデバッグポートの仕様変更は、事前に知らなければ原因特定に時間がかかるポイントであり、手順として残す価値が高い。
  2. 認証をコード側で肩代わりしない設計が、安全性と監査性の両面で有効だった。 ログインは常に人間が行い、自動化するのは「クリック」だけに限定したことで、パスワードやトークンの管理という別の課題を持ち込まずに済んだ。
  3. 公開可否の判断(レビュー)を自動化の対象から明確に外したことが、運用上の安心材料になった。 自動化はあくまで「レビュー済みのものを機械的に公開する」役割に限定し、内容の正しさの担保は人が行うという役割分担を保っている。

今後、数百〜数千件規模での本番運用を検討する場合は、長時間連続実行時の安定性検証(セッション維持・レート制限への耐性)が次の検証課題となります。また、この方式は各実行者のローカルPC上で完結する仕組みであるため、複数人・複数環境への展開を前提とするなら、環境構築手順の標準化が展開の鍵になります。

CTA
  • URLをコピーしました!
  • URLをコピーしました!
この記事を書いた人
目次