اگر تصور می‌کنید ذخیره‌ی مقدار HTTP_REFERER در یک ستون، برای تحلیل منبع ورود کاربران کافی است، احتمالاً تا امروز بخش بزرگی از کانال‌های ورود سایت‌تان به‌عنوان «direct» یا «unknown» ثبت شده و تصویر واقعی بازاریابی‌تان هرگز کامل نبوده است.

چرا طبقه‌بندی Referrer، یک مسئله‌ی استراتژیک است؟

در یکی از پروژه‌های فروشگاهی که چند سال پیش روی آن کار می‌کردم، تیم بازاریابی شکایت داشت که «سرمایه‌گذاری روی اینستاگرام جواب نمی‌دهد». آمار نشان می‌داد که سهم اینستاگرام از کل ورودی‌ها فقط ۳ درصد است. بعد از بررسی دقیق Referer، متوجه شدیم که حدود ۳۵ درصد ورودی‌ها به‌عنوان «direct» ثبت شده بودند، در حالی که در واقعیت از لینک‌های اینستاگرام می‌آمدند. علت: Referrer Policy اینستاگرام، مقدار Referer را حذف می‌کرد و ما این ورودی‌ها را به‌عنوان مستقیم می‌دیدیم. تیم بازاریابی بر اساس داده‌ی اشتباه تصمیم گرفته بود بودجه را از اینستاگرام به جای دیگری منتقل کند.

این تجربه نشان می‌دهد که طبقه‌بندی Referrer، فقط یک مسئله‌ی فنی نیست؛ یک مسئله‌ی استراتژیک در سطح کسب‌وکار است. اگر داده‌ی ورودی‌ها اشتباه باشد، تصمیم‌های بازاریابی هم اشتباه خواهند بود.

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

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

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

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

طبقه‌بندی Referrer، یک ستون در دیتابیس نیست؛ یک لایه‌ی تفسیری است که داده‌ی خام HTTP را به بینش کسب‌وکار تبدیل می‌کند.

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

تکامل Referrer؛ از HTTP_REFERER تا Referrer Policy

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

دهه‌ی ۱۹۹۰، تولد Referer. در سال ۱۹۹۶، RFC 1945 هدر Referer را معرفی کرد. هدف اصلی این بود که سرورها بتوانند بفهمند کاربر از کجا آمده. توجه کنید که املای آن Referer است، نه Referrer، چون در متن اصلی RFC اشتباه تایپی داشته و همان اشتباه حفظ شده است.

دهه‌ی ۲۰۰۰، رشد موتورهای جستجو. با رشد گوگل، یاهو و بقیه موتورهای جستجو، Referer به یک سیگنال مهم برای تحلیل ورودی‌ها تبدیل شد. ولی هر موتور جستجو الگوی متفاوتی داشت، به همین دلیل regex برای تشخیص آن‌ها اهمیت پیدا کرد.

دهه‌ی ۲۰۱۰، رشد شبکه‌های اجتماعی. با رشد فیسبوک، توییتر و اینستاگرام، Referer پیچیده‌تر شد. بعضی شبکه‌ها مقدار Referer را به‌کلی حذف می‌کردند، بعضی فقط دامنه را نگه می‌داشتند و بعضی اطلاعات بیشتری می‌فرستادند.

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

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

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

اکثر پروژه‌های جنگو، طبقه‌بندی Referrer را با یک کد ساده انجام می‌دهند:

def classify_referrer_simple(referer: str) -> str:
    if not referer:
        return "direct"
    if "google" in referer:
        return "google"
    if "facebook" in referer:
        return "facebook"
    return "other"

این کد، پنج مشکل جدی دارد.

مشکل اول، عدم پارس درست دامنه. عبارت "google" می‌تواند در مسیر یا پارامتر باشد، نه در دامنه. مثلاً https://example.com/blog/google-analytics-guide به‌اشتباه به‌عنوان گوگل تشخیص داده می‌شود.

مشکل دوم، عدم پوشش موتورهای جستجوی دیگر. Bing، Yahoo، DuckDuckGo، Yandex، Baidu، Ask، Ecosia، Brave Search و ده‌ها موتور دیگر وجود دارند که این کد آن‌ها را به‌عنوان «other» می‌شمارد.

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

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

مشکل پنجم، عدم پشتیبانی از UTM. بسیاری از کمپین‌های بازاریابی از UTM parameters استفاده می‌کنند که در URL هستند، نه در Referer. این اطلاعات در این کد دیده نمی‌شود.

