<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[LurkAPI]]></title><description><![CDATA[LurkAPI is a social media data API. Send one GET request and get clean JSON from public pages on 16 platforms, including TikTok, Instagram, YouTube, Reddit, X a]]></description><link>https://lurkapi.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 21:07:27 GMT</lastBuildDate><atom:link href="https://lurkapi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Is web scraping legal? The social media cases so far]]></title><description><![CDATA[In the US, scraping public pages logged out is likely not hacking. Scrapers get sued over terms they accepted, fake accounts and, now, bot checks.]]></description><link>https://lurkapi.hashnode.dev/is-web-scraping-legal-the-social-media-cases-so-far</link><guid isPermaLink="true">https://lurkapi.hashnode.dev/is-web-scraping-legal-the-social-media-cases-so-far</guid><category><![CDATA[web scraping]]></category><category><![CDATA[legal]]></category><category><![CDATA[privacy]]></category><category><![CDATA[#gdpr]]></category><dc:creator><![CDATA[Mesmer Lord]]></dc:creator><pubDate>Tue, 03 Nov 2026 23:08:40 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published at <a href="https://lurkapi.com/blog/is-web-scraping-legal">https://lurkapi.com/blog/is-web-scraping-legal</a></em></p>
<p><img src="https://media.lurkapi.com/blog/is-web-scraping-legal/hero.webp" alt="A balance scale holding two blank browser windows level, one of them with an amber padlock on it." /></p>
<p>In the US, mostly yes, if the pages are public and you aren’t logged in. Courts have ruled that reading a public page isn’t hacking, even with a bot and even after the site tells you to stop. Scrapers who lose get caught on something else, and the best-known case shows how.</p>
<p>hiQ, the startup that won the “public pages aren’t hacking” ruling twice, still agreed to a $500,000 judgment and a permanent ban. It had accepted LinkedIn’s terms when it bought ads. Bright Data had Facebook and Instagram accounts for its own ads too, and beat Meta, because it scraped logged out.</p>
<p>The new risk, since July 2026, is bot checks. Four rulings let platforms and creators sue companies accused of getting past them, under the DMCA, a copyright law. That law protects posts, photos and videos, not facts: a follower count isn’t copyrighted, a video is. None of this is legal advice.</p>
<h2>Public pages, logged out: likely not hacking</h2>
<p>The federal anti-hacking law, the CFAA, covers accessing a computer “without authorization”. The Supreme Court read that in 2021 as a question of gates: did you go somewhere that was closed to you? Applying it to hiQ, which scraped public LinkedIn profiles, the Ninth Circuit found that a public website <a href="https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/18/17-16783.pdf">“has erected no gates to lift or lower in the first place”</a>. A cease-and-desist letter didn’t change that.</p>
<p>Logging in is different. Power Ventures kept pulling users’ data from behind Facebook’s login after Facebook told it to stop, and the Ninth Circuit called that access without authorization in 2016. Even then, a company suing under the CFAA has to show $5,000 of loss in a year. Ryanair won a jury verdict against Booking.com and had it set aside in 2025, because it could prove only $2,457.72.</p>
<p>Having accounts doesn’t put you behind the login either. Bright Data scraped Meta logged out, and the judge wrote that “a visitor is not a ‘user’” bound by Meta’s terms. X’s suit against Bright Data was dismissed the same year: rate limits and CAPTCHAs didn’t make public posts “any less public”.</p>
<table>
<thead>
<tr>
<th>What the scraper did</th>
<th>What courts said</th>
</tr>
</thead>
<tbody><tr>
<td>Read public pages, logged out</td>
<td>Likely not hacking (hiQ, 2022). Meta and X lost their suits against Bright Data (2024).</td>
</tr>
<tr>
<td>Scraped after accepting the site’s terms</td>
<td>Breach of contract. hiQ agreed to a $500,000 judgment and a ban (2022).</td>
</tr>
<tr>
<td>Used fake accounts, or a login after being told to stop</td>
<td>Breach (hiQ, ProAPIs), and behind a login, access without authorization (Power Ventures, 2016).</td>
</tr>
<tr>
<td>Accused of getting past a bot check</td>
<td>DMCA claims went ahead in four rulings, July to October 2026.</td>
</tr>
</tbody></table>
<p><em>What courts have said so far, by what the scraper did.</em></p>
<h2>Terms you accepted: hiQ won twice and ended with a $500,000 judgment</h2>
<p><a href="https://media.lurkapi.com/blog/is-web-scraping-legal/hiq-v-linkedin.mp4"><img src="https://media.lurkapi.com/blog/is-web-scraping-legal/hiq-v-linkedin.webp" alt="hiQ won the hacking question twice, then lost on the terms it had accepted: $500,000 and a ban." /></a></p>
<p><em>hiQ won the hacking question twice, then lost on the terms it had accepted: $500,000 and a ban.</em></p>
<p>Most articles call hiQ a win for scrapers. It ended in December 2022 with hiQ agreeing to a <a href="https://storage.courtlistener.com/recap/gov.uscourts.cand.312704/gov.uscourts.cand.312704.405.0.pdf">$500,000 judgment</a> and a permanent ban on scraping LinkedIn “whether logged in to a LinkedIn account or not”.</p>
<p>The hacking rulings never touched the contract. hiQ had accepted LinkedIn’s User Agreement when it bought ads and subscriptions, and that agreement bans scraping. The trial judge found hiQ in breach, and found that contractors it hired had breached it too by making fake accounts. A month later hiQ settled.</p>
<p>The same judge read Meta’s terms the other way in the Bright Data case: they reached only scraping done while logged in. So whether paying a platform ties your scraping to its terms comes down to their wording.</p>
<p>Terms can bind you even if you never clicked Accept. Reddit’s sit behind a link saying that using the site means accepting them, and in March 2026 a federal judge said that could still bind Anthropic: Reddit alleges Anthropic’s bots kept visiting more than 100,000 times after Anthropic said it had stopped.</p>
<p>Reddit, X, LinkedIn and Meta all ban scraping without permission in their terms. X’s add $15,000 in liquidated damages for requesting, viewing or accessing more than 1,000,000 posts in 24 hours.</p>
<h2>In 2026, the fight moved to bot checks</h2>
<p><a href="https://media.lurkapi.com/blog/is-web-scraping-legal/bot-check-suits.mp4"><img src="https://media.lurkapi.com/blog/is-web-scraping-legal/bot-check-suits.webp" alt="Hacking-law suits have gone the scrapers’ way since 2019. Suits over bot checks only started with four rulings, July to October 2026." /></a></p>
<p><em>Hacking-law suits have gone the scrapers’ way since 2019. Suits over bot checks only started with four rulings, July to October 2026.</em></p>
<p>With public pages mostly out of the hacking law’s reach, platforms and creators turned to the DMCA. Its section 1201 bars getting around a measure that “effectively controls access” to a copyrighted work. Between July and October 2026, four rulings let claims like that go ahead against companies accused of getting past bot checks.</p>
<ul>
<li><a href="https://storage.courtlistener.com/recap/gov.uscourts.nysd.651592/gov.uscourts.nysd.651592.104.0.pdf">Reddit v. SerpApi</a>: a New York judge said Google’s bot check, which lets people through and keeps automated users out, plausibly controls access to the Reddit posts in Google’s results. Reddit’s claims go on against SerpApi and Perplexity.</li>
<li>Ted Entertainment v. Snap, ByteDance and Apple: YouTube creators say the companies got past YouTube’s protections to scrape their videos. Apple argued that public videos have no access control at all, and the judge in its case wrote that this “defies logic and Ninth Circuit precedent.”</li>
</ul>
<p>In 2024, X v. Bright Data said CAPTCHAs didn’t make posts any less public, but X had sued over its terms. Under the DMCA, these courts treat the check itself as a possible access control.</p>
<p>The DMCA has a built-in limit: it protects copyrighted works only. Google’s own suit against SerpApi failed for search results with no copyrighted content in them. Facts such as prices or follower counts aren’t copyrighted. Posts, photos and videos are, so most social media content is covered.</p>
<p>All four are rulings on motions to dismiss. The judges took the complaints’ facts as true and let the cases go on; none found that anyone broke the law.</p>
<h2>Six answers to have before you scrape</h2>
<p>A lawyer will ask you these:</p>
<ul>
<li>Are you logged in, with any account, while you collect? LurkAPI, which publishes this blog, <a href="https://lurkapi.com/privacy">reads public pages and doesn’t log in to anyone’s account</a>.</li>
<li>Has your company accepted the platform’s terms, and do they mention scraping?</li>
<li>Has the site told you to stop?</li>
<li>Is there a bot check between you and the page?</li>
<li>Are you copying posts, photos or videos, or only facts about them?</li>
<li>Is any of it personal data about people in the EU or UK?</li>
</ul>
<h2>In Europe, public personal data is still personal data</h2>
<p>California’s privacy law leaves “publicly available information” out. The GDPR and the UK’s version of it have no such exception: data about a person is personal data wherever it was posted, and a company that collects it owes those people notice, unless that would take disproportionate effort. France’s regulator fined KASPR €240,000 in 2024 for a Chrome extension that pulled LinkedIn users’ contact details into a database of about 160 million contacts, some of them from people who had shown their details only to their connections.</p>
<h2>Questions</h2>
<p><strong>Is web scraping legal?</strong></p>
<p>In the US, scraping public pages without logging in is likely not hacking under the CFAA, the Ninth Circuit said in hiQ v. LinkedIn (2019 and 2022). Scrapers can still be sued over terms they accepted, fake accounts, getting past bot checks (under the DMCA) and personal data.</p>
<p><strong>Is it legal to scrape social media?</strong></p>
<p>Courts have sided with scrapers of public, logged-out pages: judges ruled against Meta and X in their 2024 suits against Bright Data. Companies that had accepted the terms or were accused of using fake accounts, hiQ in 2022 and ProAPIs in 2026, agreed to bans.</p>
<p><strong>Is scraping LinkedIn legal?</strong></p>
<p>Scraping public LinkedIn profiles is likely not hacking, the Ninth Circuit said in 2022. But hiQ, which had accepted LinkedIn’s User Agreement, was found in breach and agreed to a $500,000 judgment and a permanent ban in December 2022.</p>
<p><strong>Can you get sued for web scraping?</strong></p>
<p>Yes. Platforms and creators have sued scrapers for breach of contract, trespass, hacking under the CFAA, trade-secret theft and DMCA circumvention. Between July and October 2026, four rulings let DMCA claims go ahead against companies accused of getting past bot checks.</p>
<p><strong>Is web scraping legal in the EU?</strong></p>
<p>Public personal data is still personal data under the GDPR, so its rules apply to scraping it. France’s CNIL fined KASPR €240,000 in 2024 for collecting LinkedIn users’ contact details.</p>
]]></content:encoded></item><item><title><![CDATA[Why youtube-transcript-api gets blocked on servers, and what works]]></title><description><![CDATA[YouTube sends cloud IPs a bot check, so youtube-transcript-api raises RequestBlocked. Cookies are off in 1.2.4: use residential proxies or an API.]]></description><link>https://lurkapi.hashnode.dev/why-youtube-transcript-api-gets-blocked-on-servers-and-what-works</link><guid isPermaLink="true">https://lurkapi.hashnode.dev/why-youtube-transcript-api-gets-blocked-on-servers-and-what-works</guid><category><![CDATA[Python]]></category><category><![CDATA[youtube]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Mesmer Lord]]></dc:creator><pubDate>Tue, 03 Nov 2026 23:07:29 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published at <a href="https://lurkapi.com/blog/youtube-transcript-api-blocked">https://lurkapi.com/blog/youtube-transcript-api-blocked</a></em></p>
<p><img src="https://media.lurkapi.com/blog/youtube-transcript-api-blocked/hero.webp" alt="A white envelope sliding out of a server rack, stopped by a lowered jade-and-white barrier arm under an amber lamp." /></p>
<p>When youtube-transcript-api works on your laptop and raises <code>RequestBlocked</code> on AWS, a VPS, Colab or a CI runner, your code is fine. YouTube is refusing your server’s IP: it answers requests from cloud and datacenter addresses with “Sign in to confirm you’re not a bot” instead of captions, and the library gives up in under a second.</p>
<p>From a fresh cloud server, 1 of 10 popular videos comes back; the other nine raise <code>RequestBlocked</code>. Two fixes still work: send the library through rotating residential proxies, or let a hosted transcript API make the request so your server never talks to YouTube. The third fix the error message offers, cookies, is switched off in the current release.</p>
<h2>The block hides inside an HTTP 200</h2>
<p>Each fetch is three requests. The library loads the video’s watch page to read an API key, posts to YouTube’s internal player endpoint as the Android app, then downloads the caption track the player lists. From a datacenter IP the first request goes through: about 1.3 MB of HTML, key included, no captcha. The second also returns 200, but its body says <code>LOGIN_REQUIRED</code>, with the bot check as the reason. The library raises <code>RequestBlocked</code> and never sends the third.</p>
<p><a href="https://media.lurkapi.com/blog/youtube-transcript-api-blocked/inside-a-200.mp4"><img src="https://media.lurkapi.com/blog/youtube-transcript-api-blocked/inside-a-200.webp" alt="Both requests return 200; the second one’s body is the bot check." /></a></p>
<p><em>Both requests return 200; the second one’s body is the bot check.</em></p>
<p>So curling youtube.com from your server proves nothing: the page loads and every status code is 200. Without a proxy config the library doesn’t retry, and retrying yourself from the same IP gets the same answer.</p>
<p><code>IpBlocked</code> is the other flavour: an HTTP 429, or a reCAPTCHA page where the watch page should be. It subclasses <code>RequestBlocked</code>, so one <code>except RequestBlocked</code> catches both. Versions before 1.0.0 reported the same block as <code>TranscriptsDisabled</code> (“Subtitles are disabled for this video”), so if an old pinned version swears a popular video has no subtitles, it’s probably being blocked too.</p>
<h2>A green smoke test can hide a blocked server</h2>
<p>On that blocked server, one video still worked: Rick Astley’s Never Gonna Give You Up, <code>dQw4w9WgXcQ</code>, the go-to test video. It came back all three times. Nine other popular videos raised <code>RequestBlocked</code>. A deploy check that fetches only that id goes green while the jobs behind it fail.</p>
<p>Check a server with the videos your job needs. If most of them raise <code>RequestBlocked</code>, it’s the IP.</p>
<p><strong>Check your server</strong> · Python</p>
<pre><code class="language-python">from youtube_transcript_api import YouTubeTranscriptApi

