How to Scrape IMDb in 2026: Movies, TV Shows, Ratings and Reviews

IMDb remains the go-to database for movie and TV information. With over 10 million titles and hundreds of millions of ratings, it’s a goldmine for data projects — recommendation engines, market analysis, sentiment tracking, and more.

In this guide, I’ll show you how to scrape IMDb effectively in 2026 with working Python code.

IMDb’s Structure in 2026

IMDb is still primarily server-rendered HTML, which makes it easier to scrape than heavily JavaScript-dependent sites. However, they’ve added more dynamic loading and anti-bot protections over the years.

Key pages you’ll want to scrape:

  • Title pages (/title/tt1234567/) — movie/show details, ratings, cast
  • Search results (/find/) — finding titles by name
  • Charts (/chart/) — top rated, most popular
  • Reviews (/title/tt1234567/reviews) — user reviews and ratings
  • Name pages (/name/nm1234567/) — actor/director filmography

Basic Scraping with BeautifulSoup

Let’s start with extracting movie details from a title page:

import requests
from bs4 import BeautifulSoup
import json

def scrape_imdb_title(title_id: str) -> dict:
    url = f"https://www.imdb.com/title/{title_id}/"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept-Language": "en-US,en;q=0.9"
    }

    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.text, "html.parser")

    # IMDb embeds structured data as JSON-LD
    json_ld = soup.find("script", {"type": "application/ld+json"})
    if json_ld:
        data = json.loads(json_ld.string)
        return {
            "title": data.get("name"),
            "type": data.get("@type"),
            "year": data.get("datePublished", "")[:4],
            "rating": data.get("aggregateRating", {}).get("ratingValue"),
            "rating_count": data.get("aggregateRating", {}).get("ratingCount"),
            "genres": data.get("genre", []),
            "description": data.get("description"),
            "directors": [
                d["name"] for d in (data.get("director", []) if isinstance(data.get("director"), list) else [data.get("director", {})])
                if isinstance(d, dict)
            ],
            "actors": [
                a["name"] for a in data.get("actor", [])[:5]
                if isinstance(a, dict)
            ],
            "duration": data.get("duration"),
            "content_rating": data.get("contentRating")
        }
    return {}

# Example: Scrape The Shawshank Redemption
movie = scrape_imdb_title("tt0111161")
print(json.dumps(movie, indent=2))

The JSON-LD approach is robust because IMDb uses it for SEO — they’re unlikely to remove it.

Scraping IMDb Search Results

To find movies by keyword or title:

def search_imdb(query: str, max_results: int = 10) -> list:
    url = "https://www.imdb.com/find/"
    params = {
        "q": query,
        "s": "tt",  # Search titles only
        "exact": "true"
    }
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept-Language": "en-US,en;q=0.9"
    }

    response = requests.get(url, params=params, headers=headers)
    soup = BeautifulSoup(response.text, "html.parser")

    results = []
    # IMDb's search results use a specific section structure
    for item in soup.select(".ipc-metadata-list-summary-item")[:max_results]:
        link = item.select_one("a[href*=/title/]")
        if link:
            href = link["href"]
            title_id = href.split("/title/")[1].split("/")[0]
            title_text = link.get_text(strip=True)

            year_el = item.select_one(".ipc-metadata-list-summary-item__li")
            year = year_el.get_text(strip=True) if year_el else None

            results.append({
                "title_id": title_id,
                "title": title_text,
                "year": year,
                "url": f"https://www.imdb.com/title/{title_id}/"
            })

    return results

results = search_imdb("Inception")
for r in results:
    print(f"{r["title"]} ({r["year"]}) — {r["url"]}")

Extracting Ratings and Reviews

User reviews are valuable for sentiment analysis:

import time
import random

def scrape_imdb_reviews(title_id: str, max_reviews: int = 25) -> list:
    url = f"https://www.imdb.com/title/{title_id}/reviews"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept-Language": "en-US,en;q=0.9"
    }

    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.text, "html.parser")

    reviews = []
    for review in soup.select(".review-container")[:max_reviews]:
        title_el = review.select_one(".title")
        content_el = review.select_one(".text")
        rating_el = review.select_one(".rating-other-user-rating span")
        date_el = review.select_one(".review-date")
        helpful_el = review.select_one(".actions")

        reviews.append({
            "review_title": title_el.get_text(strip=True) if title_el else None,
            "content": content_el.get_text(strip=True) if content_el else None,
            "rating": int(rating_el.get_text(strip=True)) if rating_el else None,
            "date": date_el.get_text(strip=True) if date_el else None,
            "helpful": helpful_el.get_text(strip=True) if helpful_el else None
        })

    return reviews

reviews = scrape_imdb_reviews("tt0111161")
for r in reviews[:3]:
    print(f"★{r["rating"]}/10 — {r["review_title"]}")
    print(f"  {r["content"][:100]}...")
    print()

Scraping Top Charts

IMDb’s charts (Top 250, Most Popular) are great for building recommendation datasets:

def scrape_top_250() -> list:
    url = "https://www.imdb.com/chart/top/"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept-Language": "en-US,en;q=0.9"
    }

    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.text, "html.parser")

    # Extract from JSON-LD structured data
    json_ld = soup.find("script", {"type": "application/ld+json"})
    if json_ld:
        data = json.loads(json_ld.string)
        movies = []
        for item in data.get("itemListElement", []):
            movie = item.get("item", {})
            movies.append({
                "rank": item.get("position"),
                "title": movie.get("name"),
                "url": movie.get("url"),
                "rating": movie.get("aggregateRating", {}).get("ratingValue"),
                "rating_count": movie.get("aggregateRating", {}).get("ratingCount"),
                "description": movie.get("description")
            })
        return movies

    return []

top_movies = scrape_top_250()
for m in top_movies[:10]:
    print(f"#{m["rank"]} {m["title"]} — ★{m["rating"]}")

Handling Anti-Bot Protection

IMDb has gotten stricter about automated access. Here’s what you need:

Proxy Rotation

Scraping IMDb at scale without proxies will get your IP blocked. I recommend ScrapeOps — they provide a proxy API specifically optimized for web scraping. You just route your requests through their endpoint:

def get_with_scrapeops(url: str, api_key: str) -> str:
    response = requests.get(
        "https://proxy.scrapeops.io/v1/",
        params={
            "api_key": api_key,
            "url": url,
            "render_js": "false"
        }
    )
    return response.text

html = get_with_scrapeops(
    "https://www.imdb.com/title/tt0111161/",
    "YOUR_SCRAPEOPS_KEY"
)

For higher volume scraping, ThorData residential proxies give you a rotating pool of real residential IPs:

proxies = {
    "http": "http://user:[email protected]:9090",
    "https": "http://user:[email protected]:9090"
}

response = requests.get(
    "https://www.imdb.com/chart/top/",
    headers=headers,
    proxies=proxies
)

Rate Limiting

Always add delays between requests:

import time
import random

def polite_request(url: str, headers: dict) -> requests.Response:
    time.sleep(random.uniform(2, 5))  # Random delay
    return requests.get(url, headers=headers)

Building a Complete Movie Dataset

Here’s how to combine everything into a dataset builder:

import csv
import time
import random