در یکی از پروژه‌ها، بعد از سه ماه کار با این کد ساده، متوجه شدیم که حدود ۴۵٪ رکوردهای آماری ما به‌عنوان «other» ثبت شده بود، در حالی که در واقعیت بخش بزرگی از آن‌ها از موتورهای جستجوی مختلف و شبکه‌های اجتماعی می‌آمدند.

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

لایه‌ی اول؛ پارس دقیق URL و دامنه

لایه‌ی اول، پارس دقیق URL است. این کار، پایه‌ی تمام طبقه‌بندی‌های بعدی است.

# analytics/utils/referrer.py

from urllib.parse import urlparse, parse_qs


def parse_referrer(referer: str) -> dict:
    if not referer:
        return {
            "is_valid": False,
            "scheme": "",
            "host": "",
            "host_no_www": "",
            "path": "",
            "query": {},
            "raw": "",
        }

    try:
        parsed = urlparse(referer)
    except Exception:
        return {
            "is_valid": False,
            "scheme": "",
            "host": "",
            "host_no_www": "",
            "path": "",
            "query": {},
            "raw": referer[:2000],
        }

    host = (parsed.netloc or "").lower()
    if ":" in host:
        host = host.split(":")[0]

    host_no_www = host[4:] if host.startswith("www.") else host

    return {
        "is_valid": True,
        "scheme": parsed.scheme.lower(),
        "host": host,
        "host_no_www": host_no_www,
        "path": parsed.path or "/",
        "query": parse_qs(parsed.query),
        "raw": referer[:2000],
    }

این تابع، چند نکته‌ی مهم را رعایت می‌کند.

نکته‌ی اول، استفاده از urlparse. این تابع استاندارد پایتون، به‌درستی scheme، host، path و query را جدا می‌کند.

نکته‌ی دوم، نرمال‌سازی host. حذف پورت و www. از ابتدای دامنه، تشخیص‌های بعدی را ساده‌تر می‌کند. توجه کنید که حذف www. باید فقط از ابتدا باشد، نه از وسط دامنه.

نکته‌ی سوم، محدودیت طول. طول Referer را به ۲۰۰۰ کاراکتر محدود می‌کنیم تا دیتابیس را از ورودی‌های بزرگ محافظت کنیم. اگر با الگوی طراحی مدل Visitor و Visit در جنگو کار کرده باشید، می‌دانید که این محدودیت‌ها چقدر اهمیت دارند.

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

تشخیص موتورهای جستجو و تفکیک آن‌ها

موتورهای جستجو، یکی از مهم‌ترین کانال‌های ورود هستند. تفکیک دقیق آن‌ها، به شما اجازه می‌دهد ROI هر موتور را جداگانه اندازه بگیرید.

SEARCH_ENGINES = {
    "google": (
        "google.", "googleusercontent.",
    ),
    "bing": (
        "bing.", "msn.",
    ),
    "yahoo": (
        "yahoo.", "search.yahoo.",
    ),
    "duckduckgo": (
        "duckduckgo.", "duck.com",
    ),
    "yandex": (
        "yandex.", "ya.ru",
    ),
    "baidu": (
        "baidu.",
    ),
    "ask": (
        "ask.", "ask.com",
    ),
    "ecosia": (
        "ecosia.",
    ),
    "brave": (
        "search.brave.",
    ),
    "startpage": (
        "startpage.",
    ),
    "qwant": (
        "qwant.",
    ),
    "seznam": (
        "seznam.",
    ),
    "naver": (
        "naver.",
    ),
    "sogou": (
        "sogou.",
    ),
    "yep": (
        "yep.com",
    ),
    "mojeek": (
        "mojeek.",
    ),
}


def classify_search_engine(host_no_www: str) -> str | None:
    if not host_no_www:
        return None

    for engine, patterns in SEARCH_ENGINES.items():
        for pattern in patterns:
            if pattern in host_no_www:
                return engine

    return None

این ساختار، چند مزیت دارد.

مزیت اول، گستردگی. علاوه بر موتورهای معروف، موتورهای کوچک‌تر مثل Ecosia، Brave Search، Startpage، Qwant، Mojeek و Yep را هم پوشش می‌دهد.

مزیت دوم، جداسازی موتورهای زیرمجموعه. مثلاً googleusercontent. زیرمجموعه‌ی گوگل است، ولی مسیر متفاوتی دارد.

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

نکته‌ی مهم: بعضی موتورهای جستجو، لینک‌های داخلی‌شان را از یک دامنه‌ی متفاوت می‌فرستند. مثلاً Google Images از images.google.com می‌آید و Google News از news.google.com. اگر می‌خواهید این تفکیک را ببینید، باید زیردامنه را هم در نظر بگیرید.

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

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

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

