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

۱. سه قرارداد جدا: محدوده، خزش و دسترسی

تفکیک محدودهٔ نشانی از قاعدهٔ دریافت صفحه
تصویر مفهومی: تفکیک محدودهٔ نشانی از قاعدهٔ دریافت صفحه؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

مبدأ انتخاب‌شدهٔ مثال https://docs.example است. فقط همین نام میزبان، طرح HTTPS و درگاه پیش‌فرض آن پذیرفته می‌شوند. یک درگاه ۴۴۳ صریح نیز در قرارداد ما همان مبدأ محسوب می‌شود، اما HTTP یا درگاه ۸۰ کنار گذاشته می‌شود. این محدودیت متعلق به تمرین است و از robots.txt استخراج نشده است. هیچ فایل واقعی از این دامنه خوانده نمی‌شود؛ قواعد به صورت چند خط ثابت در حافظه قرار دارند.

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

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

۲. قواعد کوچک و مرزهایی که باید آزمایش شوند

مرز مسیر و اهمیت نویسه‌های نشانی در تصمیم خزش
تصویر مفهومی: مرز مسیر و اهمیت نویسه‌های نشانی در تصمیم خزش؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل ساختگی یک گروه عمومی دارد: مسیرهای زیر /private/ منع و مسیرهای زیر /public/ مجازند. قواعد ما روی هم نمی‌افتند؛ این درس انتخاب طولانی‌ترین تطبیق، ادغام گروه‌های پیچیده یا الگوهای ستاره‌دار را پیاده و ارزیابی نمی‌کند. موضوع، آزمودن یک قرارداد محدود است. برای پیاده‌سازی خزندهٔ عمومی، خواندن کامل استاندارد و بررسی رفتار نسخهٔ انتخاب‌شدهٔ تحلیل‌گر همچنان لازم است.

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

کنترل نشانی پیش از فراخوانی تحلیل‌گر انجام می‌شود. از urlsplit پایتون برای تفکیک اجزا استفاده می‌کنیم، اما این تابع اعتبارسنج کامل نشانی نیست. قاعدهٔ اضافی ما نام میزبانِ شبیه‌سازی‌شده، طرح، درگاه و نبود اطلاعات کاربر در نشانی را می‌سنجد. این چند شرط، محافظت کامل یک دریافت‌کنندهٔ واقعی در برابر تغییر مسیر، نام‌گشایی یا ورودی‌های ناسالم را ثابت نمی‌کند؛ در این تمرین اصلاً دریافت‌کننده‌ای وجود ندارد.

۳. کد کامل تصمیم آفلاین با چهارده نشانی

آزمون آفلاین قواعد دریافت با ورودی‌های برچسب‌دار
تصویر مفهومی: آزمون آفلاین قواعد دریافت با ورودی‌های برچسب‌دار؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

مستندات RobotFileParser میان خواندن فایل و تحلیل خطوط آماده فرق می‌گذارند. اینجا فقط parse و can_fetch را مصرف می‌کنیم و read فراخوانی نمی‌شود. کد با پایتون ۳٫۱۴٫۵ اجرا شده است. فایل را با نام robots_crawl_policy.py ذخیره و دستور python robots_crawl_policy.py را اجرا کنید؛ نیاز به بستهٔ بیرونی، حساب سرویس یا اتصال به دامنهٔ نمونه ندارد.

"""Offline, fixed-origin crawl-decision fixture; no HTTP requests or full crawler."""
import json
import platform
from urllib.parse import urlsplit
from urllib.robotparser import RobotFileParser

ORIGIN = 'https://docs.example'
AGENT = 'EngineeringExampleBot'
ROBOTS_LINES = ['User-agent: *', 'Disallow: /private/', 'Allow: /public/']


def make_parser():
    parser = RobotFileParser(ORIGIN + '/robots.txt')
    parser.parse(ROBOTS_LINES)
    return parser


def decision(parser, url):
    if not isinstance(url, str):
        return {'eligible': False, 'reason': 'invalid_input'}
    try:
        parts = urlsplit(url)
        in_scope = (parts.scheme == 'https' and parts.hostname == 'docs.example'
                    and parts.port in (None, 443)
                    and parts.username is None and parts.password is None)
    except ValueError:
        return {'eligible': False, 'reason': 'invalid_url'}
    if not in_scope:
        return {'eligible': False, 'reason': 'outside_fixed_origin'}
    allowed = parser.can_fetch(AGENT, url)
    return {'eligible': allowed, 'reason': 'robots_allow' if allowed else 'robots_disallow'}