api = YouTubeTranscriptApi()
ids = [  # use your own
    "dQw4w9WgXcQ",
    "kJQP7kiw5Fk",
    "arj7oStGLkU",
]
for vid in ids:
    try:
        t = api.fetch(vid)
        print(vid, "OK", len(t))
    except Exception as e:
        print(vid, type(e).__name__)

# From a cloud server:
# dQw4w9WgXcQ OK 61
# kJQP7kiw5Fk RequestBlocked
# arj7oStGLkU RequestBlocked
</code></pre>
<h2>Cookies are off in 1.2.4, and proxies need upkeep</h2>
<p>The error message offers cookies as its second fix, with a warning that YouTube will eventually ban the account you log in with. In 1.2.4, the current release, cookie auth is <a href="https://github.com/jdepoix/youtube-transcript-api/blob/v1.2.4/youtube_transcript_api/_api.py">switched off</a> (“Cookie auth has been temporarily disabled”), CLI flag included.</p>
<p>Its first fix is a proxy. The <a href="https://github.com/jdepoix/youtube-transcript-api#working-around-ip-bans-requestblocked-or-ipblocked-exception">README</a> recommends rotating residential proxies, because YouTube bans static ones after extended use. There’s a built-in config for one residential provider, and <code>GenericProxyConfig</code> for any other:</p>
<p><strong>Any proxy</strong> · adapted from the README</p>
<pre><code class="language-python">from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.proxies import GenericProxyConfig