SOCIAL_NETWORKS = {
    "facebook": ("facebook.", "fb.com", "fb.me"),
    "instagram": ("instagram.", "ig.me"),
    "twitter": ("twitter.", "x.com", "t.co"),
    "linkedin": ("linkedin.", "lnkd.in"),
    "pinterest": ("pinterest.", "pin.it"),
    "reddit": ("reddit.", "redd.it"),
    "tumblr": ("tumblr."),
    "vk": ("vk.com", "vk.ru"),
    "ok": ("ok.ru"),
    "weibo": ("weibo."),
    "douyin": ("douyin."),
    "quora": ("quora."),
    "medium": ("medium."),
    "substack": ("substack."),
}


MESSENGERS = {
    "whatsapp": ("whatsapp.", "wa.me", "api.whatsapp."),
    "telegram": ("telegram.", "t.me", "telegram.me"),
    "discord": ("discord.", "discordapp."),
    "slack": ("slack."),
    "skype": ("skype."),
    "signal": ("signal."),
    "viber": ("viber."),
    "line": ("line.me", "line."),
    "wechat": ("wechat.", "weixin."),
}


def classify_social(host_no_www: str) -> str | None:
    if not host_no_www:
        return None

    for network, patterns in SOCIAL_NETWORKS.items():
        for pattern in patterns:
            if pattern in host_no_www:
                return network

    return None


def classify_messenger(host_no_www: str) -> str | None:
    if not host_no_www:
        return None

    for messenger, patterns in MESSENGERS.items():
        for pattern in patterns:
            if pattern in host_no_www:
                return messenger

    return None

نکته‌ی مهم درباره‌ی این طبقه‌بندی: برخی از این پلتفرم‌ها، Referer را حذف می‌کنند. یعنی لینکی که در WhatsApp به اشتراک گذاشته می‌شود، ممکن است به‌عنوان «direct» ثبت شود، نه WhatsApp. برای این حالت‌ها، باید از UTM parameters استفاده کنید یا از سیگنال‌های دیگری مثل هدر Sec-Fetch-Site بهره ببرید.

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

اگر با الگوی ذخیره‌ی PageView و Interaction کار کرده باشید، می‌دانید که این تفکیک‌ها به شما اجازه می‌دهند تحلیل‌های عمیق‌تری انجام دهید.

تفکیک لینک‌های داخلی از خارجی

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

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

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

def is_internal_referrer(host_no_www: str, current_host: str) -> bool:
    if not host_no_www or not current_host:
        return False

    current = current_host.lower()
    if current.startswith("www."):
        current = current[4:]

    return host_no_www.endswith(current) or current.endswith(host_no_www)

این تابع، دو حالت را پوشش می‌دهد.

حالت اول، زیردامنه. اگر سایت شما example.com است و لینک از blog.example.com می‌آید، این یک لینک داخلی است.

حالت دوم، دامنه‌های وابسته. اگر سایت شما example.com و shop.example.com دارد، لینک بین این دو، داخلی محسوب می‌شود.

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

INTERNAL_DOMAINS = (
    "example.com",
    "shop.example.com",
    "blog.example.com",
    "cdn.example.com",
)


def is_internal_multi(host_no_www: str) -> bool:
    if not host_no_www:
        return False
    return any(
        host_no_www.endswith(domain) for domain in INTERNAL_DOMAINS
    )

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

UTM parameters؛ سیگنال مکمل Referer

UTM parameters، مجموع پارامترهایی هستند که به URL اضافه می‌شوند تا منبع ترافیک را مشخص کنند. این پارامترها توسط بازاریابان استفاده می‌شوند و در URL هستند، نه در Referer.

پنج پارامتر اصلی UTM وجود دارد:

پارامتر اول، utm_source. منبع ترافیک (مثلاً google، newsletter، facebook).

پارامتر دوم، utm_medium. نوع کانال (مثلاً cpc، email، social).

پارامتر سوم، utm_campaign. نام کمپین (مثلاً summer_sale).

پارامتر چهارم، utm_term. کلمه‌ی کلیدی (در کمپین‌های تبلیغاتی).

پارامتر پنجم، utm_content. محتوای خاص کمپین (برای A/B testing).

def extract_utm(query_params: dict) -> dict:
    utm_keys = [
        "utm_source",
        "utm_medium",
        "utm_campaign",
        "utm_term",
        "utm_content",
        "utm_id",
    ]

    result = {}
    for key in utm_keys:
        value = query_params.get(key)
        if value and isinstance(value, list):
            result[key] = value[0][:255]
        elif value:
            result[key] = str(value)[:255]

    return result

