Googleマップスクレイパーのドキュメント。 クエリからエクスポートまで。

初めての検索から、繰り返し使えるエージェントワークフローまで。 手順、範囲、API契約はこちらをご覧ください。

このページの内容
  1. 解凍した拡張機能をインストールする
  2. 実際のタスクを1つ作成します。
  3. エンリッチメントを保存済みリストに書き戻します。
  4. ビジネスのホームページを検査する
  5. REST API
  6. 制限付きジョブを作成する
  7. よくあるエラー
  8. AI検索の回答を検査する
  9. エリアを検索して追加のページを取得
  10. ポリゴン内の調査を計画する
  11. 保持されたページチェーンをエクスポートします。
  12. 引用された証拠に基づいてクチコミテーマを分析します。
  13. クライアントレポートを準備して共有する
  14. 保持PDFを生成する
  15. 署名付きジョブレシートを受信します
  16. 結果を Google スプレッドシート、Zapier、Make、n8n に送信
  17. 重要な境界線

解凍した拡張機能をインストールする

Google Maps scraper 拡張機能は、ワークスペースの API に接続します。Chrome・Edge のストアでの配布は承認されていません。

  1. 拡張機能パッケージをダウンロードして解凍します。
  2. Chromeでは、chrome://extensionsを開きます。 Edgeでは、edge://extensionsを開きます。 開発者モードを有効にし、「パッケージ化されていないファイルを読み込む」を選択して、解凍したフォルダを選択します。
  3. 拡張機能を開き、「MapsScraper.dev で接続」を選びます。開いたページでサインインすると、ワークスペースの所有者または管理者が承認し、拡張機能はこのブラウザーセッション専用のキーを受け取ります。代わりに、「API とキー」で作成した「制限付きタスクの読み取りと実行」権限のあるワークスペース API キーを貼り付けることもできます。
  4. 言語を選び、現在の Maps 検索を使い、レコード数とユニットの上限を設定して実行します。見積もりがユニット上限内の場合にのみ検索が送信されます。
  5. レスポンスが失われた場合は、「元の送信を復元」を使用してください。 セッションが終了した場合は、再送信する前にタスク履歴を確認してください。
解凍済み拡張機能ZIPをダウンロード
の 定冠詞 あの その というもの OpenAPI 3.1契約 機能ディレクトリ 各ワークスペースページについて。

実際のタスクを1つ作成します。

  1. ワークスペースを作成し、「ビジネスを探す」を開きます。
  2. 場所を指定してクエリを入力します。例:「テキサス州オースティンの歯科医」。
  3. レコード数の上限とオプションのフィルターを選択します。 フィルターは取得後に適用されます。
  4. 最大ユニット使用量を確認します。 データエンリッチメントにより、予約上限が増加します。
  5. ソースを実行し、検査し、ビジネスを選択してリストに保存するか、CSVにエクスポートします。

エンリッチメントを保存済みリストに書き戻します。

「保存したリスト」で、ソースIDを持つレコードを選択し、「選択した項目を拡張」を最大ユニット予算で承認します。 「バッチ書き戻しを確認」で新しいバッチが開きます。保留中のタスクは、後のプレビューまでスキップされたままになります。 リストツールバーから、既存の詳細バッチまたはエンリッチメントバッチを選択することもできます。

要求された正確なIDをプレビューし、前後の比較をそれぞれ展開します。 空のフィールドを埋めると、既存の値が保持されます。 選択したフィールドを置き換えると、選択した空でないソースフィールドのみがコピーされます。 スキップされたリストで、重複、不一致、期限切れ、または停止されたタスクを確認します。 適用する前に、表示されている変更されたレコード数とスキップされたタスク数を確認します。 変更されたレコード、ソース、または構成には、新しいプレビューが必要です。

対象となるすべての更新がまとめてコミットされます。 ステージ、メモ、タグ、割り当て、およびカスタムフィールドはそのまま保持されます。 バッチ書き戻し履歴には元の値とレコードごとの取り消しが表示されます。 後続の編集は復元をブロックします。 ワークスペース設定で有効化とレコードの上限を制御できます。 保持された結果を書き戻しても、調査単位は使用されず、データソースも呼び出されません。 未解決のインポートでは、引き続き個別の「照合とマージ」が使用されます。

エージェントは、preview_batch_merge、apply_reviewed_batch_merge、およびread_batch_merge_receiptを、同じレビューハッシュ、スコープ、および冪等性の要件で使用します。 ソースレコードから書き込み権限を推測しないでください。