proxy = GenericProxyConfig(
    http_url="http://user:pass@my-custom-proxy.org:port",
    https_url="https://user:pass@my-custom-proxy.org:port",
)
ytt_api = YouTubeTranscriptApi(proxy_config=proxy)
ytt_api.fetch(video_id)
</code></pre>
<p>Know how retries behave before you buy traffic. The built-in provider config retries a blocked video up to 10 times; <code>GenericProxyConfig</code> tries once unless you subclass it and raise <code>retries_when_blocked</code>. Every attempt starts over at the watch page, so each refusal still costs a full page of traffic, and residential traffic is billed by the gigabyte.</p>
<p>If the videos are your own, there’s an official route. The YouTube Data API’s <a href="https://developers.google.com/youtube/v3/docs/captions/download">captions.download</a> needs OAuth and permission to edit the video, and each call costs 200 of your 10,000 daily quota units, so 50 downloads a day. It can’t read anyone else’s videos.</p>
<h2>Or take YouTube off your server: one GET</h2>
<p>With a hosted API, your server makes one HTTPS call and the YouTube side happens elsewhere, so it stops mattering where your code runs. LurkAPI’s transcript endpoint fetches the captions for you, and a cached repeat comes back in 20 ms.</p>
<p><a href="https://media.lurkapi.com/blog/youtube-transcript-api-blocked/one-in-ten.mp4"><img src="https://media.lurkapi.com/blog/youtube-transcript-api-blocked/one-in-ten.webp" alt="Same ten videos: 1 from the library, 10 from LurkAPI." /></a></p>
<p><em>Same ten videos: 1 from the library, 10 from LurkAPI.</em></p>
<p><strong>Video transcript</strong>: What's said in a video or Short: its captions as timed segments and as one block of text.<br /><code>GET https://api.lurkapi.com/v1/youtube/video/transcript</code> · 1 credit per call · <a href="https://lurkapi.com/docs/youtube/transcript">Read the docs</a></p>
<p><strong>Get a transcript</strong> · 1 credit</p>
<pre><code class="language-bash">curl "https://api.lurkapi.com/v1/youtube/video/transcript?url=https://www.youtube.com/watch?v=arj7oStGLkU" \
  -H "x-api-key: $LURK_KEY"
