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

۱. قرارداد قطعهٔ ورودی را پیش از متن‌برداری مشخص کنیم

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

ورودی تابع، فهرستی از رشته‌های پایتون است؛ نه URL، فایل باینری یا پاسخ شبکه. فرض می‌کنیم تبدیل بایت به متن قبلاً با سیاست مشخص انجام شده است. مجموع ورودی حداکثر ۸۱۹۲ نقطهٔ کد و تعداد قطعه‌ها حداکثر ۱۰۲۴ است. این واحد با بایت UTF-8 یا تعداد نویسهٔ دیداری برابر نیست؛ نیم‌فاصله نیز در همان رشته باقی می‌ماند.

برای همین مثال، چند تگ خطی و بلوکی، br و دو ظرف خام script و style مجازند. متن ویژگی‌ها، مانند title یک پیوند، وارد خروجی نمی‌شود؛ توضیح HTML هم حذف می‌شود. تگ ناشناخته را به‌جای حدس‌زدن کاربردش رد می‌کنیم. این انتخاب برای قطعه‌های از پیش محدود مناسب است و برای استخراج هر صفحهٔ اینترنتی کافی نیست.

این مرز با کشف پیوند نسبی از مرجع‌های صریح متفاوت است: آن درس اصلاً HTML را تجزیه نمی‌کند؛ اینجا هم مقصد پیوندها استخراج یا دریافت نمی‌شود. هر مرحله باید خروجی و مسئولیت معلوم داشته باشد تا خطای یک مرحله پشت موفقیت مرحلهٔ دیگر پنهان نشود.

۲. مرز تگ‌های خطی و بلوکی و حذف محتوای خام

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

در سیاست ما p و div و چند تگ بلوکی دیگر در ابتدا و انتها فاصله اضافه می‌کنند. br نیز مرز فاصله می‌سازد. اما span و em و strong و a چنین فاصله‌ای ندارند. اگر «داده‌» بیرون strong و «کاوی» داخل آن باشد، نتیجه باید «داده‌کاوی» بماند؛ فاصله‌گذاری یکسان دور همهٔ تگ‌ها، این واژه را تغییر می‌دهد.

بدنهٔ script و style را کنار می‌گذاریم و در دو سوی ظرف خام مرز می‌سازیم تا واژه‌های قبل و بعد به هم نچسبند. استاندارد زندهٔ HTML این دو عنصر را از نوع raw text و br را void معرفی می‌کند. اما فهرست بلوک‌ها و فاصله‌گذاری این برنامه سیاست نمایه‌سازی خودمان است، نه بازسازی CSS یا متنِ قابل دیدن در مرورگر.

یک پشته، تطابق تگ‌های شروع و پایانِ مشاهده‌شده را بررسی می‌کند؛ عمق بیشتر از شانزده و بیش از ۱۲۸ تگ آغاز رد می‌شوند. تگ خطیِ خودبسته مثل span/ را نمی‌پذیریم؛ فقط br می‌تواند این شکل را داشته باشد. این قواعد، تشخیص خطاهای مشخص قراردادند و ادعای پوشش تمام قواعد پیچیدهٔ ترمیم HTML ندارند.

۳. entity و خوراک قطعه‌ای را بدون شکستن واژه مدیریت کنیم

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

در مستندات HTMLParser، feed برای ورودی تدریجی و close برای پایان ورودی توضیح داده شده است. callback متن ممکن است برای یک رشتهٔ پیوسته چند بار فراخوانی شود. بنابراین روی هر تکه strip اجرا نمی‌کنیم؛ حذف فاصلهٔ انتهای «one » پیش از رسیدن «two»، دو واژه را به onetwo تبدیل می‌کند.

تبدیل خودکار ارجاع نویسه را خاموش کرده‌ایم و فقط شش نام مشخص، شامل amp و nbsp، را می‌پذیریم. ارجاع عددی ده‌دهی یا شانزدهی باید یک مقدار اسکالر مجاز یونیکد باشد؛ صفر، ناحیهٔ surrogate و مقدار بالاتر از سقف یونیکد رد می‌شوند. این جدول کوچک، پیاده‌سازی تمام نام‌ها و قواعد بازیابی خطای HTML5 نیست.

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

۴. کد کامل را با متن مرجع و مرزهای ورودی مقایسه کنیم

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

