یک فهرست نشانی می‌تواند از نظر XML خراب باشد، حتی اگر نشانی‌های داخلش درست به نظر برسند. این درس یک قرارداد کوچک برای خواندن sitemap می‌سازد: فضای نام، ساختار عنصرها، نشانی و تاریخ اختیاری بررسی می‌شوند. دو نشانیِ مقالهٔ موجود را به‌عنوان دادهٔ آزمایش داریم، اما فایل عمومی سایت عوض نمی‌شود و برنامه هیچ دریافت یا ارسالی انجام نمی‌دهد.

۱. نام عنصر را همراه فضای نام بخوانیم

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

پروتکل Sitemaps برای قالب XML از ریشهٔ urlset، مدخل url و فرزند loc استفاده می‌کند. فضای نام نیز بخشی از قرارداد است. اگر فقط دنبال رشتهٔ loc بگردیم، ممکن است عنصری با همان نام ظاهری ولی متعلق به فضای نام دیگری را اشتباه بپذیریم. فایل UTF-8 و مقدارهای متنیِ درست escapeشده نیز اهمیت دارند.

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

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

۲. escape در XML را از ساخت نشانی جدا کنیم

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

در آزمون، loc باید نشانی کامل HTTPS با میزبان دقیق hooshmandsazanjavan.ir و مسیر آغازشده با اسلش باشد. نام کاربری، پورت صریح و fragment را در قرارداد خودمان نمی‌پذیریم. این محدودیت‌ها سیاستِ همین ورودی‌اند؛ از آن‌ها قاعدهٔ همهٔ sitemapها یا محافظ امنیتیِ دریافت URL نتیجه نمی‌گیریم. فایل را هم فرضاً در ریشهٔ سایت در نظر گرفته‌ایم.

برای query ساختگیِ mode=a و view=b، آمپرسندِ میان دو پارامتر هنگام نوشتن XML به ampersand escape تبدیل می‌شود؛ پس از تحلیل XML، رشتهٔ نشانی دوباره آمپرسند دارد. اگر آمپرسند خام را مستقیم در سند بگذاریم، نمونه خطای تحلیل می‌دهد. تولید سند با ElementTree انجام می‌شود تا دادهٔ متن با قالب عنصرها قاطی نشود.

در مقابل، %26 در خود URI همچنان سه نویسهٔ کدگذاری‌شده می‌ماند؛ تحلیل XML آن را به جداکنندهٔ query تبدیل نمی‌کند. این دو لایه را نباید با جایگزینیِ سراسری رشته‌ها یکی گرفت. کد ترتیب query را مرتب نمی‌کند و نشانی‌ها را نرمال‌سازی عمومی نمی‌کند؛ loc تکراریِ دقیق نیز خطای قرارداد است، نه مدخلی که پنهانی حذف شود.

۳. تاریخ آخرین تغییر را با زمان ساخت فایل اشتباه نگیریم

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

lastmod در پروتکل، به تغییر صفحهٔ مربوط است، نه زمان تولید sitemap. در تمرین، مقدار 2026-10-16 را فقط دادهٔ ساختگی می‌گذاریم؛ واقعی‌بودن تاریخ آخرین ویرایش آن مقاله ادعا نمی‌شود. رکورد دوم این فیلد را ندارد و با None برمی‌گردد. خواندن شکل تاریخ، حقیقت تاریخیِ آن را اثبات نمی‌کند.

زیرمجموعهٔ ما فقط تاریخ گریگوریِ سال، ماه و روز را می‌پذیرد. 2000-02-29 معتبر، ولی 1900-02-29 و 2026-02-30 نامعتبرند. قالب دارای ساعت، با وجود دامنهٔ گسترده‌تر استاندارد، در این نسخه عمداً رد می‌شود. تاریخ گمشده را هم با زمان امروز پر نمی‌کنیم؛ این کار می‌توانست بی‌دلیل دادهٔ تازه‌بودن تولید کند.