ビジネスのホームページを検査する

開く Webサイトの監査または、保存済みのレコードから「このWebサイトを検査する」を選択します。 正規化されたホームページを確認し、1単位を見積もって承認します。 完了したHTTP/HTMLの観測を検査し、エクスポートして、レポートを作成するか、定期的な監視を設定します。 一括リサーチでは、1行に1つのWebサイトを指定することもできます。

ホームページのチェックでは、ライブマップのデータソースを使用しません。 最大256 KiBのデータを読み込み、最大5回の安全なリダイレクトをたどり、15秒後に停止します。 JavaScriptは実行せず、ランキングスコアも計算しません。 ブロックまたはタイムアウトした観測でも、1ユニットを消費します。

{"kind":"website_audit","url":"https://example.com/","limit":1}

REST API

GET      /api/health
GET      /api/me
GET      /api/v1/usage
POST     /api/v1/estimate
POST     /api/v1/jobs
GET      /api/v1/jobs/{id}
GET      /api/v1/jobs/{id}/results
POST     /api/v1/jobs/{id}/cancel
GET      /api/v1/jobs/{id}/export
GET/POST /api/v1/lists
GET/POST /api/v1/lists/{id}/items
PATCH    /api/v1/lists/{id}/items/{business_id}
GET/POST /api/monitors
PATCH    /api/monitors/{id}
POST     /mcp

制限付きジョブを作成する

curl -X POST https://mapsscraper.dev/api/v1/jobs \
  -H "Authorization: Bearer $MAPSSCRAPER_API_KEY" \
  -H "Idempotency-Key: dentists-austin-tx-001" \
  -H "X-Max-Units: 1" \
  -H "Content-Type: application/json" \
  -d '{"kind":"search","query":"Dentists in Austin, TX","limit":20,"region":"us"}'

HTTP/1.1 202 Accepted
{"id":"6f1c2b3a-0000-4000-8000-000000000000","status":"queued","reserved":1}

同じ冪等性キーを使って同じ業務操作を再試行します。そのキーで内容を変えたリクエストは 409 を返します。ジョブは、キュー待ち、実行中、完了、一部完了、失敗、キャンセル、不確定のいずれかの状態を経ます。データソースへのリクエストが不確定な場合は、自動の有料再実行ではなく照合が必要です。

ほとんどのタスクは 1 ユニットを使います。検索または順位スナップショットで連絡先を補完すると、要求したレコードごとに 1 ユニットが追加で予約され、実際に届いたレコード分だけが課金されます。ビジネス詳細は 1 ユニット、連絡先付きなら 2 ユニット、補完は 2 ユニットです。X-Max-Units は見積もり以上に設定してください。

Pythonでは、タスクの見積もりを取得し、requestsライブラリでジョブを作成して、最終ステータスを待ってから保持された結果を読み取ります:

import os
import time
import uuid

import requests

API = "https://mapsscraper.dev/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['MAPSSCRAPER_API_KEY']}"}
task = {"kind": "search", "query": "Dentists in Austin, TX", "limit": 20, "region": "us"}

# An estimate reserves nothing.
units = requests.post(f"{API}/estimate", json=task, headers=HEADERS, timeout=30).json()["units"]

# Keep the key: a retry of the same task must send it again.
key = str(uuid.uuid4())
job = requests.post(
    f"{API}/jobs",
    json=task,
    headers={**HEADERS, "Idempotency-Key": key, "X-Max-Units": str(units)},
    timeout=30,
)
job.raise_for_status()
job_id = job.json()["id"]

while True:
    status = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS, timeout=30).json()["status"]
    if status not in ("queued", "running"):
        break
    time.sleep(5)

if status in ("completed", "partial"):
    page = requests.get(
        f"{API}/jobs/{job_id}/results",
        params={"offset": 0, "limit": 100},
        headers=HEADERS,
        timeout=30,
    ).json()
    for business in page["items"]:  # follow page["next_offset"] for more
        print(business.get("name"), business.get("phone_number"), business.get("website"))

よくあるエラー

すべてのエラー応答は安定したコードを含む JSON で、一部には詳細も含まれます。コードで処理を分岐してください。複数のコードが同じ HTTP ステータスを共有することがあります。

