Skip to content

ChocoData-com/bing-videos-scraper

Repository files navigation

Bing Videos Scraper

Bing Videos Scraper

Bing Videos Scraper for extracting video titles, destination links, durations, channels, view counts and publish dates from Bing video search. This repo has a free Bing videos script you can run right now, and a Bing video data API that returns real structured JSON.

Last updated: 2026-07-20. 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_videos_api_data/, captured 2026-07-20, and each block states what was trimmed from it. Every code example calls the API and is runnable from bing_videos_api_codes/.

This repo covers one endpoint in depth. For Bing organic search results, images 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_videos_api_codes/videos.py "laptop"

Those three lines return this, live from Bing.com (results cut to 1 of 18; the result object is complete, all 9 fields verbatim, and the full sample is uncut):

{
  "query": "laptop",
  "results_count": 18,
  "results": [
    {
      "position": 1,
      "title": "$1000 to $4000 Laptop - Snapdragon Edition",
      "link": "https://www.youtube.com/watch?v=s9igt2i91q0",
      "thumbnail": "https://th.bing.com/th/id/OVF.nGgnP3jNqITkWJgZaUWNRw?w=196&h=110&c=7&rs=1&qlt=70&o=7&pid=2.1&rm=3",
      "duration": "0:51",
      "source": "YouTube",
      "channel": "Linus Tech Tips",
      "published": "6 days ago",
      "views": "110.8K views"
    }
  ]
}

...multiplied by 18 rows, with all 9 fields populated on every one of them:

Retrieved Bing video results

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.


Contents


Free Bing Videos Scraper

Bing server-renders its video results into div.mc_vtvc cards, and each card carries an mmeta attribute holding a small JSON blob with the real destination URL. So you can extract video titles, links, durations, channels and view counts from Bing without a headless browser or JavaScript rendering. No key, no cost:

pip install requests beautifulsoup4
python free_scraper/bing_videos_free_scraper.py "laptop" --n 4

Source: free_scraper/bing_videos_free_scraper.py. It walks the mc_vtvc cards, decodes each card's mmeta JSON for the destination URL, and emits position, title, link, thumbnail, duration, source, channel, published, views.

It works. Run against bing.com on 2026-07-20 it returned video cards on 8 of 8 fetches, in 407 to 440 KB pages, with no bot check, no challenge page and no rate limiting.

Free Bing videos scraper, both User-Agents

So the next section is not about a wall. It is about the two things that do cost you data here, one of which is the exact opposite of what scraping Bing teaches you.

Avoid getting blocked when scraping Bing videos

Mostly, you do not get blocked. What bites you on this surface is subtler, and the first one is genuinely counterintuitive if you have scraped Bing's web results before.

Sending a realistic Chrome User-Agent does nothing here, and that is worth knowing precisely because it is not true next door. On Bing's web SERP a browser User-Agent gets you the client-side-rendered shell and zero organic results. On the video surface the same request returns the same cards it always did. Both measured on the same machine, on the same day, with the same market pinning:

Surface, q="laptop", n=6 each No User-Agent Chrome 124 User-Agent
/search, li.b_algo blocks 10 on 6 of 6 0 on 6 of 6
/search, page size 115,230 to 118,419 bytes 73,879 to 74,796 bytes
/videos/search, div.mc_vtvc cards 51 on 6 of 6 51 on 6 of 6
/videos/search, page size 407,854 to 414,368 bytes 413,833 to 414,350 bytes

The Chrome User-Agent trap does not reach the video surface

Reproduce it yourself in ten seconds: python free_scraper/bing_videos_free_scraper.py "laptop" --chrome-ua --n 4, and compare with the same command without the flag. Every run behind that table is in user_agent_survey.json.

The 51 is the raw count of mc_vtvc elements in the DOM, which is what makes it a clean before-and-after signal. It is not the number of distinct videos: the free script above de-duplicates by destination URL and reports 24 for the same page, and the API returns 18 for the same query. Three different numbers, three different things being counted.

The lesson generalises past Bing: a rule you learned on one of a site's surfaces is a hypothesis on the next one, not a finding. This one costs a --chrome-ua flag to check.