</code></pre>
<p><strong>The JSON, trimmed to 2 segments</strong></p>
<p><strong>Response</strong></p>
<pre><code class="language-json">{
  "success": true,
  "credits_charged": 1,
  "videoId": "arj7oStGLkU",
  "url": "https://www.youtube.com/watch?v=arj7oStGLkU",
  "language": "English",
  "languageCode": "en",
  "isGenerated": false,
  "transcript": [
    { "text": "So in college,", "startMs": 12645, "endMs": 14015, "startTimeText": "0:12" },
    { "text": "I was a government major,", "startMs": 15349, "endMs": 16913, "startTimeText": "0:15" }
  ],
  "transcript_only_text": "So in college, I was a government major, …",
  "availableLanguages": [
    { "code": "ar", "name": "Arabic", "isGenerated": false },
    { "code": "hy", "name": "Armenian", "isGenerated": false },
    { "code": "bg", "name": "Bulgarian", "isGenerated": false }
  ]
}
</code></pre>
<p>Each call is 1 credit: $0.45 to $0.90 per 1,000 transcripts depending on the <a href="https://lurkapi.com/pricing">pack</a>, and a new account starts with 100 free credits.</p>
<p>Switching from the library takes a few lines. Segments come back as <code>text</code>, <code>startMs</code> and <code>endMs</code>; <code>to_snippet</code> converts one to the library’s <code>text</code>, <code>start</code> and <code>duration</code>:</p>
<p><strong>Python</strong> · 1 credit</p>
<pre><code class="language-python">import requests

