動静ダブル特典

動的 IP50GB毎に +10GB 無料

静的 IP30日毎に +7日 延長 & 500MB 贈呈

今すぐ購入 >

セキュリティ警告最近、フィッシングサイト(rola-ip.com)が当社の旧公式サイトを装っています。ROLA IP の公式サイトは rola-ip.co のみです(ドメイン末尾が .co であることをご確認ください)。安全のため、他のドメインでは決済しないでください。

Bingスクレイピング入門|PythonとRola IPで検索結果を取得

Marcus Bennett

2026年10月12日 · ガイド · 約12分で読める

Bingの検索結果をスクレイピングすると、検索上位に表示されるページのタイトル、URL、スニペットを一覧にできます。PythonのRequestsとBeautifulSoupを使い、取得したHTMLから自然検索の結果を抽出してCSVとJSONに保存する方法を紹介します。SEOの定点観測や競合ページの調査など、同じ検索語の結果を比較したい場面で使える手順です。

この記事のポイント: 1件の自然検索結果を1行として扱い、広告やサイトリンクは分けます。ページ送りでは重複を確認し、検索語・取得時刻・取得元URLも保存します。地域別に比較するときは、言語設定と接続元IPの条件をそろえます。

コード例の検索語は「Python ドキュメント」、言語設定はja-JPです。掲載画面はpython documentationの英語表示を使った操作例で、検索結果の構造や確認する項目を示しています。

Bingの検索結果から取得するデータを決める

検索結果ページ(SERP)は、自然検索、広告、関連検索、Copilotの回答などから構成されます。すべてのリンクをまとめて抽出すると、検索順位を比較しにくくなります。まずは自然検索のタイトルリンクと説明文に対象を絞りましょう。

保存する項目 内容 分析するときの使い方
query 検索語 同じ語句の結果を比較する
page・position_on_page 取得したページ番号とページ内の抽出順 ページをまたぐ順位とは区別する
title・url タイトルとリンク先URL ページの特定、競合調査に使う
snippet Bingが表示する説明文 空欄になる場合もある
raw_href HTML内のリンク属性そのもの リダイレクトURLの確認に使う
source_url・fetched_at_utc 実際の応答URLと記録時刻(UTC) 取得条件や結果を後から確認する

タイトルの下にある「Tutorial」などのサイトリンクは、親の結果と同列に数えません。また、検索画面に表示される概算件数は、そのままダウンロードできる件数ではありません。

Bingの自然検索結果に表示されるタイトルとスニペット

「Sponsored」は広告の表示です。後述のコードではli.b_algoを対象にすることで、ページ内の見出しやリンクを無差別に収集することを避けます。

Sponsoredの表示で広告と自然検索結果を見分ける例

取得前にMicrosoftのサービス規約と利用条件を確認してください。コードはアクセス制限や確認画面を検出した場合に停止する構成です。

Pythonの実行環境を準備する

Python 3.10以降を使用します。macOSやLinuxでは、作業フォルダーを作って次のコマンドを実行します。

mkdir bing-scraper
cd bing-scraper
python3 -m venv .venv
source .venv/bin/activate
python -m pip install requests beautifulsoup4

WindowsのPowerShellでは、仮想環境内のPythonを直接指定できます。次の方法なら有効化スクリプトの実行ポリシーを変更する必要はありません。

mkdir bing-scraper
cd bing-scraper
py -m venv .venv
.\.venv\Scripts\python.exe -m pip install requests beautifulsoup4

以降のpythonコマンドも、Windowsでは.\.venv\Scripts\python.exeに読み替えてください。

JupyterLabで処理を確認する場合は、同じ環境に追加でインストールします。通常のPythonファイルとして実行する場合は不要です。

python -m pip install jupyterlab
python -m jupyterlab

バージョンを調べるには、ターミナルで次のコマンドを実行します。ノートブックでは、ライブラリをインストールした仮想環境と同じカーネルを選びます。

python --version
python -m pip show requests beautifulsoup4

JupyterLabでPythonと依存ライブラリのバージョンを確認する例

HTMLのどこに検索結果があるかを確認する

