اگر تصور می‌کنید با یک regex ساده می‌توانید نام و نسخه مرورگر را از User-Agent استخراج کنید، احتمالاً تا امروز تعداد قابل توجهی از بازدیدهای مرورگرهای نادر و نسخه‌های جدید را به‌اشتباه دسته‌بندی کرده‌اید و تحلیل‌های سازگاری مرورگر شما هرگز دقیق نبوده است.

چرا تشخیص دقیق مرورگر، یک مسئله‌ی کسب‌وکار است؟

در یکی از پروژه‌های فروشگاهی که چند سال پیش روی آن کار می‌کردم، تیم پشتیبانی شکایت داشت که «برخی کاربران موبایل نمی‌توانند پرداخت کنند». بعد از بررسی دقیق، متوجه شدیم که مشکل فقط در کاربران مرورگر Firefox روی اندروید است و ما این دسته را در آمار به‌عنوان Chrome ثبت می‌کردیم. چون در گزارش‌ها این تفکیک وجود نداشت، کسی متوجه گستردگی مشکل نشده بود. این تجربه نشان می‌دهد که تشخیص دقیق مرورگر، فقط یک گزارش آماری نیست؛ یک ابزار عیب‌یابی و اولویت‌بندی است.

دقت در تشخیص مرورگر، در چند سطح اهمیت دارد.

سطح اول، سازگاری مرورگر. ویژگی‌های CSS و JavaScript در مرورگرهای مختلف تفاوت دارند. اگر بدانید چه درصدی از کاربران شما از مرورگری استفاده می‌کنند که از یک ویژگی خاص پشتیبانی نمی‌کند، می‌توانید اولویت‌بندی درستی برای polyfill و fallback داشته باشید.

سطح دوم، عیب‌یابی هدفمند. وقتی یک باگ گزارش می‌شود، اولین سؤال همیشه این است: «کاربر از کدام مرورگر استفاده می‌کرد؟» اگر داده‌ی دقیق نداشته باشید، عیب‌یابی حدس و گمان می‌شود.

سطح سوم، تحلیل کاربران خاص. اگر متوجه شوید کاربران یک مرورگر خاص نرخ تبدیل بالاتری دارند، می‌توانید روی بهینه‌سازی برای آن مرورگر تمرکز کنید.

سطح چهارم، امنیت. کاربران مرورگرهای قدیمی و بدون پشتیبانی، بیشتر در معرض حملات هستند. اگر این کاربران را بشناسید، می‌توانید هشدارهای به‌روزرسانی ارسال کنید.

تشخیص مرورگر، یک لاگ نیست؛ یک سیگنال عملیاتی است که در تصمیم‌های محصول، پشتیبانی و مهندسی مستقیماً استفاده می‌شود.

این اهمیت، در ساختار پروژه‌های آماری مثل اپ جنگو برای ردیابی بازدیدکننده به‌طور مستقیم دیده می‌شود. اگر لایه‌ی تشخیص مرورگر دقیق نباشد، تمام تحلیل‌های بالای آن بی‌اعتبار می‌شوند. برای درک عمیق‌تر این موضوع، پیشنهاد می‌کنم ابتدا تشخیص دستگاه کاربر در جنگو را مطالعه کنید، چون تشخیص مرورگر در کنار تشخیص دستگاه، یک جفت مکمل تشکیل می‌دهند.

تکامل User-Agent؛ از Netscape تا Client Hints

User-Agent از روزهای اول وب وجود داشته و ابتدا فقط برای سازگاری با مرورگرهای قدیمی استفاده می‌شد. مفهوم User-Agent در ویکی‌پدیا توضیح داده شده است، ولی تاریخچه‌ی آن پر از تناقض و تصمیم‌های عجیب است.

دهه‌ی ۱۹۹۰، جنگ مرورگرها. Netscape Navigator در ۱۹۹۴ خودش را با Mozilla/1.0 معرفی کرد. وقتی Internet Explorer 2.0 در ۱۹۹۵ عرضه شد، مایکروسافت می‌خواست که سایت‌ها IE را به‌عنوان Mozilla بشناسند تا سایت‌های موجود برای IE هم کار کنند. پس IE خودش را Mozilla/2.0 (compatible; MSIE 3.0) معرفی کرد. از آن زمان، همه‌ی مرورگرها خودشان را Mozilla معرفی می‌کنند، حتی اگر مربوط به Netscape نباشند.

دهه‌ی ۲۰۰۰، شکل‌گیری الگوی امروزی. Firefox، Chrome و Safari هر کدام به‌نوعی همان الگوی Mozilla را ادامه دادند. تا امروز، تمام مرورگرهای مدرن خودشان را Mozilla/5.0 معرفی می‌کنند، حتی روی iOS، Android یا Windows.

دهه‌ی ۲۰۱۰، پیچیدگی روزافزون. ورود مرورگرهای مبتنی بر Chromium (Chrome، Edge، Opera، Brave، Vivaldi، Samsung Internet) باعث شد که رشته‌ی User-Agent شبیه یک اثر باستانی پیچیده شود. برای مثال، Opera به‌عنوان Chrome معرفی می‌شود، Edge به‌عنوان Chrome و Safari، و Samsung Internet هم به‌عنوان Chrome. این تو در تویی، تشخیص مرورگر را به یک مسئله‌ی چندلایه تبدیل کرده است.

دهه‌ی ۲۰۲۰، ورود Client Hints. گوگل با توجه به مشکلات حریم خصوصی و پیچیدگی User-Agent، API جدیدی به نام User-Agent Client Hints معرفی کرد. این API، اطلاعات را به‌صورت ساختاریافته در هدرهای جداگانه برمی‌گرداند. ولی پشتیبانی آن هنوز در همه‌ی مرورگرها کامل نیست.