loc باید نخست بیاید و lastmod، اگر هست، یک بار پس از آن قرار بگیرد. فیلد تکراری، صفت ناشناخته، متن اضافه میان عنصرها یا فرزند تودرتو خطا می‌دهد. وقتی ورودی چنین خطایی دارد، تابع فهرست نصفه و ظاهراً سالم برنمی‌گرداند؛ کل فراخوانی خطا می‌دهد تا فراخواننده علت را بررسی کند.

۴. کد کامل، پاسخ دستی و محدودیت XML را بررسی کنیم

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

فایل sitemap_contract.py با Python 3.14.5 و Expat 2.8.0 اجرا شده است. پاسخ دستی دو رکورد دارد: نشانی درس پیوند نسبی با تاریخ ساختگی و نشانی درس robots با تاریخ غایب. XML دستیِ مرجع را تابع تولید سند نساخته است؛ این مسیر جدا، خطای تولیدکننده و خواننده را صرفاً با یک رفت‌وبرگشت مشترک پنهان نمی‌کند.

"""Bounded offline sitemap subset; no fetching, submission or security guarantee."""
import json
import platform
import re
import pyexpat
from datetime import date
from urllib.parse import urlsplit
from xml.etree import ElementTree as ET

NAMESPACE = 'http://www.sitemaps.org/schemas/sitemap/0.9'
HOST = 'hooshmandsazanjavan.ir'
MAX_BYTES = 16384
ARTICLE_URLS = [
    'https://hooshmandsazanjavan.ir/magazine/relative-link-discovery-urljoin-tests-python/',
    'https://hooshmandsazanjavan.ir/magazine/robots-txt-python-offline-crawl-policy/',
]


def tag(name):
    return '{' + NAMESPACE + '}' + name


def blank(text):
    return text is None or not text.strip()


def location_reason(value):
    if type(value) is not str or not (1 <= len(value) < 2048):
        return 'loc_text'
    if any(ord(c) <= 32 or ord(c) > 126 or c in '\\<>"\'`{}|^' for c in value):
        return 'loc_characters'
    if re.search(r'%(?![0-9A-Fa-f]{2})', value):
        return 'loc_percent'
    try:
        parts = urlsplit(value)
        port = parts.port
    except ValueError:
        return 'loc_url'
    if parts.scheme != 'https' or parts.hostname != HOST or not parts.path.startswith('/'):
        return 'loc_scope'
    if parts.username is not None or parts.password is not None or port is not None or parts.netloc.lower() != HOST or '#' in value:
        return 'loc_scope'
    return None


def read_sitemap(payload):
    if type(payload) is not bytes or len(payload) > MAX_BYTES:
        raise ValueError('bytes_contract')
    try:
        text = payload.decode('utf-8-sig')
    except UnicodeDecodeError:
        raise ValueError('utf8_required') from None
    if '\x00' in text:
        raise ValueError('utf8_required')
    if re.search(r'<!\s*(?:DOCTYPE|ENTITY)\b', text, re.IGNORECASE):
        raise ValueError('dtd_entity_not_supported')
    declaration = re.match(r'\s*<\?xml(?=\s)([^?]*)\?>', text)
    if declaration:
        encoding = re.search(r'encoding\s*=\s*[\'"]([^\'"]+)[\'"]', declaration[1])
        if encoding and encoding[1].lower() != 'utf-8':
            raise ValueError('utf8_required')
    remainder = text[declaration.end():] if declaration else text
    if '<?' in remainder:
        raise ValueError('processing_instruction_not_supported')
    try:
        root = ET.fromstring(text)
    except ET.ParseError:
        raise ValueError('xml_parse') from None
    if root.tag != tag('urlset') or root.attrib or not blank(root.text):
        raise ValueError('urlset_contract')
    if not 1 <= len(root) <= 16:
        raise ValueError('url_count')
    output, seen = [], set()
    for node in root:
        if node.tag != tag('url') or node.attrib or not blank(node.text) or not blank(node.tail):
            raise ValueError('url_contract')
        fields = list(node)
        names = [field.tag for field in fields]
        if names not in ([tag('loc')], [tag('loc'), tag('lastmod')]):
            raise ValueError('field_contract')
        if any(field.attrib or len(field) or not blank(field.tail) for field in fields):
            raise ValueError('field_contract')
        loc = fields[0].text
        if reason := location_reason(loc):
            raise ValueError(reason)
        if loc in seen:
            raise ValueError('duplicate_loc')
        seen.add(loc)
        lastmod = None
        if len(fields) == 2:
            lastmod = fields[1].text
            if type(lastmod) is not str or not re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}', lastmod):
                raise ValueError('lastmod_date')
            try:
                date.fromisoformat(lastmod)
            except ValueError:
                raise ValueError('lastmod_date') from None
        output.append({'loc': loc, 'lastmod': lastmod})
    return output