API = ("https://api.lurkapi.com"
       "/v1/youtube/video/transcript")
KEY = "lk_live_..."

def transcript(video_id):
    r = requests.get(
        API,
        params={"url": video_id},
        headers={"x-api-key": KEY},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["transcript"]

def to_snippet(seg):
    s = seg["startMs"]
    e = seg["endMs"]
    return {
        "text": seg["text"],
        "start": s / 1000,
        "duration": (e - s) / 1000,
    }

segs = transcript("arj7oStGLkU")
print(len(segs), to_snippet(segs[0]))
# 315 {'text': 'So in college,',
#  'start': 12.645, 'duration': 1.37}
</code></pre>
<p>One default differs. The library asks for English only and raises <code>NoTranscriptFound</code> otherwise; the endpoint returns English when the video has it and falls back to the video’s main language when it doesn’t. Pass <code>language=es</code> (or <code>pt-BR</code>, and so on) for a specific track, and check <code>languageCode</code> if you need English. A video with no captions returns 404 <code>not_found</code>.</p>
<p>For a whole channel, list its uploads with <a href="https://lurkapi.com/docs/youtube/channel-videos">channel videos</a> and make one transcript call per video. For one transcript without code, the free <a href="https://lurkapi.com/free-tools/youtube-transcript">YouTube transcript tool</a> runs the same endpoint in your browser, or try it here:</p>
<p><a href="https://lurkapi.com/docs/youtube/transcript">Run GET /v1/youtube/video/transcript in the live playground</a></p>
<h2>Questions</h2>
<p><strong>What does RequestBlocked mean in youtube-transcript-api?</strong></p>
<p>YouTube answered the library’s player request with “Sign in to confirm you’re not a bot” instead of captions, almost always because the request came from a cloud or datacenter IP. IpBlocked, a subclass, means an HTTP 429 or a reCAPTCHA page; except RequestBlocked catches both.</p>
<p><strong>Why does youtube-transcript-api work locally but not on a server?</strong></p>
<p>Same code, different IP. YouTube blocks most IPs that belong to cloud providers such as AWS, Google Cloud and Azure, and a home IP can be blocked too after too many requests.</p>
<p><strong>Do cookies work with youtube-transcript-api?</strong></p>
<p>Not in 1.2.4. Cookie auth is switched off in the code, CLI flag included, even though the RequestBlocked message still suggests it.</p>
<p><strong>How do I use a proxy with youtube-transcript-api?</strong></p>
<p>Pass a proxy_config to YouTubeTranscriptApi: the built-in config for one residential provider, or GenericProxyConfig(http_url=..., https_url=...) for any other. The README recommends rotating residential proxies, since static ones get banned after extended use.</p>
<p><strong>Is there an official YouTube transcript API?</strong></p>
<p>Only for videos you can edit. The YouTube Data API’s captions.download needs OAuth and edit permission, and costs 200 of the default 10,000 daily quota units per call. For anyone else’s videos, LurkAPI’s transcript endpoint returns the captions for 1 credit a call.</p>
]]></content:encoded></item><item><title><![CDATA[403 Forbidden in Python requests: which bot wall sent it]]></title><description><![CDATA[A new User-Agent rarely fixes it. Bot walls judge how your client connects, and the 403’s headers say which wall sent it and what gets past.]]></description><link>https://lurkapi.hashnode.dev/403-forbidden-in-python-requests-which-bot-wall-sent-it</link><guid isPermaLink="true">https://lurkapi.hashnode.dev/403-forbidden-in-python-requests-which-bot-wall-sent-it</guid><category><![CDATA[Python]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[http]]></category><category><![CDATA[cloudflare]]></category><dc:creator><![CDATA[Mesmer Lord]]></dc:creator><pubDate>Tue, 03 Nov 2026 23:05:18 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published at <a href="https://lurkapi.com/blog/scraper-403-forbidden">https://lurkapi.com/blog/scraper-403-forbidden</a></em></p>
<p><img src="https://media.lurkapi.com/blog/scraper-403-forbidden/hero.webp" alt="Novelty disguise glasses with a fake nose and mustache beside a fingerprint card under a magnifying glass." /></p>
<p>If Chrome opens a page and your Python script gets 403 Forbidden, it’s usually a bot wall in front of the site that refused you: Cloudflare, DataDome, Akamai, PerimeterX or AWS WAF. Walls judge how your client connects (the TLS handshake, the HTTP version and its settings, the order of your headers) and compare that with the name in your User-Agent. <code>requests</code> gets every one of those wrong for a browser, which is why pasting in Chrome’s User-Agent so rarely helps.</p>
<p>The 403 also says which wall sent it and whether it’s a block or a challenge, and each has a different fix.</p>
<h2>Send Chrome’s handshake, not just its User-Agent</h2>
<p><code>requests</code> introduces itself as <code>python-requests/</code> plus a version number, so swapping the User-Agent feels like the obvious fix. Put Chrome’s User-Agent on a plain HTTP client, though, and Kickstarter and Priceline still answer 403 with the same block page as before. The rest of the request still says “script”: <code>requests</code> negotiates TLS through OpenSSL with its own ciphers and extensions, and it only speaks HTTP/1.1, where Chrome talks to these sites over HTTP/2. Cloudflare calls the result a <a href="https://developers.cloudflare.com/bots/additional-configurations/ja3-ja4-fingerprint/">JA3 or JA4 fingerprint</a>, and a Chrome name over a non-Chrome fingerprint is an easy catch.</p>
<p><a href="https://media.lurkapi.com/blog/scraper-403-forbidden/user-agent-vs-handshake.mp4"><img src="https://media.lurkapi.com/blog/scraper-403-forbidden/user-agent-vs-handshake.webp" alt="The same request three ways. Chrome’s User-Agent alone still gets a 403 from Kickstarter and Priceline; Chrome’s handshake gets both pages and six more, and IMDb answers it with a JavaScript challenge (a 202)." /></a></p>
<p><em>The same request three ways. Chrome’s User-Agent alone still gets a 403 from Kickstarter and Priceline; Chrome’s handshake gets both pages and six more, and IMDb answers it with a JavaScript challenge (a 202).</em></p>
<p>Send the same request with Chrome’s handshake, HTTP/2 settings and header order, and both pages load, and so do six more walled sites. In Python, <a href="https://github.com/lexiforest/curl_cffi">curl_cffi</a> gives you that handshake with one argument:</p>
<p><strong>curl_cffi</strong> · Python</p>
<pre><code class="language-python">from curl_cffi import requests