این تاریخچه، توضیح می‌دهد که چرا تشخیص مرورگر امروز پیچیده است: چون ساختار User-Agent، محصول دو دهه تصمیم‌های سازگاری عقب‌رو است، نه یک طراحی مهندسی‌شده. اگر با الگوی تشخیص بات از کاربر انسانی کار کرده باشید، این پیچیدگی برایتان آشناست.

روش ساده‌ای که در پروژه‌های واقعی می‌شکند

اکثر پروژه‌های جنگو، تشخیص مرورگر را با یک regex ساده انجام می‌دهند:

import re


def detect_browser(user_agent: str) -> tuple[str, str]:
    ua = user_agent or ""

    m = re.search(r"Chrome/([d.]+)", ua)
    if m:
        return "Chrome", m.group(1)

    m = re.search(r"Firefox/([d.]+)", ua)
    if m:
        return "Firefox", m.group(1)

    m = re.search(r"Version/([d.]+).*Safari", ua)
    if m:
        return "Safari", m.group(1)

    return "Unknown", ""

این کد، چهار مشکل جدی دارد که هر کدام در پروژه‌های واقعی دیده‌ام.

مشکل اول، ترتیب جستجو. Edge و Opera خودشان را به‌عنوان Chrome معرفی می‌کنند. اگر اول Chrome را چک کنید، Edge و Opera به‌اشتباه Chrome تشخیص داده می‌شوند. باید قبل از Chrome، الگوهای Edge و Opera را چک کنید.

مشکل دوم، iOS و Safari جعلی. در iOS، همه‌ی مرورگرها (حتی Chrome و Firefox) از WebKit داخلی استفاده می‌کنند و خودشان را به‌عنوان Safari معرفی می‌کنند. برای تشخیص صحیح، باید CriOS (Chrome iOS) و FxiOS (Firefox iOS) را جداگانه چک کنید.

مشکل سوم، عدم پشتیبانی از مرورگرهای نادر. Brave، Vivaldi، Yandex Browser، Samsung Internet، UC Browser و ده‌ها مرورگر دیگر وجود دارند که این regex آن‌ها را به‌عنوان Chrome یا Unknown تشخیص می‌دهد.

مشکل چهارم، نبود نسخه‌ی دقیق. وقتی regex فقط Chrome/([d.]+) را می‌گیرد، ممکن است نسخه‌ی نادرست را استخراج کند. مثلاً در Opera، رشته‌ی OPR/105.0.0.0 نسخه‌ی Opera است و Chrome/119.0.0.0 نسخه‌ی موتور Chromium. اگر هر دو را نگیرید، نمی‌توانید تشخیص دهید کدام مرورگر است.

در یکی از پروژه‌ها، بعد از سه ماه کار با این کد ساده، متوجه شدیم که حدود ۱۸٪ رکوردهای آماری ما به‌عنوان Chrome ثبت شده بود، در حالی که در واقعیت Edge، Opera، Brave یا Samsung Internet بودند. این خطا، تحلیل سازگاری مرورگر را کاملاً بی‌اعتبار کرده بود.

برای رفع این مشکلات، به یک معماری چندلایه نیاز دارید. اگر با الگوی services.py در جنگو آشنا هستید، این معماری در قالب یک سرویس اختصاصی پیاده‌سازی می‌شود.

لایه‌ی اول؛ الگوهای پایه در regex

لایه‌ی اول، الگوهای پایه را با ترتیب دقیق بررسی می‌کند. این ترتیب اهمیت حیاتی دارد، چون بعضی مرورگرها خودشان را به‌عنوان مرورگر دیگر معرفی می‌کنند.

# analytics/utils/browser.py

import re


BROWSER_PATTERNS = [
    # ترتیب اهمیت دارد!
    ("Edge", re.compile(r"Edg(?:e|A|iOS)?/([d.]+)")),
    ("Opera", re.compile(r"(?:OPR|Opera)[/ ]([d.]+)")),
    ("Brave", re.compile(r"Brave/([d.]+)")),
    ("Vivaldi", re.compile(r"Vivaldi/([d.]+)")),
    ("Yandex", re.compile(r"YaBrowser/([d.]+)")),
    ("Samsung Internet", re.compile(r"SamsungBrowser/([d.]+)")),
    ("UC Browser", re.compile(r"UCBrowser/([d.]+)")),
    ("DuckDuckGo", re.compile(r"DuckDuckGo/([d.]+)")),
    ("Chrome iOS", re.compile(r"CriOS/([d.]+)")),
    ("Firefox iOS", re.compile(r"FxiOS/([d.]+)")),
    ("Chrome", re.compile(r"Chrome/([d.]+)")),
    ("Firefox", re.compile(r"Firefox/([d.]+)")),
    ("Safari", re.compile(r"Version/([d.]+).*Safari")),
    ("IE", re.compile(r"MSIE ([d.]+)")),
    ("IE", re.compile(r"Trident/.*rv:([d.]+)")),
    ("Edge Legacy", re.compile(r"Edge/([d.]+)")),
]


def detect_browser(user_agent: str) -> dict:
    if not user_agent:
        return {"name": "Unknown", "version": "", "source": "empty"}

    ua = user_agent.strip()

    for name, pattern in BROWSER_PATTERNS:
        m = pattern.search(ua)
        if m:
            return {
                "name": name,
                "version": _normalize_version(m.group(1)),
                "source": "user_agent",
            }

    return {"name": "Unknown", "version": "", "source": "user_agent"}


def _normalize_version(version: str) -> str:
    """
    نرمال‌سازی نسخه: از 119.0.0.0 به 119
    """
    if not version:
        return ""
    parts = version.split(".")
    if len(parts) >= 1:
        return parts[0]
    return version

این الگوها، از چند نکته‌ی مهم پیروی می‌کنند.