def make_sitemap(rows):
    """Fixture serializer; ElementTree handles XML text escaping."""
    root = ET.Element(tag('urlset'))
    for loc, lastmod in rows:
        child = ET.SubElement(root, tag('url'))
        ET.SubElement(child, tag('loc')).text = loc
        if lastmod is not None:
            ET.SubElement(child, tag('lastmod')).text = lastmod
    return ET.tostring(root, encoding='utf-8', xml_declaration=True)


def rejects(call, message):
    try:
        call()
    except ValueError as error:
        return str(error) == message
    return False


def run_example():
    rows = [(ARTICLE_URLS[0], '2026-10-16'), (ARTICLE_URLS[1], None)]
    payload = make_sitemap(rows)
    original = bytes(payload)
    parsed = read_sitemap(payload)
    expected = [{'loc': ARTICLE_URLS[0], 'lastmod': '2026-10-16'}, {'loc': ARTICLE_URLS[1], 'lastmod': None}]
    # Independently authored XML and literal answers, not produced by the serializer.
    manual = ('<s:urlset xmlns:s="' + NAMESPACE + '"><s:url><s:loc>' + ARTICLE_URLS[0] + '</s:loc><s:lastmod>2026-10-16</s:lastmod></s:url><s:url><s:loc>' + ARTICLE_URLS[1] + '</s:loc></s:url></s:urlset>').encode()
    default = re.sub(rb'(<\/?)s:', rb'\1', manual).replace(b'xmlns:s=', b'xmlns=')
    query = 'https://' + HOST + '/magazine/example/?mode=a&view=b'
    escaped = make_sitemap([(query, None)])
    bad_scopes = ['relative/path', 'http://' + HOST + '/a', 'https://other.example/a',
                  'https://' + HOST + '.evil/a', 'https://user@' + HOST + '/a',
                  'https://' + HOST + ':443/a', 'https://' + HOST + '/a#part',
                  'https://' + HOST + ':/a']
    batch16 = [('https://' + HOST + '/p/' + str(i) + '/', None) for i in range(16)]
    # Tiny controlled declarations are rejected before parsing; no expansion payload is executed.
    dtd = ('<!DOCTYPE urlset [<!ENTITY marker "tiny">]>' + default.decode()).encode()
    entity = ('<!ENTITY marker "tiny">' + default.decode()).encode()
    wrong_ns = default.replace(NAMESPACE.encode(), b'https://wrong.example/ns')
    prefix = 'https://' + HOST + '/'
    url2047 = prefix + 'a' * (2047 - len(prefix))
    out_of_order = ('<urlset xmlns="' + NAMESPACE + '"><url><lastmod>2026-10-16</lastmod><loc>' + ARTICLE_URLS[0] + '</loc></url></urlset>').encode()
    altered = read_sitemap(payload)
    altered[0]['loc'] = 'changed'
    checks = {
        'hand_two_records_and_synthetic_optional_date': parsed == expected,
        'independently_written_default_and_prefixed_namespaces': read_sitemap(manual) == expected and read_sitemap(default) == expected,
        'missing_or_wrong_protocol_namespace_reject': rejects(lambda: read_sitemap(wrong_ns), 'urlset_contract') and rejects(lambda: read_sitemap(b'<urlset><url><loc>x</loc></url></urlset>'), 'urlset_contract'),
        'exactly_one_required_loc_rejects_missing_or_duplicate': rejects(lambda: read_sitemap(default.replace(b'<loc>', b'<lastmod>').replace(b'</loc>', b'</lastmod>')), 'field_contract') and rejects(lambda: read_sitemap(make_sitemap([(ARTICLE_URLS[0], None)]).replace(b'</ns0:loc>', b'</ns0:loc><ns0:loc>x</ns0:loc>')), 'field_contract'),
        'optional_lastmod_order_and_unknown_fields_are_explicit': rejects(lambda: read_sitemap(default.replace(b'</url>', b'<priority>0.5</priority></url>', 1)), 'field_contract') and rejects(lambda: read_sitemap(default.replace(b'</lastmod>', b'</lastmod><lastmod>2026-10-16</lastmod>', 1)), 'field_contract') and rejects(lambda: read_sitemap(out_of_order), 'field_contract'),
        'attributes_nested_content_and_mixed_text_reject': rejects(lambda: read_sitemap(default.replace(b'<url>', b'<url extra="x">', 1)), 'url_contract') and rejects(lambda: read_sitemap(default.replace(b'<loc>', b'<loc><inner/>', 1)), 'field_contract') and rejects(lambda: read_sitemap(default.replace(b'<url>', b'<url>unexpected', 1)), 'url_contract'),
        'absolute_same_host_https_scope_is_required': all(rejects(lambda value=value: read_sitemap(make_sitemap([(value, None)])), 'loc_scope') for value in bad_scopes[:4]),
        'userinfo_explicit_port_and_fragments_are_outside_own_subset': all(rejects(lambda value=value: read_sitemap(make_sitemap([(value, None)])), 'loc_scope') for value in bad_scopes[4:]) and rejects(lambda: read_sitemap(make_sitemap([('https://' + HOST + '/a#', None)])), 'loc_scope'),
        'loc_ascii_whitespace_percent_and_protocol_length_limit': rejects(lambda: read_sitemap(make_sitemap([('https://' + HOST + '/a b', None)])), 'loc_characters') and rejects(lambda: read_sitemap(make_sitemap([('https://' + HOST + '/%GG', None)])), 'loc_percent') and location_reason(url2047) is None and location_reason('x' * 2048) == 'loc_text',
        'xml_ampersand_escaping_roundtrip_and_raw_ampersand_reject': b'?mode=a&amp;view=b' in escaped and read_sitemap(escaped)[0]['loc'] == query and rejects(lambda: read_sitemap(escaped.replace(b'&amp;', b'&')), 'xml_parse'),
        'uri_percent_escape_and_xml_escape_are_different_layers': read_sitemap(make_sitemap([('https://' + HOST + '/p?q=a%26b', None)]))[0]['loc'] == 'https://' + HOST + '/p?q=a%26b' and query != 'https://' + HOST + '/magazine/example/?mode=a%26view=b',
        'lastmod_calendar_date_validation_and_leap_boundaries': read_sitemap(make_sitemap([(ARTICLE_URLS[0], '2000-02-29')]))[0]['lastmod'] == '2000-02-29' and rejects(lambda: read_sitemap(make_sitemap([(ARTICLE_URLS[0], '1900-02-29')])), 'lastmod_date') and rejects(lambda: read_sitemap(make_sitemap([(ARTICLE_URLS[0], '2026-02-30')])), 'lastmod_date'),
        'timestamp_lastmod_rejected_by_declared_date_only_subset': rejects(lambda: read_sitemap(make_sitemap([(ARTICLE_URLS[0], '2026-10-16T10:00:00Z')])), 'lastmod_date') and rejects(lambda: read_sitemap(make_sitemap([(ARTICLE_URLS[0], '')])), 'lastmod_date'),
        'duplicate_exact_loc_is_a_contract_error': rejects(lambda: read_sitemap(make_sitemap([(ARTICLE_URLS[0], None)] * 2)), 'duplicate_loc'),
        'one_to_sixteen_url_records_and_no_sitemapindex': len(read_sitemap(make_sitemap(batch16))) == 16 and rejects(lambda: read_sitemap(make_sitemap([])), 'url_count') and rejects(lambda: read_sitemap(make_sitemap(batch16 + [('https://' + HOST + '/p/16/', None)])), 'url_count') and rejects(lambda: read_sitemap(default.replace(b'urlset', b'sitemapindex')), 'urlset_contract'),
        'bytes_limit_strict_utf8_and_declared_encoding': read_sitemap(payload + b' ' * (MAX_BYTES - len(payload))) == expected and rejects(lambda: read_sitemap(payload.decode()), 'bytes_contract') and rejects(lambda: read_sitemap(b'x' * (MAX_BYTES + 1)), 'bytes_contract') and rejects(lambda: read_sitemap(b'\xff'), 'utf8_required') and rejects(lambda: read_sitemap(payload.replace(b"encoding='utf-8'", b"encoding='UTF-16'")), 'utf8_required'),
        'controlled_dtd_entity_and_processing_instruction_prechecks': rejects(lambda: read_sitemap(dtd), 'dtd_entity_not_supported') and rejects(lambda: read_sitemap(entity), 'dtd_entity_not_supported') and rejects(lambda: read_sitemap(b'<?test x?>' + default), 'processing_instruction_not_supported') and rejects(lambda: read_sitemap(b'<?xml-stylesheet type="text/xsl" href="local.xsl"?>' + default), 'processing_instruction_not_supported'),
        'caller_bytes_preserved_and_fresh_independent_output': payload == original and read_sitemap(payload) == expected and altered[0]['loc'] == 'changed' and parsed == expected,
    }
    assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
            'xml_parser_version': pyexpat.EXPAT_VERSION,
            'scope': 'Original offline bounded XML sitemap subset, not XSD conformance, crawler, SSRF or DoS security proof',
            'results': {'records': parsed, 'record_count': len(parsed), 'example_xml_utf8': payload.decode('utf-8'),
                        'independent_hand_reference_records': expected,
                        'ampersand_roundtrip_loc': read_sitemap(escaped)[0]['loc'],
                        'limits': {'max_input_bytes': MAX_BYTES, 'max_url_records': 16, 'loc_characters_less_than': 2048,
                                   'namespace': NAMESPACE, 'host': HOST, 'source_location_assumption': 'site root',
                                   'lastmod': 'optional Gregorian YYYY-MM-DD; synthetic fixture values, not verified real modification timestamps',
                                   'network_requests_made': 0, 'sitemap_files_changed': 0}}, 'checks': checks}


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