r = requests.get(url,
                 impersonate="chrome")
</code></pre>
<h2>Read the 403: each wall signs its response</h2>
<p>Each vendor marks its response:</p>
<table>
<thead>
<tr>
<th>Wall</th>
<th>Look for</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td>Cloudflare</td>
<td><code>cf-mitigated: challenge</code> and a “Just a moment...” page. Without that header, <code>server: cloudflare</code> and <code>cf-ray</code> over a 403 page (error 1020, or the site’s own)</td>
<td>A challenge with the header, a block without it</td>
</tr>
<tr>
<td>DataDome</td>
<td><code>x-datadome-cid</code> header, <code>datadome</code> cookie, a page of about 780 bytes that loads <code>captcha-delivery.com</code></td>
<td>A block, often sent as a 401</td>
</tr>
<tr>
<td>Akamai</td>
<td><code>server: AkamaiGHost</code>, an “Access Denied” page with “Reference #18…”</td>
<td>A block, sometimes sent with a 200</td>
</tr>
<tr>
<td>PerimeterX (HUMAN)</td>
<td><code>_pxhd</code> cookie, <code>window._pxAppId</code> and <code>px-captcha</code> in the page, “Press &amp; Hold”</td>
<td>A block page with a press-and-hold challenge</td>
</tr>
<tr>
<td>AWS WAF</td>
<td><code>x-amzn-waf-action: challenge</code> on a 202. On CloudFront, a 403 that says “Request blocked.”</td>
<td>A challenge (202), captcha (405) or block (403)</td>
</tr>
<tr>
<td>No vendor marks</td>
<td>A bare “403 Forbidden” with no vendor headers or cookies</td>
<td>An IP or User-Agent rule, or the site itself</td>
</tr>
</tbody></table>
<p><em>Check the headers first, then the cookies, then the page. The first match names the wall.</em></p>
<p>Don’t read <code>server: cloudflare</code> as a Cloudflare block: Priceline’s 403 travels through Cloudflare, but the page is PerimeterX’s. And Akamai’s <code>_abck</code> and <code>bm_sz</code> cookies show up on pages that load fine; they mean Akamai is there, not that you’re blocked.</p>
<p>To check in code, pass the status, headers and body to this function. It looks for the challenge headers and Akamai’s page before it looks at the status, because those can arrive with a 202 or even a 200.</p>
<p><strong>which_wall()</strong> · Python</p>
<pre><code class="language-python">import re
import requests

AKAMAI_PAGE = re.compile(
    r"errors\.edgesuite\.net|Reference&amp;#32;&amp;#35;18")

def which_wall(status, headers, body):
    h = {k.lower(): v for k, v in headers.items()}
    via = h.get("via", "").lower()
    if h.get("cf-mitigated") == "challenge":
        return "Cloudflare challenge"
    if h.get("x-amzn-waf-action"):
        return f"AWS WAF {h['x-amzn-waf-action']}"
    if AKAMAI_PAGE.search(body):
        return "Akamai block"
    if status not in (401, 403, 429, 503):
        return None
    if "x-datadome-cid" in h or "captcha-delivery.com" in body:
        return "DataDome block"
    if "px-captcha" in body or "_pxAppId" in body:
        return "PerimeterX block"
    if h.get("server", "").lower() == "akamaighost":
        return "Akamai block"
    if "cf-ray" in h:
        return f"Cloudflare {status}"
    if "Request blocked" in body and "cloudfront" in via:
        return "AWS WAF block"
    return f"Unbranded {status}"