فایل html_index_text.py با Python 3.14.5 و فقط کتابخانهٔ استاندارد اجرا شده است. قطعهٔ تألیفی ۱۹۰ نقطهٔ کد دارد؛ متن ولتاژ و جریان صرفاً محتوای نمونه است و نتیجهٔ اندازه‌گیری یا آزمون مدار نیست. پاسخ دستیِ خروجی «داده‌کاوی & حسگر ولتاژ 3.3 V جریان 12 mA. پایان» است.

"""Original bounded fragment-to-index-text example; no fetch or browser execution."""
from html.parser import HTMLParser
import json
import platform
import re

BLOCK = frozenset({'p', 'div', 'h1', 'h2', 'ul', 'li'})
INLINE = frozenset({'a', 'span', 'em', 'strong'})
RAW = frozenset({'script', 'style'})
ENTITIES = {'amp': '&', 'lt': '<', 'gt': '>', 'quot': '"', 'apos': "'", 'nbsp': '\u00a0'}


class FragmentText(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=False)
        self.stack = []
        self.pieces = []
        self.start_tags = 0

    def handle_starttag(self, tag, attrs):
        if tag not in BLOCK | INLINE | RAW | {'br'}:
            raise ValueError('tag outside the fragment contract')
        self.start_tags += 1
        if self.start_tags > 128:
            raise ValueError('too many start tags')
        if tag == 'br':
            self.pieces.append(' ')
            return
        if len(self.stack) >= 16:
            raise ValueError('nesting limit')
        if tag in BLOCK | RAW:
            self.pieces.append(' ')
        self.stack.append(tag)

    def handle_startendtag(self, tag, attrs):
        if tag != 'br':
            raise ValueError('only br may use empty-tag syntax')
        self.handle_starttag(tag, attrs)

    def handle_endtag(self, tag):
        if not self.stack or self.stack[-1] != tag:
            raise ValueError('unmatched or misnested end tag')
        self.stack.pop()
        if tag in BLOCK | RAW:
            self.pieces.append(' ')

    def handle_data(self, data):
        if self.stack and self.stack[-1] in RAW:
            return
        # Own deliberately narrow fragment policy, not an HTML conformance check.
        if any(c in data for c in '<>&'):
            raise ValueError('unescaped or incomplete text syntax')
        self.pieces.append(data)

    def handle_entityref(self, name):
        if name not in ENTITIES:
            raise ValueError('unsupported named entity')
        self.pieces.append(ENTITIES[name])

    def handle_charref(self, name):
        if re.fullmatch(r'[0-9]+', name):
            value = int(name, 10)
        elif re.fullmatch(r'[xX][0-9a-fA-F]+', name):
            value = int(name[1:], 16)
        else:
            raise ValueError('invalid numeric reference')
        if not 1 <= value <= 0x10FFFF or 0xD800 <= value <= 0xDFFF:
            raise ValueError('not an allowed Unicode scalar')
        self.pieces.append(chr(value))

    def handle_comment(self, data):
        pass

    def handle_decl(self, decl):
        raise ValueError('document declarations are outside fragment contract')

    def unknown_decl(self, data):
        raise ValueError('unknown declaration')

    def handle_pi(self, data):
        raise ValueError('processing instruction')


def index_text(chunks):
    if type(chunks) is not list or len(chunks) > 1024:
        raise ValueError('expected at most 1024 string chunks')
    if any(type(chunk) is not str for chunk in chunks):
        raise ValueError('chunk must be actual str')
    if sum(map(len, chunks)) > 8192:
        raise ValueError('input codepoint limit')
    parser = FragmentText()
    for chunk in chunks:
        parser.feed(chunk)
    parser.close()
    if parser.stack:
        raise ValueError('unclosed tags')
    result = re.sub(r'\s+', ' ', ''.join(parser.pieces)).strip()
    if len(result) > 4096:
        raise ValueError('output codepoint limit')
    return result


def rejects(chunks):
    try:
        index_text(chunks)
    except (ValueError, AssertionError):
        return True
    return False