نکته‌ی اول، ترتیب دقیق. Edge، Opera، Brave، Vivaldi، Yandex، Samsung Internet و UC Browser همه در User-Agent خود رشته‌ی Chrome را دارند. اگر اول Chrome را چک کنید، همه‌شان Chrome تشخیص داده می‌شوند. ترتیب درست این است که مرورگرهای مبتنی بر Chromium قبل از Chrome چک شوند.

نکته‌ی دوم، Edge‌های مختلف. Edge قدیمی با Edge/ و Edge جدید (مبتنی بر Chromium) با Edg/ معرفی می‌شود. Edge موبایل با EdgA/ و Edge iOS با EdgiOS/. اگر همه‌ی این‌ها را پوشش ندهید، بخشی از کاربران Edge اشتباه تشخیص داده می‌شوند.

نکته‌ی سوم، Opera‌های مختلف. Opera کلاسیک با Opera/، Opera جدید با OPR/، Opera Mini با Opera Mini/ و Opera Touch با OPT/ معرفی می‌شود.

نکته‌ی چهارم، iOS و CriOS/FxiOS. در iOS، همه‌ی مرورگرها از WebKit اپل استفاده می‌کنند، ولی Chrome و Firefox خودشان را با CriOS و FxiOS معرفی می‌کنند. اگر این‌ها را قبل از Safari چک نکنید، همه به‌عنوان Safari ثبت می‌شوند.

نکته‌ی پنجم، IE و Trident. IE 10 با MSIE و IE 11 با Trident/.*rv: معرفی می‌شود. برای پوشش کامل، هر دو الگو لازم است.

نکته‌ی مهم: regex باید در یک تابع مستقل و تست‌پذیر قرار بگیرد. اگر با الگوی نوشتن Middleware سفارشی در جنگو کار کرده باشید، می‌دانید که این تابع باید در یک سرویس اختصاصی باشد، نه مستقیم در middleware.

مرورگرهای لبه؛ Safari، Edge، Opera و Brave

چهار مرورگر لبه وجود دارند که در پروژه‌های واقعی بیشترین خطا را ایجاد می‌کنند. هرکدام را جداگانه بررسی می‌کنیم.

Safari. Safari روی macOS خودش را با Version/X.Y Safari معرفی می‌کند، ولی روی iOS با رشته‌ی متفاوتی می‌آید. تشخیص Safari، به‌خاطر وجود Safari در User-Agent تمام مرورگرهای Chromium-based، پیچیده است. برای تشخیص دقیق، باید هم Safari و هم Version را چک کنید و مطمئن شوید که Chrome، Edge، Opera و بقیه در رشته نیستند.

Edge. Edge چهار نوع مختلف دارد: Edge Legacy (Edge/), Edge Chromium دسکتاپ (Edg/), Edge Android (EdgA/), Edge iOS (EdgiOS/). هر کدام الگوی متفاوتی دارند. اگر فقط Edg/ را چک کنید، Edge موبایل را از دست می‌دهید.

Opera. Opera همچنین چندین نوع دارد: Opera کلاسیک (Opera/), Opera مبتنی بر Chromium (OPR/), Opera Mini (Opera Mini/), Opera Touch (OPT/). نکته‌ی مهم این است که در Opera، دو نسخه در User-Agent وجود دارد: نسخه‌ی Opera (با OPR/) و نسخه‌ی Chromium (با Chrome/). شما باید نسخه‌ی Opera را به‌عنوان نسخه‌ی نهایی انتخاب کنید، نه Chromium را.

Brave. Brave به‌خاطر رویکرد حریم‌خصوصی‌اش، User-Agent خودش را بدون اشاره به نام Brave می‌فرستد. یعنی Brave دقیقاً مثل Chrome ظاهر می‌شود و تشخیص آن در سمت سرور غیرممکن است. تنها راه تشخیص، استفاده از JavaScript در سمت کلاینت است که navigator.brave را چک می‌کند.

// در سمت کلاینت
async function detectBrave() {
  if (navigator.brave && await navigator.brave.isBrave()) {
    return "Brave";
  }
  return null;
}
مرورگرالگوی اصلیدام
Safari macOSVersion/X Safariحضور Safari در Chrome
Safari iOSVersion/X Mobile Safariتشخیص CriOS/FxiOS
Edge ChromiumEdg/Xحضور Chrome در رشته
Edge AndroidEdgA/Xنادیده گرفتن A
Edge iOSEdgiOS/Xترتیب با CriOS
Opera جدیدOPR/Xدو نسخه در رشته
Opera MiniOpera Mini/Xفاصله در نام
Braveمثل Chromeنیاز به JS

این جدول، نشان می‌دهد که تشخیص دقیق مرورگر، ذاتاً یک مسئله‌ی چندلایه است. هیچ regex واحدی نمی‌تواند همه‌ی این حالت‌ها را پوشش دهد. راه‌حل عملی، ترکیب چند سیگنال است. اگر می‌خواهید جزئیات بیشتری ببینید، تشخیص بات از کاربر انسانی رویکرد مشابهی را در حوزه‌ی خودش نشان می‌دهد.

لایه‌ی دوم؛ Client Hints و APIهای مدرن

گوگل در سال ۲۰۲۰، API جدیدی به نام User-Agent Client Hints معرفی کرد که جایگزین تدریجی User-Agent است. این API، اطلاعات مرورگر را به‌صورت ساختاریافته در هدرهای جداگانه برمی‌گرداند.

سه هدر مهم در این API وجود دارد.

هدر Sec-CH-UA. لیست برندها و نسخه‌های اصلی مرورگر. مثلاً "Chromium";v="119", "Google Chrome";v="119", "Not?A_Brand";v="24".

هدر Sec-CH-UA-Full-Version-List. نسخه‌ی کامل هر برند. این هدر فقط در صورت درخواست از سمت کلاینت ارسال می‌شود.