def build_movie_dataset(title_ids: list, output_file: str):
    fieldnames = [
        "title_id", "title", "year", "rating", "rating_count",
        "genres", "directors", "actors", "duration", "content_rating"
    ]

    with open(output_file, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()

        for title_id in title_ids:
            try:
                movie = scrape_imdb_title(title_id)
                if movie:
                    movie["title_id"] = title_id
                    movie["genres"] = ", ".join(movie.get("genres", []))
                    movie["directors"] = ", ".join(movie.get("directors", []))
                    movie["actors"] = ", ".join(movie.get("actors", []))
                    writer.writerow(movie)
                    print(f"✓ {movie.get("title", title_id)}")

                time.sleep(random.uniform(2, 4))

            except Exception as e:
                print(f"✗ {title_id}: {e}")
                continue

# Build a dataset from Top 250
top_movies = scrape_top_250()
title_ids = [m["url"].split("/title/")[1].rstrip("/") for m in top_movies[:50]]
build_movie_dataset(title_ids, "imdb_top50.csv")

The Easy Way: Pre-Built IMDb Scraper

Maintaining an IMDb scraper means keeping up with their HTML changes. If you need reliable data extraction without the maintenance, check out the IMDb Scraper on Apify. It handles all the parsing, proxy rotation, and anti-bot detection automatically.

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("cryptosignals/imdb-scraper").call(
    run_input={
        "searchTerms": ["inception", "interstellar"],
        "maxItems": 100,
        "includeReviews": True
    }
)

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(f"{item["title"]} ({item["year"]}) — ★{item["rating"]}")

It returns clean JSON with all the fields you need — no HTML parsing required.

Legal Considerations

A few things to keep in mind:

  • Respect robots.txt — IMDb’s robots.txt restricts certain paths. Check it before scraping.
  • Rate limit your requests — don’t hammer their servers. 1 request per 2-5 seconds is reasonable.
  • Don’t redistribute copyrighted content — movie descriptions and reviews have copyright protections.
  • Use data for analysis, not replication — building a competing movie database from scraped data could be problematic.
  • IMDb has an official API — for commercial use, consider their data licensing options.

Conclusion

IMDb is one of the more scraper-friendly sites thanks to its server-rendered HTML and rich JSON-LD data. The BeautifulSoup examples above should handle most use cases.

For production scraping, use ScrapeOps or ThorData proxies to avoid IP blocks. And if you want to skip the code entirely, the IMDb Scraper on Apify does everything out of the box.

Happy scraping!

5 tools that turn Claude Code from good to unstoppable (exact install commands)

Most developers use Claude Code the same way they used Stack Overflow. Ask a question, get an answer, move on.

The developers shipping fastest in 2026 use a specific stack around it. Five tools. Each one fixes a specific failure mode. Here they are with exact install commands.

The three things that break Claude Code

Context rot — Quality drops at ~50% context usage, not at the limit. Most developers don’t notice until Claude writes duplicate code it wrote 20 messages ago.

Hallucinated APIs — Training data is 6–12 months behind. Claude confidently suggests functions that were renamed, deprecated, or never existed in your version.

No planning phase — Claude dives into code on your first vague prompt. No spec. No architecture. Three hours later you’re rewriting everything.

Each tool below attacks one of these.

Tool 1 — cc-statusline

Fixes: Context rot — you can’t manage what you can’t see

npx cc-statusline@latest

Adds a status bar to your terminal showing model, context%, session cost, and git branch in real time.

Configure your lines:

Line 1: model | context% | session_cost | session_clock  
Line 2: git_branch | git_worktree

The rule: When context hits 50%, stop and /clear. Don’t use /compact — it’s lossy compression that keeps context poisoning. /clear reloads your CLAUDE.md and starts completely fresh with zero information loss.

Built-in alternative: type /statusline directly in Claude Code and it generates a script for you.

Tool 2 — Superpowers plugin

Fixes: Vague prompts → vague code. No planning phase.

Stats: 99,000+ GitHub stars. Official Anthropic marketplace.

/plugin marketplace add obra/superpowers-marketplace
/plugin install superpowers@superpowers-marketplace

Restart. Skills activate automatically — no manual triggers needed.

The three-step workflow it enforces:

Brainstorm — Claude asks clarifying questions, proposes 2–3 approaches, produces a design doc you approve. Code doesn’t start until you sign off.

Write Plan — Breaks your spec into 2–5 minute tasks with exact file paths and complete code. A proper plan is 300–500 lines. Not a vague outline.

Execute Plan — Dispatches subagents, each in their own isolated context window. Main instance only gets small update summaries. Context never bloats.

This is spec-driven development. The biggest tech companies use it. Now you can with two install commands.

Tool 3 — Context7 MCP

Fixes: Hallucinated APIs from stale training data

claude mcp add context7 -- npx -y @upstash/context7-mcp@latest

Or add to ~/.claude/mcp.json:

{
  "mcpServers": {
    "context7": {
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp@latest"]
    }
  }
}

How to use: Append “use context7” to any library-related prompt.

Build a Next.js 15 server component that fetches from Supabase. use context7

Context7 fetches current documentation from the official source and injects it before Claude generates code. Claude then uses actual APIs instead of guessing from 12-month-old training data.

Works with React, Next.js, Supabase, Tailwind, Prisma, and thousands of other libraries.

Tool 4 — Warp terminal

Fixes: Claude is a black box — you can’t see what it’s building while it builds

Free at warp.dev (macOS and Linux)

Three features that matter:

Side panel — Open your file tree alongside Claude. Review generated code as it’s written, not after.

Split terminals (Cmd+D) — Run multiple Claude Code instances in parallel. One building, one reviewing, one testing.

Tabs (Ctrl+T) — Multiple projects open simultaneously without losing session state.

Core habit: keep your file tree open in the side panel while Claude executes a plan. Review each file as it’s generated. Catch bad decisions before they compound.

Tool 5 — Sequential Thinking MCP

Fixes: Claude jumping to solutions before reasoning through the problem

Tell Claude: "Please install the sequential thinking MCP server"

Claude finds the package, installs it, and configures everything. Restart when done.

Forces chain-of-thought reasoning before Claude produces an answer. Pairs especially well with Superpowers brainstorming — design doc quality improves noticeably.

Use for: architecture decisions, complex debugging, designing systems that will be hard to change later.

Skip for: simple tasks, variable renames, quick fixes. Adds latency and tokens — match it to the complexity.

The full stack in action

1. Open Warp → start Claude Code
2. Status bar shows context% (cc-statusline)
3. Describe the feature
4. Superpowers brainstorm activates automatically
5. Claude reasons through approaches (sequential thinking)
6. Mention a library → Context7 fetches live docs
7. Approve spec → Superpowers writes detailed plan
8. Review plan in Warp side panel
9. Execute → subagents build in isolated context windows
10. Context% stays low (subagents handle heavy lifting)
11. Main context hits 50% → /clear and reload

Production-quality code. No hallucinated APIs. Documented plan you can review and hand off.

Install priority if you have 10 minutes

  1. cc-statusline — visibility first, 2 min
  2. Context7 — eliminates the most common failure mode, 2 min
  3. Superpowers — biggest quality improvement, 3 min
  4. Warp — quality of life, 5 min
  5. Sequential Thinking — just ask Claude, 1 min

For hands-on labs building the agentic patterns these tools enable — coordinator-subagent systems, MCP servers, production Claude/Bedrock architectures — Cloud Edventures CCA-001 track, 22 labs, real AWS sandboxes.

Search: Cloud Edventures CCA-001

Which tool made the biggest difference in your setup? Drop a comment.

How to Scrape TikTok in 2026: Videos, Profiles, and Hashtags

TikTok has become one of the most data-rich platforms on the internet. Whether you’re tracking trending hashtags, analyzing creator performance, or building a content aggregation tool, scraping TikTok data is a common need in 2026.

But TikTok doesn’t make it easy. In this guide, I’ll walk you through the challenges and show you working Python code to extract videos, profiles, and hashtag data.

Why Scraping TikTok Is Hard

TikTok’s frontend is heavily JavaScript-rendered. A simple requests.get() returns a mostly empty HTML shell. The actual content loads dynamically via their internal API calls.

On top of that, TikTok employs aggressive anti-bot measures:

  • Browser fingerprinting — they check canvas, WebGL, and navigator properties
  • Rate limiting — rapid requests from the same IP get blocked fast
  • CAPTCHAs — automated access triggers interactive challenges
  • Signed API requests — internal API calls require dynamic signatures that change frequently

Approach 1: Using Playwright for Browser-Based Scraping

The most reliable way to scrape TikTok is with a real browser. Here’s a working example using Playwright:

import asyncio
from playwright.async_api import async_playwright
import json

async def scrape_tiktok_profile(username: str):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        )
        page = await context.new_page()

        # Navigate to user profile
        await page.goto(f"https://www.tiktok.com/@{username}", wait_until="networkidle")
        await page.wait_for_timeout(3000)

        # Extract profile data from the page's embedded JSON
        profile_data = await page.evaluate("""
            () => {
                const scripts = document.querySelectorAll("script#__UNIVERSAL_DATA_FOR_REHYDRATION__");
                if (scripts.length > 0) {
                    const data = JSON.parse(scripts[0].textContent);
                    const userInfo = data?.__DEFAULT_SCOPE__?.["webapp.user-detail"]?.userInfo;
                    if (userInfo) {
                        return {
                            nickname: userInfo.user?.nickname,
                            followers: userInfo.stats?.followerCount,
                            following: userInfo.stats?.followingCount,
                            likes: userInfo.stats?.heartCount,
                            videos: userInfo.stats?.videoCount,
                            bio: userInfo.user?.signature
                        };
                    }
                }
                return null;
            }
        """)

        await browser.close()
        return profile_data