{"error":{"code":"TASK_BUDGET_TOO_LOW","details":{"needed":11,"max_units":5}}}
HTTPステータスエラーコード詳細
400VALIDATION_ERRORINVALID_JSONINVALID_TASKIDEMPOTENCY_KEY_REQUIRED本文、ヘッダー、またはパラメーターがないか無効です。再試行する前にリクエストを修正してください。
400TASK_BUDGET_REQUIREDX-Max-Units がないか、1 から 10000 までの整数ではありません。
401UNAUTHORIZEDサインインするか、有効なキーを使用してください。
403INSUFFICIENT_SCOPEWORKSPACE_ACCESS_REVOKEDキーまたはメンバーシップでは、この操作が許可されていません。
404NOT_FOUNDこのリソースはこのワークスペースに存在しません。
405METHOD_NOT_ALLOWEDMCP は POST リクエストのみ受け付けます。GET と DELETE には Allow: POST ヘッダー付きでこのコードが返されます。
409IDEMPOTENCY_CONFLICT変更されたリクエストに対して冪等性キーが再利用されました。
409JOB_NOT_CANCELLABLEタスクはすでにデータソースに送信済みか完了しているため、キャンセルできません。
details.status
410RESULT_EXPIRED保持されていた結果の期限が切れました。もう一度取得するには新しいタスクを実行してください。
410WORKSPACE_UNAVAILABLEこのワークスペースは消去中です。このワークスペースへのリクエストは拒否されます。
422TASK_BUDGET_TOO_LOW見積もりが X-Max-Units の予算を超えています。
details.needed, details.max_units
422INSUFFICIENT_BALANCE利用可能なユニットがタスクの上限を下回っています。
details.needed, details.available
422DAILY_BUDGET_EXCEEDEDこのタスクはワークスペースの 1 日あたりのユニット上限を超えます。
details.needed, details.daily_limit, details.used_today
422PENDING_LIMIT_REACHEDキュー待ちまたは実行中のタスクがすでに多すぎます。
details.pending, details.max_pending
422WORKSPACE_LIMIT_OR_BALANCE_EXCEEDED利用可能なユニットまたはワークスペースのタスク上限が不足しています。
423, 409WORKSPACE_DELETION_PENDINGこのワークスペースは削除前の猶予期間中です。変更は 423 で拒否されますが、読み取り、エクスポート、アカウント操作、読み取り専用の MCP ツールは引き続き使えます。このコードまたは WORKSPACE_NOT_ACTIVE を伴う 409 は、タスクの受け付け中に削除が始まった場合にのみ返されます。
429RATE_LIMITEDレート制限に達しました。Retry-After に示された秒数だけ待ってから再試行してください。
429RESEARCH_DAILY_LIMIT_REACHED本日のライブ調査の上限に達しました。Retry-After に示された秒数(次の UTC 日まで)待ってください。何も予約されません。
500INTERNAL_ERROR予期しないエラーが発生しました。後で同じ冪等性キーを使って再試行してください。
503PROVIDER_NOT_CONFIGUREDこのワークスペースにはライブ調査が開放されていないか、タスクのデータソースが設定されていません。

AI検索の回答を検査する

AI検索可視性では、Google AI概要の観測項目(質問、国、言語、オプションの都市バイアス、回答テキスト、安全な引用リンク)が1つ保持されます。 最大5つのブランド名とドメインを追加し、1ユニットの上限を確認してから、タスクを明示的に承認してください。 管理者はワークスペースを有効にし、ライセンスされたソースを接続する必要があります。

データソースを再度呼び出すことなく、履歴を検査したり、証拠をエクスポートしたりできます。 クエリ、ロケール、ターゲットが一致する場合にのみ、ベースラインを比較します。 概要が欠落している場合は、可視性が不明です。 引用順序は検索順位ではありません。 証拠が不完全な場合は、否定一致を確立できません。 マルチエンジン追跡は利用できません。

決まった質問を継続して追跡するには、確認済みのオーナーまたは管理者が定期シリーズをスケジュールできます。プロンプトは最大 10 件、間隔は 24〜720 時間、90 日以内に最大 30 回の観測です。シリーズ全体の最大ユニットを確認し、明示的に承認してください。承認や設定が変わった場合、または観測が失敗・不確定になった場合、シリーズは一時停止します。再開するには新しいプレビューが必要です。

Open AI検索可視性

エリアを検索して追加のページを取得

Exploreで、テキストの位置、ポイントバイアス、半径、またはバウンディングボックスを選択します。 ポイントバイアスは境界ではありません。 半径とボックスの結果では、外側または使用できない座標が除外され、除外数が保持されます。 ワークスペースの設定で半径とボックスのサイズを制御できます。 対蹠線を横切るボックスを分割してください。 境界付きサンプルでは完全なカバレッジは証明されません。