هدر Sec-CH-UA-Platform. نام پلتفرم، مثل "Windows" یا "macOS" یا "Android".

def detect_from_client_hints(request) -> dict | None:
    ua_full = request.META.get("HTTP_SEC_CH_UA", "")
    if not ua_full:
        return None

    # پارس کردن لیست برندها
    brands = _parse_ua_brands(ua_full)
    if not brands:
        return None

    # انتخاب بهترین برند
    best_brand = _pick_best_brand(brands)
    if not best_brand:
        return None

    return {
        "name": best_brand["name"],
        "version": best_brand["version"],
        "source": "client_hints",
    }


def _parse_ua_brands(header: str) -> list[dict]:
    """
    فرمت: "Chromium";v="119", "Google Chrome";v="119", ...
    """
    import re
    pattern = re.compile(r'"([^"]+)"s*;s*vs*=s*"([^"]+)"')
    return [
        {"name": m.group(1), "version": m.group(2)}
        for m in pattern.finditer(header)
    ]


def _pick_best_brand(brands: list[dict]) -> dict | None:
    # اولویت‌بندی: مرورگر اصلی قبل از Chromium
    priority = [
        "Google Chrome",
        "Microsoft Edge",
        "Opera",
        "Brave",
        "Vivaldi",
        "Samsung Internet",
        "Chromium",
    ]

    for name in priority:
        for brand in brands:
            if brand["name"] == name:
                return {"name": name, "version": brand["version"].split(".")[0]}

    # اگر هیچ‌کدام پیدا نشد، Chromium
    for brand in brands:
        if "Chromium" in brand["name"]:
            return {
                "name": "Chromium",
                "version": brand["version"].split(".")[0],
            }

    return None

این API، چند مزیت نسبت به User-Agent دارد.

مزیت اول، ساختاریافته بودن. نیازی به regex نیست؛ اطلاعات در قالب JSON ساختاریافته می‌آید.

مزیت دوم، حریم خصوصی. کاربر می‌تواند انتخاب کند که چه اطلاعاتی به سرور ارسال شود.

مزیت سوم، پایداری. برندها به‌جای رشته‌های بلند، با نام کوتاه و مشخص می‌آیند.

ولی Client Hints دو محدودیت جدی دارد.

محدودیت اول، پشتیبانی ناقص. Safari و Firefox از Client Hints پشتیبانی نمی‌کنند. یعنی اگر بخش بزرگی از کاربران شما از این مرورگرها استفاده می‌کنند، باز هم به User-Agent نیاز دارید.

محدودیت دوم، نیاز به درخواست صریح. بعضی هدرها مثل Sec-CH-UA-Full-Version-List فقط در صورت درخواست صریح از سمت کلاینت ارسال می‌شوند. این کار با هدر Accept-CH انجام می‌شود.

لایه‌ی سوم؛ تحلیل سمت کلاینت با JavaScript

لایه‌ی سوم، از سیگنال‌های سمت کلاینت استفاده می‌کند. این سیگنال‌ها، دقیق‌تر از User-Agent هستند، ولی همیشه در دسترس نیستند.

چهار سیگنال مفید در این لایه:

سیگنال اول، navigator.userAgentData. این API جدید، معادل Client Hints در سمت کلاینت است.

function detectBrowserClient() {
  if (navigator.userAgentData && navigator.userAgentData.brands) {
    const brands = navigator.userAgentData.brands;
    const priority = ["Google Chrome", "Microsoft Edge", "Opera", "Brave", "Chromium"];

    for (const name of priority) {
      const found = brands.find(b => b.brand === name);
      if (found) {
        return {
          name: name,
          version: found.version.split(".")[0],
          source: "userAgentData"
        };
      }
    }
  }
  return null;
}

سیگنال دوم، navigator.brave. Brave یک API اختصاصی دارد که با آن می‌توان تشخیص داد.

سیگنال سوم، navigator.vendor. این مقدار در Safari برابر Apple Computer, Inc. و در Chrome برابر Google Inc. است.

سیگنال چهارم، CSS prefixes. با چک کردن وجود یک ویژگی CSS خاص، می‌توان موتور رندر را تشخیص داد. مثلاً CSS.supports("-webkit-appearance: none") فقط در WebKit و Blink true است.

نکته‌ی مهم: این سیگنال‌ها باید از طریق beacon به سرور ارسال شوند. اگر با الگوی ذخیره‌ی PageView و Interaction کار کرده باشید، می‌دانید که این ساختار با یک API endpoint واحد پیاده می‌شود. اگر می‌خواهید الگوی دقیق‌تری برای این endpoint ببینید، ساخت API Endpoint برای Beacon راهنمای کاملی است.

لایه‌ی چهارم؛ ترکیب سیگنال‌ها با امتیاز اطمینان

لایه‌ی چهارم، ترکیب سیگنال‌های مختلف برای رسیدن به یک تصمیم نهایی است. در این لایه، برای هر سیگنال یک امتیاز اطمینان محاسبه می‌کنیم.

def resolve_browser(request, client_signal: dict = None) -> dict:
    signals = []

    # سیگنال Client Hints
    ch = detect_from_client_hints(request)
    if ch:
        signals.append({
            "source": "client_hints",
            "name": ch["name"],
            "version": ch["version"],
            "confidence": 98,
        })

    # سیگنال User-Agent
    ua = request.META.get("HTTP_USER_AGENT", "")
    ua_result = detect_browser(ua)
    if ua_result["name"] != "Unknown":
        signals.append({
            "source": "user_agent",
            "name": ua_result["name"],
            "version": ua_result["version"],
            "confidence": 85,
        })

    # سیگنال سمت کلاینت
    if client_signal and client_signal.get("name"):
        signals.append({
            "source": "client",
            "name": client_signal["name"],
            "version": client_signal.get("version", ""),
            "confidence": 95,
        })

    if not signals:
        return {"name": "Unknown", "version": "", "confidence": 0}

    best = max(signals, key=lambda s: s["confidence"])

    return {
        "name": best["name"],
        "version": best["version"],
        "confidence": best["confidence"],
        "signals": signals,
    }