result = asyncio.run(scrape_tiktok_profile("khaby.lame"))
print(json.dumps(result, indent=2))

This extracts the hydration data that TikTok embeds in the page — no need to parse the rendered DOM.

Approach 2: Intercepting TikTok’s Internal API

For bulk data collection, intercepting network requests is more efficient:

import asyncio
from playwright.async_api import async_playwright
import json

async def scrape_hashtag_videos(hashtag: str, max_videos: int = 30):
    videos = []

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context()
        page = await context.new_page()

        # Intercept API responses
        async def handle_response(response):
            if "/api/challenge/item_list" in response.url:
                try:
                    data = await response.json()
                    for item in data.get("itemList", []):
                        videos.append({
                            "id": item["id"],
                            "description": item.get("desc", ""),
                            "author": item["author"]["uniqueId"],
                            "likes": item["stats"]["diggCount"],
                            "comments": item["stats"]["commentCount"],
                            "shares": item["stats"]["shareCount"],
                            "plays": item["stats"]["playCount"],
                            "created": item["createTime"]
                        })
                except Exception:
                    pass

        page.on("response", handle_response)
        await page.goto(f"https://www.tiktok.com/tag/{hashtag}", wait_until="networkidle")

        # Scroll to load more
        for _ in range(3):
            await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
            await page.wait_for_timeout(2000)

        await browser.close()

    return videos[:max_videos]

results = asyncio.run(scrape_hashtag_videos("python", max_videos=20))
for v in results:
    print(f"{v["author"]}: {v["plays"]} plays - {v["description"][:50]}")

The Proxy Problem

Running these scripts from a single IP will get you blocked within minutes. TikTok’s anti-bot system tracks request patterns aggressively.

You need residential proxies — IPs that look like real home internet connections. I recommend ThorData for this. Their residential proxy pool works well with TikTok because the IPs rotate automatically and come from real ISP ranges.

Here’s how to add proxy support to the Playwright script:

browser = await p.chromium.launch(
    headless=True,
    proxy={
        "server": "http://proxy.thordata.com:9090",
        "username": "your-username",
        "password": "your-password"
    }
)

For a simpler approach, ScraperAPI handles the proxy rotation and browser rendering for you. Just send your URL through their endpoint and get back rendered HTML:

import requests

url = "https://www.tiktok.com/@khaby.lame"
response = requests.get(
    "https://api.scraperapi.com",
    params={
        "api_key": "YOUR_KEY",
        "url": url,
        "render": "true"
    }
)
html = response.text

Downloading TikTok Videos

Once you have video metadata, downloading the actual video files requires extracting the video URL from TikTok’s CDN:

import asyncio
from playwright.async_api import async_playwright

async def download_tiktok_video(video_url: str, output_path: str):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        video_src = None

        async def handle_response(response):
            nonlocal video_src
            content_type = response.headers.get("content-type", "")
            if "video/mp4" in content_type:
                video_src = response.url

        page.on("response", handle_response)
        await page.goto(video_url, wait_until="networkidle")
        await page.wait_for_timeout(3000)

        if video_src:
            import httpx
            async with httpx.AsyncClient() as client:
                resp = await client.get(video_src)
                with open(output_path, "wb") as f:
                    f.write(resp.content)
                print(f"Downloaded to {output_path}")

        await browser.close()

asyncio.run(download_tiktok_video(
    "https://www.tiktok.com/@user/video/123456",
    "video.mp4"
))

The Easy Way: Pre-Built TikTok Scraper

Building and maintaining a TikTok scraper is a constant battle against their anti-bot updates. If you need reliable, production-grade scraping, I’d recommend using the TikTok Scraper on Apify. It handles all the browser rendering, proxy rotation, and anti-detection out of the box.

You can run it via the Apify API:

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("cryptosignals/tiktok-scraper").call(
    run_input={
        "profiles": ["khaby.lame", "charlidamelio"],
        "hashtags": ["python", "coding"],
        "maxVideos": 50
    }
)

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

It handles CAPTCHAs, rotates proxies automatically, and returns clean structured data. Pay-per-use pricing means you only pay for what you scrape.

Rate Limiting Best Practices

Whether you build your own scraper or use a tool, respect these limits:

  1. Add random delays between requests (2-5 seconds minimum)
  2. Rotate user agents on every request
  3. Use residential proxies — datacenter IPs get flagged instantly
  4. Don’t scrape logged-in pages — TikTok monitors authenticated sessions more closely
  5. Cache aggressively — don’t re-scrape data you already have

Conclusion

Scraping TikTok in 2026 requires a browser-based approach with solid proxy infrastructure. The Playwright examples above will get you started, but for production workloads, consider using residential proxies from ThorData or a managed scraping service like ScraperAPI.

If you want to skip the infrastructure headaches entirely, the TikTok Scraper on Apify handles everything end-to-end.

Happy scraping!

How to Scrape YouTube in 2026: Videos, Channels, Comments, and Metadata

YouTube hosts over 800 million videos. Whether you’re building a competitor analysis tool, tracking trends, or collecting training data — extracting YouTube data programmatically is a common need.

In this guide, I’ll walk you through the practical ways to scrape YouTube in 2026: what data you can get, Python code examples, and when to use the official API vs web scraping.