クチコミと写真メタデータでは、ソースページごとに最大100件のレコードを処理できます。 結果が完了したら、Exploreまたはタスク履歴で結果ページを開きます。 正確なソースオプションを確認し、承認チェックボックスをオンにして、予算を設定してさらに1ページを取得します。 ソート、言語、翻訳、フィルターはチェーン全体で固定されます。 カーソルの欠落または重複、有効期限切れ、設定された制限、不確実な結果が発生すると、処理は中断されます。

次の保存済みレコードページを読み取り、既存のページをエクスポートする場合、データソースは呼び出されません。 新しいソースページの取得は、別途料金が発生する操作です。 SDKとエージェントの場合、継続プレビューを読み、安定した冪等性キーと明示的な予算を指定して、正確なタスクを送信してください。 カーソルの継続をスケジュールまたはバッチ処理しないでください。

ポリゴン内の調査を計画する

探索でポリゴン境界を選択すると、正確なポリゴンフィルタリングで境界付き矩形のリクエストが1つ実行されます。 一括リサーチでは、ポリゴン境界を選択してGeoJSONポリゴンをインポートまたは貼り付け、その形状を確認し、セルごとの行、列、レコードを選択します。 この図は座標と計画セルを示しています。 道路地図ではありません。

位置は[経度、緯度]を使用します。 各リングは最初の位置を繰り返して閉じます。 最初のリングは外縁で、最大4つの追加のリングは除外された穴です。 自己交差、接触または重なり合う穴、および対蹠線交差は拒否されます。 日付変更線をまたぐ場合は、別々のポリゴンを使用します。 ワークスペース設定では、境界位置、スパン、および計画セルが設定されます。

「セルと 総予算」はデータソースを呼び出しません。 保持されたすべてのセルを確認し、最大単位を設定して、実行前に明示的に確認します。 すべてのタスクはまとめて予約されるか、またはどれも受け入れられません。 領収書には、元の計画と構成バージョンが保持されます。 ソース結果はポリゴンとセル両方に対してフィルタリングされます。 共有エッジによって重複するビジネスが生成される場合があります。 要求されたレコード数は上限であり、全数ではありません。

エージェントは、preview_polygon_planに続いてcreate_reviewed_polygon_batchを、まったく同じコンパクトプラン、プレビューハッシュ、確認、安定キー、および集計予算で使用します。 既存のバッチ履歴、キャンセル、およびタスクごとの結果/エクスポート制御を使用します。 不確実な結果は自動的に再スキャンされません。

保持されたページチェーンをエクスポートします。

結果ページで [複数ページのエクスポート] を開き、[完全なチェーンのエクスポートを確認] を選択します。 選択したタスクを通してページを確認し、各正確な ID に対して最初のレコードを保持するか、すべてのレコードを保持するかを選択してから、プライベートエクスポートを確認します。 この操作では、新しいデータは取得されず、調査単位も使用されません。 エクスポートは、進捗状況、キャンセル、および整合性の領収書とともにデータ制御にも表示されます。

完了後、JSONL、JSON、CSV、またはExcel形式でダウンロードできます。 内容が変更された重複IDは競合としてカウントされ、最初のレコードが保持されます。 IDのないレコードは別々に保存されます。 各ソースページには検証済みのJSONLパートがそれぞれ存在するため、転送が失敗した場合でもパートごとに再開できます。 Excelは最大5MBの保存パートをサポートし、それより大きいサイズのエクスポートにはストリーミング形式が使用されます。 CSVはextra_fields_jsonに追加の列を保持します。

オーナーと管理者は、設定で日々のリクエスト数、保留中のエクスポート数、ページとソースのバイト制限、および保持期間を設定します。 ソースの有効期限は常に最も早いものが適用されます。 JSONまたはJSONLを使用する際は完全なマーカーを保持し、ダウンロードした部分をSHA-256レシートと照合して検証してください。 凍結されたチェーンは保持されたサンプルであり、市場全体のカバレッジを証明するものではありません。

引用された証拠に基づいてクチコミテーマを分析します。

レポートでクチコミ統計レポートから始めます。 オーナーまたは管理者は、設定と使用状況でAIクチコミ分析を有効にし、日々の試行回数、保留中のタスク、抜粋数、キューの有効期間を設定します。 デフォルトでは無効になっています。