این معماری، سه مزیت کلیدی دارد.

مزیت اول، شفافیت. هر تصمیم، با یک منبع و یک امتیاز اطمینان همراه است.

مزیت دوم، توسعه‌پذیری. می‌توانید در طول زمان، سیگنال‌های جدید اضافه کنید.

مزیت سوم، انعطاف. اگر یک سیگنال خراب بود، بقیه سیگنال‌ها تصمیم را نجات می‌دهند.

اگر با الگوی طراحی مدل Visitor و Visit در جنگو کار کرده باشید، می‌دانید که این ساختار، به یک فیلد JSON در مدل Visitor نیاز دارد که همه‌ی سیگنال‌ها را ذخیره کند.

معماری نهایی؛ سرویس، middleware و beacon

حالا بیایید همه‌ی لایه‌ها را در یک معماری نهایی ترکیب کنیم.

# analytics/services/browser.py

from analytics.utils.browser import (
    detect_browser,
    detect_from_client_hints,
)


def resolve_browser(request, client_signal: dict = None) -> dict:
    signals = []

    ch = detect_from_client_hints(request)
    if ch:
        signals.append({
            "source": "client_hints",
            "name": ch["name"],
            "version": ch["version"],
            "confidence": 98,
        })

    ua = request.META.get("HTTP_USER_AGENT", "")
    ua_result = detect_browser(ua)
    if ua_result["name"] != "Unknown":
        signals.append({
            "source": "user_agent",
            "name": ua_result["name"],
            "version": ua_result["version"],
            "confidence": 85,
        })

    if client_signal and client_signal.get("name"):
        signals.append({
            "source": "client",
            "name": client_signal["name"],
            "version": client_signal.get("version", ""),
            "confidence": 95,
        })

    if not signals:
        return {"name": "Unknown", "version": "", "confidence": 0}

    best = max(signals, key=lambda s: s["confidence"])

    return {
        "name": best["name"],
        "version": best["version"],
        "confidence": best["confidence"],
        "signals": signals,
    }

و در middleware:

# analytics/middleware.py

class VisitorTrackingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        from analytics.services.browser import resolve_browser

        if request.method == "GET" and not self._should_skip(request):
            browser_info = resolve_browser(request)
            request._browser_info = browser_info

        response = self.get_response(request)
        return response

    def _should_skip(self, request):
        from django.conf import settings
        skip_paths = getattr(settings, "ANALYTICS_SKIP_PATHS", ())
        return request.path.startswith(tuple(skip_paths))

و در API beacon:

# analytics/api.py

@require_POST
@csrf_exempt
def track_browser(request):
    payload = json.loads(request.body or b"{}")
    client_signal = payload.get("browser")

    visit_id = request.session.get("_analytics_visit_id")
    if not visit_id:
        return JsonResponse({"ok": False})

    visit = Visit.objects.filter(pk=visit_id).first()
    if not visit:
        return JsonResponse({"ok": False})

    from analytics.services.browser import resolve_browser

    browser_info = resolve_browser(request, client_signal)

    if browser_info["confidence"] >= 95:
        Visit.objects.filter(pk=visit.pk).update(
            browser_override=browser_info["name"],
            browser_version_override=browser_info["version"],
        )
        Visitor.objects.filter(pk=visit.visitor_id).update(
            browser_name=browser_info["name"],
            browser_version=browser_info["version"],
        )

    return JsonResponse({"ok": True})

این معماری، سه مزیت کلیدی دارد. اول، منطق تشخیص در یک سرویس متمرکز است. دوم، middleware فقط مسئول هماهنگی است. سوم، سیگنال سمت کلاینت می‌تواند تشخیص سرور را اصلاح کند.

نکته‌ی مهم: این endpoint باید با rate limiting محافظت شود تا کسی نتواند با ارسال beaconهای جعلی، دیتابیس شما را پر کند. اگر با الگوی استفاده از sendBeacon برای ارسال رویداد خروج کار کرده باشید، می‌دانید که این endpoint‌ها باید در برابر سوءاستفاده مقاوم باشند.

طراحی داده برای ذخیره‌ی مرورگر و نسخه

ذخیره‌ی تشخیص مرورگر، به‌اندازه‌ی خود تشخیص اهمیت دارد. چند فیلد کلیدی را در نظر بگیرید.

class Visitor(models.Model):
    # ... فیلدهای دیگر
    browser_name = models.CharField(
        max_length=50, blank=True, db_index=True,
    )
    browser_version = models.CharField(max_length=30, blank=True)

    browser_confidence = models.PositiveSmallIntegerField(default=0)
    browser_signals = models.JSONField(default=dict, blank=True)


class Visit(models.Model):
    # ... فیلدهای دیگر
    browser_override = models.CharField(max_length=50, blank=True)
    browser_version_override = models.CharField(max_length=30, blank=True)

سه نکته در این طراحی:

نکته‌ی اول، ایندکس روی browser_name. اگر می‌خواهید گزارش «چند درصد کاربران از Firefox استفاده می‌کنند» را سریع بگیرید، این ایندکس ضروری است.

نکته‌ی دوم، ذخیره‌ی نسخه به‌صورت رشته. نسخه‌ی مرورگر می‌تواند مقادیر پیچیده مثل "119.0.6045.199" داشته باشد. اگر می‌خواهید فقط نسخه‌ی اصلی را ذخیره کنید، می‌توانید از تابع _normalize_version استفاده کنید.