UTM parameters، اطلاعات دقیق‌تری از Referer می‌دهند، چون توسط بازاریاب کنترل می‌شوند. اگر UTM در URL موجود باشد، باید آن را به‌عنوان اولویت بالاتر از Referer در نظر بگیرید.

Referer می‌گوید کاربر «از کجا» آمده، UTM می‌گوید کاربر «از کدام کمپین» آمده. این دو، مکمل هم هستند، نه جایگزین.

نکته‌ی مهم: UTM parameters در URL، همیشه قابل اعتماد نیستند، چون کاربر می‌تواند آن‌ها را تغییر دهد یا حذف کند. ولی برای تحلیل کلی، این اطلاعات فوق‌العاده مفید هستند. اگر با الگوی انتقال منطق از services.py به templatetags کار کرده باشید، می‌دانید که این نوع پارس‌ها باید در لایه‌ی سرویس انجام شوند، نه در لایه‌ی نمایش.

چالش «direct»؛ جایی که داده گم می‌شود

دسته‌ی «direct» یا «مستقیم»، معمولاً بیشترین سهم را در آمار پروژه‌های مختلف دارد. ولی واقعیت این است که بخش بزرگی از این «مستقیم»ها، در واقع مستقیم نیستند.

چند منبع رایج ورودی‌های «مستقیم» که در واقع مستقیم نیستند:

منبع اول، اپلیکیشن‌های موبایل. وقتی کاربر روی یک لینک در اپلیکیشن اینستاگرام یا واتس‌اپ کلیک می‌کند، Referer ممکن است حذف شود. نتیجه: ورودی به‌عنوان «direct» ثبت می‌شود.

منبع دوم، Referrer Policy. اگر سایت مبدأ Referrer Policy را روی no-referrer تنظیم کرده باشد، هیچ Referer‌ای ارسال نمی‌شود. این تنظیم در سایت‌های بزرگ مثل گوگل، فیسبوک و اینستاگرام رایج است.

منبع سوم، لینک‌های HTTPS به HTTP. در گذشته، اگر سایت شما HTTPS باشد و سایت مبدأ HTTP، Referer حذف می‌شد. امروز این مشکل کمتر شده، ولی همچنان در موارد خاص رخ می‌دهد.

منبع چهارم، بوکمارک و تایپ مستقیم. کاربری که لینک سایت شما را از بوکمارک باز می‌کند یا URL را تایپ می‌کند، Referer ندارد. این‌ها واقعاً «direct» هستند.

راه‌حل این چالش، استفاده از UTM parameters در تمام لینک‌های بازاریابی است. اگر لینک‌های اینستاگرام خود را با UTM tag بزنید، حتی اگر Referer حذف شود، UTM اطلاعات لازم را می‌دهد.

def resolve_entry_source(referrer: dict, utm: dict) -> dict:
    """
    اولویت: UTM > Referer > direct
    """
    if utm.get("utm_source"):
        return {
            "source": utm["utm_source"],
            "medium": utm.get("utm_medium", ""),
            "campaign": utm.get("utm_campaign", ""),
            "method": "utm",
        }

    if referrer["is_valid"] and referrer["host_no_www"]:
        return {
            "source": referrer["host_no_www"],
            "medium": "",
            "campaign": "",
            "method": "referer",
        }

    return {
        "source": "",
        "medium": "",
        "campaign": "",
        "method": "direct",
    }

این رویکرد، دو مزیت دارد. اول، اولویت را به UTM می‌دهد، چون دقیق‌تر است. دوم، منبع تصمیم را ذخیره می‌کند، تا در تحلیل‌های بعدی بدانید چرا یک ورودی به‌عنوان «direct» ثبت شده.

Referrer Policy و محدودیت‌های مرورگرهای مدرن

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

چند مقدار مهم در Referrer Policy:

مقدار اول، no-referrer. هیچ Referer‌ای ارسال نمی‌شود.

مقدار دوم، origin. فقط origin (scheme + host) ارسال می‌شود، نه path و query.

مقدار سوم، same-origin. Referer فقط برای درخواست‌های همان دامنه ارسال می‌شود.

مقدار چهارم، strict-origin. مشابه origin، ولی فقط برای درخواست‌های HTTPS به HTTPS.

مقدار پنجم، strict-origin-when-cross-origin. مقدار پیش‌فرض مدرن. برای درخواست‌های cross-origin، فقط origin ارسال می‌شود.

در سایت خودتان، توصیه می‌کنم مقدار strict-origin-when-cross-origin را تنظیم کنید، چون تعادل خوبی بین حریم خصوصی و قابلیت تحلیل فراهم می‌کند.

