scraping// scraping-dynamic-spa-without-browser

استخراج داده از وبسایتهای مدرن SPA (Next.js & React) بدون نیاز به مرورگر

استخراج مستقیم دادههای ساختاریافته از Hydration State، اندپوینتهای مخفی _next/data و استریمهای React Server Components (RSC) با سرعت ۱۰۰ برابر مرورگر.

احسان شهبازی·۱۴۰۴/۰۷/۲۹·۱۱ دقیقه مطالعه
// SBU Data Science Lab

توهم نیاز به مرورگر در وبسایتهای مدرن

یکی از رایجترین باورهای نادرست در وب اسکرپینگ این است که به محض دیدن یک وبسایت مبتنی بر React، Next.js یا Nuxt.js، باید سراغ مرورگرهای سنگینی مثل Playwright یا Selenium رفت. در واقعیت، بیش از ۹۰ درصد وبسایتهای فروشگاهی، نیازمندیها و پلتفرمهای محتوایی مدرن به دلیل نیاز مبرم به SEO و سرعت بارگذاری اولیه، از معماری SSR (Server-Side Rendering) یا ISR (Incremental Static Regeneration) استفاده میکنند.

در این معماریها، سرور تمام دادههای صفحه را پیش از ارسال پاسخ جمعآوری کرده و علاوه بر رندر HTML، کل درخت داده را به صورت یک شیء JSON تمیز درون سورس صفحه تزریق میکند تا فریمورک در سمت کلاینت بتواند فرآیند Hydration را انجام دهد.

کالبدشکافی Hydration State: از __NEXT_DATA__ تا React Server Components

بسته به نسخه فریمورک مورد استفاده در سایت هدف، دادههای خام در یکی از سه الگو قرار میگیرند:

  • معماری Next.js Pages Router: تمام پراپهای صفحه داخل تگ <script id="__NEXT_DATA__" type="application/json"> در انتهای سند HTML قرار دارند. مسیر props.pageProps دقیقاً همان خروجی دیتابیس یا API داخلی بکاند است.
  • اندپوینتهای مخفی `_next/data`: زمانی که کاربر در یک سایت Next.js بین صفحات جابهجا میشود (Client-Side Navigation)، مرورگر کل HTML را دانلود نمیکند، بلکه یک فایل JSON خالص را از مسیر /_next/data/<buildId>/<path>.json دریافت میکند. با استخراج buildId در اولین درخواست، میتوانیم هزاران صفحه بعدی را مستقیماً به صورت JSON و با کسری از حجم ترافیک دریافت کنیم!
  • معماری Next.js App Router (RSC Flight Payloads): در نسخههای جدیدتر (Next.js 13/14/15)، تگ __NEXT_DATA__ حذف شده و دادهها به صورت تکههای استریم در فراخوانیهای self.__next_f.push([1, "..."]) تزریق میشوند.

ارسال هدر RSC: 1 در Next.js App Router

یک ترفند فوقالعاده حرفهای در مواجهه با سایتهای مبتنی بر App Router این است که در درخواست HTTP خود، هدر RSC: 1 را اضافه کنید. با ارسال این هدر، سرور Next.js به جای رندر کردن کل تگهای سنگین HTML، مستقیماً استریم متنی React Flight را برمیگرداند که پارس کردن آن بسیار سریعتر و کمحجمتر است.

پیادهسازی استخراجگر ترکیبی Next.js (پشتیبانی از Pages Router و App Router)

کد زیر یک موتور استخراج غیرهمگام (Async) است که به صورت خودکار معماری سایت هدف را تشخیص میدهد، buildId را کش میکند، در صورت دیپلوی نسخه جدید و دریافت خطای 404 به صورت خودکار buildId جدید را واکشی میکند و هر دو ساختار __NEXT_DATA__ و استریمهای self.__next_f را استخراج مینماید:

nextjs_extractor.py
python
import json
import re
import asyncio
from typing import Dict, Any, List, Optional
import httpx

class NextJsDirectExtractor:
    NEXT_DATA_REGEX = re.compile(r'<script id="__NEXT_DATA__" type="application/json"[^>]*>(.*?)</script>', re.DOTALL)
    RSC_FLIGHT_REGEX = re.compile(r'self\.__next_f\.push\(\[\d+,\s*"(.*?)"\]\)', re.DOTALL)

    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip('/')
        self.build_id: Optional[str] = None
        self.client = httpx.AsyncClient(http2=True, timeout=15.0, headers={
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
        })

    async def refresh_build_id(self, sample_path: str = '/') -> Optional[str]:
        resp = await self.client.get(f'{self.base_url}{sample_path}')
        resp.raise_for_status()
        match = self.NEXT_DATA_REGEX.search(resp.text)
        if match:
            payload = json.loads(match.group(1))
            self.build_id = payload.get('buildId')
            return self.build_id
        return None

    async def fetch_page_props(self, route_path: str, query_params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
        clean_route = route_path.strip('/') or 'index'
        if not self.build_id:
            await self.refresh_build_id()

        if self.build_id:
            data_url = f'{self.base_url}/_next/data/{self.build_id}/{clean_route}.json'
            resp = await self.client.get(data_url, params=query_params)
            if resp.status_code == 404:
                # Target deployed a new build; refresh buildId once and retry
                await self.refresh_build_id()
                data_url = f'{self.base_url}/_next/data/{self.build_id}/{clean_route}.json'
                resp = await self.client.get(data_url, params=query_params)
            if resp.status_code == 200:
                return resp.json().get('pageProps', {})

        # Fallback to HTML extraction
        full_url = f'{self.base_url}/{route_path.lstrip("/")}'
        resp = await self.client.get(full_url, params=query_params)
        resp.raise_for_status()
        match = self.NEXT_DATA_REGEX.search(resp.text)
        if match:
            return json.loads(match.group(1)).get('props', {}).get('pageProps', {})
        raise ValueError('No __NEXT_DATA__ found; target may use App Router RSC.')

    async def fetch_rsc_flight_stream(self, route_path: str) -> List[str]:
        """Fetches raw React Server Components payload using the RSC header."""
        full_url = f'{self.base_url}/{route_path.lstrip("/")}'
        resp = await self.client.get(full_url, headers={'RSC': '1'})
        if resp.status_code == 200 and 'text/x-component' in resp.headers.get('content-type', ''):
            return resp.text.splitlines()
        return [m.encode('utf-8').decode('unicode_escape') for m in self.RSC_FLIGHT_REGEX.findall(resp.text)]

    async def close(self):
        await self.client.aclose()

نکته کلیدی مهندسی: هنگام استفاده از مسیر /_next/data/<buildId>/...، توجه داشته باشید که با هر بار Deploy شدن کد جدید توسط تیم فنی سایت هدف، مقدار buildId تغییر کرده و درخواستهای شما با خطای 404 Not Found مواجه میشوند. همیشه مکانیسم Auto-Refresh BuildID را در لایه Middleware اسکرپر خود پیادهسازی کنید تا پایپلاین بدون توقف به کار خود ادامه دهد.