自然検索と広告のコンテナを区別する

ブラウザーでBingを開き、「Python ドキュメント」を検索します。Chromeではタイトルを右クリックして「検証」を選び、Elementsパネルでリンクのa、見出しのh2、外側のliを順に確認します。

今回のパーサーは、自然検索のコンテナli.b_algo内にあるh2 a[href]を読み取ります。次の画像で選択されているのは広告のタイトルで、DOMにもb_adが見えます。h2 aだけで検索すると広告も含まれるため、外側のコンテナまで確認するのがポイントです。

Bingの広告タイトルを検証した例:Sponsoredとb_adのコンテナ

ブラウザーのDOMと応答HTMLを見比べる

NetworkパネルでDocを選び、ページを再読み込みします。searchリクエストのResponseを開き、b_algoや実際のタイトルを探してください。Elementsはブラウザーが構築したDOM、Responseはそのリクエストで返された内容です。詳しい操作はChrome DevToolsのNetworkパネルの説明で確認できます。

Pythonが受け取るHTMLは、ブラウザーの表示と一致するとは限りません。HTTPステータス、応答先のドメイン、自然検索の件数を確認し、セレクターが実際の応答に合っているかを調べます。

応答ステータス・ドメイン・自然検索の件数を確認する例

応答HTML内のタイトルリンクとスニペットの要素

タイトル・URL・スニペットを抽出する仕組み

タイトルはh2 aのテキスト、URLはそのhrefから取得します。画面上の表示URLは省略される場合があるため、リンク先の完全なアドレスとしては使いません。

Bingのリンクには/ck/a形式のリダイレクトが含まれることがあります。コードではuパラメーターが認識できるa1形式の場合だけデコードし、元の属性値はraw_hrefに残します。解析できない場合はBingのURLを保持し、リンク先のサイトにはアクセスしません。

説明文は.b_caption pなどから抽出し、見つからなければ空文字にします。スニペットがなくても、タイトルとリンクがある結果は保存します。スニペットはBingの表示文であり、リンク先ページの全文やmeta descriptionと常に一致するものではありません。

タイトル・リンク先URL・スニペットを抽出した表示例

ページ送りとCSV保存の考え方

次ページのリンクをたどる

first=11、first=21のようにURLを組み立てるより、受け取ったHTMLのa.sb_pagN[href]を読み取る方が、ページに含まれる検索条件を引き継ぎやすくなります。

Bing検索結果の下部にある次ページへのボタン

次ページでも前のページと同じ結果が返ることがあります。コードはURLの並びが完全に同じページを検出して停止します。個々のURLが別のページに再登場した場合は、その出現位置を残します。サイト一覧を作る場合に限って、取得後にURL単位で重複を整理してください。

同じ検索結果ページの繰り返しを検出する例

日本語を扱いやすい形式で保存する

CSVはUTF-8 BOM付きで保存し、Excelで日本語を読み込みやすくします。=や@などで始まる文字列には先頭にアポストロフィを付け、数式として扱われるのを防ぎます。元の文字列はJSONに保存します。

ページ内順位は、抽出できた自然検索の順番です。1ページ10件とは限らないため、「2ページ目の先頭=11位」とは決めつけず、pageとposition_on_pageを分けて記録します。

Bing検索結果を取得するPythonコード

次のコード全体をbing_scraper.pyという名前で保存してください。必要な関数と実行処理をすべて含んでいます。プロキシは--proxyを指定した場合だけ有効になります。

初期設定は1スレッド、最大3ページ、ページ間の待機は5秒です。この待機時間は例示値で、Bingの許可された取得頻度を示すものではありません。接続10秒・読み取り30秒のタイムアウトを設定し、403・429では自動再試行せず停止します。

import argparse
import base64
import csv
import getpass
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit

import requests
from bs4 import BeautifulSoup


def bing_url(url):
    p = urlsplit(url)
    return (p.scheme == "https" and p.hostname in
            {"www.bing.com", "cn.bing.com", "bing.com"}
            and p.path == "/search" and not p.username and p.port in (None, 443))