# settings.py

SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

نکته‌ی مهم: حتی با تنظیم این مقدار، سایت‌های مقصد (مثل اینستاگرام) می‌توانند مقدار Referer را در سمت خودشان محدود کنند. بنابراین، شما فقط می‌توانید کنترل کنید سایت خودتان چه اطلاعاتی می‌فرستد، نه سایت‌های دیگر.

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

معماری نهایی؛ سرویس، middleware و ذخیره‌سازی

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

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

# analytics/services/referrer.py

from analytics.utils.referrer import (
    parse_referrer,
    classify_search_engine,
    classify_social,
    classify_messenger,
    extract_utm,
)


def classify_entry(request) -> dict:
    raw_referer = request.META.get("HTTP_REFERER", "")
    parsed = parse_referrer(raw_referer)

    current_host = request.get_host().split(":")[0]

    utm = extract_utm(parsed["query"])

    # تشخیص نوع Referrer
    referrer_type = "direct"
    referrer_name = ""

    if parsed["is_valid"] and parsed["host_no_www"]:
        if _is_internal(parsed["host_no_www"], current_host):
            referrer_type = "internal"
            referrer_name = "internal"
        else:
            search = classify_search_engine(parsed["host_no_www"])
            if search:
                referrer_type = "search"
                referrer_name = search
            else:
                social = classify_social(parsed["host_no_www"])
                if social:
                    referrer_type = "social"
                    referrer_name = social
                else:
                    messenger = classify_messenger(parsed["host_no_www"])
                    if messenger:
                        referrer_type = "messenger"
                        referrer_name = messenger
                    else:
                        referrer_type = "external"
                        referrer_name = parsed["host_no_www"]

    # اگر UTM داریم، آن را اولویت بده
    if utm.get("utm_source"):
        referrer_type = _resolve_type_from_medium(
            utm.get("utm_medium", "")
        )
        referrer_name = utm["utm_source"]

    return {
        "raw": raw_referer[:2000],
        "domain": parsed["host_no_www"],
        "path": parsed["path"],
        "type": referrer_type,
        "name": referrer_name,
        "utm": utm,
        "method": "utm" if utm else "referer",
    }


def _is_internal(host: str, current_host: str) -> bool:
    if not host or not current_host:
        return False
    current = current_host.lower().replace("www.", "")
    return host.endswith(current) or current.endswith(host)


def _resolve_type_from_medium(medium: str) -> str:
    if not medium:
        return "campaign"
    medium = medium.lower()
    if medium in ("cpc", "ppc", "paid"):
        return "paid"
    if medium in ("email", "newsletter"):
        return "email"
    if medium in ("social", "social-media"):
        return "social"
    if medium in ("organic",):
        return "search"
    return "campaign"

بخش دوم، middleware. این لایه، سرویس را در لحظه‌ی درخواست صدا می‌زند و نتیجه را در Visit ذخیره می‌کند.

# analytics/middleware.py

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

    def __call__(self, request):
        from analytics.services.referrer import classify_entry

        if request.method == "GET" and not self._should_skip(request):
            entry_info = classify_entry(request)
            request._entry_info = entry_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))

بخش سوم، ذخیره‌سازی. در مرحله‌ی ساخت Visit، اطلاعات طبقه‌بندی‌شده ذخیره می‌شوند.

# analytics/services/tracking.py

def _get_or_create_visit(request, visitor, now):
    visit_id = request.session.get("_analytics_visit_id")

    if visit_id:
        visit = Visit.objects.filter(
            pk=visit_id, visitor=visitor, is_active=True,
        ).first()
        if visit and not _is_expired(visit, now):
            return visit

    entry_info = getattr(request, "_entry_info", {})

    visit = Visit.objects.create(
        visitor=visitor,
        entry_time=now,
        entry_url=request.build_absolute_uri()[:2000],
        entry_path=request.path[:500],
        referrer_url=entry_info.get("raw", "")[:2000],
        referrer_domain=entry_info.get("domain", "")[:255],
        referrer_type=entry_info.get("type", "direct"),
        referrer_name=entry_info.get("name", "")[:100],
        utm_source=entry_info.get("utm", {}).get("utm_source", "")[:100],
        utm_medium=entry_info.get("utm", {}).get("utm_medium", "")[:100],
        utm_campaign=entry_info.get("utm", {}).get("utm_campaign", "")[:100],
        last_activity=now,
        is_active=True,
    )
    request.session["_analytics_visit_id"] = visit.pk
    return visit

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

