DEV Community

Coco
Coco

Posted on

How to scrape Bing in 2026?

Scraping Bing search results in Python: ranking position, title, link, displayed link, snippet and publication date for any query, using the Chocodata search endpoint. Every request below was executed on 27 July 2026 and the screenshots are that output.

Here is what the finished script prints:

query                        realpython.com
python web scraping          [2, 4]
python asyncio tutorial      [1]
fastapi tutorial             [5]

wrote bing_ranks.csv (15 rows)
9 of 15 results carried a date
Enter fullscreen mode Exit fullscreen mode

Those two counts move between runs, and that is a property of the live result page rather than a bug in the script. The measurement is in the failures section.

TL;DR

  • One GET returns four top-level fields and five organic results, each with seven fields of its own. No wrapper, so r.json()["organic_results"] is the whole access path.
  • position arrives pre-assigned, so you never count nodes and never inherit the off-by-one that ad and answer modules cause in the raw markup.
  • date was None on 6 of 15 results. Pass one to strptime and you get TypeError: strptime() argument 1 must be str, not None.
  • A domain can hold more than one position. realpython.com came back at 2 and 4 for the same query, so next() silently reports half the footprint.
  • Repeat calls are usually identical but not guaranteed: across 30 calls, 27 returned their query's modal top five and 3 returned a different set. Tested on Python 3.13.7 and requests 2.34.2, July 2026.

Why is it hard to scrape Bing?

Scraping Bing is hard because the result page is assembled per visitor, so the market and language inferred from the connection change both which results appear and their order. Ads, answer panels and video modules sit in the same container as organic results, which means node-counting in raw markup assigns positions that are quietly wrong. The layer that wastes the most time is the consent and verification interstitial served to automated clients, because it arrives shaped like a normal successful response rather than an error.

Prerequisites

To scrape Bing search results with the code below you need three things, and the first is free.

  1. A free Chocodata API key. Sign up, copy the key from the dashboard, and pass it as api_key on every request. Free to start, no card required.

Copying the free API key from the dashboard

  1. Python 3.9 or newer and requests. No browser driver, no proxy, no Microsoft account.
python -m venv .venv
pip install requests
Enter fullscreen mode Exit fullscreen mode
  1. The queries you want to track, for example python web scraping.

Tested on Python 3.13.7 with requests 2.34.2 in July 2026.

How to scrape Bing search results?

One GET to the search endpoint returns the ranked organic results as JSON, and the three steps below take it from a query string to a CSV of dated ranks.

1. Define the query

Bing search scraping takes the query itself as input, sent as q alongside your key, so there is no URL to construct beyond the endpoint.

import requests

BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"

url = f"{BASE}/bing/search"
params = {"q": "python web scraping", "api_key": API_KEY}

r = requests.get(url, params=params, timeout=60)
print(r.status_code, r.headers.get("content-type"))
Enter fullscreen mode Exit fullscreen mode
200 application/json
Enter fullscreen mode Exit fullscreen mode

The search request returning 200 and a JSON content type

2. Call the search endpoint and guard on the payload

The Bing search response has four top-level fields, so check for organic_results before you index into it rather than trusting the status code.

r.raise_for_status()
data = r.json()

if "organic_results" not in data:
    raise RuntimeError(f"no results in response for {params['q']!r}")

print(list(data.keys()))
print(data["query"], "|", data["engine"], "|",
      data["results_count"], "|", len(data["organic_results"]))
Enter fullscreen mode Exit fullscreen mode
['query', 'engine', 'results_count', 'organic_results']
python web scraping | bing | 5 | 5
Enter fullscreen mode Exit fullscreen mode

The four top-level fields and the five-result count printed

Indexing straight into a response that came back short gives you KeyError: 'organic_results' at whatever hour the job runs, which is why the guard is three lines up rather than in a wrapper you add later.

3. Extract position, link and date

Each organic result carries seven fields, and position, link and date are the three that make a row comparable across runs.

for item in data["organic_results"]:
    print(f"{item['position']:>2}  {item['date'] or '-':>12}  {item['link']}")