1回の生成を承認する前に、「分析入力の確認」を選択して、正確な抜粋とモデルを検査します。 レポート順で最大40個の異なる抜粋と10KBの抜粋テキストが選択されます。 1回の生成には独自のAI分析の上限があり、マップウォレットユニットは使用しません。 失敗またはキャンセルされた試行もカウントされます。

保存された各テーマには、チェック済みの引用と出典参照が含まれます。 カウントは、引用された個別の抜粋から計算されます。テーマは重複する可能性があり、AI の解釈が間違っている場合もあります。 結果は別のレポートとして開きます。レポートで確認し、共有または PDF としてエクスポートする内容を選択してください。 「クチコミの抜粋」列を非表示にすると、AI 分析も非表示になります。

サポート対象の顧客体験が含まれていない抜粋は、レポートなしで証拠不十分のレシートを返す場合があります。 試行は制限にカウントされます。 別の分析を承認する前に、入力内容を確認してください。 機能のアップグレードにより、推論前にキューに入っている古い承認が停止されます。 新しいプレビューを確認してください。 既存のレポートは、元のモデルとプロンプトのバージョンを保持します。

レシートには、キューに入っている、実行中、完了、失敗、不確実、キャンセル済み、期限切れの結果が表示されます。 キャンセルするとレポートの作成は行われませんが、ディスパッチされた分析は引き続き制限にカウントされます。 不確実な呼び出しは自動的に再再生されません。 API、SDK、MCPは、同じプレビューハッシュ、明示的な確認、および冪等性キーを使用します。

クライアントレポートを準備して共有する

レポートで完了した調査からレポートを作成します。 タイトル、ブランド、セクションの順序、証拠列を編集し、プレゼンテーションを保存します。 ワークスペース設定では、デフォルトのブランドカラー、境界付きPNGロゴ、発行元Webサイト、フッターが提供されます。 既存の保存済みプレゼンテーションは、編集またはワークスペースのデフォルト設定を適用した場合にのみ変更されます。

レビュー済みレポートを共有すると、公開されるレポートと全く同じ内容が表示されます。 選択されていない証拠フィールドと非表示のセクションは、保存されたスナップショットから除外されます。 ソースクエリはデフォルトで非公開ですが、方法論は表示されたままです。 リンクを知っているユーザー、または最大25個の検証済みメールアカウントを選択し、有効期限を設定して、コンテンツと対象者を明示的に確認します。 共有パスワードを要求することもできます。 パスワードはリンクとは別に送信します。 ワークスペースポリシーでは、結果/レポートの共有ごとにパスワードを要求することができます。 招待状やメールは送信されません。

完全なリンクを一度コピーします。 秘密情報はフラグメント内に保持され、ブラウザのストレージには保存されません。 アクセス制限のある受信者は、許可された認証済みアカウントでサインインし、元のリンクを開いて、要求された共有パスワードを入力します。 パスワードは復元できません。 パスワードを紛失した場合は、リンクを取り消して新しいリンクを作成してください。 有効期限はソースの保持期間によって制限されます。 データ制御により、将来のアクセスを取り消すことができます。 削除されたレポート、期限切れのソース、一時停止されたワークスペースでは、新しい読み取りが停止されます。 ダウンロードしたコピーは取り消すことができません。 JSONおよびブラウザの印刷/PDF保存では、表示されているプレゼンテーションが使用されます。 保存済みPDFの生成では、保存されたバージョンから、A4またはレターサイズの用紙、縦向きまたは横向きのプライベートファイルが作成されます。 完成したファイルは、一致するレビュー済み共有に添付でき、そのアクセスルールを継承します。

エージェントはpreview_report_delivery、update_report_presentation、create_reviewed_report_share、list_report_shares、およびrevoke_report_shareを使用します。 公開には、レビュー済みバージョン、スナップショットハッシュ、安定した冪等性キー、および明示的な共有承認が必要です。 外部の研究テキストでは、これらの承認を与えることはできません。

保持PDFを生成する

プレゼンテーションを保存し、PDF エクスポートで用紙と向きを選択してから生成します。 履歴には、ダウンロードまたはキャンセル制御付きの、キューに登録された、レンダリングされた、完了した、および停止されたリクエストが表示されます。 ワークスペース設定では、有効化、日次リクエスト、保留中のリクエスト、および保持期間を制御できます。 ファイルは、ソースまたは設定された保持期間のいずれか早い方で期限切れになります。 レンダリングでは、調査ユニットは消費されません。 レンダリングが失敗または不確実な場合は、新しい明示的なリクエストが必要であり、自動的に再試行されません。