The second one is that the row count moves, and sometimes it collapses. Across 45 successful calls at the default count, over four queries:

How many rows the endpoint actually returns

Four of those 45 came back with results_count: 1 and a single video that had nothing to do with the query. On q=laptop the stray card was a Google Sheets tutorial one time and a Thessaloniki travel clip another. HTTP 200 both times, well-formed JSON both times, all 9 fields populated on the row. Every call is recorded in row_count_survey.json, including the 3 of 52 that returned 502.

Here is the rest of what bites you, and what each one costs:

What bites you Why What it costs you
The single-row response 4 of 45 calls returned one unrelated video at HTTP 200. Nothing in the status, the schema or the field population marks it as degraded. Silent bad data. A pipeline that trusts results[0] files a Google Sheets tutorial under "laptop" and never raises.
count is a cap, not a target It bounds the list we return. It does not make Bing server-render more cards. count=100 still returned 18 rows on q=laptop; count=5 returned exactly 5. Sizing a job on count and getting a fraction of it. Read results_count, always.
What you learned on /search may not hold The Chrome User-Agent trap is real one URL over and absent here. Assumptions carried across surfaces are how you end up debugging the wrong thing. Hours spent removing a header that was never the problem.
url is accepted and ignored The endpoint takes a url param for shape-parity with targets that fetch one. It is validated as a URL and then not used: passing url=https://example.com and passing a YouTube watch URL both returned the same 18 laptop rows, top result included, as q=laptop on its own. A silent no-op. If you thought you were scraping a specific page, you were scraping a keyword.
The links are not Bing links, and not all YouTube link resolves to the destination. On the committed 18-row capture that is 17 youtube.com and 1 tiktok.com. A pipeline that assumes YouTube URLs breaks on the rows that are not.
views and published are strings Bing renders "110.8K views" and "6 days ago", and they pass through unparsed. "110.8K" has already lost precision at the source. Sorting by popularity lexicographically, and a "date" you cannot subtract.
country sets a market, not a content filter It maps to Bing's mkt=en-XX. The committed capture was taken at the us default and still contains videos aimed squarely at other markets. It changes which SERP you are served, not which countries the results are about. Building a regional report on the assumption that country=us means US content.
The thumbnail host is not the video host thumbnail points at th.bing.com, Bing's own CDN, while link points at the publisher. Those age differently. Cached thumbnails that rot while the videos are still up.

Using the Chocodata Bing Videos Scraper API

The managed option, and the one this repo is built around. The Chocodata Bing Videos Scraper API returns Bing video titles, destination links, durations, channels and view counts as parsed JSON for Bing video data extraction at scale, with no proxy management. Free for the first 1,000 requests.

The free script above is a real deliverable, not a strawman, and on this surface it works. What the endpoint adds is operational rather than magical:

  • The market is a request parameter. country selects the Bing market per call. The script hardcodes mkt=en-US, which is fine until you want two markets side by side and are editing the source to get them.
  • It runs from anywhere. Your CI runner does not need its own supply of clean egress, and keeping the request shape working is our problem rather than yours.
  • Somebody else maintains the parser. When mc_vtvc or the mmeta encoding moves, that is our on-call.
  • One contract across endpoints. Same auth, same error shapes, same results_count convention as the rest of the API, so video results drop into a pipeline that already handles other targets.

The official Bing Search APIs are 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."

"Customers are encouraged to migrate to Grounding with Bing Search as part of Azure AI Agents. Grounding with Bing Search allows Azure AI Agents to incorporate real-time public web data when generating responses with an LLM."

Source: Microsoft Learn, "Bing Search APIs Retiring on August 11, 2025", at learn.microsoft.com/en-us/lifecycle/announcements/bing-search-api-retirement. Both paragraphs are quoted in full because the second one is the part that matters: the migration path Microsoft offers is a different product. It feeds web context into an LLM answer. It does not hand you ranked video results as structured rows you can store, diff and chart.


Bing Videos Scraper API reference

Below is the Bing Videos Scraper API reference to get you started: authentication, the parameters, errors and rate limits, then the endpoint with its response.

Quickstart

