<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Omar Eldeeb</title>
    <description>The latest articles on DEV Community by Omar Eldeeb (@odeeb).</description>
    <link>https://dev.to/odeeb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3961363%2F988e15d2-f7cd-489d-8bb8-4e3cfae6e4e2.png</url>
      <title>DEV Community: Omar Eldeeb</title>
      <link>https://dev.to/odeeb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/odeeb"/>
    <language>en</language>
    <item>
      <title>How to Scrape YouTube Shorts Data (Exact View, Like &amp; Comment Counts)</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Mon, 06 Jul 2026 18:00:54 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-scrape-youtube-shorts-data-exact-view-like-comment-counts-2jc3</link>
      <guid>https://dev.to/odeeb/how-to-scrape-youtube-shorts-data-exact-view-like-comment-counts-2jc3</guid>
      <description>&lt;p&gt;If you have ever tried to figure out &lt;strong&gt;how to scrape YouTube Shorts data&lt;/strong&gt; — the view counts, like counts, publish dates, and channel stats for a batch of Shorts — you have probably discovered that it is more annoying than it looks. Shorts are technically just videos, but YouTube treats them as a distinct surface, and the tools most people reach for either cost quota you do not have or return numbers that are rounded and incomplete.&lt;/p&gt;

&lt;p&gt;This post walks through the real options: what the official API does and does not give you, how the "internal API" scraping approach actually works, and a runnable example you can drop into a script today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why scraping YouTube Shorts data is harder than it should be
&lt;/h2&gt;