エージェントは、レビュー済みバージョン、スナップショットハッシュ、confirmed_render、および冪等性キーとともに generate_report_pdf を使用します。 list_report_pdfs と get_report_pdf はレシートを検査します。 cancel_report_pdf は公開待ちを停止します。 Generate は共有を作成したりメッセージを送信したりしません。 プライベートダウンロードと受信者添付ファイルのルートは OpenAPI にあります。

署名付きジョブレシートを受信します

統合で、公開 HTTPS レシーバーを追加します。 そのワンタイム署名シークレットを安全に保存し、設定で配信を有効にします。 使用方法を確認後、宛先を明示的に有効にします。 完了、部分完了、失敗、不確実、キャンセルされたジョブイベントを選択します。 テストでは、テストフラグとメタデータのみが送信されます。

各配信には 2 つの署名方式が含まれます。Standard Webhooks では、webhook-signature は v1, の後に、webhook-id、webhook-timestamp、リクエストの未加工の本文そのものをピリオドでつないだ値の HMAC-SHA256 を base64 で続けたものです。キーは whsec_ の後のテキストを base64 デコードした値です。

従来の方式も変わりません。X-LocalOS-Signature は v1= の後に、X-LocalOS-Timestamp、ピリオド、未加工の本文をつないだ値の 16 進 HMAC-SHA256 を続けたもので、キーは whsec_ シークレット全体をテキストとして使います。シークレットは X-LocalOS-Key-ID で選択してください。

どちらの方式でも、タイムスタンプが 5 分以内であることを確認し、署名は定数時間で比較してください。webhook-id と X-LocalOS-Event-ID のイベント ID は再試行の間も変わりません。処理を適用する前に一意に保存し、重複には 2xx 応答を返してください。

Node.js で Standard Webhooks の署名を検証する:

// Verifies a webhook delivery with Web Crypto only, so it runs on
// Node.js 20+, Deno, Bun and Cloudflare Workers without dependencies.
//   const event = await verifyWebhook({ secret, headers, body });
// secret: "whsec_..." or, while rotating, ["whsec_new", "whsec_old"].
// headers: a Headers object or a plain map such as req.headers.
// body: the raw body as text or bytes, before any JSON parsing.
// Returns the parsed event. Otherwise throws an error whose code says
// why the delivery must not be trusted.
export class WebhookVerificationError extends Error {
  constructor(code) {
    super(code);
    this.name = "WebhookVerificationError";
    this.code = code;
  }
}

const encoder = new TextEncoder();

function header(headers, name) {
  if (typeof headers.get === "function") {
    return headers.get(name) ?? undefined;
  }
  for (const [key, value] of Object.entries(headers)) {
    if (key.toLowerCase() === name) {
      return Array.isArray(value) ? value[0] : value;
    }
  }
  return undefined;
}

function base64(value) {
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4) {
    return null;
  }
  try {
    return Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
  } catch {
    return null;
  }
}

// Checks every byte, so the time taken does not reveal a mismatch.
function sameBytes(a, b) {
  if (a.length !== b.length) return false;
  let difference = 0;
  for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i];
  return difference === 0;
}

export async function verifyWebhook({
  secret,
  headers,
  body,
  toleranceSeconds = 300,
}) {
  if (!Number.isFinite(toleranceSeconds) || toleranceSeconds < 0) {
    throw new RangeError("toleranceSeconds must be 0 or more");
  }
  const fail = (code) => new WebhookVerificationError(code);
  const keys = [secret]
    .flat()
    .map((value) =>
      typeof value === "string" ? base64(value.replace(/^whsec_/, "")) : null,
    );
  if (!keys.length || keys.some((key) => !key?.length)) {
    throw fail("WEBHOOK_SECRET_INVALID");
  }
  const id = header(headers, "webhook-id");
  const timestamp = header(headers, "webhook-timestamp");
  const signatures = header(headers, "webhook-signature");
  if (!id || !timestamp || !signatures) {
    throw fail("WEBHOOK_HEADER_MISSING");
  }
  if (!/^\d{1,12}$/.test(timestamp)) {
    throw fail("WEBHOOK_TIMESTAMP_INVALID");
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    throw fail("WEBHOOK_TIMESTAMP_OUT_OF_RANGE");
  }
  const subtle = globalThis.crypto?.subtle;
  if (!subtle) throw fail("WEBHOOK_CRYPTO_UNAVAILABLE");
  const raw =
    typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
  const prefix = encoder.encode(`${id}.${timestamp}.`);
  const signed = new Uint8Array(prefix.length + raw.length);
  signed.set(prefix);
  signed.set(raw, prefix.length);
  const expected = await Promise.all(
    keys.map(async (key) => {
      const hmac = await subtle.importKey(
        "raw",
        key,
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["sign"],
      );
      return new Uint8Array(await subtle.sign("HMAC", hmac, signed));
    }),
  );
  // Any listed v1 signature may match; at most 16 are checked.
  const verified = signatures
    .split(" ")
    .filter(Boolean)
    .slice(0, 16)
    .some((entry) => {
      const [version, value] = entry.split(",", 2);
      const given = version === "v1" && value ? base64(value) : null;
      return given !== null && expected.some((d) => sameBytes(given, d));
    });
  if (!verified) throw fail("WEBHOOK_SIGNATURE_INVALID");
  try {
    return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
  } catch {
    throw fail("WEBHOOK_PAYLOAD_INVALID");
  }
}