طراحی داده برای ذخیره‌ی Referrer

ذخیره‌ی اطلاعات Referrer، به‌اندازه‌ی خود طبقه‌بندی اهمیت دارد. چند فیلد کلیدی را در نظر بگیرید.

class Visit(models.Model):
    REFERRER_TYPES = [
        ("direct", "Direct"),
        ("search", "Search Engine"),
        ("social", "Social Network"),
        ("messenger", "Messenger"),
        ("email", "Email"),
        ("paid", "Paid"),
        ("campaign", "Campaign"),
        ("external", "External"),
        ("internal", "Internal"),
        ("bot", "Bot"),
    ]

    referrer_url = models.CharField(max_length=2000, blank=True)
    referrer_domain = models.CharField(
        max_length=255, blank=True, db_index=True,
    )
    referrer_type = models.CharField(
        max_length=20, choices=REFERRER_TYPES,
        default="direct", db_index=True,
    )
    referrer_name = models.CharField(
        max_length=100, blank=True, db_index=True,
    )

    # UTM parameters
    utm_source = models.CharField(max_length=100, blank=True, db_index=True)
    utm_medium = models.CharField(max_length=100, blank=True)
    utm_campaign = models.CharField(max_length=100, blank=True)
    utm_term = models.CharField(max_length=100, blank=True)
    utm_content = models.CharField(max_length=100, blank=True)

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

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

نکته‌ی دوم، جداسازی referrer_type و referrer_name. دسته‌ی کلی در referrer_type و نام خاص در referrer_name. این جداسازی، کوئری‌های گروهی را ساده‌تر می‌کند.

نکته‌ی سوم، فیلدهای UTM جداگانه. به‌جای ذخیره‌ی UTM در یک فیلد JSON، آن‌ها را در فیلدهای جداگانه ذخیره کنید. این کار، کوئری‌های تحلیلی را بسیار سریع‌تر می‌کند.

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

مدل‌های attribution و انتساب ورود

در تحلیل کانال‌های ورود، یک مفهوم مهم وجود دارد که اغلب نادیده گرفته می‌شود: attribution یا انتساب.

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

مدل اول، last-click attribution. آخرین کانالی که کاربر از آن آمده، اعتبار کامل را می‌گیرد. این مدل ساده است، ولی کانال‌های بالای قیف را نادیده می‌گیرد.

مدل دوم، first-click attribution. اولین کانالی که کاربر را با سایت شما آشنا کرده، اعتبار کامل را می‌گیرد. این مدل برای تحلیل برندسازی مفید است، ولی کانال‌های تبدیل‌کننده را نادیده می‌گیرد.

مدل سوم، linear attribution. اعتبار به‌طور مساوی بین تمام کانال‌ها تقسیم می‌شود. این مدل تعادل خوبی فراهم می‌کند، ولی پیاده‌سازی آن پیچیده‌تر است.

def resolve_attribution(visitor) -> dict:
    """
    محاسبه‌ی attribution برای یک Visitor.
    """
    visits = list(
        Visit.objects.filter(visitor=visitor)
        .order_by("entry_time")
        .values("referrer_type", "referrer_name", "entry_time")
    )

    if not visits:
        return {}

    first = visits[0]
    last = visits[-1]

    return {
        "first_touch": {
            "type": first["referrer_type"],
            "name": first["referrer_name"],
            "at": first["entry_time"],
        },
        "last_touch": {
            "type": last["referrer_type"],
            "name": last["referrer_name"],
            "at": last["entry_time"],
        },
        "total_visits": len(visits),
    }

نکته‌ی مهم: هر پروژه‌ای بر اساس هدف کسب‌وکار خود، باید مدل attribution مناسب را انتخاب کند. توصیه می‌کنم از هر سه مدل، داده را ذخیره کنید و در گزارش‌ها، امکان انتخاب مدل را فراهم کنید. اگر با الگوی ساخت API JSON برای آمار زنده کار کرده باشید، می‌دانید که این نوع انعطاف در تحلیل، ارزش زیادی در طول زمان دارد.

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

طبقه‌بندی Referrer، به‌خاطر تنوع حالت‌ها، به تست‌های گسترده‌ای نیاز دارد. سه سطح تست را در نظر بگیرید.

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

import pytest
from analytics.utils.referrer import parse_referrer


@pytest.mark.parametrize("url,expected_host", [
    ("https://www.google.com/search?q=test", "google.com"),
    ("https://m.facebook.com/page", "m.facebook.com"),
    ("http://example.com:8080/path", "example.com"),
    ("https://blog.example.com/post", "blog.example.com"),
    ("", ""),
])
def test_parse_referrer(url, expected_host):
    result = parse_referrer(url)
    assert result["host_no_www"] == expected_host

