A scheduled news monitor for bloomberg.com, written as a working companion to ScrapingBee's Bloomberg API. Cron starts a Python job every half hour; the job reads the public section fronts you name, keeps each story's headline, byline, publish and update timestamps and URL, drops anything it has already written, and appends the rest to a JSONL file. Quote pages can ride along as delayed price snapshots.
It collects what a logged out visitor sees on a section front and stops there. It does not open article pages. Every request and number below comes from runs against bloomberg.com on 2026-10-29.
$ python examples/bloomberg_monitor.py --sections markets technology --quotes AAPL:US SPX:IND --out data
/markets: 39 stories on the page, 39 written
/technology: 45 stories on the page, 42 written
AAPL:US: 343.83 USD (-0.94%), as of 2026-10-29T00:00:00.000Z, delayed 15 Minutes
SPX:IND: 7743.98 USD (+0.28%), as of 2026-10-28T21:18:42.000Z, delayed 15 Minutes
{"run": "2026-10-29T13:20:33+00:00", "new_or_updated": 81, "quotes": 2, "credits": 4, "problems": 0}
Three Technology stories were already on the Markets front, so 84 listings became 81 rows. The same command 30 seconds later:
/markets: 39 stories on the page, 0 written
/technology: 45 stories on the page, 0 written
{"run": "2026-10-29T13:21:04+00:00", "new_or_updated": 0, "quotes": 0, "credits": 2, "problems": 0}
One line of data/headlines.jsonl, with the topic code list cut short:
{"story_id": "TNMW0CRKV2UR00", "headline": "Nasdaq Futures Sink as AI Trade Shows New Cracks: Markets Wrap", "url": "https://www.bloomberg.com/news/articles/2026-10-28/stock-market-today-dow-s-p-live-updates", "published_at": "2026-10-28T22:11:47.959Z", "updated_at": "2026-10-29T12:37:50.412Z", "authors": ["Robert Brand", "Anand Krishnamoorthy"], "summary": "", "kind": "article", "brand": "markets", "section": "markets", "module": "top_single_story", "premium_flag": false, "topic_codes": ["004969NODE", "015227NODE", "2500", "ACEXCLUDE", "..."], "seen_at": "2026-10-29T13:20:33+00:00", "event": "new"}And of data/quotes.jsonl:
{"ticker": "AAPL:US", "name": "Apple Inc", "price": 343.83, "change": -3.27, "change_pct": -0.9420916, "currency": "USD", "exchange": "NASDAQ GS", "last_update": "2026-10-29T00:00:00.000Z", "delayed": "15 Minutes", "market_open": false, "seen_at": "2026-10-29T13:20:33+00:00"}Across the 81 rows: 62 articles, 10 videos, 7 features, 2 audio items. The 10 videos had no byline; every article and feature had at least one author.
examples/run_monitor.sh is the cron entry point. Cron runs with a bare environment and no terminal, so the wrapper sets its own paths, reads the key from a file, and uses a lock directory so a slow run never overlaps the next one:
LOCK="$DATA/.lock"
if ! mkdir "$LOCK" 2>/dev/null; then
echo "$(date -u +%FT%TZ) previous run still active, skipping"
exit 0
fi
trap 'rmdir "$LOCK"' EXITmkdir is used instead of flock because it is atomic on Linux and macOS alike. The crontab line, weekdays every 30 minutes:
*/30 * * * 1-5 /path/to/bloomberg-api/examples/run_monitor.sh >> /path/to/data/cron.log 2>&1Sections and tickers come from environment variables (BLOOMBERG_SECTIONS, BLOOMBERG_QUOTES), the key from SCRAPINGBEE_API_KEY or a chmod 600 file at ~/.config/scrapingbee/key. At the default three sections, a weekday schedule is 3 credits a run, 48 runs a weekday, about 144 credits a day.
examples/bloomberg_monitor.py, in the order a run executes.
examples/robots_guard.py reads bloomberg.com/robots.txt with the standard library parser and checks every path before the job requests it. If the file cannot be read, the guard refuses the path rather than guessing.
$ python examples/robots_guard.py
allow /markets
allow /technology
allow /quote/AAPL:US
DENY /search
allow /news/articles/
groups with Disallow: / (a few keep Allow exceptions): 360Spider, AmazonAdBot, ... python-requests, ...
Two things in that file deserve a plain statement. It disallows /search, so this job does not use Bloomberg search to find stories. And it names python-requests, Python-urllib and Python-http-client among the agents barred from the whole site, next to CCBot, GPTBot, ClaudeBot and others. The monitor's own HTTP calls go to ScrapingBee, and the request Bloomberg receives is sent by ScrapingBee rather than by your requests session, so the guard checks the User-agent: * group. That is a technical answer, not permission: read Bloomberg's terms and the robots file yourself before running this on a schedule, and keep the frequency modest.
def page(self, path):
params = {"url": SITE + path, "render_js": "false"}
for attempt in range(self.retries):
r = self.s.get(API, params=params, timeout=90)
if r.status_code != 500:
break
time.sleep(3 * (attempt + 1))
self.credits += int(r.headers.get("spb-cost", 0) or 0)
if r.status_code != 200:
raise RuntimeError(f"{path}: HTTP {r.status_code}, initial {r.headers.get('spb-initial-status-code')}")
m = NEXT_DATA_RE.search(r.text)
if not m:
raise RuntimeError(f"{path}: no __NEXT_DATA__ in the response")
return json.loads(m.group(1))["props"]["pageProps"]Measured from the spb-cost and spb-initial-status-code headers:
| Bloomberg request | Parameters | spb-cost | Outcome |
|---|---|---|---|
/markets |
mode=auto |
1 | 825,663 bytes, __NEXT_DATA__ present |
/markets, /technology |
render_js=false |
1 each | Same, used by the job |
/technology, /quote/SPX:IND |
render_js left at its default |
5 each | Same data, five times the price |
/quote/AAPL:US |
mode=auto |
1 | 460,317 bytes, full quote JSON |
/quote/AAPL:US |
render_js=true, json_response=true |
0 | ScrapingBee 500, Bloomberg answered 403 |
/news/articles/2026-10-29/... |
render_js left at its default |
0 | ScrapingBee 500, Bloomberg answered 403 |
A failed call is not billed, which is why the retry loop is free. The job sets render_js=false explicitly because ScrapingBee renders JavaScript unless told not to, and a rendered section front costs 5 credits for markup the job never reads. The two 403 rows are the edge of this repo, covered under Where the monitor stops.
What the visible markup offers for a story on the Markets front:
<a class="StoryBlock_storyLink__5nXw8" href="/news/articles/2026-10-28/stock-market-today-dow-s-p-live-updates?srnd=phx-markets" data-link-index="1" data-component="story-link"><div class="StoryBlock_storyBlock__ja_vR" data-component="story-block"><div data-component="optional-eyebrow" class="OptionalEyebrow_optionalEyebrow__mNUnT">Markets Wrap</div><div class="Headline_phoenix__Dvz0u typography_articleHeadlineLg__It0HJ ..." data-component="headline" data-testid="headline"><span>Nasdaq Futures Sink as AI Trade Shows New Cracks</span></div></div></a>A headline and a link, but the byline appeared in a data-component="byline" element only once on the whole page:
<div data-component="byline" class="typography_articleLabel__G1TBw typography_articleLabelBase__4WylQ"><span>By Saikat Das, Ranjani Raghavan and Nic Querolo</span></div>and the times are relative. The page had twelve <time> elements, all of this kind ("2 hr ago" four times, "3 hr ago" twice, "4 hr ago" six times):
<time>2 hr ago</time>A monitor cannot dedupe or sort on "2 hr ago". The page's <script id="__NEXT_DATA__" type="application/json">, 496,524 characters on the Markets front, has every story with absolute times. Under props.pageProps.initialState.modulesById sit 27 modules (top_single_story, top_stories, archive_story_list, video_package and so on, ad slots included), and each content module has an items list:
{
"brand": "markets",
"byline": "Carmen Reinicke",
"credits": [{"name": "Carmen Reinicke", "roles": ["by", "reporter"], "editorialBoard": false}],
"headline": "Amazon’s Ailing Stock Needs Booming Cloud Growth to Revive Rally",
"id": "TNKGTIKGIFRK00",
"publishedAt": "2026-10-29T11:30:01.000Z",
"updatedAt": "2026-10-29T11:30:01.000Z",
"summary": "",
"type": "article",
"url": "/news/articles/2026-10-29/amazon-s-ailing-stock-needs-booming-cloud-growth-to-revive-rally?srnd=phx-markets",
"raw": {"premium": false, "slug": "2026-10-29/amazon-s-ailing-stock-needs-booming-cloud-growth-to-revive-rally", "nicodes": [{"id": "013666NODE"}, {"id": "2500"}, {"id": "ACEXCLUDE"}, "..."]}
}Note that the JSON headline for the top story carries the franchise label the HTML shows as a separate eyebrow: "Nasdaq Futures Sink as AI Trade Shows New Cracks: Markets Wrap". The parser walks every module, keeps four item types and skips link items, which are navigation tiles such as "Deals" and "ETFs":
STORY_TYPES = {"article", "feature", "video", "audio"}
for module_id, module in mods.items():
for item in module.get("items") or []:
if not isinstance(item, dict) or item.get("type") not in STORY_TYPES:
continue
sid = item.get("id")
if not sid or sid in rows:
continue
raw = item.get("raw") or {}
authors = [c["name"] for c in item.get("credits") or [] if c.get("name")]
...
rows[sid] = Headline(
story_id=sid,
headline=item.get("headline") or "",
url=_clean_url(item.get("url") or ""),
published_at=item.get("publishedAt"),
updated_at=item.get("updatedAt"),
authors=authors,
...
premium_flag=raw.get("premium"),
topic_codes=[n["id"] for n in raw.get("nicodes") or []],
)The same story can appear in two modules on one page, so rows are keyed by id within the page too. _clean_url drops the ?srnd=phx-markets tracking suffix, which would otherwise make the same URL look different per section. topic_codes comes from raw.nicodes, a long list of Bloomberg's internal topic tags (AI, BITCOIN, CHINA, CRUDE among them on the wrap story); it is the cheapest way to filter for, say, M&A coverage without keyword matching the headline. premium_flag is stored as the page reported it: false on all 71 articles, features and audio items in our run, absent on videos. We have not tested what it controls.
A small JSON index maps each story id to the last updatedAt written:
def classify(self, h):
prev = self.data.get(h.story_id)
if prev is None:
return "new"
if h.updated_at and h.updated_at > prev:
return "updated"
return NoneA new id is written with "event": "new". A known id whose updatedAt moved is written again with "event": "updated", which matters on live blogs such as the Markets Wrap above (published 22:11 UTC, updated 12:37 UTC the next day). Anything else is skipped. ISO timestamps in one format compare correctly as strings. The index is saved through a temp file and a rename, so a killed run cannot leave it half written.
bloomberg.com/quote/<TICKER> is also server rendered, and its __NEXT_DATA__ has a quote object under pageProps:
{
"id": "AAPL:US",
"name": "APPLE INC",
"longName": "Apple Inc",
"price": "343.83",
"priceChange1Day": -3.27,
"percentChange1Day": -0.9420916,
"primaryExchange": "NASDAQ GS",
"issuedCurrency": "USD",
"lastUpdate": "2026-10-29T00:00:00.000Z",
"isExchangeDelayed": true,
"exchangeDelay": "15 Minutes",
"isOpen": false,
"marketCap": "5.018T",
"priceEarningsRatio": "39.43",
"dayRange": ["343.52", "347.18"],
"fiftyTwoWeekRange": ["243.42", "351.73"]
}The object has 97 fields, including fund and index fields that are null on a stock, and priceMovements1Day through priceMovements5Years arrays of {dateTime, value} points. Prices arrive as strings, sometimes with thousands separators ("7,743.98" on SPX:IND), so the job converts them. The page also carries three JSON-LD blocks, NewsMediaOrganization, WebPage and Corporation.
What the quote page did not have was news. Its server HTML and its __NEXT_DATA__ held no /news/articles/ links at all; the company news on that page loads in the browser, and the rendered attempt was refused (the 403 row in the cost table). So headlines come from section fronts, and quote pages contribute prices only. Every quote is marked 15 minutes delayed by Bloomberg itself; store last_update, not your fetch time.
examples/headlines.js prints the newest stories on one front, Node 18+ and no packages:
$ node examples/headlines.js technology 5
/technology: 45 stories, 1 credit(s)
2026-10-29 12:42 Bridgewater CEO Nir Bar Dea on AI Investing, Markets and Leadership | The David Rubenstein Show
https://www.bloomberg.com/news/videos/2026-10-29/bridgewater-ceo-on-ai-investing-debt-leadership-video
2026-10-29 11:58 After the Budget, Before the Bell With The Opening Trade
https://www.bloomberg.com/news/videos/2026-10-29/after-the-budget-before-the-bell-with-the-opening-trade-video
2026-10-29 11:52 Anthropic IPO May Spark AI Rally as Short Bets on Tech Pile Up (Natalia Kniazhevich)
https://www.bloomberg.com/news/articles/2026-10-29/anthropic-ipo-may-spark-ai-rally-as-short-bets-on-tech-pile-up
Article pages. A request for one of the story URLs above, on the classic proxy, came back as HTTP 403 with a page titled "Bloomberg - Are you a robot?" asking the visitor to tick a box. That is a CAPTCHA wall, and this repo does not try to get past it. No article body is collected, no paywall is touched, and the monitor never requests /news/articles/ at all. What you have is what the section front publishes about each story: headline, summary where the front shows one (19 of 81 rows had one), authors, timestamps and URL. Bloomberg's own interface strings on the section page include "Subscribe" and "Subscriber Only" labels; reading an article remains a matter for a reader with whatever access Bloomberg requires.
Rendered pages. The quote page with JavaScript rendering got the same 403. The job only uses requests that work without a browser.
Logins. Nothing here signs in. ScrapingBee does not allow scraping behind a login, and a Bloomberg account would change what the page shows anyway.
Section depth. A front lists what its editors put there, 39 to 45 items in our captures. The page state includes "shouldPaginate": true and module nextItemOffset values, but this job reads only the first page of each front. Running often is how it avoids missing stories.
Bloomberg publishes an API library for its professional products at bloomberg.com/professional/support/api-library/. What that page said when we fetched it:
- The libraries are BLPAPI, documented in a BLPAPI Developer's Guide for C++, Java and C# (.NET), with a Python package installed through pip from Bloomberg's own index (
python -m pip install --index-url=https://blpapi.bloomberg.com/repository/releases/python/simple/ blpapi). - Terminal users work through the Desktop API. The page says the Linux and macOS ARM builds are not for Terminal users and are "only compatible with the Bloomberg Server API and B-Pipe data feed products".
So BLPAPI is a client for Bloomberg's network, and it needs one of those Bloomberg products behind it. If you have a Terminal or a data feed licence, that is the route for real market data. This repo answers a narrower question, what bloomberg.com is publishing right now, for people who only need public headlines and delayed quotes.
Docs: HTML API parameters, json_response, CSS extraction rules if you would rather select headlines than parse JSON, markdown output, pricing with 1,000 free credits to try it.
Other finance and news scrapers: financial news feed API, economic news API, Yahoo finance scraper API, Nasdaq API, Google news scraper API, PR Newswire API, GlobeNewswire API.
Is there a Bloomberg news API for headlines?
Bloomberg's documented developer interface is BLPAPI, which works against the Terminal or Bloomberg's Server API and B-Pipe products. For public headlines, the section fronts on bloomberg.com carry every listed story with byline and ISO timestamps in their __NEXT_DATA__, at 1 ScrapingBee credit per front.
Can a Bloomberg scraper read full articles? Not this one. Article URLs answered with Bloomberg's robot check and HTTP 403, and the job does not try to bypass it. It records headline, summary, authors, timestamps and URL from the section listing.
How do I get Bloomberg stock quotes into Python?
Fetch bloomberg.com/quote/<TICKER> with render_js=false and read props.pageProps.quote from __NEXT_DATA__. Price, change, exchange and lastUpdate are there, marked 15 minutes delayed.
How often should the monitor run? Every 15 to 30 minutes covers a front that lists around 40 stories. Each run costs 1 credit per section, and the dedupe index keeps reruns from writing duplicates.
Why does my Bloomberg scraper show "2 hr ago" instead of a time?
The visible <time> elements are relative. Read publishedAt and updatedAt from the page JSON instead.
MIT. See LICENSE.