配信は少なくとも 1 回行われます。ネットワーク障害、タイムアウト、2xx 以外のすべての応答は再試行されます。リダイレクトも同様で、リダイレクト先には決して従いません。最大 8 回の試行が約 28 時間にわたって行われます。間隔は 5 秒、5 分、30 分、2 時間、5 時間、その後 10 時間を 2 回で、受信側が Retry-After を返した場合はそれ以降 (最大 1 日) になります。410 Gone の応答があると、送信先は一時停止され、再開するまで保留中の配信はキャンセルされます。 試行とエラーは統合に表示されます。送信先を一時停止または削除すると、保留中の受領記録はキャンセルされます。すでに受け付けられたリクエストは 10 秒以内に完了する場合があります。保存される配信記録には、ビジネスレコード、検索語句、アカウントの認証情報、受信側の応答本文は含まれません。

結果を Google スプレッドシート、Zapier、Make、n8n に送信

これらのレシピでは、上記の署名付き Webhook と REST API のみを使用します。配信に含まれるのはタスク ID、ステータス、課金ユニット数、結果件数で、結果の行は含まれません。配信は合図としてのみ扱い、そこから data.job_id を取り出してから、読み取り専用キーを使って API 経由でタスクとその行を読み取ります。これらのリクエストは認証されているため、誰かが配信を偽造しても、行は常に自分のワークスペースから取得されます。

このキーは「API とキー」で「既存の結果の読み取り」アクセス権を付けて作成します。タスクとエクスポートは読み取れますが、有料のリサーチは開始できません。各レシピは各ツールの汎用的な Webhook、コード、HTTP リクエスト機能を使用します。MapsScraper.dev には、これらのツール向けの独自アプリはありません。

各レシピは、キーを Authorization: Bearer <key> として送る GET リクエストを 2 回行います。まず GET /api/v1/jobs/{job_id} を呼び出し、その status が completed または partial の場合にのみ、GET /api/v1/jobs/{job_id}/export で行を取得します。

配信の仕組み

  • 10 秒以内に任意の 2xx ステータスで応答し、時間のかかる処理はその後に行ってください。
  • 再試行では同じ webhook-id ヘッダーが使われるため、保存しておき、処理済みの配信はスキップしてください。
  • 失敗した配信は再試行されます。約 28 時間で最大 8 回、受信側が Retry-After を送った場合はそれより後になります。リダイレクトはたどりません。
  • 410 Gone の応答を受けると、送信先は一時停止され、保留中の配信はキャンセルされます。受信側を修正してから、再びオンにしてください。

n8n

すぐに応答するよう設定した Webhook node、キーを Header Auth 認証情報に入れた HTTP Request node をリクエストごとに 1 つ、その間にステータスを確認する IF node を使用します。このレシピは署名を検証せず、代わりに認証済みのリクエストに依拠します。

Zapier

Zapier の Catch Raw Hook トリガーは、解析されていないボディとヘッダーをそのまま渡すため、このレシピでは署名も検証します。JavaScript を実行する Code by Zapier ステップを追加し、Input Data で body に生のボディ、id、timestamp、signature にそれぞれ webhook-id、webhook-timestamp、webhook-signature ヘッダー、secret に署名シークレットを設定します。Zap を編集できる人は誰でもこのシークレットを見られます。

const crypto = require("crypto");
const { body, id, timestamp, signature, secret } = inputData;
const fresh =
  /^\d+$/.test(timestamp || "") &&
  Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const key = Buffer.from(
  String(secret).replace(/^whsec_/, ""),
  "base64",
);
const expected = crypto
  .createHmac("sha256", key)
  .update(`${id}.${timestamp}.${body}`)
  .digest();
