Bing Image Scraper for extracting full-size image URLs, thumbnails, titles and source pages from Bing image search. This repo has a free Bing image scraping script you can run right now, and a Bing image data API that returns real structured JSON.
Last updated: 2026-07-21. Working against Bing.com as of July 2026, and re-verified whenever Bing changes their markup.
The uncut responses behind every block on this page are committed in bing_image_scraper_api_data/, the images response captured 2026-07-21 and the survey, error and latency runs 2026-07-20, and each block states what was trimmed from it. Every code example calls the API and is runnable from bing_image_scraper_api_codes/.
This repo covers one endpoint in depth. For Bing organic search results, videos and recipes, see the Bing Scraper.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python bing_image_scraper_api_codes/images.py "red panda"Those three lines return this, live from Bing image search (one result of 12; the object is complete, all 7 fields verbatim, and the full sample is uncut):
{
"position": 1,
"id": "animalfactguide.com/wp-content/uploads/2020/12/red_panda_220-scaled.jpg",
"title": "Red Panda Facts for Kids | Red Pandas | Cute Red Panda Photos",
"image": "https://animalfactguide.com/wp-content/uploads/2020/12/red_panda_220-scaled.jpg",
"thumbnail": "https://ts2.mm.bing.net/th?id=OIP.s0Yw4WGhmTuvKmjOWEJHGwHaE6&pid=15.1",
"source_page": "https://animalfactguide.com/animal-facts/red-panda/",
"source": "animalfactguide.com"
}...multiplied by 12 images, 7 fields each, across 10 distinct source domains:
That is the whole point of this repo. The rest of this page is the free script, what actually costs you data on this surface, and the endpoint with its parameters and response.
- Free Bing Image Scraper
- Avoid getting blocked when scraping Bing images
- Bing Image Scraper API reference
- Build an image dataset with source attribution
- Measured latency
Bing server-renders its image grid into plain <a class="iusc"> anchors, so you can extract image data from Bing without a headless browser or JavaScript rendering. No key, no cost:
pip install requests beautifulsoup4
python free_scraper/bing_image_free_scraper.py "red panda"Source: free_scraper/bing_image_free_scraper.py. Each anchor carries an m attribute holding a JSON blob per image, with its inner quotes HTML-entity-escaped, so the parser decodes the entities and then reads murl (the full-size image), turl (Bing's thumbnail), purl (the page it was found on) and t (the title).
After running the command, your terminal should look something like this:
It works. Run against Bing.com on 2026-07-20 across 25 sequential requests on rotating queries, every one came back with images, in 20.5 seconds total, with no bot check, no challenge page and no rate limiting.
What is not stable is how much you get. The next section is about that, and about a trap that catches most people coming to this surface from Bing's web results.
Mostly, you do not get blocked. What costs you data here is subtler, and the first one is genuinely counterintuitive if you have scraped Bing's web SERP before.
Sending a realistic Chrome User-Agent does nothing to the image grid. On Bing's web results it is the single most expensive mistake you can make: spoofing a modern browser gets you the client-side-rendered SERP and zero results, because a real browser is expected to run the JavaScript. The image grid does not behave that way. Same query, same machine, same minute, 4 requests per cell:
| What we measured (q="red panda", n=4 each) | /images/search |
/search (web) |
|---|---|---|
| HTTP status, no browser UA | 200 | 200 |
| Images or organic blocks, no browser UA | 35 images parsed | 10 li.b_algo blocks |
| HTTP status, Chrome UA | 200 | 200 |
| Images or organic blocks, Chrome UA | 35 images parsed | 0 li.b_algo blocks |
| Response size, Chrome UA | 467 KB | 73 to 74 KB |
Reproduce it yourself in ten seconds: python free_scraper/bing_image_free_scraper.py "red panda" --chrome-ua
Sizes are approximate because Bing's page weight drifts by a few hundred bytes per request. This is worth knowing in both directions: carry a browser UA over from a Bing web scraper and the image grid still works, but carry the image scraper's assumptions back to /search and you get an HTTP 200 with nothing in it.
The row count is not fixed, on either path. This is the thing that actually breaks image pipelines, because it fails quietly:
Across 25 free-script requests at count=50 the yield ran from 1 image to 48, and 5 of those 25 requests returned a single image. Across the 6 API calls made at the same count=50 the smallest response was 20 rows. Either way, results_count is the number to read rather than the number you asked for.
Here is the rest of what bites you, and what each one costs:
| What bites you | Why | What it costs you |
|---|---|---|
| Yield swings per request | Bing decides how much of the grid to server-render, and it varies request to request on the same query. | A job that assumes 50 images silently ships a batch of 1. Read results_count, never the number you asked for. |
The m blob is double-encoded |
The per-image JSON sits in an HTML attribute with its inner quotes as ". Parse it raw and json.loads throws. |
A parser that works on one Bing build and returns [] on the next. |
thumbnail and image are different hosts |
thumbnail points at Bing's own CDN, image at the publisher. |
Store the thumbnail and your dataset rots when Bing expires the cache. |
source_page can contain literal spaces |
Bing emits the source URL as it found it, spaces and all, e.g. .../search/baby golden retriever/. |
A fetch on that URL fails until you run it through urllib.parse.quote. |
| The SERP binds to your egress IP | Bing localises to the country your request exits from. | Results you cannot compare across machines, CI runners or teammates. |
| The markup moves | iusc and the m payload shape change periodically. Your parser silently returns []. |
Ongoing maintenance, plus alerting smart enough to tell "empty" from "broken". |
| There is no official API to fall back on | Microsoft retired the Bing Search APIs. See below. | When your scraper breaks there is no supported paid escape hatch to switch to. |
The official API is gone. From Microsoft's lifecycle announcement, dated 2025-05-15:
"Bing Search APIs will be retired on August 11, 2025. Any existing instances of Bing Search APIs will be decommissioned completely, and the product will no longer be available to be used or new customer signup."
Source: Bing Search APIs Retiring on August 11, 2025, Microsoft Learn. The announcement retires the Bing Search APIs as a product family and does not carve out individual APIs. In the same passage Microsoft directs customers to Grounding with Bing Search as part of Azure AI Agents, which "allows Azure AI Agents to incorporate real-time public web data when generating responses with an LLM". That is a different product: it feeds web context into an LLM answer rather than handing you image URLs as rows you can store, filter and download.
The managed option, and the one this repo is built around. The Chocodata Bing Image Scraper API returns Bing's image grid as parsed JSON for image data extraction at scale, with the m payloads already decoded and no proxy management. Free for the first 1,000 requests.
What it adds over the script above:
- Parsed rows, not attribute soup. The
mblob decoding, the entity handling, the de-duplication by image URL and the host extraction all happen upstream, so what arrives is 7 clean fields per image. - It runs from anywhere. Your CI runner does not need its own clean IP supply, and keeping the request shape working as Bing's grid moves is our on-call rather than yours.
- Concurrency. Fan out across queries against a documented rate limit instead of pacing a single client by hand.
Chocodata's platform benchmark is a ~99% success rate; the measured numbers for this endpoint are in Measured latency.
Below is the Bing Image Scraper API reference to get you started: authentication, the parameters, errors and rate limits, then the endpoint with its response.
curl "https://api.chocodata.com/api/v1/bing/images?api_key=YOUR_KEY&q=red%20panda&count=20"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/bing/images",
params={"api_key": "YOUR_KEY", "q": "red panda", "count": 20},
timeout=90,
)
top = r.json()["results"][0]
print(top["source"], top["image"])
# animalfactguide.com https://animalfactguide.com/wp-content/uploads/2020/12/red_panda_220-scaled.jpgAfter running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
q |
string | yes | - | Search keywords. Must be at least 1 character. |
count |
int (1-200) | no | 50 |
Upper bound on rows. Bing decides how much of the grid it server-renders, so this caps the list rather than guaranteeing it. |
country |
string (ISO-2) | no | us |
Selects the Bing market to query, from the 35 markets Bing actually serves: ar at au be br ca ch cl cn de dk es fi fr gb hk id in it jp kr mx my nl no nz ph pl ru sa se tr tw us za. A code Bing has no market for is a 400 naming the supported list, not a silently different result set (pt is the one that catches people out: Bing ships pt-BR but no Portugal market). Defaults to us. |
safe_search |
strict / moderate / off |
no | strict |
SafeSearch level. strict also drops any row Bing itself flagged explicit and reports how many in filtered_count. moderate maps to Bing's demote, which ranks adult results down rather than removing them, so only strict carries a filtering guarantee. |
first |
int (>=1) | no | 1 |
Result offset, echoed back as page. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
A required param is missing or out of range. Body lists the exact path and constraint. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade at chocodata.com. |
429 |
RATE_LIMITED |
Over the rate limit or your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
extraction_failed |
Bing did not return a grid we could parse for this request. | no | Retry. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.
The scripts in this repo turn each documented status into an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/bing/images?api_key=totally_invalid_key_123&q=red%20panda"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A count above the maximum, verbatim. It names the exact field and the constraint it violated, which is why the scripts can print 400 invalid_params: count: Number must be less than or equal to 200 instead of a traceback:
{"error":"invalid_params","issues":[{"code":"too_big","maximum":200,"type":"number","inclusive":true,"exact":false,"message":"Number must be less than or equal to 200","path":["count"]}]}Two limits apply at once, and the one that binds first is usually the rate limit.
Rate limit: 120 requests per 60 seconds, per key (sliding window). That is a hard ceiling of 2 requests/second sustained, whatever your plan.
Concurrency: how many requests you may have in flight at once, which varies by plan:
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed either and you get 429, not a queue. Every request is a synchronous GET: there is no webhook, callback, or async job to poll. Bing is a fast target here (a ~1.9s median), but the tail runs to 7s, which is why the examples use timeout=90.
Sizing: at the 2 requests/second ceiling, 1,000 queries is about 8.3 minutes of pulling. Keep your pool at or under your plan's concurrency and pace it to the rate limit:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(q):
r = requests.get("https://api.chocodata.com/api/v1/bing/images",
params={"api_key": KEY, "q": q, "count": 20}, timeout=90)
return q, (r.json().get("results", []) if r.ok else [])
queries = ["red panda", "bonsai tree", "northern lights", "coral reef"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for q, results in pool.map(one, queries):
print(q, len(results))Bing image search results, with the full-size image URL on the publisher's own host, Bing's thumbnail, and the page each image was found on.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
q |
string | yes | - | Search keywords. |
count |
int (1-200) | no | 50 |
Upper bound on rows. |
country |
string (ISO-2) | no | us |
Bing market to query. Unsupported codes return 400 naming the supported list. |
safe_search |
strict / moderate / off |
no | strict |
SafeSearch level. strict also drops rows Bing flagged explicit (see filtered_count). |
first |
int (>=1) | no | 1 |
Result offset, echoed back as page. |
curl "https://api.chocodata.com/api/v1/bing/images?api_key=YOUR_KEY&q=red%20panda&count=20"Real response. results is cut to 1 of 12; the result object is complete, all 7 fields verbatim (full sample):
{
"query": "red panda",
"page": 1,
"results_count": 12,
"total_results": 12,
"safe_search": "strict",
"filtered_count": 0,
"safe_search_blocked": false,
"results": [
{
"position": 1,
"id": "animalfactguide.com/wp-content/uploads/2020/12/red_panda_220-scaled.jpg",
"title": "Red Panda Facts for Kids | Red Pandas | Cute Red Panda Photos",
"image": "https://animalfactguide.com/wp-content/uploads/2020/12/red_panda_220-scaled.jpg",
"thumbnail": "https://ts2.mm.bing.net/th?id=OIP.s0Yw4WGhmTuvKmjOWEJHGwHaE6&pid=15.1",
"source_page": "https://animalfactguide.com/animal-facts/red-panda/",
"source": "animalfactguide.com"
}
]
}image is the field most people come for, and it is the one the thumbnail grid cannot give you: it is the full-size original on the publisher's own host, while thumbnail points at Bing's CDN and will rot when that cache expires. source_page is where the image was found and source is that page's host, already parsed, which is what makes attribution and domain filtering a one-liner rather than a URL-parsing chore. id is the image's content path, host plus path, lowercased, on all 12 rows of the committed sample, which makes it a usable de-duplication key when you merge results across queries.
Three response fields describe the SafeSearch pass rather than the images:
safe_searchechoes the level actually applied, so a caller can prove what was in force.filtered_countis how many rows were dropped because Bing itself flagged them explicit. It is0on an ordinary query like this one. A non-zero value means Bing served adult rows despite being asked not to, which is worth alerting on rather than swallowing.safe_search_blockedistruewhen Bing withheld the whole result set on content grounds. It lets you tell "this query has no images" from "this query was refused", which are very different things when you are filling a catalogue. When it istruethe call is still a success with an emptyresults, not an error.
Two things to build against, both measured on 2026-07-20:
countis an upper bound. Atcount=10all 6 test queries returned exactly 10. Atcount=20five returned 20 and one returned 12. Atcount=50the same six queries returned 20, 30, 35, 35, 35 and 48. If your pipeline needs exactly N images, request more than N and slice. The full run is committed asrow_count_survey.json.- Field completeness held across the survey. All 7 fields are populated on all 12 rows of the committed sample, and across the 375 rows returned by that 18-call survey none arrived without a
source_page.
first is accepted and echoed back as page. Build against the first grid rather than treating it as an offset into a stable result set, because the grid Bing renders varies per request.
Runnable: bing_image_scraper_api_codes/images.py
Collecting images for a training set or a moodboard is the main reason people scrape image search, so that use case is in the repo end to end rather than as a snippet. image_dataset.py pulls the grid for each query, keeps one row per distinct image URL, and writes a CSV manifest that carries the source page and host next to every image:
python bing_image_scraper_api_codes/image_dataset.py "red panda" "bonsai tree"That is a real run: 40 unique images across 2 queries, 14 and 15 source domains respectively. Add --download to fetch the image bytes as well; without it you get the manifest, which is the part that matters.
The manifest columns are query, position, title, image, thumbnail, source_page, source, file. Keeping source_page and source beside every row is the whole point: an image URL on its own tells you nothing about where it came from or who to credit, and that context is impossible to reconstruct later. Filter on source to keep a dataset to hosts you have cleared, and de-duplicate on image across queries.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20. This includes the upstream fetch and the parse:
| Endpoint | Median | Range | n | Success |
|---|---|---|---|---|
/bing/images |
1.9s | 0.9 to 7.0s | 8 | 8/8 |
Read the range, not just the median: the slowest call in that set took more than seven times the fastest, which is why the examples use a generous timeout rather than the median. Small sample (n=8) from a single day; reproduce it with the scripts here. The full run is committed as latency.json.
MIT. See LICENSE.