نکته‌ی سوم، فیلدهای override در Visit. این فیلدها به شما اجازه می‌دهند اگر سیگنال سمت کلاینت دقیق‌تر بود، تشخیص سرور را اصلاح کنید.

کالیبراسیون؛ جایی که پروژه‌ها در سکوت شکست می‌خورند

تشخیص مرورگر، فقط با نوشتن کد به‌دست نمی‌آید. باید مدل خود را با داده‌ی واقعی سایت کالیبره کنید.

مرحله‌ی اول، جمع‌آوری داده‌ی برچسب‌دار. برای یک هفته، همه‌ی سیگنال‌ها را با هم ذخیره کنید. سپس به‌طور دستی، ۵۰۰ تا ۱۰۰۰ نمونه را برچسب بزنید: واقعاً کدام مرورگر بوده.

مرحله‌ی دوم، مقایسه‌ی مدل با برچسب‌ها. دقت مدل خود را با برچسب‌ها مقایسه کنید. اگر دقت زیر ۹۵٪ است، جایی از مدل اشتباه است.

مرحله‌ی سوم، تنظیم آستانه‌ها. اگر آستانه‌ی اطمینان برای تصمیم‌گیری را روی ۹۰ گذاشته‌اید، ولی مدل شما در واقعیت ۸۵٪ دقت دارد، آستانه را تنظیم کنید.

در یکی از پروژه‌ها، بعد از کالیبراسیون متوجه شدیم که تعدادی از کاربران، از نسخه‌های قدیمی Firefox ESR استفاده می‌کنند که در الگوهای regex ما به‌عنوان مرورگر اصلی تشخیص داده نمی‌شد. با اضافه کردن یک الگوی خاص برای Firefox ESR، دقت مدل از ۹۱٪ به ۹۷٪ رسید.

هر مدل تشخیص، بدون کالیبراسیون دوره‌ای، در طول زمان منحرف می‌شود. مرورگرها به‌روز می‌شوند و User-Agentها هم تغییر می‌کنند.

اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این نوع کالیبراسیون، بخشی از انضباط مهندسی است، نه یک کار لوکس.

تست‌نویسی سناریوهای مختلف مرورگر

سه سطح تست را در نظر بگیرید.

سطح اول، تست واحد regex. برای هر الگو، یک تست جدا بنویسید:

import pytest
from analytics.utils.browser import detect_browser


@pytest.mark.parametrize("ua,expected_name", [
    ("Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 "
     "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Chrome"),
    ("Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 "
     "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0", "Edge"),
    ("Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 "
     "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0", "Opera"),
    ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
     "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", "Safari"),
    ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
     "AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.0.0 "
     "Mobile/15E148 Safari/604.1", "Chrome iOS"),
    ("Mozilla/5.0 (Android 13; Mobile; rv:119.0) "
     "Gecko/119.0 Firefox/119.0", "Firefox"),
])
def test_detect_browser(ua, expected_name):
    result = detect_browser(ua)
    assert result["name"] == expected_name

سطح دوم، تست سرویس. با RequestFactory، یک درخواست ساختگی بسازید و سرویس را تست کنید:

from django.test import RequestFactory
from analytics.services.browser import resolve_browser


def test_resolve_browser_with_client_hints():
    request = RequestFactory().get("/")
    request.META["HTTP_SEC_CH_UA"] = (
        '"Chromium";v="119", "Google Chrome";v="119", "Not?A_Brand";v="24"'
    )

    result = resolve_browser(request)
    assert result["name"] == "Google Chrome"
    assert result["confidence"] >= 90

سطح سوم، تست یکپارچه. با django.test.Client، یک درخواست کامل بفرستید و بررسی کنید که Visitor با مرورگر درست ساخته شده است:

@pytest.mark.django_db
def test_tracking_creates_firefox_visitor(client):
    from analytics.models import Visitor

    client.get(
        "/",
        HTTP_USER_AGENT=(
            "Mozilla/5.0 (Windows NT 10.0; rv:119.0) "
            "Gecko/20100101 Firefox/119.0"
        ),
    )

    visitor = Visitor.objects.first()
    assert visitor.browser_name == "Firefox"
    assert visitor.browser_version == "119"

این سه سطح تست، به شما اجازه می‌دهند که در طول زمان، تغییرات را با اطمینان اعمال کنید.

anti-patternهای رایج در تشخیص مرورگر

در بازبینی پروژه‌های مختلف، این اشتباهات را زیاد دیده‌ام:

۱. عدم ترتیب دقیق در regex. چک کردن Chrome قبل از Edge، Opera، Brave و بقیه، باعث می‌شود همه‌ی این مرورگرها به‌اشتباه Chrome تشخیص داده شوند.

۲. نادیده گرفتن iOS CriOS/FxiOS. در iOS، همه‌ی مرورگرها از WebKit استفاده می‌کنند و بدون چک کردن CriOS و FxiOS، همه Safari تشخیص داده می‌شوند.

۳. عدم مدیریت Edge موبایل. اگر فقط Edg/ را چک کنید، Edge Android (EdgA) و Edge iOS (EdgiOS) از دست می‌روند.

۴. استفاده از نسخه‌ی Chromium به‌جای نسخه‌ی مرورگر. در Edge و Opera، دو نسخه در User-Agent وجود دارد. اگر اولی را چک کنید، نسخه‌ی اشتباه ثبت می‌شود.

۵. نادیده گرفتن Brave. Brave خودش را به‌عنوان Chrome معرفی می‌کند. بدون سیگنال سمت کلاینت، قابل تشخیص نیست.

۶. عدم ذخیره‌ی سیگنال‌های خام. اگر فقط نتیجه‌ی نهایی را ذخیره کنید، در آینده نمی‌توانید مدل را بهبود دهید.