def run_example():
    fixture = ('<h2>داده‌<strong>کاوی</strong> &amp; حسگر</h2>'
               '<p>ولتاژ <em>3.3</em> V<br>جریان&nbsp;12 mA.</p>'
               '<script>if (x < 2) { send("not indexed"); }</script>'
               '<style>p { color: red; }</style><p>پایان</p>')
    expected = 'داده‌کاوی & حسگر ولتاژ 3.3 V جریان 12 mA. پایان'
    matrix = [('<p>A</p><p>B</p>', 'A B'), ('A<em>B</em>C', 'ABC'),
              ('<div>A<span>B</span></div><div>C</div>', 'AB C'),
              ('A<!-- ignored -->B', 'AB'), ('A<script>x & y</script>B', 'A B'),
              ('<p>&lt;x&gt; &#65; &#x42;</p>', '<x> A B')]
    cuts = [index_text([fixture[:i], fixture[i:]]) == expected for i in range(len(fixture) + 1)]
    checks = {
        'full_fragment_hand_answer': index_text([fixture]) == expected,
        'inline_boundaries_do_not_add_spaces': index_text(['داده‌<strong>کاوی</strong>']) == 'داده‌کاوی',
        'blocks_and_breaks_add_boundaries': index_text(['<p>A</p><p>B<br/>C</p>']) == 'A B C',
        'raw_script_style_are_excluded': index_text(['A<script><bad>&foo;</script><style>x & y</style>B']) == 'A B',
        'comments_and_attributes_are_not_output': index_text(['<a title="not text">A<!--x-->B</a>']) == 'AB',
        'limited_named_entities': index_text(['&amp;&lt;&gt;&quot;&apos;&nbsp;X']) == '&<>"\' X' and rejects(['&copy;']),
        'numeric_scalar_references': index_text(['&#65;&#x42;&#x1F642;']) == 'AB🙂' and all(rejects([x]) for x in ['&#0;', '&#xD800;', '&#1114112;']),
        'all_two_chunk_cuts_match_hand_answer': all(cuts),
        'single_codepoint_feed_preserves_result': index_text(list(fixture)) == expected,
        'data_callbacks_are_not_individually_stripped': index_text(['<p>one ', 'two', ' three</p>']) == 'one two three',
        'unsupported_tags_and_empty_syntax_rejected': all(rejects([x]) for x in ['<img>', '<span/>', '<p/>']),
        'misnested_and_unclosed_tags_rejected': all(rejects([x]) for x in ['<p><em>A</p></em>', '<p>A', '</p>']),
        'depth_limit_exact_boundary': index_text(['<span>' * 16 + 'A' + '</span>' * 16]) == 'A' and rejects(['<span>' * 17 + 'A' + '</span>' * 17]),
        'start_tag_limit_exact_boundary': index_text(['<br>' * 128]) == '' and rejects(['<br>' * 129]),
        'input_and_output_codepoint_limits': index_text([' ' * 8192]) == '' and rejects([' ' * 8193]) and index_text(['A' * 4096]) == 'A' * 4096 and rejects(['A' * 4097]),
        'chunk_container_and_type_limits': index_text([''] * 1024) == '' and all(rejects(x) for x in [[''] * 1025, ('A',), [b'A'], [None]]),
        'declarations_pi_and_raw_delimiters_rejected': all(rejects([x]) for x in ['<!DOCTYPE html>', '<?x?>', 'A & B', 'A < B', 'A > B']),
        'independent_literal_matrix': all(index_text([raw]) == text for raw, text in matrix),
    }
    assert len(checks) == 18 and all(type(v) is bool and v for v in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
            'example_kind': 'Original offline synthetic bounded HTML fragment; not browser rendering or sanitizer',
            'hand_expected_text': expected, 'actual_text': index_text([fixture]),
            'fixture_codepoints': len(fixture), 'two_chunk_cut_cases_inside_one_group': len(cuts),
            'independent_literal_matrix_cases_inside_one_group': len(matrix),
            'observed_recovery_limit_unfinished_start_tag': index_text(['<p']), 'checks': checks}


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

همهٔ ۱۹۱ محل برشِ دو‌قطعه‌ای را با همین رشتهٔ دستی مقایسه می‌کنیم؛ حتی برش درون نام تگ یا entity نباید پاسخ نمونه را عوض کند. خوراک تک‌نقطه‌ای هم جدا بررسی می‌شود. شش قطعهٔ کوتاه با جواب‌های نوشته‌شدهٔ مستقل داریم تا رفتار بلوک، تگ خطی، توضیح، محتوای خام و ارجاع نویسه در یک پاسخ بزرگ پنهان نماند.