def run_example():
    parser = make_parser()
    fixture = [
        (ORIGIN + '/public/guide', True),
        (ORIGIN + '/private/report', False),
        (ORIGIN + '/private/report?format=html', False),
        (ORIGIN + '/Private/report', True),
        (ORIGIN + '/private', True),
        (ORIGIN + '/', True),
        ('https://docs.example:443/public/guide', True),
        ('https://docs.example:80/public/guide', False),
        ('http://docs.example/public/guide', False),
        ('https://elsewhere.example/public/guide', False),
        ('https://docs.example.evil.example/public/guide', False),
        ('https://name@docs.example/public/guide', False),
        ('https://docs.example:bad/public/guide', False),
        ('/public/guide', False),
    ]
    outcomes = [{'url': u, **decision(parser, u), 'expected': expected}
                for u, expected in fixture]
    checks = {
        'all_fourteen_labeled_cases_match': all(x['eligible'] == x['expected'] for x in outcomes),
        'public_path_eligible': decision(parser, ORIGIN+'/public/guide')['eligible'],
        'private_path_disallowed': decision(parser, ORIGIN+'/private/report')['reason'] == 'robots_disallow',
        'query_does_not_escape_private_prefix': not decision(parser, ORIGIN+'/private/report?a=1')['eligible'],
        'path_case_sensitive_in_fixture': decision(parser, ORIGIN+'/Private/report')['eligible'],
        'trailing_slash_boundary_explicit': decision(parser, ORIGIN+'/private')['eligible'],
        'default_https_port_equivalent': decision(parser, 'https://docs.example:443/public/guide')['eligible'],
        'other_origin_rejected_before_robots': decision(parser, 'https://elsewhere.example/')['reason'] == 'outside_fixed_origin',
        'userinfo_rejected': not decision(parser, 'https://name@docs.example/')['eligible'],
        'malformed_port_rejected': decision(parser, 'https://docs.example:bad/')['reason'] == 'invalid_url',
        'non_string_rejected': decision(parser, None)['reason'] == 'invalid_input',
        'fragment_does_not_change_public_decision': decision(parser, ORIGIN+'/public/guide#s1') == decision(parser, ORIGIN+'/public/guide'),
    }
    assert all(checks.values()), checks
    return {'python': platform.python_version(), 'network_requests_made': 0,
            'origin': ORIGIN, 'agent': AGENT, 'robots_lines': ROBOTS_LINES,
            'labeled_cases': len(outcomes), 'eligible_cases': sum(x['eligible'] for x in outcomes),
            'outcomes': outcomes, 'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run_example(), indent=2))

چهارده ورودی، پاسخ مورد انتظار دارند. در میان آن‌ها مسیر عمومی، مسیر منع‌شده، تغییر بزرگی حرف، مرز ممیز، query، درگاه پیش‌فرض، درگاه متفاوت، میزبان دیگر و درگاه نامعتبر دیده می‌شود. مقدار مورد انتظار به صورت مستقل کنار دادهٔ هر مورد نوشته شده است. نخستین بررسی تمام این جدول را با تصمیم تابع مقایسه می‌کند؛ بررسی‌های دیگر علت‌ها و چند رفتار مرزی را صریح‌تر می‌سنجند. همهٔ این ورودی‌ها نمایندهٔ تمام نشانی‌های ممکن نیستند.

مقدار None به عنوان ورودی نامعتبر رد می‌شود و خطای درگاه به علت جداگانه می‌رسد. رشتهٔ نسبی نیز چون مبدأ کاملِ مورد انتظار را ندارد، از محدوده کنار گذاشته می‌شود. در یک سامانهٔ دیگر شاید ابتدا نشانی نسبی به پایه وصل شود؛ این برنامه چنین مرحله‌ای ندارد. تعریف همین ترتیب مراحل مهم است، زیرا پاسخ درست برای ورودی، به قراردادی وابسته است که پیش از آزمون نوشته‌ایم.

۴. خواندن خروجی و علت تصمیم‌ها

ثبت دلیل رد یا پذیرش نامزدهای خزش
تصویر مفهومی: ثبت دلیل رد یا پذیرش نامزدهای خزش؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

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

{
  "python": "3.14.5",
  "network_requests_made": 0,
  "origin": "https://docs.example",
  "agent": "EngineeringExampleBot",
  "robots_lines": [
    "User-agent: *",
    "Disallow: /private/",
    "Allow: /public/"
  ],
  "labeled_cases": 14,
  "eligible_cases": 5,
  "outcomes": [
    {
      "url": "https://docs.example/public/guide",
      "eligible": true,
      "reason": "robots_allow",
      "expected": true
    },
    {
      "url": "https://docs.example/private/report",
      "eligible": false,
      "reason": "robots_disallow",
      "expected": false
    },
    {
      "url": "https://docs.example/private/report?format=html",
      "eligible": false,
      "reason": "robots_disallow",
      "expected": false
    },
    {
      "url": "https://docs.example/Private/report",
      "eligible": true,
      "reason": "robots_allow",
      "expected": true
    },
    {
      "url": "https://docs.example/private",
      "eligible": true,
      "reason": "robots_allow",
      "expected": true
    },
    {
      "url": "https://docs.example/",
      "eligible": true,
      "reason": "robots_allow",
      "expected": true
    },
    {
      "url": "https://docs.example:443/public/guide",
      "eligible": true,
      "reason": "robots_allow",
      "expected": true
    },
    {
      "url": "https://docs.example:80/public/guide",
      "eligible": false,
      "reason": "outside_fixed_origin",
      "expected": false
    },
    {
      "url": "http://docs.example/public/guide",
      "eligible": false,
      "reason": "outside_fixed_origin",
      "expected": false
    },
    {
      "url": "https://elsewhere.example/public/guide",
      "eligible": false,
      "reason": "outside_fixed_origin",
      "expected": false
    },
    {
      "url": "https://docs.example.evil.example/public/guide",
      "eligible": false,
      "reason": "outside_fixed_origin",
      "expected": false
    },
    {
      "url": "https://name@docs.example/public/guide",
      "eligible": false,
      "reason": "outside_fixed_origin",
      "expected": false
    },
    {
      "url": "https://docs.example:bad/public/guide",
      "eligible": false,
      "reason": "invalid_url",
      "expected": false
    },
    {
      "url": "/public/guide",
      "eligible": false,
      "reason": "outside_fixed_origin",
      "expected": false
    }
  ],
  "checks": {
    "all_fourteen_labeled_cases_match": true,
    "public_path_eligible": true,
    "private_path_disallowed": true,
    "query_does_not_escape_private_prefix": true,
    "path_case_sensitive_in_fixture": true,
    "trailing_slash_boundary_explicit": true,
    "default_https_port_equivalent": true,
    "other_origin_rejected_before_robots": true,
    "userinfo_rejected": true,
    "malformed_port_rejected": true,
    "non_string_rejected": true,
    "fragment_does_not_change_public_decision": true
  }
}

نام میزبان ظاهراً مشابهی که پسوند دیگری دارد از محدوده رد می‌شود. حضور نام کاربر در نشانی نیز در قرارداد ما پذیرفته نیست. در عوض، قطعهٔ پس از علامت # برای مسیر عمومیِ آزمایش، تصمیم را عوض نمی‌کند. این موارد کمک می‌کنند بفهمیم کنترل محدوده و تحلیل قاعده دو مسئولیت جدا هستند. اگر بعداً تابع دریافت اضافه شود، باید رفتار مقصد نهایی و خطاها نیز جداگانه مدل و بررسی شوند.

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

۵. تمرین‌های پاسخ‌دار و مرز نمایه‌سازی

تفاوت دریافت صفحه با نمایه‌سازی در موتور جست‌وجو
تصویر مفهومی: تفاوت دریافت صفحه با نمایه‌سازی در موتور جست‌وجو؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تمرین اول: چرا نشانی با درگاه ۴۴۳ پذیرفته و با ۸۰ رد می‌شود؟ پاسخ: قرارداد محدودهٔ ما فقط HTTPS با درگاه پیش‌فرض را می‌پذیرد؛ این تصمیم پیش از robots انجام می‌شود. تمرین دوم: آیا منع مسیر با ممیز پایانی، مسیر بدون آن را هم در این مثال می‌پوشاند؟ پاسخ: خیر؛ جدول و بررسی مرزی نشان می‌دهند که باید مسیر مورد نظر را دقیق نوشت و انتظار را پیشاپیش تعیین کرد.

تمرین سوم: آیا خروجی مجاز یعنی سند در گوگل ایندکس می‌شود؟ پاسخ: خیر؛ اینجا نه گوگل آزمایش شده و نه مرحلهٔ نمایه‌سازی. راهنمای Google Search Central نیز تفاوت منع خزش و حضور احتمالی نشانی در نتایج را توضیح می‌دهد. این درس هیچ robots.txt واقعی را تغییر نمی‌دهد و برای حذف نشانی از نتایج، نتیجهٔ اجرایی ارائه نمی‌کند.

تمرین چهارم: آیا موفقیت این بررسی‌ها، انطباق کامل با RFC را ثابت می‌کند؟ پاسخ: خیر؛ خطاهای HTTP، ذخیرهٔ قواعد، چند گروه و الگوهای پیچیده در مدل نیستند. پس از مراحل خزش و پردازش، ارزیابی ارتباط نیز موضوع جداگانه‌ای است؛ درس سنجش رتبه‌بندی جست‌وجو آن بخش را توضیح می‌دهد. منابع در ۲۴ مهر ۱۴۰۵ خوانده شده‌اند؛ این متن آموزش تألیفی بر پایهٔ مستندات است و خبر تازه یا گزارش عملکرد محصول نیست.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.