What YouTube Data Can You Extract?

You can collect:

  • Video metadata: title, description, view count, likes, upload date, duration, tags
  • Channel info: subscriber count, total videos, channel description, creation date
  • Comments: text, author, likes, reply count, timestamps
  • Search results: videos matching keywords, filters by date/relevance/views
  • Playlists: video lists, playlist metadata

Method 1: YouTube Data API v3

The official API is the cleanest option for structured data.

Setup

import requests

API_KEY = 'YOUR_YOUTUBE_API_KEY'
BASE_URL = 'https://www.googleapis.com/youtube/v3'

def get_video_details(video_id: str) -> dict:
    """Fetch video metadata via YouTube Data API."""
    params = {
        'part': 'snippet,statistics,contentDetails',
        'id': video_id,
        'key': API_KEY
    }
    response = requests.get(f'{BASE_URL}/videos', params=params)
    response.raise_for_status()
    items = response.json().get('items', [])
    if not items:
        return {}

    item = items[0]
    return {
        'title': item['snippet']['title'],
        'description': item['snippet']['description'],
        'views': int(item['statistics'].get('viewCount', 0)),
        'likes': int(item['statistics'].get('likeCount', 0)),
        'comments': int(item['statistics'].get('commentCount', 0)),
        'duration': item['contentDetails']['duration'],
        'published_at': item['snippet']['publishedAt'],
        'channel': item['snippet']['channelTitle'],
        'tags': item['snippet'].get('tags', []),
    }

video = get_video_details('dQw4w9WgXcQ')
print(f"{video['title']} — {video['views']:,} views")

Fetching Comments

def get_comments(video_id: str, max_results: int = 100) -> list:
    """Fetch top-level comments for a video."""
    comments = []
    params = {
        'part': 'snippet',
        'videoId': video_id,
        'maxResults': min(max_results, 100),
        'order': 'relevance',
        'key': API_KEY
    }

    while len(comments) < max_results:
        response = requests.get(
            f'{BASE_URL}/commentThreads', params=params
        )
        data = response.json()

        for item in data.get('items', []):
            snippet = item['snippet']['topLevelComment']['snippet']
            comments.append({
                'author': snippet['authorDisplayName'],
                'text': snippet['textDisplay'],
                'likes': snippet['likeCount'],
                'published_at': snippet['publishedAt'],
            })

        next_page = data.get('nextPageToken')
        if not next_page:
            break
        params['pageToken'] = next_page

    return comments[:max_results]

API Limitations

The YouTube API has a 10,000 unit daily quota (free tier). Each search costs 100 units, video details cost 1-3 units. For large-scale collection, you’ll hit limits fast.

Method 2: Web Scraping with Python

When the API quota isn’t enough, or you need data the API doesn’t expose (like exact subscriber counts or revenue estimates), web scraping fills the gap.

Basic Approach with yt-dlp

yt-dlp is the most reliable tool for extracting YouTube metadata without API keys:

import subprocess
import json

def scrape_video_metadata(url: str) -> dict:
    """Extract video metadata using yt-dlp."""
    cmd = [
        'yt-dlp',
        '--dump-json',
        '--no-download',
        '--no-warnings',
        url
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f'yt-dlp failed: {result.stderr}')

    data = json.loads(result.stdout)
    return {
        'title': data.get('title'),
        'description': data.get('description'),
        'views': data.get('view_count'),
        'likes': data.get('like_count'),
        'duration': data.get('duration'),
        'upload_date': data.get('upload_date'),
        'channel': data.get('channel'),
        'channel_subscribers': data.get('channel_follower_count'),
        'tags': data.get('tags', []),
        'categories': data.get('categories', []),
        'thumbnail': data.get('thumbnail'),
    }