const signed = String(signature || "")
  .split(" ")
  .some((entry) => {
    const [version, value = ""] = entry.split(",");
    const given = Buffer.from(value, "base64");
    return (
      version === "v1" &&
      given.length === expected.length &&
      crypto.timingSafeEqual(given, expected)
    );
  });
const valid = Boolean(id) && fresh && signed;
if (!valid) return { valid };
return { valid, job_id: JSON.parse(body).data.job_id };

valid が true の場合のみ処理を続けるフィルターステップを追加し、GET リクエストのステップで 2 つのリクエストを行います。Zapier は検証の成否にかかわらず配信に 200 で応答するため、検証に失敗した配信を止めるのはレスポンスではなくフィルターです。

Make

JSON ボディをフィールドに分解する Custom webhook モジュール、リクエストごとの HTTP モジュール、その間にステータスを確認するフィルターを使用します。Make は配信に自動で 200 を返します。n8n のレシピと同様に、このレシピも署名を検証せず、代わりに認証済みのリクエストに依拠します。

Google Sheets

Google Sheets はプッシュを受け取る代わりに、自ら行を取得します。スプレッドシートで Extensions、Apps Script の順に開き、このスクリプトを貼り付けます。Project Settings で、読み取り専用 API キーを保持する MAPSSCRAPER_API_KEY という名前のスクリプトプロパティを追加します。これにより、キーをセルやコードに含めずに済みます。

const API = "https://mapsscraper.dev/api/v1";

function getJson(path) {
  const key = PropertiesService.getScriptProperties()
    .getProperty("MAPSSCRAPER_API_KEY");
  if (!key) throw new Error("Add the MAPSSCRAPER_API_KEY script property.");
  const response = UrlFetchApp.fetch(API + path, {
    headers: { Authorization: "Bearer " + key },
    muteHttpExceptions: true,
  });
  const status = response.getResponseCode();
  const text = response.getContentText();
  if (status !== 200) {
    throw new Error("HTTP " + status + ": " + text.slice(0, 200));
  }
  return JSON.parse(text);
}

// Text that starts with =, +, - or @ gets an apostrophe,
// so Sheets keeps it as text instead of running a formula.
function cell(value) {
  if (value === null || value === undefined) return "";
  if (typeof value === "object") value = JSON.stringify(value);
  const formula = typeof value === "string" && /^[=+\-@]/.test(value);
  return formula ? "'" + value : value;
}

function importLatestSearch() {
  const jobs = getJson("/jobs?page_size=25&kind=search").items;
  const job = jobs.find(
    (item) => item.status === "completed" || item.status === "partial",
  );
  if (!job) return;
  const path = "/jobs/" + job.id + "/export?format=json";
  const rows = getJson(path).items || [];
  if (!rows.length) return;
  const columns = [];
  rows.forEach((row) => Object.keys(row).forEach((name) => {
    if (!columns.includes(name)) columns.push(name);
  }));
  const values = [columns].concat(
    rows.map((row) => columns.map((name) => cell(row[name]))),
  );
  const book = SpreadsheetApp.getActive();
  const sheet =
    book.getSheetByName("MapsScraper") || book.insertSheet("MapsScraper");
  sheet.clearContents();
  sheet.getRange(1, 1, values.length, columns.length).setValues(values);
}

importLatestSearch を実行すると、最新の完了済み検索が MapsScraper という名前のシートにコピーされます。定期的に更新するには、Triggers で importLatestSearch の時間主導型トリガーを追加します。Apps Script は 1 回の実行を 6 分で停止し、50 MB を超えるレスポンスは取得できないため、大きなタスクは GET /api/v1/jobs/{job_id}/results から 1 ページずつ読み取ってください。

重要な境界線

ウェブサイトがないということは、データソースがウェブサイトを提供していないことを意味します。 これは、企業がウェブサイトを持っていないことを証明するものではありません。 ランキングスナップショットは、1つのデータソースによる結果の順序付けであり、完全なローカルSEOグリッドではありません。 クチコミの収集は、AIが生成する感情分析レポートではありません。 写真はメタデータであり、ライセンス付き画像のダウンロードではありません。 繰り返し検索ではスナップショットが保持され、安定したIDが比較され、ワークスペースの受信トレイに通知が作成されます。 署名付きWebhookレシートはオプトイン方式で、タスクのメタデータのみが含まれます。 CRM固有のフィールド同期は別途行われます。