def fetch(session, url):
    # Validate every redirect before sending the next request.
    for _ in range(5):
        if not bing_url(url):
            raise RuntimeError("Unexpected search URL; stopped.")
        r = session.get(url, timeout=(10, 30), allow_redirects=False)
        if r.is_redirect:
            url = urljoin(r.url, r.headers["Location"])
            continue
        if r.status_code == 429:
            retry_after = r.headers.get("Retry-After", "not provided")
            raise RuntimeError(f"HTTP 429; Retry-After: {retry_after}; stopped.")
        if r.status_code == 403:
            raise RuntimeError(f"HTTP {r.status_code}; stopped.")
        r.raise_for_status()
        if "text/html" not in r.headers.get("Content-Type", ""):
            raise RuntimeError("Expected HTML; stopped.")
        r.encoding = "utf-8"
        return r
    raise RuntimeError("Too many redirects; stopped.")

def destination(href, base_url):
    url = urljoin(base_url, href)
    p = urlsplit(url)
    if p.hostname in {"www.bing.com", "cn.bing.com", "bing.com"}:
        if p.path == "/ck/a":
            value = parse_qs(p.query).get("u", [""])[0]
            if value.startswith("a1"):
                try:
                    payload = value[2:]
                    decoded = base64.urlsafe_b64decode(
                        payload + "=" * (-len(payload) % 4)
                    ).decode("utf-8")
                    if urlsplit(decoded).scheme in {"http", "https"}:
                        return decoded
                except (ValueError, UnicodeError):
                    pass
    return url if p.scheme in {"http", "https"} else ""

def parse_page(html, page_url, query, page_number):
    soup = BeautifulSoup(html, "html.parser")
    if soup.select_one('#b_captcha, iframe[src*="captcha"]'):
        raise RuntimeError("Verification page; stopped.")
    rows = []
    stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
    for item in soup.select("li.b_algo"):
        a = item.select_one("h2 a[href]")
        if a is None or not a.get_text(strip=True):
            continue
        url = destination(a["href"], page_url)
        if not url:
            continue
        snippet = item.select_one(".b_caption p")
        if snippet is None:
            snippet = item.select_one("p.b_lineclamp2, p.b_lineclamp3")
        rows.append({
            "query": query, "page": page_number,
            "position_on_page": len(rows) + 1,
            "title": a.get_text(" ", strip=True),
            "url": url,
            "snippet": snippet.get_text(" ", strip=True) if snippet else "",
            "raw_href": a["href"],
            "source_url": page_url, "fetched_at_utc": stamp,
        })
    if not rows:
        raise RuntimeError("No organic rows; inspect HTML, do not assume end.")
    link = soup.select_one("a.sb_pagN[href]")
    next_url = urljoin(page_url, link["href"]) if link else None
    if next_url and not bing_url(next_url):
        raise RuntimeError("Unexpected next-page URL; stopped.")
    return rows, next_url

def excel_safe(value):
    if isinstance(value, str):
        if value.lstrip().startswith(("=", "+", "-", "@")):
            return "'" + value
        if value.startswith(("\t", "\r", "\n")):
            return "'" + value
    return value