هجده گروه، فضای نام، فیلدها، میزبان، escape، تقویم، تکرار، اندازه، نوع بایت و خروجی مستقل را پوشش می‌دهند. ورودی باید bytes و حداکثر ۱۶۳۸۴ بایت باشد؛ UTF-8 سخت‌گیرانه بررسی می‌شود. سقف شانزده مدخلِ این تمرین را با سقف پروتکل، یعنی پنجاه‌هزار نشانی و ۵۲۴۲۸۸۰۰ بایت، یکسان معرفی نمی‌کنیم.

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

۵. فایل پذیرفته‌شده را نتیجهٔ جست‌وجو ندانیم

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

راهنمای جاری Google Search Central نیز تضمین نمی‌دهد که تمام مدخل‌های sitemap خزیده و نمایه شوند. نتیجهٔ این کد محدودتر است: ورودی با قرارداد کوچک ما سازگار است. نه HTTP صفحات در خود مثال بررسی می‌شود، نه مجوز دریافت، وضعیت ایندکس یا رتبهٔ جست‌وجو تعیین می‌شود.

{
  "python": "3.14.5",
  "dependencies": "Python standard library only",
  "xml_parser_version": "expat_2.8.0",
  "scope": "Original offline bounded XML sitemap subset, not XSD conformance, crawler, SSRF or DoS security proof",
  "results": {
    "records": [
      {
        "loc": "https://hooshmandsazanjavan.ir/magazine/relative-link-discovery-urljoin-tests-python/",
        "lastmod": "2026-10-16"
      },
      {
        "loc": "https://hooshmandsazanjavan.ir/magazine/robots-txt-python-offline-crawl-policy/",
        "lastmod": null
      }
    ],
    "record_count": 2,
    "example_xml_utf8": "<?xml version='1.0' encoding='utf-8'?>\n<ns0:urlset xmlns:ns0=\"http://www.sitemaps.org/schemas/sitemap/0.9\"><ns0:url><ns0:loc>https://hooshmandsazanjavan.ir/magazine/relative-link-discovery-urljoin-tests-python/</ns0:loc><ns0:lastmod>2026-10-16</ns0:lastmod></ns0:url><ns0:url><ns0:loc>https://hooshmandsazanjavan.ir/magazine/robots-txt-python-offline-crawl-policy/</ns0:loc></ns0:url></ns0:urlset>",
    "independent_hand_reference_records": [
      {
        "loc": "https://hooshmandsazanjavan.ir/magazine/relative-link-discovery-urljoin-tests-python/",
        "lastmod": "2026-10-16"
      },
      {
        "loc": "https://hooshmandsazanjavan.ir/magazine/robots-txt-python-offline-crawl-policy/",
        "lastmod": null
      }
    ],
    "ampersand_roundtrip_loc": "https://hooshmandsazanjavan.ir/magazine/example/?mode=a&view=b",
    "limits": {
      "max_input_bytes": 16384,
      "max_url_records": 16,
      "loc_characters_less_than": 2048,
      "namespace": "http://www.sitemaps.org/schemas/sitemap/0.9",
      "host": "hooshmandsazanjavan.ir",
      "source_location_assumption": "site root",
      "lastmod": "optional Gregorian YYYY-MM-DD; synthetic fixture values, not verified real modification timestamps",
      "network_requests_made": 0,
      "sitemap_files_changed": 0
    }
  },
  "checks": {
    "hand_two_records_and_synthetic_optional_date": true,
    "independently_written_default_and_prefixed_namespaces": true,
    "missing_or_wrong_protocol_namespace_reject": true,
    "exactly_one_required_loc_rejects_missing_or_duplicate": true,
    "optional_lastmod_order_and_unknown_fields_are_explicit": true,
    "attributes_nested_content_and_mixed_text_reject": true,
    "absolute_same_host_https_scope_is_required": true,
    "userinfo_explicit_port_and_fragments_are_outside_own_subset": true,
    "loc_ascii_whitespace_percent_and_protocol_length_limit": true,
    "xml_ampersand_escaping_roundtrip_and_raw_ampersand_reject": true,
    "uri_percent_escape_and_xml_escape_are_different_layers": true,
    "lastmod_calendar_date_validation_and_leap_boundaries": true,
    "timestamp_lastmod_rejected_by_declared_date_only_subset": true,
    "duplicate_exact_loc_is_a_contract_error": true,
    "one_to_sixteen_url_records_and_no_sitemapindex": true,
    "bytes_limit_strict_utf8_and_declared_encoding": true,
    "controlled_dtd_entity_and_processing_instruction_prechecks": true,
    "caller_bytes_preserved_and_fresh_independent_output": true
  }
}