video = scrape_video_metadata('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
print(json.dumps(video, indent=2))

Scraping Channel Videos

def scrape_channel_videos(channel_url: str, max_videos: int = 50) -> list:
    """Get all video metadata from a channel."""
    cmd = [
        'yt-dlp',
        '--dump-json',
        '--no-download',
        '--flat-playlist',
        '--playlist-end', str(max_videos),
        f'{channel_url}/videos'
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)

    videos = []
    for line in result.stdout.strip().split('n'):
        if line:
            data = json.loads(line)
            videos.append({
                'id': data.get('id'),
                'title': data.get('title'),
                'views': data.get('view_count'),
                'duration': data.get('duration'),
                'url': data.get('url'),
            })

    return videos

Method 3: Using a Managed Scraper

For production workloads where you need reliability and scale, a managed scraping solution saves you from maintaining infrastructure.

YouTube Scraper on Apify handles the heavy lifting — it extracts video metadata, channel info, comments, and search results with built-in proxy rotation and retry logic. You just provide the URLs and get structured JSON back.

Handling Anti-Scraping Measures

YouTube actively blocks automated requests. Here’s what works in 2026:

Proxy Rotation

Using residential proxies is essential for any volume:

import requests

# Using ThorData residential proxies
# Sign up: https://affiliate.thordata.com/0a0x4nzu7tvv
proxies = {
    'http': 'http://user:[email protected]:9090',
    'https': 'http://user:[email protected]:9090',
}

response = requests.get(
    'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
    proxies=proxies,
    headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
)

Alternatively, ScraperAPI handles proxy rotation and CAPTCHA solving automatically:

import requests

SCRAPERAPI_KEY = 'YOUR_KEY'
url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'

response = requests.get(
    f'http://api.scraperapi.com?api_key={SCRAPERAPI_KEY}&url={url}'
)
html = response.text

Rate Limiting

import time
import random

def polite_request(url: str, session: requests.Session) -> requests.Response:
    """Make a request with random delay to avoid detection."""
    time.sleep(random.uniform(2, 5))
    return session.get(url)

Storing the Data

For any serious scraping project, dump results to a structured format:

import csv
import json

def save_to_csv(videos: list, filename: str = 'youtube_data.csv'):
    """Save scraped video data to CSV."""
    if not videos:
        return

    keys = videos[0].keys()
    with open(filename, 'w', newline='', encoding='utf-8') as f:
        writer = csv.DictWriter(f, fieldnames=keys)
        writer.writeheader()
        writer.writerows(videos)

def save_to_json(videos: list, filename: str = 'youtube_data.json'):
    """Save scraped video data to JSON."""
    with open(filename, 'w', encoding='utf-8') as f:
        json.dump(videos, f, indent=2, ensure_ascii=False)

YouTube API vs Scraping: When to Use What

Factor YouTube API Web Scraping
Quota 10K units/day Unlimited (with proxies)
Data quality Structured JSON Requires parsing
Setup API key required No auth needed
Cost Free (within quota) Proxy costs
Reliability High Breaks with site changes
Best for Small-medium projects Large-scale collection

Legal Considerations

YouTube’s ToS restricts automated access. The YouTube API has its own terms of service. For web scraping:

  • Respect robots.txt
  • Don’t overload servers
  • Use data responsibly
  • Check local laws (GDPR, CCPA apply to personal data in comments)

Wrapping Up

The best approach depends on your scale:

  1. < 10K requests/day: Use the YouTube Data API
  2. Moderate scale: Use yt-dlp with rate limiting
  3. Production scale: Use a managed YouTube scraper with built-in proxy rotation
  4. Custom needs: Build your own scraper with residential proxies

Pick the method that matches your volume and reliability needs, and always be respectful of the platform’s resources.

Why 96% of the Web Is Invisible to AI Agents (And What You Can Do About It)

Your website looks great. Clean design, fast load times, maybe even a perfect Lighthouse score.

But to an AI agent? It might as well not exist.

AI agents don’t have eyes

Here’s something most developers haven’t internalized yet: AI agents don’t open a browser. They don’t render your beautiful CSS. They don’t execute your JavaScript.

They send an HTTP request. They get back HTML (hopefully). They try to parse it into something meaningful.

That’s it. No clicking cookie banners. No waiting for React to hydrate. No solving CAPTCHAs.

And this is where things fall apart for most of the web.

We tested 834 websites. The results were brutal.

At SiliconFriendly, we built a scoring system (L0 to L5) that rates how well a website works with AI agents. Then we pointed it at 834 real websites.

The breakdown:

  • 50.4% scored L0-L1 — essentially hostile to agents. Blocked crawlers, no structured data, JavaScript-only rendering.
  • 35.2% scored L2-L3 — partially accessible, but missing key pieces.
  • 13.6% scored L4 — good, with structured APIs or solid metadata.
  • 0.8% scored L5 — fully agent-friendly. That’s 7 websites out of 834.

Let that sink in. 96% of the websites we tested are partially or fully invisible to AI agents.

What actually blocks AI agents?

It’s not one thing. It’s a stack of problems:

1. JavaScript-rendered content. If your content only exists after JS executes, agents see an empty page. SPAs are the worst offenders. Your React app with client-side rendering? To an AI agent, it’s a blank <div id="root"></div>.

2. Aggressive bot blocking. CAPTCHAs, Cloudflare challenges, rate limiting — all designed to stop bots. They work. They stop AI agents too. The irony: you’re blocking the same AI systems that could be recommending your product to millions of users.

3. No structured data. Schema.org markup, OpenGraph tags, clean semantic HTML — these are how agents understand what your page is about. Without them, agents are guessing.

4. No llms.txt. This is the new robots.txt for the AI era. A simple file at your domain root that tells AI agents what your site does, what content matters, and how to interact with it. Almost nobody has one yet.

5. No machine-readable APIs. If the only way to get data from your site is to scrape HTML, you’re making agents work ten times harder than they need to.

What makes a website agent-friendly?

The websites that scored L4-L5 had a few things in common:

They serve real HTML. Server-side rendering or static generation. Content is in the initial response, not loaded by JavaScript after the fact.

They have an llms.txt file. A simple, markdown-formatted file that tells AI systems: here’s who we are, here’s what we offer, here’s our most important content. Think of it as a cover letter for AI agents visiting your site.

They expose structured data. JSON-LD schema markup on key pages. Not just basic Organization schema — product info, FAQs, how-to guides, all marked up.

They have documented APIs. Even a simple REST API with clear docs gives agents a clean way to interact with your service.

They don’t block AI crawlers by default. Check your robots.txt. If you’re blocking GPTBot, ClaudeBot, or similar user agents, you’re opting out of the AI-powered web.

Why this matters now

AI agents are going to be a massive traffic and conversion channel. Not next year — now.

When someone asks ChatGPT “what’s the best project management tool for small teams?”, the answer comes from somewhere. If your website is invisible to the systems generating that answer, you don’t exist in that conversation.

This isn’t theoretical. Companies are already seeing measurable traffic from AI referrals. The ones that show up are the ones whose sites are actually parseable by these systems.

Check your own site

We built SiliconFriendly specifically for this. Punch in your URL and get an instant score (L0-L5) with specific recommendations.

It checks your llms.txt, structured data, JavaScript dependency, crawler accessibility, and more. Takes about 30 seconds.

The fix isn’t hard for most sites. Add an llms.txt. Add some Schema.org markup. Make sure your critical content is in the HTML response. Unblock AI crawlers in robots.txt.

The web is splitting into two: sites that AI agents can work with, and sites that are invisible to them. Right now, 96% of sites are on the wrong side.

Don’t be part of the 96%.

Check your site’s AI-agent readiness at siliconfriendly.com

GoFr — The Go Framework That Finally Made Backend Development Click for Me

I have been exploring backend development for a while now, and if there is one thing I have learned, it is that setting everything up from scratch is honestly the most frustrating part. You want to build something cool, but before you write a single line of actual logic, you are already dealing with logging setup, database connections, tracing, configs, and a hundred other things that have nothing to do with what you actually want to build.
That is exactly why I got excited when I came across GoFr.
So What Actually Is GoFr?
GoFr is an open source framework written in Go, and it is designed specifically for building microservices and backend APIs. The whole philosophy behind it is simple: give developers a solid, production ready foundation so they can focus on writing business logic instead of boilerplate.
It is not trying to be everything. It is trying to be the thing that gets you from zero to a working, production quality service as fast as possible.
The Problem GoFr Solves
Think about the last time you started a new backend project. You probably spent the first few hours just deciding which logging library to use, how to structure your folders, how to connect to your database, how to handle errors properly. And that is before you have even thought about observability, tracing, or health checks.
Most frameworks give you the routing and not much else. GoFr gives you the full picture. Logging, tracing, metrics, database support, Redis support, and a clean project structure are all included and already wired together properly. You do not have to make those decisions. You just start building.
What I Found Really Impressive
The first thing that stood out to me was how little code you need to get a working API running. A basic REST endpoint in GoFr is just a few lines. There is no magic happening behind the scenes that you cannot understand, but there is also no unnecessary repetition.
The built in observability is another big deal. In most projects, adding proper logging and tracing is a whole separate task that gets pushed to later. GoFr builds it in from the start, which means your service is production ready from day one and not just in theory.
The documentation is also genuinely good. It is clear, it has real examples, and it does not assume you already know everything. For someone who is still learning, that matters a lot.
The Community Side of Things
GoFr is fully open source and lives on GitHub. The maintainers are active, pull requests get reviewed, and the community is welcoming to newcomers. Whether you want to report a bug, suggest a feature, or just ask a question, there are real people on the other side who actually respond.
This is the kind of project where you can tell the people building it actually use it themselves. The decisions make sense. Nothing feels like it was added just to add it.
Who Should Try GoFr?
Honestly, if you are someone who works with Go or wants to start, GoFr is worth your time. It is also a great way to understand what a well structured microservice actually looks like in practice, because the framework basically teaches you good patterns just by using it.
Even if you are not planning to use it in production right away, spending a weekend with GoFr will change how you think about backend architecture.
How to Get Started
Head over to gofr.dev and take a look at the documentation. You can have a basic API running locally in under ten minutes. The getting started guide is beginner friendly and walks you through everything without assuming prior experience with Go frameworks.
After that, check out their GitHub repository at github.com/gofr-dev/gofr. Star it, explore the issues, and if you feel like contributing, the community is very open to new contributors whether you are fixing a bug, improving docs, or writing about your experience.
Final Thoughts
GoFr reminded me why I enjoy building things. Not because it is flashy or overhyped, but because it just works. It removes the friction, gives you a clean foundation, and lets you actually focus on the problem you are trying to solve.
If you have been looking for a reason to try Go, or if you have been frustrated with how much setup your current backend workflow requires, give GoFr a shot. I think you will be pleasantly surprised.

Google Agent Skills: How a Single SKILL.md File Boosted AI Coding Accuracy from 6.8% to 96%

The Knowledge Gap Problem

If you’ve used AI coding agents, you’ve hit this wall: you ask for code using a new SDK, and the agent generates deprecated API calls. Google quantified this as the “Knowledge Gap” — the mismatch between static LLM training data and rapidly evolving software ecosystems.

Their benchmark puts it starkly: Gemini 3.0 Pro scored just 6.8% on Gemini SDK code generation without skill assistance. That means 93 out of 100 code generation attempts failed.

What Are Agent Skills?

An Agent Skill is a standalone instruction package that bridges this gap:

my-skill/
  SKILL.md           # Core instructions for the agent
  references/        # Official docs excerpts
  assets/            # Diagrams, images
  scripts/           # Helper scripts

The SKILL.md file loads into the agent’s context window, providing up-to-date instructions on how to use specific APIs and frameworks correctly.

The Benchmark Results

Google tested gemini-api-dev skill with 117 prompts across Python and TypeScript:

Model Vanilla With Skill Improvement
Gemini 3.0 Pro 6.8% 96% 14x
Gemini 3.0 Flash 6.8% 87% 13x
ADK-specific 29% 99% 3.4x

One file. 14x improvement. That’s not a marginal gain — it’s a paradigm shift in how we should be using AI coding agents.

ADK Integration

Google’s Agent Development Kit makes skill integration straightforward:

from google.adk.skills import SkillToolset
from google.adk.agents import Agent

skill = SkillToolset.load_skill_from_dir("./skills/gemini-api-dev")

agent = Agent(
    name="coding_agent",
    model="gemini-3.0-pro",
    tools=[skill],
    instruction="You are a Gemini API coding agent."
)

Cross-Platform Standard

Here’s what makes this particularly interesting: SKILL.md is becoming a de facto industry standard. The same skill file works across:

  • Claude Code (Anthropic)
  • Cursor (AI-native IDE)
  • Windsurf (AI pair programming)
  • Gemini CLI (Google)
  • Codex CLI (OpenAI)
  • GitHub Copilot

The awesome-agent-skills repo has 1,234+ skills registered, with management tools like skillport and openskills emerging.

Building Your Own Skill

Creating a skill is surprisingly simple:

  1. Write SKILL.md — Instructions for the agent (what API to use, what’s deprecated)
  2. Add references/ — Excerpted official docs, migration guides, code examples
  3. Deploy — Place in .claude/skills/, .cursor/skills/, or load via ADK’s SkillToolset

The key is being specific about what’s correct (latest API patterns) and what’s forbidden (deprecated patterns).

Key Takeaway

The knowledge gap is a structural limitation of LLMs — no amount of model scaling fixes it. Agent Skills provide a practical engineering solution. Google’s 6.8% to 96% benchmark proves the approach works at scale.

Check out the gemini-skills repo and the Google Developers Blog post for details.

What frameworks are you building Agent Skills for? I’d love to hear about community experiences with SKILL.md across different AI coding tools.

Building a Clean Master-Detail App with Blazor Server, MudBlazor, and EF Core

Building a Clean Master-Detail App with Blazor Server, MudBlazor, and EF Core

When building business applications, one of the most common requirements is a master-detail workflow. Orders and order items are a perfect example.

You need a clean list view, a reliable detail page, inline editing, validation, filtering, and a structure that can grow without becoming messy.

That is exactly what BlazorOrderApp is designed to demonstrate.

This application is a production-ready Blazor Server solution built with MudBlazor for the UI, EF Core with SQL Server for persistence, and a clean architecture that separates concerns across Domain, Application, Infrastructure, and Web layers.

Instead of placing everything in pages or code-behind files, the app keeps business rules, data access, validation, and UI responsibilities clearly divided.

At the core of the app is a classic master-detail pattern. The Orders.razor page acts as the master list, giving users filtering, search, pagination, and a structured overview of all orders. The OrderDetail.razor page handles the detail side, where users can inspect customer information, manage order items, and update order status in a focused way.

A big strength of this project is its use of the Repository and Specification patterns. The repository layer keeps EF Core logic centralized and testable, while specifications allow queries to be defined as reusable objects instead of scattered LINQ expressions. This makes filtering, paging, and retrieving complex data much easier to maintain over time.

Validation is handled with FluentValidation, which keeps rules explicit and easy to evolve as business needs grow. Combined with MudBlazor dialogs for create, edit, and delete operations, the result is a workflow that feels polished and user-friendly without sacrificing structure behind the scenes.

Overall, BlazorOrderApp is a strong example of how to build a modern internal business application in Blazor. It shows that you can create a clean, scalable CRUD system with a professional UI, solid architecture, and maintainable backend patterns, all within the .NET ecosystem.

Source code : https://github.com/stevsharp/BlazorOrderApp

The Vinted Arbitrage War: Building a Scraper That Doesn’t Get IP-Banned

War diary. Real failures. Real fix. No inspirational BS.

I’ll be honest with you: I spent three weekends building a Vinted scraper that worked exactly once.

The second time I ran it, I got a 403. The third time, my entire datacenter IP range was silently blacklisted. By the fourth weekend, I wasn’t writing Python anymore – I was googling “residential proxy Vinted” at 2am and reading forum posts from people who had clearly given up.

This is the story of how I tried to build an arbitrage pipeline for Vinted, why everything broke, what I learned from every failure, and why I eventually stopped reinventing the wheel.

If you’re a dev or data engineer trying to extract data from Vinted for price monitoring, cross-border arbitrage, or inventory hunting – read this before you repeat my mistakes.

🔥 Why Traditional Vinted Scraping Fails (The Bot Protection Problem)

Vinted is not a public API. It never was. Their internal API is undocumented, versioned inconsistently, and protected by a stack of bot-detection layers that get more aggressive every quarter.

The first mistake most devs make – including me – is treating Vinted like a simple HTML scrape target.

It’s not. The listing data is rendered client-side via API calls. So step one is: forget BeautifulSoup. You’re not scraping HTML. You’re reverse-engineering JSON endpoints that change without warning.

That discovery costs you your first weekend.

Then come the real problems.

🛑 Datacenter IPs vs. Residential Proxies

Vinted’s bot protection stack fingerprints requests at multiple layers:

  • IP reputation – Datacenter IPs (AWS, GCP, Hetzner, OVH) are flagged by default. Even if your request headers look perfect, your IP subnet is already on a deny list.
  • TLS fingerprinting – Python’s requests library and standard httpx produce a TLS fingerprint that differs from a real browser. Vinted (like most modern platforms) uses JA3/JA4 fingerprinting to detect non-browser clients.
  • Behavioral analysis – Consistent intervals, identical User-Agents, no mouse events, no referrer chains. Bots don’t browse like humans. Vinted knows.

I tested this empirically. Same request, two IP sources:

IP Type Result
Hetzner datacenter IP 403 after 12 requests
Residential (France) 200, stable for ~2 hours
Residential (wrong country) 200 for 30 min, then soft-block

The pattern is clear: residential proxies extend your session, but they’re not a silver bullet if you’re hitting the wrong geo.

Which brings us to the next problem.

🌍 The Geo-Blocking Nightmare (19 European Countries)

Vinted operates across 19 European markets. Each market has its own subdomain:

  • www.vinted.fr (France)
  • www.vinted.de (Germany)
  • www.vinted.pl (Poland)
  • www.vinted.es (Spain)
  • www.vinted.it (Italy)
  • …and 14 more

Here’s what they don’t tell you: these aren’t just URL namespaces. The platform actively routes and restricts access based on your IP’s geolocation. Making a request to vinted.de from a French IP? You’ll get content, but category filtering, pricing, and shipping data will be inconsistent. Making the same request from a US IP? Silent redirect or empty results.

For vinted arbitrage, this is catastrophic. The entire point is to monitor price gaps between markets – buy cheap in Poland, sell at markup in France. If you can’t reliably query both markets from a consistent perspective, your arbitrage pipeline is garbage data.

To do real cross-border arbitrage monitoring, you need:

  1. A residential proxy pool with per-country targeting
  2. Session management per country (cookies, tokens, headers)
  3. Rate limiting per country (they’re independent backends)

I built this. It took two more weekends. It worked for about a week, then Vinted updated their token rotation and the whole thing broke again.

⏳ The Pagination Bottleneck and Rate Limits

Even when you solve the IP problem, Vinted’s pagination is its own circle of hell.

Vinted’s search API uses cursor-based pagination. That sounds fine until you realize:

  • The cursor becomes invalid after a few minutes
  • Deep pagination (page 10+) triggers additional verification
  • Rate limits are per-IP, not per-session, so rotating proxies too fast creates a new problem: you lose cursor continuity

Here’s what a naive pagination loop looks like:

cursor = None
while True:
    response = session.get(
        "https://www.vinted.fr/api/v2/catalog/items",
        params={"search_text": "Nike Dunk Low", "cursor": cursor},
        headers=headers,
        proxies=proxy
    )
    data = response.json()
    items = data.get("items", [])
    process(items)
    cursor = data.get("pagination", {}).get("next_cursor")
    if not cursor:
        break
    time.sleep(random.uniform(1.5, 3.5))

This works for the first two or three pages. Then one of these happens:

  • cursor expires mid-loop – restart from page 1, duplicates everywhere
  • Rate limit kicks in – 429, your proxy is flagged
  • The session token rotates – 401, re-auth required
  • The item count returns 0 unexpectedly – silent failure, you stop early without knowing

Handling all of this correctly requires retry logic, session refresh, cursor state management, and dead-letter queuing for failed pages. You’re not writing a scraper anymore. You’re writing a distributed job queue.

⚡ Why Asynchronous Requests Get You Banned Faster

The logical next step: “Let me make this faster with async.”

Wrong move.

I tried asyncio + aiohttp to parallelize requests across multiple proxies. Result: banned in under 10 minutes across my entire proxy pool.

Here’s why: async request patterns are a strong behavioral signal. Even with random delays, the statistical distribution of request timing differs from human browsing. Vinted’s rate limits aren’t just about volume – they’re about velocity and pattern regularity.

The sweet spot for staying undetected is low concurrency (1-2 simultaneous sessions per country), with jittered delays drawn from distributions that approximate real browsing behavior. Not uniform random – actual distributions skewed toward longer pauses.

By the time I had tuned this correctly, my “fast scraper” was running at 40 items/minute per country. At that rate, monitoring 5 categories across 5 countries would take hours per full cycle.

This is not a pipeline. This is a liability.

🏗️ Building the Ultimate Vinted Data Pipeline (Or Why I Stopped Trying)

Let me describe the architecture I eventually sketched out before I abandoned it.

What you’d need to build a production-grade Vinted scraper from scratch:

  1. Residential proxy pool – country-targeted, with session stickiness per market. Cost: €80-200/month minimum for a reliable pool.
  2. Browser fingerprint spoofing – TLS client hello matching a real browser, User-Agent rotation, Accept-Language matching the target country.
  3. Session management – Vinted requires a guest token for API access. This token has to be obtained via a browser-like flow, then refreshed on a schedule. Store per-country, rotate on 401.
  4. Rate limit tracker – per-country, per-proxy, with exponential backoff and cool-down periods.
  5. Cursor state manager – checkpointing pagination cursors so a failure mid-run doesn’t restart from scratch.
  6. Schema parser – Vinted’s API response schema changes. You need a versioned parser with fallback fields.
  7. Output pipeline – JSON normalization, deduplication, webhook delivery (Discord, n8n, Make, custom endpoints).
  8. Monitoring & alerting – because this thing will break silently at 3am.

Estimated build time: 4-6 weeks for a competent backend dev. Estimated maintenance: ongoing, every time Vinted updates their anti-bot stack.

And here’s the part nobody tells you: even if you build all of this, you’re in a constant arms race. Vinted has an entire security team whose job is to break your scraper. You’re one person.

I’m a developer. I understand the appeal of building this from scratch. I felt it too. But at some point, I did the math.

My time x its value vs. the cost of a working solution = I should stop building this.

🚀 Enter the Vinted Smart Scraper on Apify

After my third rebuild, a colleague pointed me to the Vinted Smart Scraper on Apify.

I was skeptical. I’d seen plenty of half-baked scrapers on Apify that stopped working after a month. But I read the documentation and ran a test, and here’s what I found:

The actor is built specifically for the problems I spent three weekends failing to solve. It’s not a generic scraper with Vinted bolted on. It’s purpose-built for the Vinted API surface, with the infrastructure already handled.

You pass in a search query, target category, and country. The actor handles:

  • Proxy rotation and country-targeting automatically
  • Session token management
  • Pagination across the full result set
  • Clean JSON output per item (title, price, condition, photos, seller info, URL, shipping)

The result set is consistent. The pagination completes. The data is structured.

Here’s what a basic run config looks like:

{
  "searchQuery": "Nike Dunk Low",
  "country": "FR",
  "maxItems": 500,
  "condition": ["Neuf avec étiquette", "Très bon état"]
}

And you get back clean, normalized items ready for your pipeline.

No proxy configuration. No session management. No cursor headaches.

🌐 Built-in Geo-Routing for European Arbitrage

The key feature for cross-border arbitrage: the Vinted Smart Scraper supports multi-country runs. You can run parallel actors per country and get consistent, comparable data across markets.

This is the part I couldn’t reliably build myself. Geo-routing residential proxies to match Vinted’s market segmentation is infrastructure-level work. It’s solved here.

For a sneaker arbitrage use case, a typical setup looks like:

  1. Run actor for country: PL (Poland) – find underpriced listings
  2. Run actor for country: FR (France) – check equivalent items
  3. Compare price delta in n8n or a simple Python script
  4. Trigger Discord webhook alert when spread exceeds threshold

The actor handles steps 1 and 2. You only have to write the delta logic.

📦 Clean JSON Output for n8n and Discord Webhooks

The output schema is consistent and webhook-ready. A sample item output:

{
  "id": "4829301",
  "title": "Nike Dunk Low Retro White Black",
  "price": 68.00,
  "currency": "EUR",
  "condition": "Très bon état",
  "size": "42",
  "brand": "Nike",
  "photos": ["https://images.vinted.net/..."],
  "url": "https://www.vinted.fr/items/4829301",
  "seller": {
    "username": "sneaker_seller_42",
    "rating": 4.9,
    "total_reviews": 230
  },
  "shipping_options": [...],
  "created_at": "2025-05-15T10:23:00Z"
}

This pipes directly into n8n HTTP nodes, Discord embed webhooks, or Google Sheets via any standard automation platform. No parsing gymnastics. No field guessing.

For those of us running lightweight arbitrage monitors on n8n or Make: this is the missing data source that makes the rest of the automation trivial.

📈 Stop Reinventing the Wheel, Start Scaling

Here’s what I actually run now:

  • Vinted Smart Scraper scheduled on Apify (every 4 hours, 5 countries, 3 search queries each)
  • n8n to receive the output webhook, compute price deltas, filter by condition
  • Discord webhook to push alerts when a deal meets criteria (min 30% below market average)
  • Google Sheets for deal history and price trend visualization

Total setup time: ~4 hours. Total recurring cost: a few euros/month of Apify compute credits.

Compare that to: 6+ weekends of my time, ongoing proxy costs (€80+/month), and a system that breaks every time Vinted updates their anti-bot.

The math is not close.

🧠 What I Would Have Told Myself at Weekend One

If you’re starting this journey now:

  1. Don’t start with raw HTTP requests. Vinted’s bot protection will end you inside an hour on datacenter IPs.
  2. Residential proxies help, but geo-targeting matters. A UK residential proxy cannot reliably query vinted.de without soft-blocks.
  3. Async is a trap. High velocity is suspicious. Vinted’s defenses aren’t just rate limits – they’re behavioral fingerprinting.
  4. Pagination is stateful. Cursor expiry plus proxy rotation equals data loss at scale.
  5. Your time has a cost. Building this yourself is an option. Maintaining it forever is the hidden cost.

What I wish had existed at weekend one is exactly this: a purpose-built actor that solves the infrastructure problem so you never have to fight the proxy/token/cursor war yourself.

✅ Final Verdict

If you’re building a Vinted arbitrage system, a price monitor, or any kind of web scraping Vinted pipeline: the bottleneck isn’t your logic. It’s the IP ban, the geo-block, the cursor expiry, and the rotating tokens.

Solve the data access layer first. The Vinted Smart Scraper does exactly that.

Use it as your foundation. Build the arbitrage logic on top. Ship it in a weekend.

→ Get the Vinted Smart Scraper on Apify

Tags: #webdev #python #dataengineering #webscraping #vinted #sideproject

📋 Article Metadata (for dev.to publication)

Title: The Vinted Arbitrage War: Building a Scraper That Doesn’t Get IP-Banned

Cover image suggestion: Dark terminal screenshot with a 403 error and a Python traceback – mood-setting, developer-resonant

Tags: webdev, python, dataengineering, tutorial, beginners

Canonical URL: (set to your own domain if cross-posting)

Series suggestion: “Scraping the Hard Way”

Connecting LINE Bot to Home Lab Without cloudflared — WebSocket Relay Architecture

Connecting LINE Bot to Home Lab Without cloudflared — WebSocket Relay Architecture

When running a LINE Bot in a local environment, the first wall you hit is receiving webhooks from the outside world. LINE sends HTTPS POST requests from their servers. Your local machine has no global IP.

The usual answers are ngrok or Cloudflare Tunnel (cloudflared). But both have friction. ngrok changes URLs on every restart with the free tier. cloudflared needs a persistent daemon and non-trivial setup.

At TechsFree, we took a different approach: a WebSocket relay server in the middle.

Architecture

LINE → HTTPS POST → Relay Server (linebot.techsfree.com)
                         ↓ WebSocket
                    Local Node (line_bridge.py)
                         ↓ HTTP POST
                    OpenClaw chatCompletions API (localhost:18789)
                         ↓
                    LINE Reply API → User

The relay server sits on the cloud side, always accepting HTTPS. The local line_bridge.py keeps a persistent outbound WebSocket connection to it. Since the connection is outbound (inside → outside), it passes through firewalls without any port forwarding. When a LINE webhook arrives, the relay forwards it over the WebSocket.

Why WebSocket?

HTTP polling adds latency. Server-Sent Events are unidirectional. WebSocket gives you bidirectional, low-latency, persistent connection — ideal for a relay pattern.

Implementation Highlights

1. Auto-Reconnect with Exponential Backoff

Networks always fail. Home routers reboot. Connections drop on sleep/wake. The bridge must recover on its own.

reconnect_delay = RECONNECT_DELAY  # 5 seconds
MAX_RECONNECT_DELAY = 60

while True:
    try:
        async with websockets.connect(relay_url, ping_interval=20, ping_timeout=10) as ws:
            reconnect_delay = RECONNECT_DELAY  # reset on success
            async for raw_message in ws:
                ...
    except ConnectionClosed as e:
        logger.warning(f"Connection closed: {e}")
    except Exception as e:
        logger.error(f"Connection error: {e}")

    await asyncio.sleep(reconnect_delay)
    reconnect_delay = min(reconnect_delay * 2, MAX_RECONNECT_DELAY)

The key detail: reset the delay on success. Only grow the backoff on errors. Cap at 60 seconds — no infinite waiting.

2. LINE Signature Verification

Every LINE webhook carries an x-line-signature header. Skip HMAC-SHA256 verification and you’ll process forged requests.

def verify_signature(body: str, signature: str, channel_secret: str) -> bool:
    hash_val = hmac.new(
        channel_secret.encode("utf-8"),
        body.encode("utf-8"),
        hashlib.sha256
    ).digest()
    expected = base64.b64encode(hash_val).decode("utf-8")
    return hmac.compare_digest(signature, expected)

Using hmac.compare_digest instead of == prevents timing attacks — string comparison time can leak whether characters match.

3. Config Reload on Every Webhook

The bridge reloads its config file on every incoming webhook:

elif msg_type == "webhook":
    current_config = load_config()  # reload every time
    asyncio.create_task(handle_webhook(msg, current_config))

This means LINE tokens and channel_secret can be swapped out without restarting the daemon. In our miniPC distribution scenario, we ship a unit to a customer, they fill in their credentials, and the bridge picks them up immediately — no restart needed.

4. OpenClaw chatCompletions API Integration

When a LINE message arrives, the bridge calls the local OpenClaw API:

async def call_openclaw(message: str, openclaw_url: str, gateway_token: str = "") -> str:
    payload = {
        "model": "gpt-4",
        "messages": [{"role": "user", "content": message}]
    }
    headers = {"Content-Type": "application/json"}
    if gateway_token:
        headers["Authorization"] = f"Bearer {gateway_token}"
    async with httpx.AsyncClient(timeout=60) as client:
        resp = await client.post(openclaw_url, json=payload, headers=headers)
        resp.raise_for_status()
        return resp.json()["choices"][0]["message"]["content"]

It uses the OpenAI-compatible chatCompletions format, so you can point it at Ollama or LM Studio just by changing the URL.

Designed for Distribution

line_bridge.py is built to ship inside the miniPCs TechsFree sells. Each unit gets a unique relay_token; the relay server uses it to route webhooks to the right local bridge.

{
  "relay_token": "gmk-xxxxxxxx",
  "channel_secret": "...",
  "channel_access_token": "...",
  "relay_url": "wss://linebot.techsfree.com/gmk/{token}/ws",
  "openclaw_url": "http://localhost:18789/v1/chat/completions"
}

One JSON file per unit. A GUI fills in the fields — that’s the entire setup for end users.

Comparison

ngrok cloudflared WebSocket Relay
Fixed URL Not on free tier Yes Yes (relay server)
Daemon setup Required Required line_bridge.py only
Offline behavior URL invalidated Tunnel drops Auto-reconnects
Custom routing No No Full control
Multi-unit management Hard Hard Token-based

cloudflared is powerful, but “dependency on an external service” was a concern. Running your own relay means authentication, logging, and routing are all under your control. For scenarios like managing many local environments — home labs or distributed miniPC products — the self-hosted relay approach is a clean fit.