سطح دوم، تست طبقه‌بندی. برای هر دسته، چند نمونه‌ی واقعی بنویسید:

@pytest.mark.parametrize("url,expected_type,expected_name", [
    ("https://www.google.com/", "search", "google"),
    ("https://www.bing.com/search", "search", "bing"),
    ("https://m.facebook.com/", "social", "facebook"),
    ("https://www.instagram.com/", "social", "instagram"),
    ("https://t.me/channel", "messenger", "telegram"),
    ("https://wa.me/123456", "messenger", "whatsapp"),
    ("https://random-blog.com/post", "external", "random-blog.com"),
])
def test_classify(url, expected_type, expected_name):
    from analytics.services.referrer import classify_entry
    from django.test import RequestFactory

    rf = RequestFactory()
    request = rf.get("/", HTTP_REFERER=url)
    request.META["HTTP_HOST"] = "example.com"

    result = classify_entry(request)
    assert result["type"] == expected_type
    assert result["name"] == expected_name

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

@pytest.mark.django_db
def test_tracking_creates_google_visit(client):
    from analytics.models import Visit

    client.get(
        "/",
        HTTP_REFERER="https://www.google.com/search?q=test",
        HTTP_USER_AGENT="Mozilla/5.0 (Windows NT 10.0) Chrome/119.0",
    )

    visit = Visit.objects.first()
    assert visit.referrer_type == "search"
    assert visit.referrer_name == "google"
    assert visit.referrer_domain == "google.com"

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

anti-patternهای رایج در طبقه‌بندی Referrer

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

۱. جستجوی کلمه‌ی کلیدی در URL به‌جای دامنه. اگر "google" را در کل URL جستجو کنید، URLهایی مثل example.com/blog/google-seo به‌اشتباه گوگل تشخیص داده می‌شوند.

۲. عدم پوشش موتورهای جستجوی کوچک. DuckDuckGo، Ecosia، Brave Search، Startpage، Qwant و Mojeek در بازارهای خاص سهم قابل توجهی دارند.

۳. عدم تفکیک زیردامنه‌های شبکه‌های اجتماعی. m.facebook.com، web.telegram.org، web.whatsapp.com همه باید به‌عنوان همان شبکه اصلی تشخیص داده شوند.

۴. نادیده گرفتن لینک‌های داخلی. اگر لینک‌های داخلی را به‌عنوان Referrer خارجی حساب کنید، آمار کانال‌های ورود شما آلوده می‌شود.

۵. عدم پشتیبانی از UTM. در سایت‌های بازاریابی، UTM اطلاعات دقیق‌تری از Referer می‌دهد. نادیده گرفتن آن، خطای بزرگ است.

۶. عدم جداسازی پیام‌رسان‌ها از شبکه‌های اجتماعی. رفتار کاربران WhatsApp با کاربران LinkedIn کاملاً متفاوت است. این دو باید جداگانه ثبت شوند.

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

۸. ذخیره‌ی نسخه‌ی کامل URL بدون محدودیت. URLهای طولانی می‌توانند دیتابیس را پر کنند. همیشه طول را محدود کنید.

۹. عدم مدیریت Refererهای خالی یا نامعتبر. اگر پارس URL شکست بخورد، باید یک مقدار پیش‌فرض منطقی داشته باشید، نه یک exception.

۱۰. نادیده گرفتن Referrer Policy. سایت‌های مقصد می‌توانند Referer را حذف کنند. اگر این را ندانید، ممکن است بخش بزرگی از ورودی‌ها را به‌عنوان «direct» ببینید.

۱۱. عدم پشتیبانی از پیشوندهای بین‌المللی. در کشورهای مختلف، موتورهای جستجو و شبکه‌های اجتماعی متفاوتی محبوب هستند. پوشش جهانی نیاز به تحقیقات بازار دارد.

۱۲. تشخیص Referrer در ویو به‌جای سرویس. اگر منطق طبقه‌بندی را در ویو بنویسید، مجبورید آن را در هر ویو تکرار کنید. اگر با الگوی انتقال منطق از services.py به templatetags کار کرده باشید، می‌دانید که این کار اشتباه است.

پرسش‌های پرتکرار درباره‌ی طبقه‌بندی Referrer در جنگو

آیا می‌توانم از کتابخانه‌ی referer-parser استفاده کنم؟ بله، این کتابخانه می‌تواند نقطه‌ی شروع خوبی باشد. ولی توصیه می‌کنم آن را با UTM parameters و یک لایه‌ی سفارشی ترکیب کنید، چون این کتابخانه همه‌ی موتورهای جستجو و شبکه‌های اجتماعی مدرن را پوشش نمی‌دهد.