curl "https://api.chocodata.com/api/v1/bing/videos?api_key=YOUR_KEY&q=laptop"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/bing/videos",
    params={"api_key": "YOUR_KEY", "q": "laptop"},
    timeout=90,
)
d = r.json()
top = d["results"][0]
print(d["results_count"], top["title"], top["link"])
# 18 $1000 to $4000 Laptop - Snapdragon Edition https://www.youtube.com/watch?v=s9igt2i91q0

After running the command, your terminal should look something like this:

Running the Bing Videos Scraper API

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

Pass api_key as a query parameter on every request. That is the whole auth model.

Parameters

Param Type Required Default Description
api_key string yes - Your Chocodata API key.
q string yes - The search query. At least 1 character. This is what decides the response.
count int (1-100) no 50 Upper bound on returned rows. Bing decides how many cards it server-renders, so this caps the list rather than guaranteeing it. Above 100 returns 400 naming the maximum.
country string (ISO-2) no us Bing market (mkt=en-XX). Pin this for result sets you can compare over time.
url string (URL) no - Accepted for shape-parity with endpoints that fetch a URL, validated as a URL, and not used to build the request. q is what gets scraped.

Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).

There is no pagination parameter on this surface. One call returns the cards Bing renders on the first video results page.

Errors

Nothing below is billed: you are only charged on a 2xx.

Status error code Meaning Billed What to do
400 invalid_params A required param is missing or out of range. The body names 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 returned a video page with no parseable cards for this request. retryable: true. no Retry once after a few seconds.
502 target_unreachable Bing refused every attempt for this request. retryable: true. no Retry once after a few seconds.

Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string plus retryable.

The scripts in this repo map each documented status onto an actionable message, so a typo'd key does not hand you a stack trace:

Bing Videos Scraper API error handling

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/bing/videos?api_key=totally_invalid_key_123&q=laptop"
{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}

Omitting the key entirely gives you the hint instead, verbatim:

{"error":{"code":"INVALID_API_KEY","message":"Missing api_key query parameter.","hint":"Pass your key as ?api_key=<your_key> in the request URL."}}

A count over the maximum, verbatim. It names the field and the ceiling, which is why the scripts can print 400 invalid_params: count: Number must be less than or equal to 100 instead of a traceback:

{"error":"invalid_params","issues":[{"code":"too_big","maximum":100,"type":"number","inclusive":true,"exact":false,"message":"Number must be less than or equal to 100","path":["count"]}]}

Omitting q returns the same shape with "path":["q"] and "message":"Required", and a malformed url returns it with "message":"Invalid url".

Both 502 codes are retryable: true and neither is billed. In the 52-call sample behind this page, 3 came back 502 and the same query succeeded on the next attempt each time. Retry once after a few seconds before concluding anything is broken, which is what the scripts in this repo do.

Rate limits and concurrency

Two limits apply at once, and the one that binds first is usually the rate limit, not the concurrency cap.

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 (a 2.1s median, see Measured latency), but the tail runs to 7.5s, which is why the examples use timeout=90.

Size against the rate limit rather than the concurrency figure. One call covers one keyword, so a 500-keyword sweep is 500 calls, or roughly 4.2 minutes at 120/minute. 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/videos",
                     params={"api_key": KEY, "q": q}, timeout=90)
    d = r.json() if r.ok else {}
    return q, d.get("results_count", 0), [v["link"] for v in d.get("results", [])]

queries = ["laptop", "mechanical keyboard", "espresso machine", "python tutorial"]
with ThreadPoolExecutor(max_workers=10) as pool:   # <= your plan's concurrency
    for q, n, links in pool.map(one, queries):
        print(q, n, links[:2])

Videos: titles, links, durations, channels and view counts

Bing video results for a keyword, with the metadata Bing renders on each card: the destination link, duration, channel, publish date and view count.

Param Type Required Default Description
q string yes - Search keywords.
count int (1-100) no 50 Upper bound on rows.
country string (ISO-2) no us Bing market (mkt=en-XX).
url string (URL) no - Accepted, validated, and not used.
curl "https://api.chocodata.com/api/v1/bing/videos?api_key=YOUR_KEY&q=laptop"