۷. عدم کالیبراسیون دوره‌ای. مدل را یک‌بار می‌سازید و بعد فراموش می‌کنید. بعد از یک سال، دقت مدل به‌شدت افتاده است.

۸. ذخیره‌ی نسخه به‌صورت کامل. ذخیره‌ی نسخه‌ی کامل مثل "119.0.6045.199" حجم دیتابیس را بالا می‌برد و در گزارش‌های گروهی مفید نیست. بهتر است فقط نسخه‌ی اصلی ذخیره شود.

۹. عدم مدیریت مرورگرهای ناشناخته. دسته‌ی Unknown باید حتماً وجود داشته باشد، ولی اگر درصد آن بالاست، یعنی مدل شما ناقص است.

۱۰. عدم هماهنگی با CDN. اگر سایت پشت CDN است، بعضی هدرهای Client Hints ممکن است حذف شوند. این نکته را در تشخیص خود در نظر بگیرید.

۱۱. تشخیص مرورگر در ویو به‌جای middleware. اگر در ویو تشخیص دهید، مجبورید آن را در هر ویو تکرار کنید. اگر با الگوی نوشتن Middleware سفارشی در جنگو کار کرده باشید، می‌دانید که این کار اشتباه است.

۱۲. نادیده گرفتن حالت‌های خاص مثل Samsung Internet یا UC Browser. این مرورگرها در بازارهای خاص سهم بالایی دارند و نادیده گرفتن آن‌ها، تحلیل کاربران را ناقص می‌کند.

پرسش‌های پرتکرار درباره‌ی تشخیص مرورگر در جنگو

آیا می‌توانم از کتابخانه‌ی ua-parser استفاده کنم؟ بله، این کتابخانه بسیار مفید است و اکثر مرورگرها را به‌درستی تشخیص می‌دهد. ولی توصیه می‌کنم آن را با Client Hints و سیگنال سمت کلاینت ترکیب کنید، چون این کتابخانه هم Brave را به‌درستی تشخیص نمی‌دهد.

آیا Client Hints جایگزین User-Agent می‌شود؟ به‌تدریج، بله. گوگل در حال کاهش اطلاعات User-Agent است، ولی فرآیند آن چند سال طول می‌کشد. تا آن زمان، باید هر دو را پشتیبانی کنید.

چطور Brave را تشخیص دهم؟ Brave خودش را به‌عنوان Chrome معرفی می‌کند. تنها راه تشخیص، استفاده از navigator.brave.isBrave() در سمت کلاینت است که از طریق beacon به سرور می‌رسد.

چطور Safari را از Chrome تفکیک کنم؟ Safari خودش را با Version/X Safari معرفی می‌کند، در حالی که Chrome با Chrome/X Safari. تفاوت در حضور یا عدم حضور Chrome/ در رشته است. ولی در iOS، همه‌ی مرورگرها خودشان را Safari معرفی می‌کنند و باید CriOS و FxiOS را جداگانه چک کنید.

آیا باید نسخه‌ی کامل مرورگر را ذخیره کنم؟ توصیه می‌کنم فقط نسخه‌ی اصلی (major version) را ذخیره کنید. نسخه‌ی کامل، حجم دیتابیس را بالا می‌برد و در گزارش‌های گروهی مفید نیست. اگر نیاز به نسخه‌ی کامل دارید، آن را در یک فیلد JSON جداگانه ذخیره کنید.

چطور مرورگرهای قدیمی را تشخیص دهم؟ User-Agent آن‌ها معمولاً شامل نسخه‌های قدیمی است. می‌توانید یک آستانه تعریف کنید: اگر نسخه‌ی Chrome کمتر از ۱۰۰ است، به‌عنوان مرورگر قدیمی علامت‌گذاری شود.

آیا تشخیص مرورگر روی سئو اثر دارد؟ به‌طور مستقیم نه، ولی اگر نسخه‌ی موبایل سایت شما با نسخه‌ی دسکتاپ متفاوت باشد، گوگل ترجیح می‌دهد نسخه‌ی موبایل را به‌عنوان نسخه‌ی اصلی در نظر بگیرد. این مفهوم به mobile-first indexing معروف است.

چطور مطمئن شوم که مدل من درست کار می‌کند؟ سه راه: اول، تست واحد regex. دوم، تست سرویس با درخواست‌های ساختگی. سوم، کالیبراسیون دوره‌ای با داده‌ی برچسب‌دار.

آیا باید از هدر Vary: User-Agent استفاده کنم؟ اگر محتوای سایت شما بر اساس User-Agent تغییر می‌کند، بله. این هدر به CDN و پروکسی‌ها می‌گوید که کش را بر اساس User-Agent جدا کنند. ولی توجه کنید که این کار، نرخ cache hit را پایین می‌آورد.

چطور مرورگرهای موبایل را از دسکتاپ تفکیک کنم؟ این کار در حوزه‌ی تشخیص دستگاه قرار می‌گیرد، نه تشخیص مرورگر. برای این موضوع، به تشخیص دستگاه کاربر در جنگو مراجعه کنید.

آیا باید در تشخیص مرورگر از یادگیری ماشین استفاده کنم؟ برای پروژه‌های کوچک و متوسط، خیر. regex + Client Hints + سیگنال سمت کلاینت، دقت بالای ۹۸٪ می‌دهد. یادگیری ماشین، فقط برای پروژه‌های بسیار بزرگ با داده‌ی برچسب‌دار با کیفیت توجیه دارد.

چطور با بروزرسانی مرورگرها هماهنگ شوم؟ regex خود را به‌طور دوره‌ای به‌روز کنید. یک کامند بنویسید که User-Agentهای ناشناخته و پرتکرار را جمع‌آوری کند و به شما نشان دهد. اگر با الگوی ساخت پنل ادمین با کارت‌های quick view کار کرده باشید، می‌دانید که این نوع پنل، برای بازبینی دوره‌ای بسیار مفید است.