چطور «direct»های واقعی را از «direct»های جعلی تشخیص دهم؟ سه راه: اول، پشتیبانی از UTM در تمام لینک‌های بازاریابی. دوم، بررسی هدر Sec-Fetch-Site. سوم، تحلیل رفتار کاربر (مدت حضور، عمق اسکرول، مسیر حرکت). اگر با الگوی ذخیره‌ی PageView و Interaction کار کرده باشید، می‌دانید که این تحلیل‌های رفتاری، اطلاعات ارزشمندی می‌دهند.

آیا باید Referer را در سطح Visitor ذخیره کنم یا Visit؟ در هر دو. در Visit، به‌عنوان اطلاعات هر سشن. در Visitor، به‌عنوان «first touch» یعنی اولین کانالی که کاربر را با سایت شما آشنا کرده.

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

آیا باید Referer را هش کنم؟ نه، Referer داده‌ی حساس نیست، ولی باید طول آن را محدود کنید تا دیتابیس را از ورودی‌های بزرگ محافظت کنید.

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

آیا باید Referer را در سطح دیتابیس ایندکس کنم؟ بله، ایندکس روی referrer_type، referrer_name و referrer_domain ضروری است. اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این ایندکس‌ها، تفاوت بین کوئری سریع و کند را می‌سازند.

چطور Refererهای مرورگرهای قدیمی را مدیریت کنم؟ مرورگرهای قدیمی ممکن است Referer را با فرمت قدیمی بفرستند. توصیه می‌کنم پارس URL را با urlparse انجام دهید که این فرمت‌ها را هم پشتیبانی می‌کند.

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

چطور با Referrer Policy مقابله کنم؟ Referrer Policy یک استاندارد وب است و نمی‌توان آن را دور زد. راه‌حل، پشتیبانی از UTM در تمام لینک‌های بازاریابی است.

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

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

آیا باید Referer را در سئو در نظر بگیرم؟ بله، Referer یکی از سیگنال‌های سئو است. اگرچه تأثیر مستقیم آن کمتر از قبل است، ولی برای تحلیل بک‌لینک‌ها و تشخیص اسپم، همچنان مفید است.

چطور لینک‌های nofollow را تشخیص دهم؟ Referer اطلاعاتی درباره‌ی rel attribute لینک ندارد. برای تحلیل nofollow، باید از ابزارهای تخصصی سئو مثل Google Search Console استفاده کنید.

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

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

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

مفهوم اول، کش و کاهش بار محاسباتی. طبقه‌بندی Referrer برای هر درخواست، می‌تواند پرهزینه باشد. یک راه‌حل رایج، ذخیره‌ی نتیجه در یک cache با TTL بالا است. برای هر دامنه‌ی Referer، نتیجه‌ی طبقه‌بندی را برای ۲۴ ساعت کش کنید.

from django.core.cache import cache


def classify_domain_cached(host_no_www: str) -> dict:
    key = f"referrer:{host_no_www}"
    result = cache.get(key)
    if result is None:
        result = {
            "type": _compute_type(host_no_www),
            "name": _compute_name(host_no_www),
        }
        cache.set(key, result, timeout=86400)
    return result

این کش، می‌تواند بار پردازشی را چند برابر کاهش دهد، چون اکثر کاربران از تعداد محدودی دامنه می‌آیند.

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

مفهوم سوم، attribution توزیع‌شده. در مقیاس بالا، محاسبه‌ی attribution به‌صورت زنده ممکن است پرهزینه باشد. توصیه می‌کنم یک task شبانه بنویسید که attribution را برای تمام Visits محاسبه کند و در یک جدول جداگانه ذخیره کند. این جدول، مبنای گزارش‌های روزانه خواهد بود. اگر با الگوی کامند مدیریتی پاک‌سازی داده‌های قدیمی کار کرده باشید، می‌دانید که این نوع task‌های شبانه، بخشی از معماری استاندارد است.

نکته‌ی آخر: در مقیاس بالا، هیچ مدل طبقه‌بندی کاملی وجود ندارد. هدف، رسیدن به یک trade-off قابل قبول بین دقت و کارایی است. بهترین کاری که می‌توانید بکنید این است که این trade-off را صریح کنید و به‌طور مداوم آن را اندازه بگیرید. اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، می‌دانید که این نوع اندازه‌گیری مداوم، بخشی از انضباط مهندسی داده است.

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

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

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