&lt;p&gt;The obvious first stop is the &lt;strong&gt;YouTube Data API v3&lt;/strong&gt;. It is a legitimate, well-documented REST API, and for many jobs it is the right tool. But three things make it awkward specifically for Shorts work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Quota.&lt;/strong&gt; Every Google Cloud project gets a default allocation of &lt;strong&gt;10,000 units per day&lt;/strong&gt;. That sounds like a lot until you learn the pricing: a &lt;code&gt;search.list&lt;/code&gt; call costs &lt;strong&gt;100 units&lt;/strong&gt;, so you get roughly &lt;strong&gt;100 searches per day&lt;/strong&gt; before you hit &lt;code&gt;quotaExceeded&lt;/code&gt;. A plain &lt;code&gt;videos.list&lt;/code&gt; read is only 1 unit, but you can only call it once you already have the video IDs — and getting those IDs by keyword or hashtag is the expensive search step. (&lt;a href="https://developers.google.com/youtube/v3/determine_quota_cost" rel="noopener noreferrer"&gt;Google's quota calculator&lt;/a&gt; lists the per-method costs.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No clean Shorts filter.&lt;/strong&gt; The Data API has no &lt;code&gt;type=short&lt;/code&gt; parameter. A Short is returned as an ordinary video resource. You can heuristically guess (short duration, vertical aspect ratio, &lt;code&gt;#shorts&lt;/code&gt; in the description) but there is no first-class "give me this channel's Shorts" endpoint.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;API key / OAuth setup.&lt;/strong&gt; You need a Google Cloud project, an enabled API, and a key — and for some data, OAuth. That is fine for a product, but heavy for a one-off dataset.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To be clear: the official API is &lt;strong&gt;not&lt;/strong&gt; unusable. If you need a handful of videos per day and you already manage an API key, it is a fine, fully-supported choice. The friction is real but bounded — it is the quota and the missing Shorts isolation that push people toward scraping when they need volume or richer data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The internal-API approach (how scraping actually works)
&lt;/h2&gt;

&lt;p&gt;Here is the part most tutorials get wrong. You do &lt;strong&gt;not&lt;/strong&gt; scrape YouTube Shorts by parsing HTML with regex, and there is &lt;strong&gt;no secret public REST endpoint&lt;/strong&gt; you can &lt;code&gt;curl&lt;/code&gt;. What actually powers &lt;code&gt;youtube.com&lt;/code&gt; in your browser is an internal JSON API (the same backend the web player talks to as you scroll). When you load a Shorts feed, a channel page, or a search results page, the site sends structured JSON payloads containing every field the UI renders — titles, exact view counts, like counts, publish timestamps, channel subscriber counts, and more.&lt;/p&gt;

&lt;p&gt;The scraping approach, conceptually, is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Issue the same requests the browser issues, with the right context/client parameters, so YouTube returns those JSON payloads instead of a rendered page.&lt;/li&gt;
&lt;li&gt;Walk the (deeply nested, frequently-renamed) JSON to pull out the fields you care about.&lt;/li&gt;
&lt;li&gt;Rotate residential proxies so you are not making thousands of requests from one datacenter IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payoff over the official API: because you are reading the &lt;strong&gt;same data the site itself renders&lt;/strong&gt;, you get &lt;strong&gt;exact&lt;/strong&gt; counts (e.g. &lt;code&gt;viewCount: 4823117&lt;/code&gt;) rather than the rounded values (&lt;code&gt;4.8M&lt;/code&gt;) you often see, no API key, no OAuth, and no 10,000-unit ceiling.&lt;/p&gt;

&lt;p&gt;The cost is engineering time. YouTube renames those JSON keys and changes the request envelope regularly, so a scraper you write today will quietly break in a few months. That is the maintenance treadmill you are signing up for if you build it yourself — and the reason a maintained tool is often worth it for this specific target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it without building the parser yourself
&lt;/h2&gt;

&lt;p&gt;Rather than reverse-engineer the payload walking, you can call a maintained actor that already does it. Here is a complete, runnable Node.js example using the Apify client to scrape Shorts by channel handle, keyword, hashtag, or a direct Short URL — each input line is auto-detected, so you do not configure any "mode":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ApifyClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apify-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;constructive_calm/youtube-shorts-scraper-pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// Each line is auto-detected: @handle, keyword, #hashtag, or a Short URL&lt;/span&gt;
  &lt;span class="na"&gt;startUrls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@MrBeast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cooking tips&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#asmr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.youtube.com/shorts/abcdEFGhijk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// per input line&lt;/span&gt;
  &lt;span class="na"&gt;publishedAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30 days&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// date "2026-01-01" or relative "7 days"&lt;/span&gt;
  &lt;span class="na"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;popular&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// newest | popular | oldest&lt;/span&gt;
  &lt;span class="na"&gt;includeComments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxComments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;listItems&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewCount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; views | `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`virality &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viralScore&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/100 | &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;startUrls&lt;/code&gt; is the only required input. Everything else has sensible defaults (&lt;code&gt;maxResults&lt;/code&gt; is 10, &lt;code&gt;maxComments&lt;/code&gt; is 20). If you flip on &lt;code&gt;downloadVideos&lt;/code&gt;, each result also gets a &lt;code&gt;videoDownloadUrl&lt;/code&gt; pointing at a 360p MP4 saved to storage with a shareable link.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you get back per Short
&lt;/h3&gt;

&lt;p&gt;Each dataset item is a flat object, which makes it trivial to dump to CSV or load into pandas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"videoId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abcdEFGhijk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.youtube.com/shorts/abcdEFGhijk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"60-second garlic butter trick"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hashtags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"cooking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shorts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4823117&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"likeCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;312044&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commentCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1876&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durationSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;58&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publishedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-14T09:32:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thumbnail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.ytimg.com/…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channelName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Kitchen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channelId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UC…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channelSubscribers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2450000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channelVerified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viralScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engagementRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.7&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first block of fields comes straight from YouTube's own data. The last two — &lt;code&gt;viralScore&lt;/code&gt; (a 0–100 index) and &lt;code&gt;engagementRate&lt;/code&gt; — are &lt;strong&gt;computed by the actor&lt;/strong&gt;, not values YouTube publishes. They are handy for ranking a batch of Shorts by breakout potential without writing your own formula, but treat them as a derived heuristic, not an official metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick note on choosing your approach
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A few Shorts, occasionally, and you already have an API key?&lt;/strong&gt; Use the official Data API. It is free within quota and fully supported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need Shorts isolated by channel/keyword/hashtag, exact counts, and volume?&lt;/strong&gt; Either write an internal-API scraper (and accept the maintenance) or use a maintained one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the exact input/output shape before running anything, there is a free &lt;strong&gt;&lt;a href="https://datatooly.xyz/youtube-shorts-search/" rel="noopener noreferrer"&gt;query-builder tool&lt;/a&gt;&lt;/strong&gt; where you paste your inputs and preview the resulting output schema in the browser. It does not fetch live results client-side — YouTube is not CORS-open, so no browser page can — it just helps you construct and understand the request.&lt;/p&gt;

&lt;p&gt;To run it for real against live data, the backing actor is &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/youtube-shorts-scraper-pro?fpr=v77kxu" rel="noopener noreferrer"&gt;youtube-shorts-scraper-pro&lt;/a&gt;&lt;/strong&gt; on Apify. It is free to start, then pay-as-you-go, and it handles the residential-proxy rotation and the JSON-walking so you do not inherit the breakage treadmill.&lt;/p&gt;

&lt;p&gt;However you go about it, keep it to &lt;strong&gt;public data only&lt;/strong&gt; — public Shorts, public channel stats, public comments — and you have a clean, repeatable way to pull YouTube Shorts data with exact numbers instead of rounded guesses.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build and maintain the youtube-shorts-scraper-pro actor referenced above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>youtube</category>
      <category>api</category>
      <category>datascience</category>
    </item>
    <item>
      <title>How to Extract Saudi Arabia Property Data Across Bayut.sa, Wasalt.sa, Aqar.fm and PropertyFinder.sa</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Tue, 30 Jun 2026 20:40:40 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-extract-saudi-arabia-property-data-across-bayutsa-wasaltsa-aqarfm-and-propertyfindersa-p0c</link>
      <guid>https://dev.to/odeeb/how-to-extract-saudi-arabia-property-data-across-bayutsa-wasaltsa-aqarfm-and-propertyfindersa-p0c</guid>
      <description>&lt;p&gt;If you have ever tried to assemble a clean dataset of &lt;strong&gt;Saudi Arabia property data&lt;/strong&gt;, you already know the pain: the same villa in Riyadh shows up on Bayut.sa, on PropertyFinder.sa, and on Aqar.fm — three different listing IDs, three slightly different prices, three different agents. Naively concatenate them and your "market" is 30–40% duplicates. This post walks through how the Saudi portals actually expose their data, and the one field that quietly solves the deduplication problem: the REGA advertisement license number.&lt;/p&gt;

&lt;p&gt;I have spent a fair amount of time poking at these portals, so the goal here is to give you the honest, verified mechanics — not a fantasy where everything is a tidy public REST API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four portals that matter
&lt;/h2&gt;

&lt;p&gt;For practical coverage of the Kingdom, four sites carry the bulk of supply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bayut.sa&lt;/strong&gt; — the dubizzle/Bayut group's Saudi portal, English + Arabic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wasalt.sa&lt;/strong&gt; — owned by the Public Investment Fund (PIF), strong on sale and off-plan inventory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aqar.fm&lt;/strong&gt; (&lt;code&gt;sa.aqar.fm&lt;/code&gt;) — one of the highest-traffic portals in the Kingdom, Arabic-only, very deep on local supply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PropertyFinder.sa&lt;/strong&gt; — the Saudi arm of Property Finder, English-first, strong agent coverage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one carries the same core fields per listing: &lt;strong&gt;price, area (m²), location, bedrooms/bathrooms, property type, listing purpose (sale / rent / off-plan), and the listing agent or brokerage.&lt;/strong&gt; That overlap is exactly what makes cross-portal joins both necessary and possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the listings are actually delivered
&lt;/h2&gt;

&lt;p&gt;Here is the first thing worth internalizing: these are modern JavaScript front-ends, but &lt;strong&gt;you usually do not need a headless browser to read a listing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Several of these portals are built as Next.js-style applications. When a Next.js page renders on the server, the framework serializes the props used for rendering into an embedded JSON blob — the &lt;code&gt;__NEXT_DATA__&lt;/code&gt; script tag — so React can hydrate on the client. Per Next.js's own maintainers, this tag is not removable, and "the data contained in there should be what is already presented to your user." Translation for our purposes: the listing data that paints the page is sitting right there in the initial HTML response as structured JSON.&lt;/p&gt;

&lt;p&gt;That means a plain HTTP GET, plus a parse of the embedded JSON, often gets you a fully structured listing object — price, beds, baths, geo, agent — without spinning up Playwright. (Some portals use a differently named state object or an embedded data-island instead of &lt;code&gt;__NEXT_DATA__&lt;/code&gt;, so always inspect the actual HTML before assuming a selector.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The REGA join key — the part most people miss
&lt;/h2&gt;

&lt;p&gt;Saudi Arabia regulates real-estate advertising through the &lt;strong&gt;Real Estate General Authority (REGA)&lt;/strong&gt;. Under the brokerage regulations, you cannot legally advertise a property without a &lt;strong&gt;real-estate advertisement license&lt;/strong&gt;, and the license number plus its expiry must be displayed on the advertisement. REGA even runs a public &lt;strong&gt;Advertisement License Inquiry&lt;/strong&gt; service so anyone can check a license number's status — active, expired, or cancelled.&lt;/p&gt;

&lt;p&gt;The downstream consequence is the useful part. Because the &lt;em&gt;property&lt;/em&gt; (via its brokerage contract) gets a REGA advertisement license, and that number must be shown on the ad, &lt;strong&gt;the same property advertised on Bayut.sa and PropertyFinder.sa carries the same REGA license number on both.&lt;/strong&gt; That makes the REGA advertisement number an excellent &lt;strong&gt;cross-portal join key&lt;/strong&gt; for deduplication — far more reliable than fuzzy-matching on price-and-address, which breaks the moment one agent rounds the price or writes "Al Malqa" instead of "Al-Malqa".&lt;/p&gt;

&lt;p&gt;A few honest caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Not every portal exposes the number with equal prominence. The Next.js-rendered portals (Bayut, PropertyFinder, Wasalt) tend to surface it cleanly; Aqar.fm is more variable, so treat REGA-on-Aqar as best-effort and keep a fuzzy fallback.&lt;/li&gt;
&lt;li&gt;A single brokerage contract with a "marketing" scope can cover an advertisement, so in rare cases one license maps to a small bundle rather than exactly one unit. Use it as a strong signal, not an infallible primary key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A runnable extraction sketch
&lt;/h2&gt;

&lt;p&gt;Here is a self-contained Node.js (18+) example showing the pattern: fetch a page, pull the embedded JSON, normalize to a common shape, and dedupe by REGA number. The selector and JSON paths are intentionally generic — you adapt them to whatever each portal actually emits — but the structure is exactly what you'd ship.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// node &amp;gt;=18, ESM. npm i cheerio&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cheerio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(KHTML, like Gecko) Chrome/124.0 Safari/537.36&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Pull the embedded Next.js JSON island from a listing/search page.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchEmbeddedJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UA&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Next.js serializes render props here. Some portals use a&lt;/span&gt;
  &lt;span class="c1"&gt;// differently-named tag, so inspect the HTML and adjust.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#__NEXT_DATA__&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Map a portal-specific listing object onto one canonical shape.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;portal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;portal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listingId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;rega&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permitNumber&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;licenseNumber&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;regaAdNumber&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sale | rent | off-plan&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;propertyType&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// villa, apartment, ...&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;areaSqm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;builtUpArea&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;beds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrooms&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;baths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bathrooms&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;district&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;district&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contactName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Dedupe across portals using the REGA advertisement number.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dedupeByRega&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byRega&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;noKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rega&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;noKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// fall back to fuzzy matching downstream&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byRega&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rega&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;byRega&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rega&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;seenOn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;portal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seenOn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;portal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Keep the richest record; here we prefer a non-null price.&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;byRega&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="na"&gt;unmatched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;noKey&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// --- usage sketch ---&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchEmbeddedJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.example-portal.sa/search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pageProps&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;listings&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// adapt this path&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;listings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;examplePortal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unmatched&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dedupeByRega&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`merged=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; unmatched=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unmatched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape of &lt;code&gt;data.props.pageProps&lt;/code&gt; differs per portal — that path is the single thing you'll spend the most time pinning down. Once you have it, the &lt;code&gt;normalize → dedupeByRega&lt;/code&gt; pipeline is portable across all four sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical notes from the trenches
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Be polite.&lt;/strong&gt; Throttle, set a real User-Agent, and respect each site's terms. These portals will rate-limit aggressive clients, and Aqar.fm in particular reacts to bursty traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxies matter for scale.&lt;/strong&gt; A single datacenter IP is fine for prototyping; for any real volume you'll want rotating (ideally residential) IPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize early.&lt;/strong&gt; Arabic district names have multiple romanizations — store the original Arabic string and a slugified key, and never join on the display name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat REGA as a strong signal, not gospel.&lt;/strong&gt; Join on it first, then fuzzy-match the remainder on &lt;code&gt;(city, district, price-bucket, beds, areaSqm)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather not build the plumbing
&lt;/h2&gt;

&lt;p&gt;I build data tools, so two honest plugs. To sketch a query — pick the platform, the listing type, and preview the output field shape before you write any code — I made a free query-builder at &lt;strong&gt;&lt;a href="https://datatooly.xyz/saudi-real-estate-search/" rel="noopener noreferrer"&gt;datatooly.xyz/saudi-real-estate-search&lt;/a&gt;&lt;/strong&gt;. It's a builder, not a live in-browser scraper, so it won't return listings itself.&lt;/p&gt;

&lt;p&gt;When you want the data flowing, the &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/saudi-real-estate-scraper?fpr=v77kxu" rel="noopener noreferrer"&gt;Saudi Real Estate Scraper&lt;/a&gt;&lt;/strong&gt; on Apify handles the multi-portal fetch, the embedded-JSON parsing, and the REGA-based dedup for you — free to start, then pay-as-you-go.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built both the query-builder and the Apify actor linked above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The takeaway, tool or no tool: in Saudi Arabia, the regulator handed you a free primary key. Use the REGA advertisement license number and your cross-portal dataset gets dramatically cleaner.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>datascience</category>
      <category>api</category>
      <category>realestate</category>
    </item>
    <item>
      <title>How to Extract Dubai Used Car Listings Data from Gulf Marketplaces</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Tue, 30 Jun 2026 20:40:19 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-extract-dubai-used-car-listings-data-from-gulf-marketplaces-2p44</link>
      <guid>https://dev.to/odeeb/how-to-extract-dubai-used-car-listings-data-from-gulf-marketplaces-2p44</guid>
      <description>&lt;p&gt;If you need &lt;strong&gt;Dubai used car listings data&lt;/strong&gt; — make, model, year, price, mileage, location, and dealer for thousands of vehicles — the good news is that the major Gulf marketplaces are far friendlier to extraction than most modern web apps. The listings render server-side, and the detail pages carry structured data you can parse without a headless browser. This post walks through the actual shape of that data on DubiCars and Dubizzle Motors, with a runnable example you can adapt today.&lt;/p&gt;

&lt;p&gt;I've spent time poking at all three. Here's what's true, what's easy, and where you'll hit a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The landscape: three platforms, three data shapes
&lt;/h2&gt;

&lt;p&gt;The UAE used-car market is concentrated across a few portals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DubiCars&lt;/strong&gt; — the UAE's largest dedicated auto portal, with tens of thousands of used listings live at any time. Listings render server-side, and individual car pages live at predictable URLs like &lt;code&gt;https://www.dubicars.com/2024-toyota-prado-vxr-&amp;lt;id&amp;gt;.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dubizzle Motors&lt;/strong&gt; — the general-classifieds giant, with tens of thousands of used cars in the UAE. It's a Next.js application, which (as we'll see) is both a gift and a headache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(YallaMotor is another large regional portal worth knowing about, though we'll focus on DubiCars and Dubizzle here since their extraction patterns cover the two dominant approaches.)&lt;/p&gt;

&lt;p&gt;The thing that makes Gulf marketplaces pleasant to work with is that they're SEO-driven. These sites &lt;em&gt;want&lt;/em&gt; Google to read their inventory, so they expose it server-side and they annotate it. That's exactly the data you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why JSON-LD is your best friend
&lt;/h2&gt;

&lt;p&gt;Dealerships and marketplaces embed &lt;a href="https://schema.org/Car" rel="noopener noreferrer"&gt;Schema.org&lt;/a&gt; structured data in their pages so search engines (and increasingly AI search systems) can render rich results — price, mileage, availability, the works. The &lt;a href="https://schema.org/docs/automotive.html" rel="noopener noreferrer"&gt;&lt;code&gt;Vehicle&lt;/code&gt;/&lt;code&gt;Car&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;Product&lt;/code&gt; types cover make, model, model year, mileage (&lt;code&gt;mileageFromOdometer&lt;/code&gt;), fuel type, transmission, body type, VIN, and price.&lt;/p&gt;

&lt;p&gt;For the data extractor, this is a free lunch. Instead of writing brittle CSS selectors against a layout that changes every quarter, you read a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block that the site maintains &lt;em&gt;for its own SEO&lt;/em&gt;. It's the most stable surface on the page.&lt;/p&gt;

&lt;p&gt;On DubiCars, the listing grid renders server-side — results are visible in raw HTML without executing a line of JavaScript (paginated, so you page through them) — and detail pages carry JSON-LD plus a clean field set: price (shown in AED with an optional USD toggle), year, mileage in km, location (emirate), regional spec (GCC / Japanese / American / Other — a DubiCars-specific field on the page, not a standard schema.org property), and dealer name with a link to the dealer profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  A runnable example
&lt;/h2&gt;

&lt;p&gt;Here's a self-contained Node.js script (Node 18+, native &lt;code&gt;fetch&lt;/code&gt;, no dependencies) that fetches a DubiCars-style detail page and extracts vehicle fields from its JSON-LD. It safely handles both a single object and the &lt;code&gt;@graph&lt;/code&gt; array form, and it normalizes the fields you actually care about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// extract-vehicle.mjs — Node 18+ (native fetch, no deps)&lt;/span&gt;

&lt;span class="cm"&gt;/** Pull every application/ld+json block out of raw HTML. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractJsonLdBlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="sr"&gt;/&amp;lt;script&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+type=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;application&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;ld&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;json&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;][^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;script&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Skip malformed blocks rather than crashing the run.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Flatten @graph and arrays into a flat list of typed nodes. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flattenNodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@graph&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@graph&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isVehicleNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Car&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Vehicle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;odo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mileageFromOdometer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;make&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;manufacturer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vehicleModelDate&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productionDate&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;priceCurrency&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mileage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;odo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mileageUnit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;odo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;odo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitText&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fuelType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fuelType&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transmission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vehicleTransmission&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bodyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bodyType&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vehicleIdentificationNumber&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractVehicle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// A realistic UA reduces the odds of an interstitial.&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(KHTML, like Gecko) Chrome/124.0 Safari/537.36&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept-Language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US,en;q=0.9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flattenNodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extractJsonLdBlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vehicle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isVehicleNode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;vehicle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No Vehicle/Car/Product JSON-LD found on page.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vehicle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Usage: node extract-vehicle.mjs &amp;lt;listing-url&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;extractVehicle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Extraction failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against a detail URL and you get a clean object. The pattern is deliberately defensive: malformed JSON-LD blocks are skipped, both &lt;code&gt;@graph&lt;/code&gt; and bare-object forms are handled, and missing fields come back as &lt;code&gt;null&lt;/code&gt; instead of throwing. That matters at scale, because no marketplace annotates every listing identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  When there's no JSON-LD: read the embedded state
&lt;/h2&gt;

&lt;p&gt;Dubizzle Motors is a Next.js app. Next.js apps serialize their server-fetched data into a &lt;code&gt;&amp;lt;script id="__NEXT_DATA__" type="application/json"&amp;gt;&lt;/code&gt; block at the bottom of the HTML — the same data the React app hydrates from. For listing and search pages, that JSON often contains the full result set (and frequently more fields than the visible card shows). The extraction approach is identical to the JSON-LD one: grab the block, &lt;code&gt;JSON.parse&lt;/code&gt; it, then walk &lt;code&gt;props.pageProps&lt;/code&gt; to find the listings array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractNextData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sr"&gt;/&amp;lt;script id="__NEXT_DATA__" type="application&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;json"&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;script&amp;gt;/&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest catch: &lt;strong&gt;Dubizzle sits behind a WAF.&lt;/strong&gt; Plain datacenter requests get challenged or blocked, and naive &lt;code&gt;fetch&lt;/code&gt; calls are often detected by their TLS fingerprint, headers notwithstanding. Reaching it reliably needs a request library that mimics a real browser's TLS handshake plus residential or well-rotated proxies, and a respectful crawl rate. DubiCars is far more forgiving — start there if you're learning.&lt;/p&gt;

&lt;p&gt;A few ground rules regardless of platform: keep concurrency low, cache pages you've already fetched, identify yourself honestly in your User-Agent where reasonable, and check each site's terms before you collect at volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you'd rather not maintain the plumbing
&lt;/h2&gt;

&lt;p&gt;Selector drift, WAF challenges, proxy rotation, and bilingual (English/Arabic) field normalization add up. If you just want the data, I built two things to skip the maintenance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A free &lt;strong&gt;&lt;a href="https://datatooly.xyz/gulf-used-car-search/" rel="noopener noreferrer"&gt;Gulf Used Car query builder&lt;/a&gt;&lt;/strong&gt; that lets you pick a platform and preview the output shape before you commit to anything. (It's a query/preview builder — it doesn't scrape live in your browser.)&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/gulf-used-car-scraper?fpr=v77kxu" rel="noopener noreferrer"&gt;Gulf Used Car Scraper&lt;/a&gt;&lt;/strong&gt; on Apify, which handles DubiCars and Dubizzle Motors end to end — JSON-LD parsing, &lt;code&gt;__NEXT_DATA__&lt;/code&gt; extraction, the WAF handling, and normalized output. It's free to start, then pay-as-you-go.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build and maintain both tools, and the Apify link is an affiliate link.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The reason &lt;strong&gt;Dubai used car listings data&lt;/strong&gt; is approachable comes down to one thing: these marketplaces publish structured data on purpose. Read the JSON-LD when it's there, fall back to &lt;code&gt;__NEXT_DATA__&lt;/code&gt; when it isn't, write defensive parsers that tolerate missing fields, and treat Dubizzle's WAF with respect. Start with the runnable script above against DubiCars, and you'll have normalized listings flowing in an afternoon.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>datascience</category>
      <category>api</category>
      <category>automotive</category>
    </item>
    <item>
      <title>How to Build an H-1B Salary Database by Employer (the Real Data Source + Python)</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Tue, 30 Jun 2026 20:39:57 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-build-an-h-1b-salary-database-by-employer-the-real-data-source-python-pc1</link>
      <guid>https://dev.to/odeeb/how-to-build-an-h-1b-salary-database-by-employer-the-real-data-source-python-pc1</guid>
      <description>&lt;p&gt;If you've searched for an &lt;strong&gt;H-1B salary database by employer&lt;/strong&gt; and hoped to find a clean JSON API you could just hit, you've probably been disappointed. There isn't one — at least not from the government. But the underlying data is fully public, authoritative, and very queryable once you know where it actually lives. This post shows you the real source, what's in it, and how to query H-1B salary data by employer and role programmatically with Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data actually comes from
&lt;/h2&gt;

&lt;p&gt;Every site you've seen with H-1B salaries — h1bdata.info, h1bgrader, and the rest — is re-publishing the same upstream source: the &lt;strong&gt;LCA (Labor Condition Application) disclosure data&lt;/strong&gt; released by the U.S. Department of Labor's Office of Foreign Labor Certification (OFLC).&lt;/p&gt;

&lt;p&gt;Here's how it works. Before an employer can file an H-1B petition, it must submit a Labor Condition Application to the DOL attesting to the wage it will pay. By law, OFLC publishes these applications as &lt;strong&gt;public disclosure data&lt;/strong&gt;. The files cover the H-1B, H-1B1, and E-3 programs and are released &lt;strong&gt;quarterly&lt;/strong&gt; on the OFLC performance-data page:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://www.dol.gov/agencies/eta/foreign-labor/performance" rel="noopener noreferrer"&gt;https://www.dol.gov/agencies/eta/foreign-labor/performance&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A few things worth being precise about, because most blog posts get them fuzzy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's bulk download, not a live API.&lt;/strong&gt; OFLC posts Excel (&lt;code&gt;.xlsx&lt;/code&gt;) files — one per quarter since FY2020, and one per year for older years going back to 2008. There is no real-time query endpoint. You download the file and parse it yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each row is one application, not one approval.&lt;/strong&gt; The &lt;code&gt;CASE_STATUS&lt;/code&gt; column tells you whether it was Certified, Denied, Withdrawn, etc. If you want "real" offers, filter to &lt;code&gt;Certified&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wage is a disclosed offered wage, not actual paid salary.&lt;/strong&gt; It's the wage the employer attested to on the application. That's still the single best public benchmark for "what does Company X pay for Role Y," but it's not the same as a verified paystub.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each file has 75+ columns. The ones you almost always care about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CASE_STATUS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Certified / Denied / Withdrawn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMPLOYER_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The sponsoring employer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JOB_TITLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Employer-supplied job title&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SOC_TITLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standardized occupation (e.g. "Software Developers")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WAGE_RATE_OF_PAY_FROM&lt;/code&gt; / &lt;code&gt;..._TO&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Offered wage range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WAGE_UNIT_OF_PAY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Year / Hour / Month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WORKSITE_CITY&lt;/code&gt; / &lt;code&gt;WORKSITE_STATE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Where the role sits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RECEIVED_DATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When it was filed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Column names have drifted slightly over the years (older files used names like &lt;code&gt;LCA_CASE_EMPLOYER_NAME&lt;/code&gt;), so always check the record layout PDF that ships alongside each year's file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Querying it in Python
&lt;/h2&gt;

&lt;p&gt;Here's a complete, runnable example. Download a quarterly LCA disclosure &lt;code&gt;.xlsx&lt;/code&gt; from the OFLC page first (they're large — tens to over a hundred MB, hundreds of thousands of rows), then point this at it. It filters to certified cases, normalizes everything to an annual wage, and lets you query by employer and SOC role.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="c1"&gt;# Path to a quarterly LCA disclosure file downloaded from
# https://www.dol.gov/agencies/eta/foreign-labor/performance
&lt;/span&gt;&lt;span class="n"&gt;FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LCA_Disclosure_Data_FY2026_Q1.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Only read the columns we need — the full file has 75+ and is slow to load whole.
&lt;/span&gt;&lt;span class="n"&gt;COLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CASE_STATUS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EMPLOYER_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JOB_TITLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SOC_TITLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WAGE_RATE_OF_PAY_FROM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WAGE_UNIT_OF_PAY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WORKSITE_CITY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WORKSITE_STATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usecols&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openpyxl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 1. Keep only certified applications.
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CASE_STATUS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CERTIFIED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Normalize every wage to an annual figure. DOL's WAGE_UNIT_OF_PAY values are
# Year / Month / Bi-Weekly / Week / Hour, but the exact label and casing drift
# across years — so match case-insensitively and cover the common variants
# (otherwise an unmatched unit silently becomes NaN and the row is dropped).
&lt;/span&gt;&lt;span class="n"&gt;PERIODS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YEAR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MONTH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BI-WEEKLY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEEK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEEKLY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HOUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2080&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# 2080 = 40h * 52w
&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WAGE_RATE_OF_PAY_FROM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WAGE_UNIT_OF_PAY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annual_wage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PERIODS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropna&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annual_wage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;employer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;employer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EMPLOYER_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;employer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;na&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SOC_TITLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;na&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WORKSITE_STATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="c1"&gt;# Example: software roles at Stripe in California
&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;employer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Software&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; matching certified applications&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annual_wage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;()[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Median:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annual_wage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pandas.read_excel&lt;/code&gt; with &lt;code&gt;usecols&lt;/code&gt; is dramatically faster than loading all 75 columns. For repeated queries, convert the file to Parquet once (&lt;code&gt;df.to_parquet(...)&lt;/code&gt;) and read that instead — it's an order of magnitude quicker.&lt;/li&gt;
&lt;li&gt;Always normalize &lt;code&gt;WAGE_UNIT_OF_PAY&lt;/code&gt;. A surprising share of records are filed hourly, and if you don't convert them you'll get a "median salary" of $85 that quietly ruins your analysis.&lt;/li&gt;
&lt;li&gt;The example uses &lt;code&gt;WAGE_RATE_OF_PAY_FROM&lt;/code&gt; (the floor, and the conservative benchmark). The file also carries &lt;code&gt;WAGE_RATE_OF_PAY_TO&lt;/code&gt; for the ceiling of the posted range — add it to &lt;code&gt;COLS&lt;/code&gt; if you want both ends.&lt;/li&gt;
&lt;li&gt;Employer names are not deduplicated. "Amazon.com Services LLC", "Amazon Web Services", and "Amazon Development Center" are separate strings. Substring matching (as above) is the pragmatic fix; for serious work you'll want a normalization pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scaling it: many quarters, many years
&lt;/h2&gt;

&lt;p&gt;One quarter is a snapshot. To build a real &lt;strong&gt;H-1B salary database by employer&lt;/strong&gt; you'll want to concatenate several years of files so you can see trends and have enough rows per employer/role to compute a stable median:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;glob&lt;/span&gt;

&lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LCA_Disclosure_Data_FY20*.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# usecols errors if any name is missing, and column names drift across years —
&lt;/span&gt;    &lt;span class="c1"&gt;# so read the header row first and request only the columns that exist in this file.
&lt;/span&gt;    &lt;span class="n"&gt;avail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nrows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openpyxl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
    &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usecols&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;COLS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;avail&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openpyxl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's also where the work starts to add up: downloading every quarterly file, handling the column-name drift between years, deduplicating employers, and keeping it refreshed each quarter when OFLC posts new data. None of it is hard — it's just plumbing — but it's the part that turns "I found a CSV" into "I have a queryable benchmark."&lt;/p&gt;

&lt;h2&gt;
  
  
  A shortcut if you'd rather skip the plumbing
&lt;/h2&gt;

&lt;p&gt;If you just want to sketch the query you'd run — employer + role + state — before committing to the full ETL, I put together a small free &lt;strong&gt;query-builder&lt;/strong&gt; that lets you compose the lookup and preview the shape of the result: &lt;a href="https://datatooly.xyz/salary-benchmark-lookup/" rel="noopener noreferrer"&gt;datatooly.xyz/salary-benchmark-lookup&lt;/a&gt;. It builds the query for you; it does not run the dataset live in your browser (these files are far too large for that).&lt;/p&gt;

&lt;p&gt;And if you want the parsed, normalized, deduplicated data delivered as structured JSON without maintaining the pipeline yourself, I built an Apify actor for exactly this — &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/salarybench-iq?fpr=v77kxu" rel="noopener noreferrer"&gt;SalaryBench IQ&lt;/a&gt;&lt;/strong&gt; — which queries the DOL LCA disclosure data by employer/role/state and returns clean records. It's free to start, then pay-as-you-go.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built both of those tools, so treat that section as me telling you about my own work — the DOL-source facts and the code above stand entirely on their own.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest takeaway
&lt;/h2&gt;

&lt;p&gt;There is no official H-1B salary JSON API. But the data behind every H-1B salary site is one well-documented, free, public source: the DOL OFLC LCA disclosure files. Download a quarter, filter to &lt;code&gt;Certified&lt;/code&gt;, normalize the wage unit, and you have a defensible salary benchmark by employer and role in about 30 lines of Python. Everything past that — multi-year history, employer normalization, quarterly refresh — is just engineering you can do yourself or offload.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>api</category>
      <category>python</category>
      <category>career</category>
    </item>
    <item>
      <title>How to Scrape a Telegram Channel Without Login (No API Key, No Phone Number)</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Tue, 30 Jun 2026 20:39:36 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-scrape-a-telegram-channel-without-login-no-api-key-no-phone-number-54dp</link>
      <guid>https://dev.to/odeeb/how-to-scrape-a-telegram-channel-without-login-no-api-key-no-phone-number-54dp</guid>
      <description>&lt;p&gt;If you want to &lt;strong&gt;scrape a Telegram channel without login&lt;/strong&gt;, you do not need MTProto, you do not need a bot token, and you do not need to hand Telegram a phone number. Every public channel quietly exposes a server-rendered HTML preview that you can fetch with a plain HTTP request and parse with any HTML library. No account. No risk to an account you don't even have to create.&lt;/p&gt;

&lt;p&gt;This is the cleanest, lowest-friction way to pull recent posts from a public channel, and most people never discover it because the Telegram developer docs push you straight toward the full Bot API or the Telethon/MTProto client libraries. Those are powerful, but they're overkill — and a liability — when all you want is the public message feed.&lt;/p&gt;

&lt;p&gt;Let me show you exactly how it works, give you a runnable script, and be honest about where this approach hits a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: t.me/s/&amp;lt;channel&amp;gt;
&lt;/h2&gt;

&lt;p&gt;Telegram publishes a public web preview for every public channel at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://t.me/s/&amp;lt;channelname&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/s/&lt;/code&gt; is the important part — it serves the "preview" (the embeddable, indexable version). Hit &lt;code&gt;https://t.me/s/durov&lt;/code&gt; in a browser with JavaScript turned off and you'll still see the posts. That's the tell: the HTML is &lt;strong&gt;server-rendered&lt;/strong&gt;, so a basic HTTP client receives the fully-populated page. No headless browser required.&lt;/p&gt;

&lt;p&gt;Inside that HTML, each post lives in a predictable structure. The classes are the same ones the official Telegram post widget uses, and have been stable in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.tgme_widget_message&lt;/code&gt; — the container for a single post. It carries a &lt;code&gt;data-post&lt;/code&gt; attribute like &lt;code&gt;durov/123&lt;/code&gt;, where &lt;code&gt;123&lt;/code&gt; is the message ID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.tgme_widget_message_text&lt;/code&gt; — the post body (with inline HTML for links, bold, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.tgme_widget_message_date time&lt;/code&gt; — a &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt; element whose &lt;code&gt;datetime&lt;/code&gt; attribute is an ISO-8601 timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.tgme_widget_message_views&lt;/code&gt; — the view count, rendered as a human string like &lt;code&gt;12.4K&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.tgme_widget_message_photo_wrap&lt;/code&gt; / &lt;code&gt;.tgme_widget_message_video_wrap&lt;/code&gt; — media wrappers; the image URL is tucked into a &lt;code&gt;background-image&lt;/code&gt; CSS rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's everything you need to reconstruct a structured feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A runnable example
&lt;/h2&gt;

&lt;p&gt;Here's a self-contained Python script using &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;beautifulsoup4&lt;/code&gt;. It fetches one page of a public channel and extracts each post's ID, text, timestamp, and view count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scrape_channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://t.me/s/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# A normal browser UA avoids the occasional stripped-down response.
&lt;/span&gt;    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 (compatible; my-scraper/1.0)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;data_post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data-post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# e.g. "durov/123"
&lt;/span&gt;        &lt;span class="n"&gt;msg_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data_post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data_post&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="n"&gt;text_el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text_el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text_el&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;

        &lt;span class="n"&gt;time_el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message_date time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time_el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time_el&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;time_el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="n"&gt;views_el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message_views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;views_el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;views_el&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;scrape_channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;telegram&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; views) #&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the two dependencies and run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;requests beautifulsoup4
python scrape.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll get the most recent posts on the channel's preview page printed as structured records. No keys, no login, nothing to authorize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paginating backwards with ?before=
&lt;/h2&gt;

&lt;p&gt;A single fetch of &lt;code&gt;t.me/s/&amp;lt;channel&amp;gt;&lt;/code&gt; returns roughly the latest 16–20 posts. To go further back, use the &lt;code&gt;before=&lt;/code&gt; query parameter with a message ID. Telegram then returns the page of posts &lt;em&gt;older&lt;/em&gt; than that ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://t.me/s/&amp;lt;channel&amp;gt;?before=&amp;lt;message_id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the loop is simple: grab a page, find the &lt;strong&gt;smallest&lt;/strong&gt; &lt;code&gt;data-post&lt;/code&gt; message ID on it, then request the next page with &lt;code&gt;?before=&amp;lt;that_id&amp;gt;&lt;/code&gt;. Repeat until you hit your target count or stop getting new posts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scrape_paginated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;collected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://t.me/s/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?before=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="c1"&gt;# scrape_channel_from_url = the parse logic from the first example,
&lt;/span&gt;        &lt;span class="c1"&gt;# factored out to accept a full URL instead of building it from the channel name
&lt;/span&gt;        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scrape_channel_from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;collected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# oldest message id on this page becomes the next cursor
&lt;/span&gt;        &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;new_before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;new_before&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="c1"&gt;# no progress -&amp;gt; reached the end
&lt;/span&gt;            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_before&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;collected&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;max_posts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a short &lt;code&gt;time.sleep()&lt;/code&gt; between requests to be polite, and dedupe by message ID since page boundaries can overlap by one or two posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this approach stops working (be honest)
&lt;/h2&gt;

&lt;p&gt;This method is genuinely useful, but it has hard limits you should know up front:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public channels only.&lt;/strong&gt; Private channels, and &lt;em&gt;all&lt;/em&gt; groups, are invisible to &lt;code&gt;t.me/s/&lt;/code&gt;. Groups in particular require Telethon/MTProto with API credentials &lt;em&gt;and&lt;/em&gt; membership — there is no login-free path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recent messages, not full archives.&lt;/strong&gt; Paginating with &lt;code&gt;before=&lt;/code&gt; walks backwards, but Telegram does not serve unbounded deep history through the preview reliably. You can get a healthy window of recent posts; you cannot count on dumping a channel's entire multi-year backlog this way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rendered fields only.&lt;/strong&gt; You get what the preview shows — text, dates, view counts, media URLs, link previews. You do not get the raw API objects (reactions breakdowns, forward chains, edit history) that MTProto exposes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting.&lt;/strong&gt; It's an HTTP endpoint like any other. Hammer it from one IP and you'll get throttled. Space out requests and rotate if you're going wide.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your use case fits inside "recent posts from public channels," none of this matters and you're done. If you need full history at scale, you've outgrown the preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you outgrow the snippet
&lt;/h2&gt;

&lt;p&gt;Two things that saved me time, in increasing order of scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;To stop hand-checking class names and &lt;code&gt;before=&lt;/code&gt; cursors, I built a small free &lt;strong&gt;query builder&lt;/strong&gt; at &lt;a href="https://datatooly.xyz/telegram-channel-search/" rel="noopener noreferrer"&gt;datatooly.xyz/telegram-channel-search&lt;/a&gt;. You type a channel and pick the preview shape you want, and it generates the request configuration for you. It's a builder — it composes the query, it doesn't run a live scrape inside your browser.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When you need full history, many channels in parallel, media downloads, and managed proxies instead of babysitting a &lt;code&gt;requests&lt;/code&gt; loop, that's where a hosted actor earns its keep. I run &lt;a href="https://apify.com/constructive_calm/telegram-intelligence-pro?fpr=v77kxu" rel="noopener noreferrer"&gt;&lt;strong&gt;Telegram Intelligence Pro&lt;/strong&gt;&lt;/a&gt; on Apify for exactly that — it's free to start, then pay-as-you-go, so you can try it on one channel before committing to a bulk job.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build the datatooly tool and the Apify actor linked above; the t.me/s/ technique and the code here work entirely on their own without either.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;To &lt;strong&gt;scrape a Telegram channel without login&lt;/strong&gt;: fetch &lt;code&gt;https://t.me/s/&amp;lt;channel&amp;gt;&lt;/code&gt;, parse &lt;code&gt;.tgme_widget_message&lt;/code&gt; blocks for &lt;code&gt;data-post&lt;/code&gt; IDs, &lt;code&gt;.tgme_widget_message_text&lt;/code&gt;, the &lt;code&gt;&amp;lt;time datetime&amp;gt;&lt;/code&gt; attribute, and &lt;code&gt;.tgme_widget_message_views&lt;/code&gt;, then page backwards with &lt;code&gt;?before=&amp;lt;oldest_id&amp;gt;&lt;/code&gt;. No API key, no phone number, no MTProto — just HTTP and an HTML parser. Mind the limits (public channels, recent posts) and reach for a hosted tool only when you actually need scale.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>api</category>
      <category>osint</category>
    </item>
    <item>
      <title>Understat xG Data Export: How to Pull Expected Goals Programmatically (Python + CSV)</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Tue, 30 Jun 2026 20:39:15 +0000</pubDate>
      <link>https://dev.to/odeeb/understat-xg-data-export-how-to-pull-expected-goals-programmatically-python-csv-4l4b</link>
      <guid>https://dev.to/odeeb/understat-xg-data-export-how-to-pull-expected-goals-programmatically-python-csv-4l4b</guid>
      <description>&lt;p&gt;If you've searched for an &lt;strong&gt;Understat xG data export&lt;/strong&gt; and come up empty, here's the honest truth up front: Understat does not publish an official public API. There's no documented REST endpoint, no API key, no &lt;code&gt;/v1/xg&lt;/code&gt; route. But the expected-goals data is absolutely reachable programmatically — it's sitting right inside the HTML of every page, and once you know the trick, exporting it to CSV is a dozen lines of Python.&lt;/p&gt;

&lt;p&gt;This post walks through exactly how Understat ships its data, the one decoding gotcha that trips most people up, and a runnable scraper that gets you league, player, and match xG into a clean DataFrame you can write straight to CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the xG data actually lives
&lt;/h2&gt;

&lt;p&gt;Understat (&lt;a href="https://understat.com" rel="noopener noreferrer"&gt;understat.com&lt;/a&gt;) covers six leagues — the Premier League, La Liga, Bundesliga, Serie A, Ligue 1, and the Russian Premier League — for every season since 2014/15. When you open a page like &lt;code&gt;https://understat.com/league/EPL/2023&lt;/code&gt;, the tables you see (xG, xGA, shots, points) are &lt;strong&gt;not&lt;/strong&gt; rendered server-side as HTML rows. The page ships a near-empty table and a block of JavaScript that builds it client-side.&lt;/p&gt;

&lt;p&gt;If you view source and search for &lt;code&gt;xG&lt;/code&gt;, you'll find something like this inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;teamsData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x7B&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x221&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x22&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x3A&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x7B&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x22id&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x22&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x3A&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x221&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;x22...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole game. The data is a JSON string passed to &lt;code&gt;JSON.parse()&lt;/code&gt;, but every character has been escaped into &lt;code&gt;\xNN&lt;/code&gt; hex sequences (and some &lt;code&gt;\uNNNN&lt;/code&gt; unicode escapes). A naive &lt;code&gt;json.loads()&lt;/code&gt; on that raw string will fail because Python sees literal backslash-x sequences, not the characters they represent.&lt;/p&gt;

&lt;p&gt;Which variable holds what:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/league/&amp;lt;LEAGUE&amp;gt;/&amp;lt;SEASON&amp;gt;&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;datesData&lt;/code&gt; (every fixture with team-level xG), &lt;code&gt;teamsData&lt;/code&gt; (season table with xG/xGA/npxG/PPDA), and &lt;code&gt;playersData&lt;/code&gt; (per-player xG, xA, shots, key passes).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/player/&amp;lt;PLAYER_ID&amp;gt;&lt;/code&gt;&lt;/strong&gt; — that player's match-by-match and grouped shot/xG data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/match/&amp;lt;MATCH_ID&amp;gt;&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;shotsData&lt;/code&gt; (every shot with xG, x/y coordinates, situation, result) plus &lt;code&gt;rostersData&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So once you can decode one variable, you can decode all of them. The only thing that changes is the URL and the variable name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decode that everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;The reliable pattern, confirmed across multiple community scrapers, is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag containing your target variable.&lt;/li&gt;
&lt;li&gt;Slice out the string between &lt;code&gt;('&lt;/code&gt; and &lt;code&gt;')&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Turn the &lt;code&gt;\x&lt;/code&gt; escapes back into real characters with &lt;code&gt;.encode("utf-8").decode("unicode_escape")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Parse the result with &lt;code&gt;json.loads()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;code&gt;decode("unicode_escape")&lt;/code&gt; step is the key line. It converts &lt;code&gt;\x7B&lt;/code&gt; into &lt;code&gt;{&lt;/code&gt;, &lt;code&gt;\x22&lt;/code&gt; into &lt;code&gt;"&lt;/code&gt;, and so on, leaving you with a normal JSON string. One gotcha worth knowing up front: &lt;code&gt;unicode_escape&lt;/code&gt; decodes those bytes as latin-1, so accented player names (Ødegaard, Sané) come out as mojibake unless you re-encode to latin-1 and decode as UTF-8 — the scraper below does exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  A runnable Python scraper
&lt;/h2&gt;

&lt;p&gt;Here's a self-contained script that pulls the season player table for a league and writes it to CSV. It only needs &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;beautifulsoup4&lt;/code&gt;, and &lt;code&gt;pandas&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 (compatible; xg-export/1.0)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_understat_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pull one JSON variable (e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;playersData&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) out of an Understat page.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;script&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;var_name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Grab the string passed to JSON.parse('...')
&lt;/span&gt;            &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
            &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="c1"&gt;# Turn \x7B etc. back into real characters, then parse.
&lt;/span&gt;            &lt;span class="c1"&gt;# NOTE: unicode_escape decodes bytes as latin-1, which mangles
&lt;/span&gt;            &lt;span class="c1"&gt;# accented names (Ødegaard, Sané). Re-encode latin-1 -&amp;gt; decode utf-8 to fix it.
&lt;/span&gt;            &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unicode_escape&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latin-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;var_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not found at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;league_players&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;league&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://understat.com/league/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;league&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_understat_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playersData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;league_players&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EPL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Keep the columns most people want for an xG export
&lt;/span&gt;    &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;player_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;team_title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;games&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;goals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assists&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key_passes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npxG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# Numeric fields arrive as strings — cast the xG ones
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npxG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ascending&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inplace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;epl_2023_xg.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and you get &lt;code&gt;epl_2023_xg.csv&lt;/code&gt; — your &lt;strong&gt;Understat xG data export to CSV&lt;/strong&gt;, sorted by expected goals, ready for a notebook or a spreadsheet.&lt;/p&gt;

&lt;p&gt;A few notes that save debugging time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The numeric fields (&lt;code&gt;xG&lt;/code&gt;, &lt;code&gt;xA&lt;/code&gt;, &lt;code&gt;shots&lt;/code&gt;) come back as &lt;strong&gt;strings&lt;/strong&gt;, not numbers. Cast them before you do math, as the script does.&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;shot-level data&lt;/strong&gt;, hit a match page and pull &lt;code&gt;shotsData&lt;/code&gt;: &lt;code&gt;get_understat_var("https://understat.com/match/26618", "shotsData")&lt;/code&gt;. Each shot carries &lt;code&gt;xG&lt;/code&gt;, pitch coordinates &lt;code&gt;X&lt;/code&gt;/&lt;code&gt;Y&lt;/code&gt;, &lt;code&gt;situation&lt;/code&gt;, &lt;code&gt;shotType&lt;/code&gt;, and &lt;code&gt;result&lt;/code&gt; — everything you need for a shot map.&lt;/li&gt;
&lt;li&gt;Be polite: add a delay between requests if you loop over a whole season, and cache responses so you're not re-hitting the site during development.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Doing it in JavaScript
&lt;/h2&gt;

&lt;p&gt;Same idea in Node — with one catch: &lt;code&gt;JSON.parse&lt;/code&gt; itself &lt;strong&gt;rejects&lt;/strong&gt; &lt;code&gt;\x&lt;/code&gt; escapes (JSON only permits &lt;code&gt;\uNNNN&lt;/code&gt;), so you can't just feed it the raw body. Convert the &lt;code&gt;\xNN&lt;/code&gt; byte escapes to real bytes first, then decode them as UTF-8 (this also handles accented names correctly):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://understat.com/league/EPL/2023&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/var&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+playersData&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*=&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*JSON&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;parse&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="sr"&gt;'&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// \xNN escapes are raw UTF-8 bytes -&amp;gt; rebuild the byte array, then decode UTF-8&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;x&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9A-Fa-f&lt;/span&gt;&lt;span class="se"&gt;]{2})&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;players&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;player_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;xG&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double &lt;code&gt;JSON.parse&lt;/code&gt; looks odd but is deliberate: the inner one turns the &lt;code&gt;\x&lt;/code&gt;-escaped body into a clean JSON string, the outer one parses that JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the DIY route stops being worth it
&lt;/h2&gt;

&lt;p&gt;Scraping one league-season is easy. Scraping &lt;em&gt;every&lt;/em&gt; shot from &lt;em&gt;every&lt;/em&gt; match across &lt;em&gt;six leagues and ten seasons&lt;/em&gt; is a different job — you're into rate limits, retries, schema drift, and de-duplication. At that point the script above becomes a small maintenance project.&lt;/p&gt;

&lt;p&gt;If you'd rather skip that, two things I built can help. The first is a free &lt;strong&gt;&lt;a href="https://datatooly.xyz/understat-football-stats/" rel="noopener noreferrer"&gt;Understat query builder&lt;/a&gt;&lt;/strong&gt; — you pick a league and season and it shows you the exact field shape you'll get back, so you can plan your export without reading raw HTML. (It assembles and previews the request; it doesn't scrape live in the browser.) The second, for production-scale pulls, is the &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/understat-football-analytics?fpr=v77kxu" rel="noopener noreferrer"&gt;Understat Football Analytics actor&lt;/a&gt;&lt;/strong&gt; on Apify, which handles the league/player/match extraction and returns structured rows you can export to CSV, JSON, or Excel. It's free to start, then pay-as-you-go.&lt;/p&gt;

&lt;p&gt;Disclosure: I built both of those tools, so treat that last paragraph as the author's pitch — the scraping method above stands entirely on its own and is everything you need to export Understat xG data yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;There's no official Understat API, but there doesn't need to be. The xG, xA, and shot data is embedded as an escaped JSON string in each page; decode the &lt;code&gt;\x&lt;/code&gt; escapes with &lt;code&gt;unicode_escape&lt;/code&gt;, run &lt;code&gt;json.loads&lt;/code&gt;, and you have clean structured data. Point the same helper at &lt;code&gt;/league&lt;/code&gt;, &lt;code&gt;/player&lt;/code&gt;, or &lt;code&gt;/match&lt;/code&gt; URLs and you've covered league tables, player seasons, and shot maps — all exportable to CSV in a few lines.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>api</category>
      <category>football</category>
    </item>
    <item>
      <title>How to Build a Threads Scraper for Meta Profiles and Posts</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Sat, 13 Jun 2026 16:32:35 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-build-a-threads-scraper-for-meta-profiles-and-posts-4amd</link>
      <guid>https://dev.to/odeeb/how-to-build-a-threads-scraper-for-meta-profiles-and-posts-4amd</guid>
      <description>&lt;p&gt;If you want to build a &lt;strong&gt;Threads scraper&lt;/strong&gt;, the first thing to get straight is &lt;em&gt;what Threads actually is in 2026&lt;/em&gt; — because the surface has changed under everyone's feet. Threads is Meta's X-competitor, and it is no longer a small experiment: Meta reported it crossed &lt;strong&gt;400 million monthly active users&lt;/strong&gt; in August 2025. That growth is exactly why marketers, researchers, and data teams suddenly want programmatic access to profiles, posts, and hashtags.&lt;/p&gt;

&lt;p&gt;This guide is the honest version. I'll show you what loads without authentication, what doesn't, where Meta's official API helps versus where it doesn't, and a runnable code example you can adapt today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fact #1: It's threads.com now, not threads.net
&lt;/h2&gt;

&lt;p&gt;A surprising number of tutorials still hardcode &lt;code&gt;threads.net&lt;/code&gt;. That's stale. On &lt;strong&gt;April 24, 2025&lt;/strong&gt;, Meta officially migrated the canonical domain from &lt;code&gt;threads.net&lt;/code&gt; to &lt;strong&gt;&lt;code&gt;threads.com&lt;/code&gt;&lt;/strong&gt;. Meta didn't own the &lt;code&gt;.com&lt;/code&gt; at launch — it belonged to a messaging startup — and acquired it in September 2024 before flipping the canonical domain the following spring. Old &lt;code&gt;threads.net&lt;/code&gt; URLs now redirect, but if you're writing a &lt;strong&gt;Threads scraper&lt;/strong&gt;, target &lt;code&gt;threads.com&lt;/code&gt; directly so you skip a redirect hop and avoid brittle string matching.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Do this
&lt;/span&gt;&lt;span class="n"&gt;PROFILE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.threads.com/@zuck&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Not this (redirects, and you may parse a redirect interstitial)
# PROFILE_URL = "https://www.threads.net/@zuck"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fact #2: There is no open public API for general scraping
&lt;/h2&gt;

&lt;p&gt;This is the part people get wrong in both directions, so let's be precise.&lt;/p&gt;

&lt;p&gt;Meta &lt;em&gt;does&lt;/em&gt; publish an official &lt;strong&gt;Threads API&lt;/strong&gt;, opened to developers in 2024. It is genuinely useful for some things: publishing posts on behalf of an authenticated user, tokenless &lt;strong&gt;oEmbed&lt;/strong&gt; for embedding public posts, and a limited ability to search public posts by author or media type. But it is &lt;strong&gt;not an open data firehose&lt;/strong&gt;. To use it meaningfully you register a Meta Developer App and go through App Review, and the read surface is narrow and account-scoped — it's built for "let my app post and embed," not "let me pull arbitrary public profiles and their post history at scale."&lt;/p&gt;

&lt;p&gt;So when someone says "just use the API," the honest answer is: the official API solves &lt;em&gt;publishing&lt;/em&gt; well and &lt;em&gt;bulk public reading&lt;/em&gt; poorly. For competitive research, audience analysis, or trend tracking across accounts you don't own, you're going to read the public web surface instead. Which brings us to the good news.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fact #3: Public profiles and posts render cookie-free
&lt;/h2&gt;

&lt;p&gt;Threads is, relative to Instagram or LinkedIn, friendly to logged-out reading. &lt;strong&gt;Public posts render in the initial server-side HTML&lt;/strong&gt; for unauthenticated visitors. You don't need cookies, a logged-in session, or GraphQL &lt;code&gt;doc_id&lt;/code&gt; juggling to read a public profile's recent posts — the data is in the page Meta serves to crawlers.&lt;/p&gt;

&lt;p&gt;The cleanest way to trigger that crawler-friendly server-rendered HTML is to identify as Meta's own link-preview crawler, &lt;code&gt;facebookexternalhit&lt;/code&gt;. This is the bot Meta runs to build link previews when a URL is shared, and it reliably receives the SSR variant of the page. Combined with structured data embedded in the HTML, you get profile and post fields without browser automation.&lt;/p&gt;

&lt;p&gt;Here's a minimal, correct example in Python. It fetches a public profile page with the crawler user-agent and pulls structured data out of the HTML. No login, no headless browser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_public_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.threads.com/@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_jsonld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pull &amp;lt;script type=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;application/ld+json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; blocks (structured data).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script type=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/ld\+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&amp;gt;]*&amp;gt;(.*?)&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_public_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zuck&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;extract_jsonld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# ProfilePage / Person objects carry name, handle, description, etc.
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes so this holds up in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parse the JSON, don't regex the fields.&lt;/strong&gt; The HTML markup churns constantly; the embedded structured-data and inline JSON blobs are far more stable. Find the script blocks, &lt;code&gt;json.loads&lt;/code&gt; them, then walk the objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect more than one JSON shape.&lt;/strong&gt; Threads has shipped at least two structured-data layouts over time (a bare &lt;code&gt;Person&lt;/code&gt; object and a &lt;code&gt;ProfilePage&lt;/code&gt; wrapping &lt;code&gt;mainEntity&lt;/code&gt;). Handle both, or your parser silently returns nulls after Meta ships a tweak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the host.&lt;/strong&gt; If you accept arbitrary input URLs, make sure the host is &lt;em&gt;exactly&lt;/em&gt; &lt;code&gt;threads.com&lt;/code&gt; (or &lt;code&gt;www.threads.com&lt;/code&gt;). A naive suffix check like "ends with threads.com" will happily accept &lt;code&gt;notthreads.com&lt;/code&gt; and open you to SSRF. Match the host, not a substring.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fact #4: Search and reply-trees are the hard part
&lt;/h2&gt;

&lt;p&gt;Here's where logged-out reading hits its ceiling, and where honest expectation-setting matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Profiles and a profile's recent posts: easy.&lt;/strong&gt; Public, in the SSR HTML, cookie-free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full reply trees: limited.&lt;/strong&gt; Without an authenticated session, a post's discussion tree returns only the publicly-indexed posts that reference or quote it — roughly 15–30 — not the complete comment list. The deep thread requires a login Threads doesn't hand to anonymous crawlers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyword search and hashtags: partial.&lt;/strong&gt; You can pull top results for a tag or query from the public surface, but the volume and depth are capped by what Threads chooses to expose to logged-out users. Treat search/hashtag as "top sample," not "exhaustive archive," and design your downstream analytics around a sample, not a census.&lt;/p&gt;

&lt;p&gt;This isn't a flaw in your code — it's the platform boundary. A good &lt;strong&gt;Threads scraper&lt;/strong&gt; is explicit about which mode returns complete data (profile, posts-by-user) and which returns a public subset (search, hashtag, reply-tree). On the legal side, logged-out scraping of public data has generally been treated more favorably by US case law than authenticated scraping (e.g., &lt;em&gt;hiQ v. LinkedIn&lt;/em&gt;, &lt;em&gt;Meta v. Bright Data&lt;/em&gt;) — but that's a posture, not legal advice. Read Meta's terms and your own jurisdiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: modes you actually want
&lt;/h2&gt;

&lt;p&gt;A complete Threads scraper usually exposes these modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Profile&lt;/strong&gt; — handle, bio, follower count, bio links, verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Posts by user&lt;/strong&gt; — recent posts for one or more usernames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post detail&lt;/strong&gt; — a source post plus its public quote-reposts/references.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search&lt;/strong&gt; — top results for a keyword (sampled).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashtag&lt;/strong&gt; — top posts for a tag (sampled).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor&lt;/strong&gt; — emit only posts new since your last run, for ongoing tracking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first three return complete-ish public data; the last three are sample-or-delta by nature. Knowing that distinction up front saves you from promising stakeholders an "everything" dataset the platform won't give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  A faster path than hand-rolling it
&lt;/h2&gt;

&lt;p&gt;The code above works, but going from "fetches one profile" to "handles both JSON shapes, retries transient failures, rotates IPs when Meta rate-limits, paginates posts, and dedupes a monitor run" is real engineering. If you'd rather skip the maintenance treadmill, I built two things to help.&lt;/p&gt;

&lt;p&gt;First, a free &lt;strong&gt;&lt;a href="https://datatooly.xyz/threads-profile-search/" rel="noopener noreferrer"&gt;Threads query builder&lt;/a&gt;&lt;/strong&gt;. Important honest caveat: it is a &lt;em&gt;query builder&lt;/em&gt;, &lt;strong&gt;not&lt;/strong&gt; a live in-browser scraper. Threads isn't CORS-open, so nothing fetches live results in your tab. You pick a mode, type usernames or a query, set limits, and it previews the exact &lt;strong&gt;output shape&lt;/strong&gt; so you know the field structure before you run anything. It's the fastest way to design your schema.&lt;/p&gt;

&lt;p&gt;Second, the backing &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/threads-scraper?fpr=v77kxu" rel="noopener noreferrer"&gt;Threads Scraper actor on Apify&lt;/a&gt;&lt;/strong&gt; runs the configured job for real. It uses the cookie-free SSR approach described here (no login, no cookie management), supports all six modes above including monitor-deltas and bio-contact extraction, and is &lt;strong&gt;free to start, then pay-as-you-go&lt;/strong&gt; — the first 50 chargeable events per run are free, so you can validate output on real data before spending anything.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built the query-builder tool and the Apify actor referenced above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Whether you hand-roll it with the snippet here or run the actor, the takeaways are the same: target &lt;code&gt;threads.com&lt;/code&gt;, expect no open public API, lean on cookie-free SSR for profiles and posts, and treat search/hashtag/reply-trees as public samples rather than complete archives. Build for those boundaries and your scraper stays correct as Threads keeps shipping changes.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webscraping</category>
      <category>socialmedia</category>
      <category>datascience</category>
    </item>
    <item>
      <title>How to Build a LinkedIn Profile Scraper: The Honest Technical Guide</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Sat, 13 Jun 2026 16:32:13 +0000</pubDate>
      <link>https://dev.to/odeeb/how-to-build-a-linkedin-profile-scraper-the-honest-technical-guide-3dhf</link>
      <guid>https://dev.to/odeeb/how-to-build-a-linkedin-profile-scraper-the-honest-technical-guide-3dhf</guid>
      <description>&lt;p&gt;If you have ever tried to build a &lt;strong&gt;LinkedIn profile scraper&lt;/strong&gt;, you have probably discovered that the obvious path — "just call the API" — is a dead end. LinkedIn does not hand out programmatic access to arbitrary member profiles, and most of the tutorials that promise a five-line solution quietly skip the parts that actually matter: the data source, the legal posture, and why naive HTML parsing breaks.&lt;/p&gt;

&lt;p&gt;This article is the honest version. I will show you where public profile data genuinely lives, a correct code pattern for reading it, and the legal nuance you need to understand before you point any automation at LinkedIn. No fabricated benchmarks, no "100% undetectable" nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no open public API for profiles
&lt;/h2&gt;

&lt;p&gt;Let's get this out of the way first, because it shapes every decision downstream.&lt;/p&gt;

&lt;p&gt;LinkedIn has an API, but it is &lt;strong&gt;not&lt;/strong&gt; a general-purpose way to read other people's profiles. Public/open access to profile data was removed back in &lt;strong&gt;2015&lt;/strong&gt;. What remains in the self-service developer portal is narrow: "Sign in with LinkedIn" gives you the &lt;em&gt;authenticated user's own&lt;/em&gt; name, headline, and photo — and only with their consent. Anything richer (the Profile API, full work history, connections) is gated behind the &lt;strong&gt;LinkedIn Partner Program&lt;/strong&gt;, requires approval, and ships with hard restrictions.&lt;/p&gt;

&lt;p&gt;A few details that surprise people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Profile API restricts data retention — under the partner terms you generally may &lt;strong&gt;not cache or store&lt;/strong&gt; profile data beyond short, strictly time-limited windows.&lt;/li&gt;
&lt;li&gt;The API Terms of Use explicitly &lt;strong&gt;prohibit&lt;/strong&gt; scraping, combining LinkedIn data with other sources, reselling data, and using the API for lead generation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if your goal is "read public profile pages at scale for research or enrichment," the official API simply does not offer that product. That is not a loophole you are missing — it is a deliberate design choice. Which leads to the real question: what &lt;em&gt;is&lt;/em&gt; publicly available on the page itself?&lt;/p&gt;

&lt;h2&gt;
  
  
  What a public LinkedIn profile actually exposes
&lt;/h2&gt;

&lt;p&gt;Open a LinkedIn profile in an incognito window — no login — and you will see a public version of the page. That HTML is rendered for search engines and social-preview crawlers, and like most modern sites built for SEO, it embeds &lt;strong&gt;structured data&lt;/strong&gt; using &lt;a href="https://schema.org/ProfilePage" rel="noopener noreferrer"&gt;schema.org&lt;/a&gt; vocabulary in JSON-LD format.&lt;/p&gt;

&lt;p&gt;Concretely, public profile pages carry a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block describing a &lt;code&gt;Person&lt;/code&gt; (often nested inside a &lt;code&gt;ProfilePage&lt;/code&gt; via its &lt;code&gt;mainEntity&lt;/code&gt; property). Google has recommended JSON-LD for &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/profile-page" rel="noopener noreferrer"&gt;profile-page structured data&lt;/a&gt; since 2017, and LinkedIn populates it, likely because it wants those rich search results.&lt;/p&gt;

&lt;p&gt;This matters enormously for a scraper. Instead of writing brittle CSS selectors against an obfuscated, frequently-changing DOM, you parse a &lt;strong&gt;machine-readable JSON object&lt;/strong&gt; that the site publishes for search engines. It is more stable, more complete, and far less likely to silently break on a redesign.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correct pattern for reading the JSON-LD
&lt;/h2&gt;

&lt;p&gt;Here is a minimal, runnable Node.js example that extracts and parses JSON-LD from an HTML document. I am showing the &lt;em&gt;parsing&lt;/em&gt; logic — the part most tutorials get wrong — rather than encouraging you to hammer LinkedIn directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cheerio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Extract a Person object from a profile page's JSON-LD.
 * Handles both shapes seen in the wild:
 *   1. A bare Person at the top level
 *   2. A ProfilePage whose `mainEntity` is the Person
 */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractPerson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script[type="application/ld+json"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip malformed blocks instead of crashing the run&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// JSON-LD may be a single object or a @graph array&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@graph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@graph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ProfilePage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mainEntity&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mainEntity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractPerson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobTitle&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;contentUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sameAs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sameAs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// linked social/profile URLs&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the function tolerates &lt;strong&gt;both&lt;/strong&gt; the bare-&lt;code&gt;Person&lt;/code&gt; shape and the &lt;code&gt;ProfilePage.mainEntity&lt;/code&gt; shape — real pages drift between them, and a scraper that assumes only one will return nulls the day the markup changes. Second, malformed JSON-LD is skipped, not fatal. Defensive parsing is the difference between an enrichment job that quietly drops one row and one that kills the whole batch.&lt;/p&gt;

&lt;p&gt;What this snippet does &lt;strong&gt;not&lt;/strong&gt; show is fetching. That is intentional, because &lt;em&gt;how&lt;/em&gt; you request the page is where both the engineering and the law get interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fetching problem (and the trick that helps)
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;fetch()&lt;/code&gt; from a datacenter IP with a generic user agent usually gets you an interstitial or a login wall, not the public HTML. The page you see in incognito is served to &lt;em&gt;recognized crawlers&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The pragmatic approach is to identify your client as one of the social-preview bots LinkedIn already whitelists for link unfurling — for example the &lt;code&gt;facebookexternalhit/1.1&lt;/code&gt; user agent — and route the request through a proxy so you are not firing thousands of calls from one address. That combination tends to return the SSR HTML with the JSON-LD intact, &lt;strong&gt;cookie-free&lt;/strong&gt; (no logged-in session, no fake accounts). That is exactly the technique the actor I mention at the end uses: social-preview UA plus a datacenter proxy, parse the JSON-LD, then augment with a few DOM-extracted engagement counts for recent posts.&lt;/p&gt;

&lt;p&gt;The reason this is worth doing carefully rather than aggressively brings us to the part nobody should skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legal reality: hiQ v. LinkedIn
&lt;/h2&gt;

&lt;p&gt;You cannot write honestly about a &lt;strong&gt;LinkedIn profile scraper&lt;/strong&gt; without the &lt;code&gt;hiQ v. LinkedIn&lt;/code&gt; saga, and it is routinely misquoted in both directions. Here is what actually happened.&lt;/p&gt;

&lt;p&gt;In April 2022, the Ninth Circuit &lt;a href="https://www.jenner.com/en/news-insights/publications/client-alert-data-scraping-in-hiq-v-linkedin-the-ninth-circuit-reaffirms-narrow-interpretation-of-cfaa" rel="noopener noreferrer"&gt;reaffirmed a narrow reading&lt;/a&gt; of the Computer Fraud and Abuse Act (CFAA). The core holding: when a site &lt;em&gt;generally permits public access&lt;/em&gt; to data, scraping that public data is &lt;strong&gt;likely not&lt;/strong&gt; "access without authorization" under the CFAA. That is the line everyone celebrates — and it is real.&lt;/p&gt;

&lt;p&gt;But the story did not end there. In late 2022 the case &lt;a href="https://www.privacyworld.blog/2022/12/linkedins-data-scraping-battle-with-hiq-labs-ends-with-proposed-judgment/" rel="noopener noreferrer"&gt;resolved with a stipulated $500,000 judgment against hiQ&lt;/a&gt;. The district court had found that LinkedIn's &lt;strong&gt;user agreement&lt;/strong&gt; — which prohibits scraping and fake accounts — was enforceable as a matter of &lt;strong&gt;contract&lt;/strong&gt;. hiQ also caught CFAA liability tied specifically to using &lt;em&gt;fake accounts to reach password-protected pages&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The honest takeaway is two-sided:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scraping genuinely &lt;strong&gt;public&lt;/strong&gt; data is, in the Ninth Circuit, unlikely to be a &lt;em&gt;CFAA&lt;/em&gt; (anti-hacking) violation.&lt;/li&gt;
&lt;li&gt;That is &lt;strong&gt;not blanket permission&lt;/strong&gt;. Terms-of-service breach-of-contract claims are a separate and live risk, logging in or using fake accounts changes the analysis entirely, and this is &lt;strong&gt;evolving case law&lt;/strong&gt; — not settled, nationwide green light. Privacy regimes like GDPR add another independent layer if you touch EU residents' data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat "public + no login + respect the ToS posture + minimize footprint + know your jurisdiction" as the baseline, and get your own legal advice for anything commercial. Anyone who tells you scraping LinkedIn is flatly "legal" or flatly "illegal" is oversimplifying a genuinely nuanced area.&lt;/p&gt;

&lt;h2&gt;
  
  
  A faster way to prototype the output
&lt;/h2&gt;

&lt;p&gt;If you want to see the exact shape of the data before writing any code, I built a free &lt;strong&gt;&lt;a href="https://datatooly.xyz/linkedin-profile-lookup/" rel="noopener noreferrer"&gt;LinkedIn Profile Lookup query builder&lt;/a&gt;&lt;/strong&gt;. Important: it is a &lt;em&gt;query builder&lt;/em&gt;, not a live scraper — it assembles a ready-to-run input config and previews the JSON output shape (name, headline, work history, education, recent posts, articles) right in the page. It does &lt;strong&gt;not&lt;/strong&gt; fetch live results in your browser. It is just the fastest way to design your query and know what fields you will get back.&lt;/p&gt;

&lt;p&gt;When you are ready to actually run extraction at scale, that config drops straight into the &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/linkedin-profile-pro?fpr=v77kxu" rel="noopener noreferrer"&gt;LinkedIn Profile Pro actor on Apify&lt;/a&gt;&lt;/strong&gt;, which implements the cookie-free JSON-LD approach described above (social-preview UA + datacenter proxy, with residential fallback). It returns the parsed profile plus up to roughly ten recent posts and articles per profile, and it is &lt;strong&gt;free to start, then pay-as-you-go&lt;/strong&gt; — the first handful of profiles per run cost nothing for testing, and you are not charged for duplicates or invalid slugs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built both the query builder and the Apify actor linked above.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The durable lesson is that a good &lt;strong&gt;LinkedIn profile scraper&lt;/strong&gt; is mostly an exercise in reading the structured data a public page already publishes — not in defeating LinkedIn — and in respecting a legal boundary that is narrower and more nuanced than the headlines suggest. Parse the JSON-LD defensively, handle both &lt;code&gt;Person&lt;/code&gt; shapes, stay on genuinely public surfaces, never use fake logins, and keep the ToS and &lt;code&gt;hiQ&lt;/code&gt; precedent in mind. Do that, and you have an enrichment pipeline that is both robust and defensible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.jenner.com/en/news-insights/publications/client-alert-data-scraping-in-hiq-v-linkedin-the-ninth-circuit-reaffirms-narrow-interpretation-of-cfaa" rel="noopener noreferrer"&gt;Ninth Circuit / CFAA analysis (Jenner &amp;amp; Block)&lt;/a&gt;, &lt;a href="https://www.privacyworld.blog/2022/12/linkedins-data-scraping-battle-with-hiq-labs-ends-with-proposed-judgment/" rel="noopener noreferrer"&gt;hiQ settlement and breach-of-contract finding (Privacy World)&lt;/a&gt;, &lt;a href="https://www.linkedin.com/legal/l/api-terms-of-use" rel="noopener noreferrer"&gt;LinkedIn API Terms of Use&lt;/a&gt;, &lt;a href="https://schema.org/ProfilePage" rel="noopener noreferrer"&gt;schema.org ProfilePage&lt;/a&gt;, &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/profile-page" rel="noopener noreferrer"&gt;Google profile-page structured data&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webscraping</category>
      <category>datascience</category>
      <category>leadgen</category>
    </item>
    <item>
      <title>The SEC EDGAR API: A Practical Guide to Free Filing Data in Python</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Sat, 13 Jun 2026 16:31:52 +0000</pubDate>
      <link>https://dev.to/odeeb/the-sec-edgar-api-a-practical-guide-to-free-filing-data-in-python-15b</link>
      <guid>https://dev.to/odeeb/the-sec-edgar-api-a-practical-guide-to-free-filing-data-in-python-15b</guid>
      <description>&lt;p&gt;The &lt;strong&gt;SEC EDGAR API&lt;/strong&gt; is one of the best-kept secrets in financial data engineering: every mandatory disclosure filed by every U.S. public company, available as clean JSON, for free, with no API key. If you've ever paid for a "fundamentals" data vendor or scraped a brokerage page for a balance sheet, you've been working harder than you need to. The raw, authoritative source — quarterly revenue, insider trades, institutional holdings, 8-K event filings — is sitting on &lt;code&gt;data.sec.gov&lt;/code&gt; waiting for an HTTP GET.&lt;/p&gt;

&lt;p&gt;The catch is small but absolute, and it trips up almost everyone on their first request. Let's walk through how the API actually works, write a correct, runnable Python example, and cover the one rule that will get your IP blocked if you ignore it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "the SEC EDGAR API" actually is
&lt;/h2&gt;

&lt;p&gt;There isn't a single endpoint. "The SEC EDGAR API" is really three free public services that work together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The structured data API&lt;/strong&gt; (&lt;code&gt;data.sec.gov&lt;/code&gt;) — JSON endpoints for company submissions and XBRL financial facts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-text search&lt;/strong&gt; (&lt;code&gt;efts.sec.gov&lt;/code&gt;) — a keyword search index over the text of every filing submitted since 2001, including exhibits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ticker map&lt;/strong&gt; (&lt;code&gt;company_tickers.json&lt;/code&gt;) — a small file that maps stock tickers and company names to the internal IDs the other two services require.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of them require registration or an API key. All of them require one HTTP header. We'll get to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CIK: EDGAR's primary key
&lt;/h2&gt;

&lt;p&gt;EDGAR doesn't index companies by ticker. It uses a &lt;strong&gt;Central Index Key (CIK)&lt;/strong&gt; — a unique integer assigned to every filer. Apple's CIK is &lt;code&gt;320193&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things bite people here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need to translate a ticker (&lt;code&gt;AAPL&lt;/code&gt;) into a CIK before you can call most endpoints. That's what &lt;code&gt;company_tickers.json&lt;/code&gt; is for.&lt;/li&gt;
&lt;li&gt;In API URLs, the CIK &lt;strong&gt;must be zero-padded to exactly 10 digits&lt;/strong&gt;. Apple's &lt;code&gt;320193&lt;/code&gt; becomes &lt;code&gt;CIK0000320193&lt;/code&gt;. Pass the un-padded number and you'll get a 404.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the single most common silent failure when getting started, so bake the padding into a helper and never think about it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: declare a User-Agent or get a 403
&lt;/h2&gt;

&lt;p&gt;The SEC enforces a &lt;strong&gt;fair-access policy&lt;/strong&gt;. Every request must include a &lt;code&gt;User-Agent&lt;/code&gt; header that identifies who you are, and the policy asks for a contact — typically your name and email. Send a request without it, or with a generic library default, and EDGAR returns &lt;strong&gt;403 Forbidden&lt;/strong&gt; and may block your IP for roughly ten minutes.&lt;/p&gt;

&lt;p&gt;I confirmed this the hard way while researching this article: an automated fetch of an SEC documentation page with no declared User-Agent came straight back as &lt;code&gt;403 Forbidden&lt;/code&gt;. That's not an edge case — it's the designed behavior.&lt;/p&gt;

&lt;p&gt;This rule has a subtle, important consequence: &lt;strong&gt;a normal web browser cannot consume these endpoints directly.&lt;/strong&gt; Browser JavaScript is forbidden by the Fetch spec from setting the &lt;code&gt;User-Agent&lt;/code&gt; header — it's a "forbidden header name." So a pure in-browser tool physically cannot make a compliant request to &lt;code&gt;data.sec.gov&lt;/code&gt;. Any browser-based EDGAR helper is therefore a &lt;em&gt;query builder&lt;/em&gt; or &lt;em&gt;preview&lt;/em&gt; — it constructs the right URL for you to run server-side — not a live in-browser fetcher. Keep that distinction in mind; it matters when you choose tooling later.&lt;/p&gt;

&lt;p&gt;The other half of fair access is a &lt;strong&gt;rate limit of 10 requests per second&lt;/strong&gt; per IP. Exceed it and you'll see &lt;code&gt;429&lt;/code&gt; responses and, again, a temporary block. A simple &lt;code&gt;time.sleep(0.1)&lt;/code&gt; between calls, or capping yourself a little lower at ~8/s, keeps you safely compliant.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correct, runnable Python example
&lt;/h2&gt;

&lt;p&gt;Here's an end-to-end script: resolve a ticker to a CIK, zero-pad it, and pull a specific financial concept (annual revenue) from the XBRL &lt;code&gt;companyconcept&lt;/code&gt; endpoint. It uses only the standard &lt;code&gt;requests&lt;/code&gt; library and follows every fair-access rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="c1"&gt;# Identify yourself. The SEC fair-access policy requires a descriptive
# User-Agent with a contact. Use your real app name + email.
&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edgar-demo/1.0 (you@example.com)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_ticker_cik_map&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Download the official ticker -&amp;gt; CIK map.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.sec.gov/files/company_tickers.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Keys are arbitrary indices; each value has cik_str, ticker, title.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cik_str&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cik_padded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cik_int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;EDGAR requires the CIK zero-padded to 10 digits.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CIK&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cik_int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;010&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_concept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cik_int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;taxonomy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-gaap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fetch one XBRL concept (e.g. Revenues) for a company.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://data.sec.gov/api/xbrl/companyconcept/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cik_padded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cik_int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;taxonomy&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;concept&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tickers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_ticker_cik_map&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cik&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tickers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AAPL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AAPL CIK: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cik&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cik_padded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cik&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# stay under 10 req/s
&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_concept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cik&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Print annual (10-K) USD figures.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;units&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10-K&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frame&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frame&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;val&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the &lt;code&gt;User-Agent&lt;/code&gt; is doing real work — remove it and every call 403s. Second, XBRL concepts are &lt;em&gt;specific&lt;/em&gt;: revenue under modern US-GAAP is usually tagged &lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt;, not a friendly &lt;code&gt;Revenue&lt;/code&gt;. Discovering the right tag for each company is part of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other endpoints worth knowing
&lt;/h2&gt;

&lt;p&gt;Once you're past authentication, the API surface is broad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Submissions&lt;/strong&gt; — &lt;code&gt;https://data.sec.gov/submissions/CIK##########.json&lt;/code&gt; returns a company's filing history: every form type, accession number, and date. This is your entry point for "list all 10-Ks for this company."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Company facts&lt;/strong&gt; — &lt;code&gt;https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json&lt;/code&gt; returns &lt;em&gt;all&lt;/em&gt; XBRL facts for a company in one call. Heavy, but great for bulk extraction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frames&lt;/strong&gt; — &lt;code&gt;https://data.sec.gov/api/xbrl/frames/us-gaap/{CONCEPT}/{UNIT}/CY{YEAR}.json&lt;/code&gt; flips the axis: one concept across &lt;em&gt;every&lt;/em&gt; company for a period. Perfect for cross-sectional analysis ("every filer's 2024 revenue").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-text search&lt;/strong&gt; — &lt;code&gt;https://efts.sec.gov/LATEST/search-index?q=...&lt;/code&gt; searches the text of all filings since 2001 by keyword, with filters for form type, date range, and entity. No key, same User-Agent rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it gets hard (and where a tool helps)
&lt;/h2&gt;

&lt;p&gt;The endpoints are free and well-documented, but turning them into a usable dataset is more work than a single GET. Real projects hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;XBRL tag archaeology&lt;/strong&gt; — companies use different, sometimes deprecated, tags for the same concept across years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form-specific parsing&lt;/strong&gt; — Form 4 (insider trades), Form 13F (institutional holdings), and 8-K item codes each have their own nested structures and quirks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination, rate-limit backoff, and ticker resolution&lt;/strong&gt; plumbing you rewrite on every project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser problem&lt;/strong&gt; — you can't prototype a live query from a web UI because of the User-Agent restriction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to &lt;em&gt;design&lt;/em&gt; a query before you write the plumbing, a free &lt;strong&gt;&lt;a href="https://datatooly.xyz/sec-edgar-search/" rel="noopener noreferrer"&gt;SEC EDGAR query builder&lt;/a&gt;&lt;/strong&gt; lets you assemble the right endpoint and parameters and preview the request shape. Because of the User-Agent rule above, it builds and previews the query — it does not execute a live fetch in your browser; you run the generated request server-side.&lt;/p&gt;

&lt;p&gt;When you'd rather skip the plumbing entirely, the &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/sec-edgar-scraper?fpr=v77kxu" rel="noopener noreferrer"&gt;SEC EDGAR Scraper actor&lt;/a&gt;&lt;/strong&gt; handles the compliant-User-Agent requests, rate limiting, and parsing for you. It exposes nine modes — filings, normalized financials, raw XBRL facts, full-text search, Form 4 insider trades, Form 13F holdings, activist (SC 13D/G) stakes, a latest-filings feed, and parsed 8-K items — with ticker-to-CIK resolution built in and output as JSON, CSV, Excel, or XML. It's free to start (the first 50 chargeable events per run are free), then pay-as-you-go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The SEC EDGAR API gives you institutional-grade financial data for the price of a well-formed HTTP header. Remember the three rules — &lt;strong&gt;declare a User-Agent, zero-pad your CIK to 10 digits, and stay under 10 requests per second&lt;/strong&gt; — and the entire corpus of U.S. public-company disclosures is yours to query. Start with the &lt;code&gt;company_tickers.json&lt;/code&gt; map, graduate to &lt;code&gt;companyconcept&lt;/code&gt; for targeted facts or &lt;code&gt;frames&lt;/code&gt; for cross-sectional pulls, and reach for the full-text index when you need to find filings by what they &lt;em&gt;say&lt;/em&gt;, not just who filed them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm the author of the SEC EDGAR Scraper actor and the linked query builder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.sec.gov/search-filings/edgar-search-assistance/accessing-edgar-data" rel="noopener noreferrer"&gt;SEC: Accessing EDGAR Data&lt;/a&gt;, &lt;a href="https://www.sec.gov/search-filings/edgar-application-programming-interfaces" rel="noopener noreferrer"&gt;SEC: EDGAR APIs&lt;/a&gt;, &lt;a href="https://www.sec.gov/edgar/search/efts-faq.html" rel="noopener noreferrer"&gt;SEC: EDGAR Full Text Search FAQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
    <item>
      <title>The TikTok Ad Library API: A Developer's Honest Guide to the DSA Commercial Content Library</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Sat, 13 Jun 2026 16:30:29 +0000</pubDate>
      <link>https://dev.to/odeeb/the-tiktok-ad-library-api-a-developers-honest-guide-to-the-dsa-commercial-content-library-5446</link>
      <guid>https://dev.to/odeeb/the-tiktok-ad-library-api-a-developers-honest-guide-to-the-dsa-commercial-content-library-5446</guid>
      <description>&lt;p&gt;If you have ever tried to find a clean, documented TikTok ad library API, you have probably hit a wall of marketing pages, half-answers, and tools that promise "global TikTok ads" without telling you what is actually inside. This guide cuts through that. I will explain exactly what TikTok exposes, what it does &lt;em&gt;not&lt;/em&gt;, where the data comes from, and how to query it programmatically without guessing.&lt;/p&gt;

&lt;p&gt;The short version: there is a real, public TikTok ad transparency database, but its scope is narrower than most people assume, and there are two very different access paths with very different rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the TikTok Ad Library actually is
&lt;/h2&gt;

&lt;p&gt;TikTok runs a &lt;strong&gt;Commercial Content Library&lt;/strong&gt; at &lt;a href="https://library.tiktok.com" rel="noopener noreferrer"&gt;library.tiktok.com&lt;/a&gt;. It exists because of the EU's &lt;strong&gt;Digital Services Act (DSA)&lt;/strong&gt;, which requires very large online platforms to keep a searchable, public archive of the advertising they serve. So this is not a marketing feature TikTok built for fun — it is a regulatory obligation.&lt;/p&gt;

&lt;p&gt;That regulatory origin shapes everything about it, including the single most important fact you need before you write a line of code:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;US ads are not in this library.&lt;/strong&gt; The Commercial Content Library covers ads shown to users in the EU/EEA, plus the UK, Switzerland, and Türkiye. It does not cover the United States, Brazil, India, Mexico, Canada, Japan, or Australia.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practice the supported set is the 27 EU member states, the three additional EEA countries (Iceland, Liechtenstein, Norway), the UK, Switzerland, and Türkiye — 33 regions in total. If you query an unsupported country, you get an HTTP 400, not an empty result. I have seen plenty of teams burn a sprint building "US TikTok ad monitoring" on top of this data before discovering the US simply is not there. Don't be that team.&lt;/p&gt;

&lt;p&gt;(Separately, TikTok also runs the &lt;strong&gt;Creative Center&lt;/strong&gt;, a global "top ads" showcase at &lt;code&gt;ads.tiktok.com/business/creativecenter&lt;/code&gt;. That is a curated highlight reel, often login-gated, and is a different surface from the DSA library. Keep the two straight — people conflate them constantly.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The two access paths
&lt;/h2&gt;

&lt;p&gt;There are two ways to programmatically reach Commercial Content Library data, and confusing them is the root of most "the TikTok ad library API doesn't work" complaints.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The official Commercial Content API (gated)
&lt;/h3&gt;

&lt;p&gt;TikTok publishes an official &lt;strong&gt;Commercial Content API&lt;/strong&gt; under &lt;a href="https://developers.tiktok.com/products/commercial-content-api" rel="noopener noreferrer"&gt;developers.tiktok.com&lt;/a&gt;. It is OAuth-based and free, but it is &lt;em&gt;gated&lt;/em&gt;. Per TikTok's own documentation, eligibility is limited to qualifying academic institutions and non-profit researchers in the US, EEA, UK, and Switzerland (plus certain Brazilian researchers studying youth safety). &lt;strong&gt;Commercial users, creators, and advertisers are explicitly ineligible.&lt;/strong&gt; Approved applications get a client key and are tightly rate-limited under a non-commercial-use commitment — TikTok's Research Tools documentation cites a ceiling on the order of 1,000 requests per day, and the Commercial Content endpoints additionally cap a single call at roughly 50 ads, so high-volume pulls mean a lot of paginated calls. TikTok says you typically hear back on a Commercial Content API application within about two working days.&lt;/p&gt;

&lt;p&gt;So if you are an academic, this is your path. If you are building anything commercial, you are not eligible — and that is by design, not an oversight.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The public library's JSON endpoints
&lt;/h3&gt;

&lt;p&gt;The public-facing library at &lt;code&gt;library.tiktok.com&lt;/code&gt; is a normal web app that talks to a JSON backend. Because the library itself is public to everyone regardless of location, those read endpoints are reachable without OAuth. This is the path that powers most third-party tooling for the DSA library.&lt;/p&gt;

&lt;p&gt;I want to be precise and honest here: this is the &lt;em&gt;public&lt;/em&gt; library data, the same archive any person can browse in their own browser. It is not a private feed, and it is rate-limited at scale. Below is the real request shape, which I verified directly against the live endpoints rather than copying from docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A working request
&lt;/h2&gt;

&lt;p&gt;The flow is: discover supported regions, then POST a search scoped to one region and a time window. Time bounds are &lt;strong&gt;mandatory&lt;/strong&gt; and expressed in &lt;strong&gt;Unix seconds&lt;/strong&gt;. Here is a runnable Node example (Node 18+ has &lt;code&gt;fetch&lt;/code&gt; built in):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://library.tiktok.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1) Supported regions (cache this ~24h)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRegions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/support-regions`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region_list&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// [{ region: "DE", name: "Germany" }, ...]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2) Keyword search, scoped to ONE region + a time window&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;searchAds&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/search`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`?region=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;type=1&amp;amp;start_time=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;end_time=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;query_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// STRING. 1=All, 2=AdvName, 3=Keyword&lt;/span&gt;
      &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;last_shown_date,desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// server caps page size at 12 regardless&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Rate-limit quirk: a soft limit returns HTTP 200 with a PLAIN-TEXT&lt;/span&gt;
  &lt;span class="c1"&gt;// body ("limit exceed"), so res.ok is true but JSON.parse throws.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/limit&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*exceed|too&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*many/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rate-limited (soft 429) — back off and retry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// { code: 0, data: [...ads...], total, has_more, search_id }&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;searchAds&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skincare&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`total=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, first page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things that will save you hours, all learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;query_type&lt;/code&gt; is a string&lt;/strong&gt; (&lt;code&gt;"1"&lt;/code&gt;/&lt;code&gt;"2"&lt;/code&gt;/&lt;code&gt;"3"&lt;/code&gt;), not an integer — and several other response fields are typed as strings too, so don't assume numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page size is server-capped at 12&lt;/strong&gt;, no matter what &lt;code&gt;limit&lt;/code&gt; you send. Paginate with &lt;code&gt;offset&lt;/code&gt; plus the &lt;code&gt;search_id&lt;/code&gt; cursor from the previous response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;region=ALL&lt;/code&gt; is rejected.&lt;/strong&gt; One ISO region code per call.&lt;/li&gt;
&lt;li&gt;The response is flat: &lt;code&gt;data&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the array of ads, and &lt;code&gt;total&lt;/code&gt; / &lt;code&gt;has_more&lt;/code&gt; / &lt;code&gt;search_id&lt;/code&gt; sit at the top level.&lt;/li&gt;
&lt;li&gt;A soft rate limit returns &lt;strong&gt;HTTP 200 with a plain-text body&lt;/strong&gt;, not JSON. Sniff the body before &lt;code&gt;JSON.parse&lt;/code&gt; and treat that as a retryable 429 with exponential backoff.&lt;/li&gt;
&lt;li&gt;Video creative URLs are signed and expire (roughly 24h), so store a &lt;code&gt;fetched_at&lt;/code&gt; timestamp next to any media URL you keep.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you get per ad
&lt;/h2&gt;

&lt;p&gt;This is where the DSA library is genuinely interesting. Because the law mandates targeting transparency, each ad detail record exposes far more than a creative thumbnail. Across the search and per-ad detail endpoints you can assemble roughly &lt;strong&gt;32 fields per ad&lt;/strong&gt;: the creative URLs, advertiser identity and registered business location, the sponsor/payer, first- and last-shown dates, and — the valuable part — &lt;strong&gt;audience targeting and reach broken down by region, age bracket, and gender&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That demographic breakdown is richer than the comparable Meta Ad Library, which only exposes reach and spend data for political and social-issue ads (outside the EU, where the DSA forces broader disclosure). On TikTok's DSA library, the targeting tree is available for commercial ads broadly. If you are doing competitive ad intelligence or studying how a brand splits spend across age and gender in different EU markets, that tree is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  A faster way to explore before you build
&lt;/h2&gt;

&lt;p&gt;Hand-building region codes, Unix timestamps, and &lt;code&gt;query_type&lt;/code&gt; values gets tedious when you are just trying to see whether a brand or keyword has any coverage. I maintain a small free query-builder, &lt;a href="https://datatooly.xyz/tiktok-ad-library-search/" rel="noopener noreferrer"&gt;TikTok Ad Library Search&lt;/a&gt;, that lets you assemble a valid query — keywords plus one of the 33 supported regions — and preview the request you would send. To be clear about what it is: it is a &lt;strong&gt;query builder and previewer&lt;/strong&gt;, not a live in-browser scraper, so it helps you get the parameters right before you run anything.&lt;/p&gt;

&lt;p&gt;When you are ready to actually pull ads at volume — paginating past the 12-per-page cap, enriching each ad with its full targeting tree, and handling the soft-rate-limit and signed-URL quirks above — that is what my Apify actor, &lt;a href="https://apify.com/constructive_calm/tiktok-ad-library-pro?fpr=v77kxu" rel="noopener noreferrer"&gt;TikTok Ad Library Pro&lt;/a&gt;, automates. It takes keywords, advertiser names, or business IDs, returns the ~32-field records described above across all 33 DSA regions, and is &lt;strong&gt;free to start, then pay-as-you-go&lt;/strong&gt; (the first chargeable events on each run are free, so you can validate it against your own use case before committing).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built both the free query-builder and the Apify actor linked above.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest bottom line
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;TikTok ad library API&lt;/strong&gt; is real and, for the DSA region, surprisingly rich — but it is an EU-transparency tool, not a global ad-spying firehose. Internalize three things and you will avoid every common trap: the data is &lt;strong&gt;EU/EEA + UK/CH/TR only (no US)&lt;/strong&gt;, the &lt;strong&gt;official API is gated to non-commercial researchers&lt;/strong&gt;, and the public library's endpoints are usable but &lt;strong&gt;rate-limited and full of small typing quirks&lt;/strong&gt; (string enums, 12-per-page caps, plain-text rate-limit bodies, mandatory Unix-second time bounds). Build with those constraints in mind and the targeting data you get back is some of the most detailed ad transparency available anywhere.&lt;/p&gt;

</description>
      <category>api</category>
      <category>marketing</category>
      <category>webdev</category>
      <category>tiktok</category>
    </item>
    <item>
      <title>Building a Facebook Ad Library Scraper: API Limits and the Real Approach</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Sat, 13 Jun 2026 16:30:25 +0000</pubDate>
      <link>https://dev.to/odeeb/building-a-facebook-ad-library-scraper-api-limits-and-the-real-approach-3bad</link>
      <guid>https://dev.to/odeeb/building-a-facebook-ad-library-scraper-api-limits-and-the-real-approach-3bad</guid>
      <description>&lt;p&gt;If you want to pull a competitor's running ads programmatically, building a &lt;strong&gt;Facebook ad library scraper&lt;/strong&gt; sounds like it should be a solved problem. Meta has a public Ad Library &lt;em&gt;and&lt;/em&gt; an official API, so surely you just grab a token and query? Not quite. The gap between what the official API covers and what most people actually need is the single most expensive misunderstanding in this space, and it sends a lot of developers down a dead end on day one.&lt;/p&gt;

&lt;p&gt;This post walks through what's real: where the data lives, exactly what the official API will and won't give you, what the data shape looks like, and what it actually takes to extract commercial ads at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different things: the Library vs. the API
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Meta/Facebook Ad Library&lt;/strong&gt; is a public, browser-accessible database of ads. You can open &lt;code&gt;https://www.facebook.com/ads/library/&lt;/code&gt;, pick a country from the dropdown, choose &lt;strong&gt;"All ads"&lt;/strong&gt; for general commercial advertising, type in an advertiser name or keyword, and results load immediately. No login, no account required for commercial ads. For each ad you can see the creative (image, video, or carousel), the primary text and headline, the call-to-action, the advertiser's Page name, which platforms it runs on (Facebook, Instagram, Messenger, Audience Network), its start date, and active/inactive status — including the multiple variations a brand is split-testing at once. It's a genuinely rich competitive-intelligence surface.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Meta Ad Library API&lt;/strong&gt; is a separate, gated product — and this is where expectations break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API only covers political and issue ads
&lt;/h2&gt;

&lt;p&gt;Here's the fact that isn't obvious until you've already spent an afternoon on it: the official Ad Library API is scoped to &lt;strong&gt;ads about social issues, elections, or politics&lt;/strong&gt;, plus ads delivered to the EU and associated territories. General &lt;strong&gt;commercial / "All ads" content is not queryable through the API.&lt;/strong&gt; The public website lets you &lt;em&gt;browse&lt;/em&gt; commercial ads; the API does not let you &lt;em&gt;pull&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;On top of the scope limit, getting access is a process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity verification.&lt;/strong&gt; You confirm your identity and location at &lt;code&gt;facebook.com/ID&lt;/code&gt;, uploading a government ID (passport, national ID, or driver's license) and confirming your country of residence. Approval typically takes one to three business days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Meta for Developers app.&lt;/strong&gt; Once verified, you create an app and add the "Ad Library API" product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokens and permissions.&lt;/strong&gt; You issue an access token with the appropriate scopes (&lt;code&gt;ads_read&lt;/code&gt;, and for the archive, &lt;code&gt;ads_archive&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Worth noting for anyone targeting Europe: as of October 6, 2025, Meta no longer permits political, electoral, or social-issue ads in the EU at all. So the API's "EU-delivered ads" coverage now effectively means the historical archive of those ads — not new ones going forward.&lt;/p&gt;

&lt;p&gt;So if your verified token &lt;em&gt;does&lt;/em&gt; clear all those hoops and you're researching, say, election spending, a call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="c1"&gt;# Official Meta Ad Library API — POLITICAL / ISSUE ads ONLY.
# Commercial "All ads" are NOT available through this endpoint.
&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_VERIFIED_ACCESS_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_terms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;climate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ad_reached_countries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;US&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ad_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POLITICAL_AND_ISSUE_ADS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# the only broadly supported type
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ad_active_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ad_creative_bodies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ad_delivery_start_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publisher_platforms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;impressions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://graph.facebook.com/v25.0/ads_archive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# use the current Graph API version
&lt;/span&gt;    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ad_delivery_start_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire official path — and it's a fine path for political-transparency research. But if you're doing competitor analysis, e-commerce product research, or creative inspiration, none of those ads are political, so the API returns nothing useful to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The commercial use case = scraping the public Library
&lt;/h2&gt;

&lt;p&gt;When people search for a "facebook ad library scraper," they almost always mean the commercial case: &lt;em&gt;"show me every active ad this brand is running, with the creatives and copy."&lt;/em&gt; Since the API doesn't serve that, the only route is extracting it from the public Library website. And the public Library is built to resist exactly that.&lt;/p&gt;

&lt;p&gt;What you run into, in roughly the order you'll hit it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a JavaScript application.&lt;/strong&gt; The ads aren't in the initial HTML. A plain &lt;code&gt;requests.get()&lt;/code&gt; returns a shell; you need a real browser engine (Playwright/Puppeteer) that executes JS and lets the results render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fingerprint and handshake checks.&lt;/strong&gt; Meta inspects the TLS handshake, the HTTP/2 settings frame, and the browser fingerprint &lt;em&gt;before&lt;/em&gt; serving content. A default headless Chromium gets flagged on the very first navigation — which is why naive &lt;code&gt;got-scraping&lt;/code&gt;-class HTTP clients also get challenged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP reputation and rate limiting.&lt;/strong&gt; Requests from datacenter IPs or repetitive patterns get throttled or blocked quickly. Rotating residential proxies are typically required so traffic blends in with organic users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shifting selectors.&lt;/strong&gt; Meta restructures the layout and renames element classes regularly, so brittle CSS selectors break without warning. Extraction logic has to be defensive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is impossible — it's just real engineering with ongoing maintenance, not a weekend script. Build it yourself and you're signing up to babysit a headless-browser fleet, a proxy budget, and a parser that breaks every time Meta ships a redesign.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the extracted data actually looks like
&lt;/h2&gt;

&lt;p&gt;Whether you build it or buy it, here's a realistic shape for one commercial ad pulled from the public Library. Designing your downstream code against this shape early saves a lot of rework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ad_archive_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567890123456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Outdoor Co."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"100064123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ad_creative"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Built for the Trail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Our lightest pack yet. Free shipping this week only."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cta_text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shop Now"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"link_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://acmeoutdoor.example/packs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://scontent.example/ad_img_01.jpg"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"videos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publisher_platforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"FACEBOOK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INSTAGRAM"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ad_delivery_start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ad_delivery_stop_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ad_snapshot_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.facebook.com/ads/library/?id=1234567890123456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's present here that the &lt;em&gt;political&lt;/em&gt; API doesn't expose for commercial advertisers — the creative assets, CTA, and destination URL — and what's absent: there are no &lt;code&gt;impressions&lt;/code&gt; or &lt;code&gt;spend&lt;/code&gt; ranges. Those metrics are only published for political/issue ads. For commercial ads, you get creative and delivery metadata, not spend. Knowing that boundary keeps you from promising a stakeholder numbers that don't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  A faster path: query builder + a hosted scraper
&lt;/h2&gt;

&lt;p&gt;If you'd rather not hand-roll the browser-and-proxy stack, two tools shorten the loop. I work on these, so treat this as a disclosure, not a neutral review.&lt;/p&gt;

&lt;p&gt;To get the request right before you write any code, the free &lt;strong&gt;&lt;a href="https://datatooly.xyz/facebook-ad-library-search/" rel="noopener noreferrer"&gt;Facebook Ad Library search builder&lt;/a&gt;&lt;/strong&gt; lets you assemble a search config — keyword, advertiser, country, filters — and preview the output shape you'll get back. It's a &lt;strong&gt;query builder&lt;/strong&gt;: it constructs the configuration and shows you the structure, not a live in-browser scrape (Meta isn't CORS-open, so no browser-side tool can fetch results directly). It's a quick way to nail down your parameters and field expectations up front.&lt;/p&gt;

&lt;p&gt;When you're ready to actually pull data, &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/facebook-ad-library-pro?fpr=v77kxu" rel="noopener noreferrer"&gt;Facebook Ad Library Pro&lt;/a&gt;&lt;/strong&gt; runs the extraction on the Apify platform — search by keyword, advertiser, or country, and get ad creatives, text, platforms, and dates, plus deeper ad-detail scraping, with the headless browser, proxy rotation, and parser maintenance handled for you. It's &lt;strong&gt;free to start, then pay-as-you-go&lt;/strong&gt; through Apify platform credits, so you can validate it against a real competitor before committing budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;For a &lt;strong&gt;facebook ad library scraper&lt;/strong&gt;, draw the line clearly: the official Meta Ad Library API is real but narrow — political and issue ads, ID-verified access, no commercial coverage. The broad competitor-research use case lives in the public Library, which means JavaScript rendering, fingerprinting, proxies, and shifting selectors. Decide which side of that line your project sits on &lt;em&gt;before&lt;/em&gt; you write code, design against the actual data shape (creatives yes, commercial spend no), and you'll skip the most common multi-day detour in this whole space.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>marketing</category>
      <category>datascience</category>
    </item>
    <item>
      <title>App Store Top Charts API: Free, Key-Free, and CORS-Open</title>
      <dc:creator>Omar Eldeeb</dc:creator>
      <pubDate>Mon, 01 Jun 2026 08:43:43 +0000</pubDate>
      <link>https://dev.to/odeeb/app-store-top-charts-api-free-key-free-and-cors-open-kb6</link>
      <guid>https://dev.to/odeeb/app-store-top-charts-api-free-key-free-and-cors-open-kb6</guid>
      <description>&lt;p&gt;If you've ever wanted an &lt;strong&gt;app store top charts API&lt;/strong&gt; that you can hit straight from a browser tab — no API key, no OAuth dance, no server proxy — there's good news. Apple still serves a legacy iTunes RSS feed that returns the App Store top charts as plain JSON, and (the part most people miss) it's CORS-open. That means a single &lt;code&gt;fetch()&lt;/code&gt; from client-side JavaScript works. No backend required.&lt;/p&gt;

&lt;p&gt;This post walks through exactly how the endpoint is shaped, what the JSON looks like, the limits you'll hit, and where it stops being usable from the browser. Everything here is verified against the live feed, with a runnable example you can paste into your console right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;

&lt;p&gt;The pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://itunes.apple.com/{cc}/rss/{chart}/limit={N}/json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three pieces matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{cc}&lt;/code&gt;&lt;/strong&gt; — a two-letter country code (&lt;code&gt;us&lt;/code&gt;, &lt;code&gt;gb&lt;/code&gt;, &lt;code&gt;jp&lt;/code&gt;, &lt;code&gt;de&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt;, …). Charts are per-country, so the US top free list is often very different from Japan's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{chart}&lt;/code&gt;&lt;/strong&gt; — one of three values:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;topfreeapplications&lt;/code&gt; — ranked by download velocity (free apps)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;toppaidapplications&lt;/code&gt; — ranked by download velocity (paid apps)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;topgrossingapplications&lt;/code&gt; — ranked by revenue, which &lt;strong&gt;includes in-app purchases&lt;/strong&gt; (this is why a free-to-download game with aggressive IAP can top grossing while sitting far down the free chart)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;&lt;code&gt;{N}&lt;/code&gt;&lt;/strong&gt; — how many entries you want, e.g. &lt;code&gt;limit=100&lt;/code&gt;.&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;So the US top free applications, top 100, is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://itunes.apple.com/us/rss/topfreeapplications/limit=100/json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole API. No registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works from a browser
&lt;/h2&gt;

&lt;p&gt;The thing that makes this endpoint special for front-end developers is that &lt;code&gt;itunes.apple.com&lt;/code&gt; returns a permissive &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header on these RSS responses. Your browser won't block the cross-origin read. You can build a chart widget, a dashboard, or a quick research tool entirely client-side.&lt;/p&gt;

&lt;p&gt;Here's a real, runnable example. Drop it into your browser console or a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag and it returns immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTopCharts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;topfreeapplications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://itunes.apple.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/rss/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/limit=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/json`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Apple RSS responded &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;im:name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;developer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;im:artist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Top 10 free apps in the US App Store&lt;/span&gt;
&lt;span class="nf"&gt;getTopCharts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;topfreeapplications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the equivalent one-liner with curl, for shell and CI use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://itunes.apple.com/us/rss/topfreeapplications/limit=10/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.feed.entry[] | {name: .["im:name"].label, dev: .["im:artist"].label}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The JSON shape
&lt;/h2&gt;

&lt;p&gt;The response is a single object with one top-level &lt;code&gt;feed&lt;/code&gt; key. The chart itself lives in &lt;code&gt;feed.entry&lt;/code&gt;, an array where each element is one ranked app. Position in the array &lt;strong&gt;is&lt;/strong&gt; the rank — index 0 is #1.&lt;/p&gt;

&lt;p&gt;Each entry I pulled from the live feed contains these fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;im:name&lt;/code&gt; — the app name (read &lt;code&gt;.label&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;im:artist&lt;/code&gt; — the developer/publisher (read &lt;code&gt;.label&lt;/code&gt;; it may also carry a developer URL in &lt;code&gt;attributes.href&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;category&lt;/code&gt; — genre, with the human-readable name under &lt;code&gt;attributes.label&lt;/code&gt; and the genre ID under &lt;code&gt;attributes.im:id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;link&lt;/code&gt; — the App Store URL under &lt;code&gt;attributes.href&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt; — the canonical app store id, including the numeric &lt;code&gt;im:id&lt;/code&gt; attribute&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;im:image&lt;/code&gt; — usually three sizes of icon&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;im:price&lt;/code&gt; — formatted price plus an &lt;code&gt;amount&lt;/code&gt;/&lt;code&gt;currency&lt;/code&gt; attribute pair&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;rights&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;im:contentType&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The slightly awkward part is Apple's namespaced keys (&lt;code&gt;im:name&lt;/code&gt;, &lt;code&gt;im:artist&lt;/code&gt;) and the consistent &lt;code&gt;{ label, attributes }&lt;/code&gt; wrapper on almost every field. Once you internalize "the value I want is usually under &lt;code&gt;.label&lt;/code&gt;, and the metadata is under &lt;code&gt;.attributes&lt;/code&gt;," parsing is trivial. The mapping function above handles it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits (so you don't get surprised)
&lt;/h2&gt;

&lt;p&gt;A few honest constraints worth knowing before you build on this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The feed caps at 100 entries per chart.&lt;/strong&gt; You can ask for &lt;code&gt;limit=200&lt;/code&gt;, but you'll get at most 100 back. There is no offset/pagination parameter to walk deeper into the rankings. If you need rank 101+, this feed can't give it to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's per-country, one country per request.&lt;/strong&gt; Want the top charts for 30 markets? That's 30 requests. There's no "all countries" call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's overall charts only via this simple path.&lt;/strong&gt; The three chart types above are the clean, reliable ones. Category-scoped charts exist on Apple's side but aren't a first-class part of this simple RSS path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's the &lt;em&gt;legacy&lt;/em&gt; feed.&lt;/strong&gt; Apple has a newer marketing-tools feed (more on that next), and while the iTunes RSS endpoint has been stable for years, it's not formally a "supported product." Treat it as best-effort.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a huge share of use cases — a "what's trending today" widget, competitor monitoring, a side-project leaderboard — 100 apps per chart per country is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newer feed: server-side only
&lt;/h2&gt;

&lt;p&gt;You may run into Apple's newer endpoints at &lt;code&gt;rss.marketingtools.apple.com&lt;/code&gt; (also referenced as &lt;code&gt;applemarketingtools.com&lt;/code&gt;). These return similar top-charts data and are perfectly usable — &lt;strong&gt;but not from a browser.&lt;/strong&gt; Those endpoints do &lt;strong&gt;not&lt;/strong&gt; send permissive CORS headers, so a client-side &lt;code&gt;fetch()&lt;/code&gt; to them will be blocked by the browser's same-origin policy.&lt;/p&gt;

&lt;p&gt;So the rule of thumb is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browser / client-side code →&lt;/strong&gt; use &lt;code&gt;itunes.apple.com/{cc}/rss/...&lt;/code&gt; (CORS-open).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side code (Node, Python, a cron job, a backend route) →&lt;/strong&gt; either feed works, including the newer marketing-tools one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't try to call &lt;code&gt;rss.marketingtools.apple.com&lt;/code&gt; from front-end JavaScript and expect it to work; it won't, and the failure looks like a confusing CORS error rather than a clear message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Google Play?
&lt;/h2&gt;

&lt;p&gt;This is the other honest caveat. There is &lt;strong&gt;no equivalent CORS-open, key-free JSON feed for Google Play top charts.&lt;/strong&gt; Play's charts aren't exposed as a browser-fetchable JSON endpoint the way Apple's RSS is, so any "Play top charts" lookup needs to run server-side (typically through a proxy or a scraping layer) rather than from the browser. If your project needs both stores, plan for an App Store-from-browser / Play-from-server split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it live, then scale it up
&lt;/h2&gt;

&lt;p&gt;If you just want to &lt;em&gt;see&lt;/em&gt; the App Store top charts right now without writing a line of code, I built a free tool that runs this exact iTunes RSS feed &lt;strong&gt;live in your browser&lt;/strong&gt;: &lt;strong&gt;&lt;a href="https://datatooly.xyz/app-store-top-charts/" rel="noopener noreferrer"&gt;datatooly.xyz/app-store-top-charts&lt;/a&gt;&lt;/strong&gt;. Pick a country and a chart type and it fetches the real feed client-side — the same endpoint described above, no key, nothing fake. It's a good way to eyeball the data shape before you wire it into your own code.&lt;/p&gt;

&lt;p&gt;When you outgrow the 100-app, one-country-at-a-time, no-history ceiling of the raw feed, the same data is available at scale through the &lt;strong&gt;&lt;a href="https://apify.com/constructive_calm/app-store-rank-tracker?fpr=v77kxu" rel="noopener noreferrer"&gt;App Store + Google Play Rank Tracker actor on Apify&lt;/a&gt;&lt;/strong&gt;. It covers 150+ countries, all chart types plus category charts, per-app enrichment (ratings, reviews, screenshots), rank deltas with risers/fallers and a forecast, scheduled history so you can track movement over time, and JSON/CSV/API output — including Google Play, which (as noted) you can't reach from the browser. It's free to start, then pay-as-you-go.&lt;/p&gt;

&lt;p&gt;Disclosure: I built both the free tool and the Apify actor.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Endpoint: &lt;code&gt;https://itunes.apple.com/{cc}/rss/{topfree|toppaid|topgrossing}applications/limit={N}/json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CORS-open → works from a browser, no API key&lt;/li&gt;
&lt;li&gt;Parse &lt;code&gt;feed.entry[]&lt;/code&gt;; read names/devs/categories under &lt;code&gt;.label&lt;/code&gt;, links/ids under &lt;code&gt;.attributes&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Caps at 100 per chart, one country per call, no deep pagination&lt;/li&gt;
&lt;li&gt;Top Free/Paid = download velocity; Top Grossing = revenue incl. IAP&lt;/li&gt;
&lt;li&gt;Use the newer &lt;code&gt;rss.marketingtools.apple.com&lt;/code&gt; feed &lt;strong&gt;server-side only&lt;/strong&gt; (not CORS-open)&lt;/li&gt;
&lt;li&gt;Google Play has no browser-fetchable equivalent — proxy it server-side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy the &lt;code&gt;fetch()&lt;/code&gt; snippet above and you'll have live App Store top charts in under a minute.&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