Enter fullscreen mode Exit fullscreen mode
 1   Dec 8, 2025  https://www.geeksforgeeks.org/python/python-web-scraping-tutorial/
 2   Dec 1, 2024  https://realpython.com/beautiful-soup-web-scraper-python/
 3  May 28, 2026  https://www.scrapingbee.com/blog/web-scraping-101-with-python/
 4  Dec 21, 2024  https://realpython.com/python-web-scraping-practical-introduction/
 5             -  https://github.com/luminati-io/Python-web-scraping
Enter fullscreen mode Exit fullscreen mode

Position, date and link printed as one line per result

The remaining fields are title, displayed_link, snippet and source. Note the or '-' on date, which is doing real work rather than cosmetic work, for the reason in the failures section. The same request works from any language with an HTTP client, so Node, Go and PHP need nothing beyond their standard library.

Compare rankings across a query set

Comparing Bing rankings across a query set is the same search call in a loop with a domain filter on top, and it is the only version of this that answers a question. One query gives a number. A set gives you which topics a domain owns.

def positions(data, domain):
    """Every position the domain holds, not just the best one."""
    return [i["position"] for i in data["organic_results"] if domain in i["link"]]


QUERIES = ["python web scraping", "python asyncio tutorial", "fastapi tutorial"]
DOMAIN = "realpython.com"

for q in QUERIES:
    resp = requests.get(f"{BASE}/bing/search",
                        params={"q": q, "api_key": API_KEY}, timeout=60)
    resp.raise_for_status()
    data = resp.json()
    hits = positions(data, DOMAIN)
    print(f"{q:28} {hits if hits else 'not in top 5'}")
Enter fullscreen mode Exit fullscreen mode
python web scraping          [2, 4]
python asyncio tutorial      [1]
fastapi tutorial             [5]
Enter fullscreen mode Exit fullscreen mode

Returning a list rather than a single rank is the design decision that matters here. The tracked domain holds two of the five slots on the first query, which a best-rank-only tracker would report as a flat #2 and lose half the picture.

Write every result to a dated CSV rather than only the matches, because the rows you did not care about this month are the competitor set you will want next month:

import csv
from datetime import date

FIELDS = ["captured", "query", "position", "link", "title", "date"]

def rows_for(data):
    captured = date.today().isoformat()
    return [{"captured": captured, "query": data["query"],
             "position": i["position"], "link": i["link"],
             "title": i["title"], "date": i.get("date")}
            for i in data["organic_results"]]
Enter fullscreen mode Exit fullscreen mode
captured,query,position,link,title,date
2026-07-27,python web scraping,1,https://www.geeksforgeeks.org/python/python-web-scraping-tutorial/,Python Web Scraping Tutorial - GeeksforGeeks,"Dec 8, 2025"
2026-07-27,python web scraping,2,https://realpython.com/beautiful-soup-web-scraper-python/,Beautiful Soup: Build a Web Scraper With Python,"Dec 1, 2024"
Enter fullscreen mode Exit fullscreen mode

The part that breaks

Four failures, all hit while writing this.

date is None on a lot of results. Nine of the fifteen rows in the run above carried a date and six did not, so any parser reached without a guard dies on the first undated result.

from datetime import datetime

resp = requests.get(f"{BASE}/bing/search",
                    params={"q": "python web scraping", "api_key": API_KEY}, timeout=60)
resp.raise_for_status()
results = resp.json()["organic_results"]

undated = [i for i in results if not i.get("date")]
print(len(undated), "of", len(results), "results carried no date")

# datetime.strptime(item["date"], "%b %d, %Y")
# TypeError: strptime() argument 1 must be str, not None


def parse_date(value):
    if not value:
        return None
    return datetime.strptime(value, "%b %d, %Y").date()


print([parse_date(i.get("date")) for i in results])
Enter fullscreen mode Exit fullscreen mode

next() hides a domain's second position. A domain can occupy more than one of the five slots, and the obvious one-liner reports only the first.

print(next((i["position"] for i in results if "realpython.com" in i["link"]), None))
# 2      <- correct, and incomplete

print([i["position"] for i in results if "realpython.com" in i["link"]])
# [2, 4]  <- the actual footprint
Enter fullscreen mode Exit fullscreen mode