Real response. results cut to 2 of the 18 returned; each result object is complete, all 9 fields verbatim (full sample):

{
  "query": "laptop",
  "results_count": 18,
  "results": [
    {
      "position": 1,
      "title": "$1000 to $4000 Laptop - Snapdragon Edition",
      "link": "https://www.youtube.com/watch?v=s9igt2i91q0",
      "thumbnail": "https://th.bing.com/th/id/OVF.nGgnP3jNqITkWJgZaUWNRw?w=196&h=110&c=7&rs=1&qlt=70&o=7&pid=2.1&rm=3",
      "duration": "0:51",
      "source": "YouTube",
      "channel": "Linus Tech Tips",
      "published": "6 days ago",
      "views": "110.8K views"
    },
    {
      "position": 13,
      "title": "Here’s how to make your laptop 10 times faster!",
      "link": "https://www.tiktok.com/@.mrj_laptops/video/7662351459534654721",
      "thumbnail": "https://th.bing.com/th/id/OVF.ujhGZ2z3TALhToMVZvEdJA?w=196&h=110&c=7&rs=1&qlt=70&o=7&pid=2.1&rm=3",
      "duration": "0:41",
      "source": "TikTok",
      "channel": ".mrj_laptops",
      "published": "6 days ago",
      "views": "13.1K views"
    }
  ]
}

link is the field most people come for, and it is the one a DIY scraper usually gets wrong: it resolves to the real destination rather than a Bing player page, which is what makes this usable as a discovery feed. The two rows above are chosen to show the same fields across two different hosts, because the mix is not all YouTube: the committed 18-row capture is 17 youtube.com and 1 tiktok.com, and source is the label Bing puts on each card.

All 9 fields are populated on all 18 rows of that capture. Diff it yourself, it is right there in the repo.

Four more things worth coding against:

  • Read results_count on every response, and sanity-check the rows. The count is Bing's decision, not yours. Over 45 successful default-count calls it was 20 on 17, 18 on 23, 10 on one and 1 on four. The four single-row responses carried a video unrelated to the query, at HTTP 200. A relevance check as cheap as "does any returned title share a token with my query" separates them from a real thin result set.
  • views and published are strings exactly as Bing renders them ("110.8K views", "6 days ago"), not numbers and not timestamps. Parse them yourself if you need them numeric, and know what you are inferring when you do: "110.8K" lost precision before we ever saw it.
  • duration is H:MM:SS or M:SS, so it is not sortable as a string. The 18-row capture runs from 0:11 to 2:41.
  • position is a rank within one response, not a stable identifier. Because the row count moves between calls, the same video can sit at a different position an hour later without anything having changed. De-duplicate and diff on link.

Runnable: bing_videos_api_codes/videos.py


Watch which videos Bing surfaces for your keywords

Knowing which videos own a keyword is the main reason people scrape a video SERP, so that use case is in the repo end to end rather than as a snippet. video_monitor.py polls a list of keywords, stores every observation as a local dataset in SQLite, and prints which videos are new since the last run:

python bing_videos_api_codes/video_monitor.py "laptop" "mechanical keyboard"

Polling two keywords and recording the videos returned

That is a real first run: nothing to compare against yet, so it seeds the database and says so. Run it again after the results move and each newcomer prints as a NEW line.

It tracks presence, not position, and that choice comes straight out of the measurements above. With the row count landing on four different values across 45 calls, a video sliding from row 7 to row 11 more often means the card count changed than that anything moved. Set membership does not have that problem. It diffs on link for the same reason.

Export the history with one command:

sqlite3 -header -csv bing_videos.db "select * from observations;" > out.csv

One API call per keyword per run. 1,000 free requests covers a year of daily checks on two keywords.


Measured latency

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/videos 2.1s 1.1 to 7.5s 49 49/52

Read the range, not just the median. Bing is a fast target and these are among the quickest numbers we publish on any endpoint. The tail is a request that needed re-attempting upstream before it came back with cards. The three misses were 502, which is retryable and not billed, and every one of the 52 calls is timed individually in row_count_survey.json.


License

MIT. See LICENSE.