def save_rows(rows, folder):
    if not rows:
        raise RuntimeError("No data to export.")
    folder = Path(folder)
    folder.mkdir(parents=True, exist_ok=True)
    (folder / "results.json").write_text(
        json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
    with (folder / "results.csv").open(
            "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows({k: excel_safe(v) for k, v in r.items()} for r in rows)

def configure_proxy(session):
    scheme = input("Proxy scheme (http/https/socks5h): ").strip()
    host = input("Proxy host only: ").strip()
    port = int(input("Proxy port: "))
    if scheme not in {"http", "https", "socks5h"}:
        raise ValueError("Unsupported scheme.")
    if not host or any(c.isspace() or c in "/@?#:[]" for c in host):
        raise ValueError("Enter only the proxy hostname or IPv4 address.")
    if not 1 <= port <= 65535:
        raise ValueError("Invalid port.")
    user = getpass.getpass("Proxy username (hidden): ")
    password = getpass.getpass("Proxy password (hidden): ")
    auth = quote(user, safe="") + ":" + quote(password, safe="")
    proxy = f"{scheme}://{auth}@{host}:{port}"
    session.proxies.update({"http": proxy, "https": proxy})

def scrape(query, max_pages=2, proxy=False):
    if not 1 <= max_pages <= 3:
        raise ValueError("This example supports 1 to 3 pages.")
    folder = Path("runs") / datetime.now().strftime("%Y%m%d-%H%M%S-%f")
    folder.mkdir(parents=True)
    session = requests.Session()
    session.trust_env = False
    session.headers["Accept-Language"] = "ja-JP,ja;q=0.9"
    url = "https://www.bing.com/search?" + urlencode(
        {"q": query, "setlang": "ja-JP"})
    all_rows, seen_pages, seen_signatures = [], set(), set()
    try:
        if proxy:
            configure_proxy(session)
        for page in range(1, max_pages + 1):
            if url in seen_pages:
                break
            seen_pages.add(url)
            r = fetch(session, url)
            (folder / f"page-{page}.html").write_text(r.text, encoding="utf-8")
            rows, next_url = parse_page(r.text, r.url, query, page)
            signature = tuple(row["url"] for row in rows)
            if signature in seen_signatures:
                print("Repeated page; stopped.")
                break
            seen_signatures.add(signature)
            all_rows.extend(rows)
            print(f"Page {page}: HTTP {r.status_code}, {len(rows)} organic rows")
            print("Response host:", urlsplit(r.url).hostname)
            if not next_url or page == max_pages:
                break
            url = next_url
            time.sleep(5)
    finally:
        session.close()
        if all_rows:
            save_rows(all_rows, folder)
            print(f"Saved {len(all_rows)} rows to {folder}")
    return all_rows, folder

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("query")
    parser.add_argument("--pages", type=int, default=2)
    parser.add_argument("--proxy", action="store_true")
    args = parser.parse_args()
    try:
        scrape(args.query, args.pages, args.proxy)
    except (requests.RequestException, RuntimeError, ValueError) as error:
        # Never print proxy exception details, which can contain credentials.
        message = str(error) if not isinstance(error, requests.RequestException) else type(error).__name__
        raise SystemExit("Stopped: " + message)

実行してCSVを確認する

まず1ページで動作を確認します。

python bing_scraper.py "Python ドキュメント" --pages 1

続けて2ページまで取得する場合は、--pages 2を指定します。WindowsのPowerShellでは次のように実行できます。

.\.venv\Scripts\python.exe bing_scraper.py "Python ドキュメント" --pages 2

runsの下に日時付きのフォルダーが作られ、取得できたページのHTMLと、1件以上解析できた場合のresults.json・results.csvが保存されます。後続ページの取得でエラーが発生した場合も、それまでに抽出した結果の保存を試みます。実際に出力されたCSVの行数と、ターミナルのエラーメッセージを確認してください。

CSVの最初の3件について、タイトル、URL、スニペットが同じフォルダーのHTMLと対応しているか確認してください。JSONでは日本語がそのまま読めること、CSVでは列が崩れていないことも確認します。

JupyterLabでCSVの列とレコードを確認する例

Excelで文字化けする場合は「データ」→「テキストまたはCSVから」でUTF-8を指定して読み込みます。.xlsxが必要なら、その後でExcelブックとして保存します。

日本語の検索語でも、結果がすべて日本語になるとは限りません。setlang=ja-JPとAccept-Languageは言語に関する設定で、日本のIPアドレスを指定する設定ではありません。海外向けページを比較する場合も、検索語・言語・接続元の地域を組として固定すると結果を比較しやすくなります。

Rola IPを使う場合の設定

比較の目的に合わせてプロキシを選ぶ

同じ地域から定期的に確認するなら、Rola IPの静的住宅用プロキシが選択肢になります。複数地域の比較には、住宅用プロキシの利用可能な地域とセッション設定を確認します。

仕組みから知りたい場合は、住宅用プロキシとは何かを解説した記事が参考になります。

Rola IPの静的住宅用プロキシ製品ページの表示例

IPをリクエストごとに変えると、ページ間で検索条件が変わる可能性があります。ひとつの検索語を複数ページ取得するときは、比較したい地域とセッションをそろえましょう。プロキシは接続経路を提供するもので、検索順位の一致やアクセス成功を保証する機能ではありません。

管理画面の値を入力する

利用中の製品画面から、接続先ホスト、ポート、認証情報、必要な地域・セッション設定を取得します。ホスト欄にはホスト名またはIPv4アドレスだけを入力し、ポートは別に指定します。このコードはユーザー名・パスワード認証を使い、入力した認証情報は画面に表示しません。

地域やセッションの指定方法はプロキシの設定パラメーター、接続コードの設定はPythonでの接続方法で確認できます。

python bing_scraper.py "Python ドキュメント" --pages 1 --proxy

HTTPプロキシのポートならhttpを選びます。BingがHTTPSでも、HTTPプロキシのCONNECTトンネルを利用できるため、プロキシURLまでhttpsにする必要はありません。httpsはプロキシ自身とのTLS接続に対応したポートで使います。

SOCKS5を使う場合は、先に次をインストールし、入力時にsocks5hを選びます。末尾のhは、接続先の名前解決をプロキシ側で行う指定です。

python -m pip install "requests[socks]"

session.trust_env=Falseにより環境変数のプロキシ設定を使わず、コードで設定した接続先を使用します。環境変数のCA設定も自動では取り込まれないため、社内ネットワークで独自CAが必要な場合はsession.verifyにCAファイルを指定します。詳しくはRequestsのプロキシ設定を参照してください。

取得できない場合の確認ポイント

症状 確認する内容 対応
HTTP 200なのに結果が0件 保存HTMLが検索結果か、確認画面か HTML内のタイトルとセレクターを照合する
HTTP 403 対象サービスのアクセス条件 停止し、利用条件やリクエスト内容を確認する
HTTP 429 表示されたRetry-Afterとリクエスト頻度 自動再試行せず停止。値は秒数またはHTTP日時として確認する
HTTP 407・ProxyError プロトコル、ポート、認証情報 管理画面の接続情報と照合する
Timeout 回線、接続先、プロキシの応答 まず1ページで接続状況を切り分ける
2ページ目が1ページ目と同じ 次ページURLと結果URLの順序 重複ページで止め、保存HTMLを比較する
cn.bing.comへ移動する 応答URL、接続地域、言語設定 実際の応答先を記録する
ブラウザーとPythonで結果が異なる cookies、言語、接続元、取得時刻 同じ条件の取得同士を比較する
Excelで文字化けする 読み込み時の文字コード UTF-8を指定してCSVをインポートする

次ページリンクが見つからない場合は、その応答から次に進めないという意味です。検索結果をすべて取り終えたことまでは示しません。また、異なる並びのページや部分的な重複は、完全一致による検出の対象外です。

HTML解析とBing SERP APIはどう使い分ける?

少数の検索語で項目やページ構造を確認するなら、RequestsとBeautifulSoupによるHTML解析が扱いやすい方法です。大量のキーワード、広告枠、デバイス別結果などを定期的に扱うなら、構造化データを返すSERP APIも比較対象になります。料金だけでなく、対象地域、取得項目、タスクの失敗判定、データの利用条件も確認します。

Microsoftの従来のBing Search APIsは、公式発表によると2025年8月11日に終了しました。現在の第三者サービスとは提供主体が異なります。たとえばDataForSEOのBing SERP APIには即時取得とキューを使う方式があり、HTTPステータスに加えてタスクの成否とorganicの種別を確認する必要があります。

画像検索も扱うなら、公式APIを使ったNaver画像検索のスクレイピング手順も参考になります。検索サービスごとに、必要なデータを取得できるインターフェースを選びましょう。

まとめ

Bingの検索結果をPythonで取得するには、自然検索のコンテナを特定し、タイトル・URL・スニペットを取り出します。ページ送りではURLの重複を確認し、HTMLと取得条件を残してCSVを照合できる状態にすることが大切です。地域を固定した比較にはRola IPを組み合わせ、まず1ページのデータを確認してから対象を広げてください。

よくある質問