Five results is the ceiling, so "absent" means "not in the top five". results_count was 5 on every query I sent. A domain at position six is indistinguishable from a domain that does not rank at all, which is fine for a comparison and wrong in a report that claims coverage.

Repeat calls are usually identical, not always. I sent five queries six times each, 30 calls in total. Twenty-seven returned their query's modal top five in the same order. Three returned a different set for the same query minutes apart, because the live page is assembled per request. Two of the five queries were stable across all six calls and three were not, so do not assume the query you tested is one of the stable ones.

assert [i["link"] for i in results] == EXPECTED_LINKS   # goes red eventually
assert len(results) == 5 and all(i["position"] for i in results)   # what to assert
Enter fullscreen mode Exit fullscreen mode

Pin the shape, not the contents, and let the stored captures carry the history.

Full script

"""Bing rank tracking across a query set via the Chocodata search endpoint.
Tested: Python 3.13.7, requests 2.34.2, 27 July 2026.
"""
import csv
import time
from datetime import date, datetime

import requests

BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
CSV_PATH = "bing_ranks.csv"

FIELDS = ["captured", "query", "position", "link", "title", "date"]


def search(query, timeout=60):
    """Return the parsed response for one query, or raise."""
    r = requests.get(f"{BASE}/bing/search",
                     params={"q": query, "api_key": API_KEY}, timeout=timeout)
    r.raise_for_status()
    data = r.json()
    if "organic_results" not in data:
        raise RuntimeError(f"no results in response for {query!r}")
    return data


def rows_for(data):
    """One dated row per organic result. .get() on date because it is often None."""
    captured = date.today().isoformat()
    return [{"captured": captured, "query": data["query"],
             "position": i["position"], "link": i["link"],
             "title": i["title"], "date": i.get("date")}
            for i in data["organic_results"]]


def positions(data, domain):
    """Every position the domain holds, not just the best one."""
    return [i["position"] for i in data["organic_results"] if domain in i["link"]]


def parse_date(value):
    """Bing dates look like 'Dec 8, 2025'. Many results carry none at all."""
    if not value:
        return None
    return datetime.strptime(value, "%b %d, %Y").date()


def track(queries, domain, path=CSV_PATH, delay=2.0):
    all_rows = []
    print(f"{'query':28} {domain}")
    for n, query in enumerate(queries):
        data = search(query)
        all_rows.extend(rows_for(data))
        hits = positions(data, domain)
        print(f"{query:28} {hits if hits else 'not in top 5'}")
        if n < len(queries) - 1:
            time.sleep(delay)

    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=FIELDS)
        w.writeheader()
        w.writerows(all_rows)

    print(f"\nwrote {path} ({len(all_rows)} rows)")
    return all_rows


if __name__ == "__main__":
    rows = track(["python web scraping", "python asyncio tutorial", "fastapi tutorial"],
                 "realpython.com")
    dated = [parse_date(r["date"]) for r in rows]
    print(f"{sum(d is not None for d in dated)} of {len(dated)} results carried a date")
Enter fullscreen mode Exit fullscreen mode

Switch open(path, "w") to open(path, "a") and drop the header write once you start running it on a schedule, since the value is entirely in the comparison between captures.

Summary

One GET to the search endpoint returns four top-level fields and the top five organic Bing results with position already assigned, which removes the node-counting step that puts an off-by-one into most hand-rolled parsers. What does not work is treating the response as complete or fixed, since five results is the ceiling, six of fifteen rows came back with no date, and two of sixteen repeat calls returned a different set for the same query. The one thing to carry away is to assert on the shape of the response and store dated captures, because the rank you fetched once is worth nothing next to the diff between two of them.

FAQ

Is scraping Bing search results legal?

Search results are public pages and the constraint is contractual rather than criminal, since Microsoft's terms restrict automated querying, and none of this is legal advice.

Why do my Bing results differ from what my browser shows?

Bing assembles the page per request using the market and language it infers from the connection, so the results reflect that inference rather than your own browser session.

How many results does one Bing request return?

Five organic results, reported in results_count, which is why a domain at position six reads as absent rather than ranked.


Top comments (0)