آیا باید مرورگرهای ناشناخته را حذف کنم؟ نه، این‌ها داده‌های ارزشمندی هستند که به شما می‌گویند کدام بخش از مدل باید بهبود یابد. اگر با الگوی کامند مدیریتی پاک‌سازی داده‌های قدیمی کار کرده باشید، می‌دانید که پاک‌سازی داده‌های ارزشمند، تصمیم اشتباهی است.

آیا باید تفاوت مرورگر را در قالب در نظر بگیرم؟ بله، در موارد خاص. مثلاً اگر می‌خواهید یک پیام هشدار برای کاربران مرورگرهای قدیمی نمایش دهید، این کار در سمت کلاینت با feature detection انجام می‌شود، نه با تشخیص User-Agent. مفهوم Browser Sniffing در ویکی‌پدیا توضیح داده شده است، ولی رویکرد مدرن، feature detection است.

آیا باید برای همه‌ی مرورگرها تست بنویسم؟ نه. توصیه می‌کنم ۱۰ تا ۱۵ مرورگر پرکاربرد را با تست پوشش دهید. برای بقیه، از یک fallback استفاده کنید.

نگاهی از منظر مهندس داده در مقیاس میلیونی

در مقیاس میلیون‌ها بازدید، تشخیص مرورگر تبدیل به یک مسئله‌ی مهندسی داده می‌شود. سه مفهوم بنیادین را باید بازتعریف کنید.

مفهوم اول، latency بودجه. تشخیص مرورگر نباید بیش از ۱ میلی‌ثانیه به هر درخواست اضافه کند. regex‌های ما سریع هستند، ولی اگر لایه‌های Client Hints و beacon را اضافه کنید، ممکن است از بودجه فراتر بروید. یک راه‌حل رایج، ذخیره‌ی نتیجه در یک cache با TTL بالا است. برای هر ترکیب User-Agent، نتیجه‌ی تشخیص را برای ۲۴ ساعت کش کنید.

from django.core.cache import cache
import hashlib


def detect_browser_cached(user_agent: str) -> dict:
    key = "browser:" + hashlib.md5(user_agent.encode()).hexdigest()
    result = cache.get(key)
    if result is None:
        from analytics.utils.browser import detect_browser
        result = detect_browser(user_agent)
        cache.set(key, result, timeout=86400)
    return result

این کش، می‌تواند بار پردازشی را چند برابر کاهش دهد، چون اکثر کاربران از تعداد محدودی User-Agent استفاده می‌کنند.

مفهوم دوم، ذخیره‌سازی توزیع‌شده. در مقیاس بالا، ذخیره‌ی نتایج تشخیص در یک دیتابیس رابطه‌ای ممکن است گران تمام شود. دیتابیس‌های ستونی مثل ClickHouse برای این نوع داده طراحی شده‌اند. اگر با الگوی ساخت API JSON برای آمار زنده کار کرده باشید، می‌دانید که این معماری، در سیستم‌های بزرگ استاندارد است.

مفهوم سوم، جریان پیوسته. در معماری‌های مدرن، تشخیص مرورگر به‌جای یک تصمیم لحظه‌ای، یک جریان پیوسته است. هر نتیجه، به یک event stream فرستاده می‌شود و مدل، به‌طور مداوم به‌روز می‌شود. اگر با الگوی طبقه‌بندی Referrer و تشخیص ورود از گوگل کار کرده باشید، می‌دانید که این معماری، برای داده‌های تحلیلی مشابه، استاندارد است.

نکته‌ی آخر: در مقیاس بالا، دقت مدل، در نهایت به کیفیت داده‌ی برچسب‌دار بستگی دارد. بدون یک فرایند بازبینی دقیق، مدل شما در طول زمان به‌تدریج منحرف می‌شود. سرمایه‌گذاری در فرایند برچسب‌گذاری و بازبینی، در مقیاس بزرگ چند برابر جواب می‌دهد.

یک نکته‌ی عملی که در پروژه‌های مختلف دیده‌ام: به‌جای نگه‌داشتن تمام User-Agentها، فقط یک hash از آن‌ها را ذخیره کنید. این کار، حجم دیتابیس را چند برابر کاهش می‌دهد. اگر بعداً نیاز به تحلیل User-Agent خام داشتید، می‌توانید از فایل‌های log استفاده کنید. اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، می‌دانید که پاک‌سازی دوره‌ای User-Agentهای قدیمی، بخشی از نگهداری استاندارد است.

پرسشی که در پایان باید پاسخ دهید

قبل از اینکه سیستم تشخیص مرورگر خود را نهایی کنید، یک پرسش را از خودتان بپرسید: «اگر امروز یک مرورگر جدید در بازار محبوب شد، چقدر طول می‌کشد که این تغییر را در آمار سایت خودم ببینم؟» اگر پاسخ شما «چند هفته» است، یعنی سیستم شما به یک فرایند بازبینی دوره‌ای نیاز دارد. اگر پاسخ شما «چند ساعت» است، یعنی سیستم شما به‌درستی طراحی شده است.

تشخیص مرورگر، در نهایت یک تصمیم مهندسی است که به کیفیت داده‌ی کسب‌وکار شما گره خورده است. اگر این تجربه را در پروژه‌ی خودتان داشته‌اید — مثلاً جایی که یک مرورگر جدید شما را غافلگیر کرده یا جایی که مدل شما در طول زمان منحرف شده — برایم جالب است بدانید. مخصوصاً اگر راه‌حل خاصی برای یک سناریوی خاص پیدا کرده‌اید، چون همان راه‌حل‌ها می‌توانند به خواننده‌ی بعدی کمک کنند. تجربه‌ی خودتان را در دیدگاه‌ها بنویسید.