r = requests.get(url)  # your request
print(r.status_code, which_wall(r.status_code, r.headers, r.text))
</code></pre>
<p>In axios, a 403 throws <code>AxiosError: Request failed with status code 403</code>, and the same three inputs are <code>err.response.status</code>, <code>err.response.headers</code> and <code>err.response.data</code>.</p>
<p><strong>What each wall’s block page looks like</strong></p>
<p><strong>kickstarter.com</strong> · Cloudflare challenge</p>
<pre><code class="language-text">HTTP/2 403
server: cloudflare
cf-mitigated: challenge
cf-ray: a480d8d37e259aac-IAD
set-cookie: __cf_bm=…

&lt;title&gt;Just a moment...&lt;/title&gt;
&lt;noscript&gt;… Enable JavaScript and cookies to continue …&lt;/noscript&gt;
&lt;script&gt;(function(){window._cf_chl_opt = {… cType: 'managed', …};
… a.src = '/cdn-cgi/challenge-platform/h/g/orchestrate/…'; …
</code></pre>
<p><strong>marketwatch.com</strong> · DataDome, as a 401</p>
<pre><code class="language-text">HTTP/2 401
server: CloudFront
x-datadome: protected
x-datadome-cid: …
x-dd-b: 2
set-cookie: datadome=…; Max-Age=31536000; Domain=.marketwatch.com; …

&lt;title&gt;marketwatch.com&lt;/title&gt;
&lt;p id="cmsg"&gt;Please enable JS and disable any ad blocker&lt;/p&gt;
&lt;script&gt;var dd={'rt':'c','cid':'…','hsh':'…','t':'bv',…,
'host':'geo.captcha-delivery.com','cookie':'…'}&lt;/script&gt;
&lt;script src="https://ct.captcha-delivery.com/c.js"&gt;&lt;/script&gt;
</code></pre>
<p><strong>sephora.com</strong> · Akamai</p>
<pre><code class="language-text">HTTP/2 403
server: AkamaiGHost
akamai-grn: 0.c424c317.1791585316.3a9f823

&lt;TITLE&gt;Access Denied&lt;/TITLE&gt;
&lt;H1&gt;Access Denied&lt;/H1&gt;
You don't have permission to access "http://www.sephora.com/"
on this server.&lt;P&gt;
Reference #18.c424c317.1791585316.3a9f823
&lt;P&gt;https://errors.edgesuite.net/18.c424c317.1791585316.3a9f823&lt;/P&gt;
</code></pre>
<p><strong>priceline.com</strong> · PerimeterX through Cloudflare</p>
<pre><code class="language-text">HTTP/2 403
server: cloudflare
cf-cache-status: DYNAMIC
set-cookie: _pxhd=…

&lt;meta name="description" content="px-captcha"&gt;
&lt;title&gt;Access to this page has been denied&lt;/title&gt;
window._pxAppId = 'PX9aTjSd0n';
script.src = 'https://captcha.px-cloud.net/PX9aTjSd0n/captcha.js?…';
Press &amp; Hold to confirm you are&lt;br&gt;a human (and not a bot).
</code></pre>
<p><strong>imdb.com</strong> · AWS WAF challenge, as a 202</p>
<pre><code class="language-text">HTTP/2 202
x-amzn-waf-action: challenge
via: 1.1 ….cloudfront.net (CloudFront)

&lt;title&gt;&lt;/title&gt;
window.gokuProps = { "key":"…", "iv":"…", "context":"…" };
&lt;script src="https://….token.awswaf.com/…/challenge.js"&gt;&lt;/script&gt;
AwsWafIntegration.getToken().then(() =&gt; { window.location.reload(true); });
</code></pre>
<h2>Some blocks don’t look like errors</h2>
<p><code>raise_for_status()</code> only fires on a 4xx or 5xx, and axios only throws outside 2xx. Walls don’t always answer that way:</p>
<ul>
<li>Lowe’s sends Akamai’s Access Denied page with a 200. A scraper that trusts the status saves the block page as data.</li>
<li>IMDb’s AWS WAF challenge, which it sends once your handshake looks like Chrome’s, comes back as a 202. Both libraries treat that as success.</li>
<li>MarketWatch’s DataDome block is a 401, which reads like a login problem.</li>
<li>Best Buy doesn’t answer at all. It drops the connection, so you get a connection error instead of a 403.</li>
<li>Kickstarter gives Chrome’s handshake a 200, but its homepage is a JavaScript app: the content appears only once a browser runs it.</li>
</ul>
<p>So run the check on every response before you parse it, 200s included, and always set a timeout.</p>
<h2>A block needs a better client, a challenge needs a browser</h2>
<p>A block refuses your client or your IP. Fix the client first with Chrome’s handshake. If the block survives that, the wall is judging your IP (DataDome scores IP reputation alongside the client), and the fix is a different network.</p>
<p>A challenge is a page that runs JavaScript to check that a browser is there. A good handshake often avoids it: Kickstarter challenges a plain client and serves Chrome’s handshake the page. When the challenge comes anyway, as IMDb’s does, only a browser that runs the script gets through, which means Playwright or Puppeteer and a Chrome to keep running.</p>
<h2>Or let one call climb for you</h2>
<p>LurkAPI’s scrape endpoint climbs that ladder for you: a quick direct request, then Chrome’s handshake from a static ISP IP, then a browser, then other networks for walls that judge the IP. It stops at the first step that gets the page and charges for that step: 1 credit up to the browser, and more only for the other networks. A call that every step fails costs nothing.</p>
<p><a href="https://media.lurkapi.com/blog/scraper-403-forbidden/scrape-climbs.mp4"><img src="https://media.lurkapi.com/blog/scraper-403-forbidden/scrape-climbs.webp" alt="Three URLs, one endpoint. Sephora opens on the first, cheapest step and Priceline on Chrome’s handshake (1 credit each); Chrome’s handshake gets only Kickstarter’s JavaScript shell, so it goes on to the browser (1 credit). 3 credits for the three pages." /></a></p>
<p><em>Three URLs, one endpoint. Sephora opens on the first, cheapest step and Priceline on Chrome’s handshake (1 credit each); Chrome’s handshake gets only Kickstarter’s JavaScript shell, so it goes on to the browser (1 credit). 3 credits for the three pages.</em></p>
<p>Priceline needed one step up:</p>
<p><strong>Scrape Priceline’s homepage</strong> · 1 credit</p>
<pre><code class="language-bash">curl -G "https://api.lurkapi.com/v1/web/scrape" \
  --data-urlencode "url=https://www.priceline.com/" \
  -d format=markdown \
  -H "x-api-key: $LURK_KEY"
</code></pre>
<p><strong>Response</strong> · trimmed</p>
<pre><code class="language-json">{
  "success": true,
  "credits_charged": 1,
  "status": 200,
  "url": "https://www.priceline.com/",
  "content": "Skip to main content\n…\n- [Hotels](https://www.priceline.com/hotels)\n- [Cars](…",
  "via": "static",
  "attempts": [
    { "via": "direct", "ms": 116, "status": 403,
      "wall": "perimeterx challenge" },
    { "via": "static", "ms": 565, "status": 200,
      "wall": null }
  ],
  "billing": { "mode": "auto", "credits": 1,
    "breakdown": { "base": 1 } }
}
</code></pre>
<p><code>attempts</code> lists every step and the wall it met, in plain labels like <code>perimeterx challenge</code> or <code>datadome block</code>, so you can see why a page cost what it did.</p>
<p>Kickstarter went one step further: Chrome’s handshake got a 200, the endpoint saw a page that JavaScript still had to build, and the browser came back with the full page in about 7 seconds for 1 credit. Most pages take 1 to 3 seconds and browser renders 5 to 15, so give the call a 90-second timeout for the slow ones. The <a href="https://lurkapi.com/docs/web-scraping">web scraping guide</a> lists every step and its price.</p>
<p>Paste the URL that’s giving you the 403 and see which step gets it:</p>
<p><a href="https://lurkapi.com/docs/web/scrape">Run GET /v1/web/scrape in the live playground</a></p>
<h2>Questions</h2>
<p><strong>Why do I get 403 Forbidden with Python requests but not in the browser?</strong></p>
<p>A bot wall in front of the site saw a client that isn’t a browser. The User-Agent says python-requests, and the TLS handshake and HTTP version don’t match Chrome’s. The response’s headers, cookies or page say which wall it was.</p>
<p><strong>AxiosError: Request failed with status code 403</strong></p>
<p>Axios throws on any status outside 2xx. Read err.response.headers and err.response.data to see who sent it. On a page Chrome opens fine, it’s usually a bot wall reacting to the axios/ User-Agent and Node’s non-browser handshake; on an API URL, usually a missing or wrong token.</p>
<p><strong>Does changing the User-Agent fix a 403?</strong></p>
<p>Rarely, on a site behind a bot wall. Kickstarter and Priceline still answer 403 to a plain client sending Chrome’s User-Agent, and serve the page once the request also has Chrome’s TLS handshake and HTTP/2 settings.</p>
<p><strong>What is cf-mitigated: challenge?</strong></p>
<p>A Cloudflare response header that marks a challenge page. The page clears only in a browser that runs Cloudflare’s JavaScript, which then gets a cf_clearance cookie.</p>
<p><strong>How do I tell if a site uses PerimeterX?</strong></p>
<p>Look for a _pxhd or _px3 cookie, window._pxAppId in the HTML, or a “Press &amp; Hold” page that loads from px-cloud.net. PerimeterX is now called HUMAN.</p>
]]></content:encoded></item></channel></rss>