هجده گروه بررسی واقعی ثبت شده‌اند؛ ۱۹۱ برش، زیرحالت‌های یک گروه‌اند و تولید مقاله یا آزمون مستقل تازه شمرده نمی‌شوند. حد دقیق عمق، تعداد تگ، طول ورودی و خروجی و نوع ظرف نیز بررسی می‌شود. کران خروجی ۴۰۹۶ نقطهٔ کد است؛ اگر بیشتر شود، به‌جای بریدن بی‌خبرِ یک واژه، خطا برمی‌گردد.

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

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

خود HTMLParser تطابق همهٔ تگ‌ها را بررسی نمی‌کند و ورودی معیوب را نیز می‌خواند. حتی با کنترل‌های ما، در اجرای ثبت‌شده، شروع ناتمامِ «<p» خروجی تهی می‌دهد؛ پس این تابع اعتبارسنج کامل نحو نیست. خروجی نیز HTML امن‌شده نیست و ممکن است علامت زاویهٔ حاصل از entity داشته باشد؛ برای نمایش دوباره باید جداگانه escape شود.

{
  "python": "3.14.5",
  "dependencies": "Python standard library only",
  "example_kind": "Original offline synthetic bounded HTML fragment; not browser rendering or sanitizer",
  "hand_expected_text": "داده‌کاوی & حسگر ولتاژ 3.3 V جریان 12 mA. پایان",
  "actual_text": "داده‌کاوی & حسگر ولتاژ 3.3 V جریان 12 mA. پایان",
  "fixture_codepoints": 190,
  "two_chunk_cut_cases_inside_one_group": 191,
  "independent_literal_matrix_cases_inside_one_group": 6,
  "observed_recovery_limit_unfinished_start_tag": "",
  "checks": {
    "full_fragment_hand_answer": true,
    "inline_boundaries_do_not_add_spaces": true,
    "blocks_and_breaks_add_boundaries": true,
    "raw_script_style_are_excluded": true,
    "comments_and_attributes_are_not_output": true,
    "limited_named_entities": true,
    "numeric_scalar_references": true,
    "all_two_chunk_cuts_match_hand_answer": true,
    "single_codepoint_feed_preserves_result": true,
    "data_callbacks_are_not_individually_stripped": true,
    "unsupported_tags_and_empty_syntax_rejected": true,
    "misnested_and_unclosed_tags_rejected": true,
    "depth_limit_exact_boundary": true,
    "start_tag_limit_exact_boundary": true,
    "input_and_output_codepoint_limits": true,
    "chunk_container_and_type_limits": true,
    "declarations_pi_and_raw_delimiters_rejected": true,
    "independent_literal_matrix": true
  }
}

متن نهایی می‌تواند به مرحله‌ای مانند نمایهٔ معکوس و جست‌وجوی بولی داده شود، ولی افزایش کیفیت بازیابی از این مثال ثابت نمی‌شود. محدودیت اندازه هم تضمین امنیت یا مقاومت عمومی در برابر ورودی مخرب نیست. منابع در ۲۵ مهر ۱۴۰۵ خوانده شدند؛ نسخهٔ مستندات 3.14.8 و به‌روزرسانی استاندارد از اجرای محلی 3.14.5 جدا ثبت شده‌اند.

پرسش اول: دور هر تگ فاصله بگذاریم؟ پاسخ: خیر؛ در نمونه، strong میان دو قسمت «داده‌کاوی» مرز واژه نیست. فقط تگ‌های تعیین‌شده مرز می‌سازند.

پرسش دوم: nbsp همان شکل صفحه را حفظ می‌کند؟ پاسخ: خیر؛ در قرارداد خروجی ما به فاصلهٔ معمولی تبدیل می‌شود و مفهوم نشکن بودن را نگه نمی‌دارد.

پرسش سوم: ۱۹۱ برش، پوشش تمام HTML است؟ پاسخ: خیر؛ فقط پایداری همان fixture را نسبت به برش می‌سنجد. جدول دستی و آزمون خطاها مکمل‌اند، نه اثبات عمومی.

پرسش چهارم: با خروجی تهیِ شروع ناتمام، سند را معتبر بدانیم؟ پاسخ: خیر؛ ترمیم parser می‌تواند بخشی را کنار بگذارد. برای منبع نامطمئن، قرارداد دریافت و اعتبارسنجی مستقل لازم است؛ این درس ابزار پاک‌سازی یا مرورگر نیست.

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

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