پیش از چنین فایلی، حل پیوند نسبی می‌تواند رشتهٔ کامل مقصد را بسازد؛ آن کار با خواندن درخت XML فرق دارد. آزمون سیاست robots.txt نیز مرحله‌ای جداست. دو پیوندِ اصلی این درس هنگام بازبینی نگارش پاسخ HTTP200 داشتند؛ برنامهٔ آموزشی خودش به شبکه نمی‌رود و sitemap واقعی را تغییر نمی‌دهد.

پرسش اول: آیا تغییر پیشوند فضای نام، به‌تنهایی فایل را خراب می‌کند؟ پاسخ: خیر؛ تا نشانی فضای نام و نام محلی ثابت‌اند، دو XML مرجع همین پاسخ دستی را می‌دهند.

پرسش دوم: آیا %26 و escape آمپرسند یک کار می‌کنند؟ پاسخ: خیر؛ اولی بخشی از کدگذاری URI می‌ماند، دومی نمایندگی نویسه در XML است و هنگام تحلیل XML باز می‌شود.

پرسش سوم: نبود lastmod را با امروز پر کنیم؟ پاسخ: خیر؛ در این قرارداد اختیاری است و نبودش None می‌دهد. تاریخ معتبر نیز به‌تنهایی صحت زمان تغییر صفحه را ثابت نمی‌کند.

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

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

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