tooling/search_index.py

Текстовый файл из учебного комплекта, не свидетельство установленного runtime. Вернуться к учебнику · Исходный файл

   1 """Строит локальный индекс: главы и текстовые файлы только из allowlist комплекта.
   2 
   3 Индекс встраивается в HTML. При чтении не нужны сервер, сеть, исходные checkout
   4 или чтение соседних пользовательских файлов. Секреты в комплект не добавляются.
   5 """
   6 from html.parser import HTMLParser
   7 import json
   8 from pathlib import PurePosixPath
   9 import re
  10 
  11 
  12 class ChapterText(HTMLParser):
  13     """Собирает видимый текст, сохраняя ссылки на ближайший заголовок главы."""
  14 
  15     def __init__(self, identifier):
  16         super().__init__(convert_charrefs=True)
  17         self.identifier = identifier
  18         self.blocks = []
  19         self.current = None
  20         self.heading = None
  21         self.skip = 0
  22 
  23     def handle_starttag(self, tag, attrs):
  24         attrs = dict(attrs)
  25         if tag in {'script', 'style', 'button'}:
  26             self.skip += 1
  27         if self.skip:
  28             return
  29         if tag in {'h1', 'h2', 'h3'} and attrs.get('id'):
  30             self.current = {'href': '#' + attrs['id'], 'title': '', 'parts': []}
  31             self.blocks.append(self.current)
  32             self.heading = tag
  33         if self.current and tag in {'p', 'div', 'li', 'tr', 'td', 'th', 'br', 'pre', 'text', 'h4', 'dt', 'dd'}:
  34             self.current['parts'].append('\n')
  35 
  36     def handle_endtag(self, tag):
  37         if tag in {'script', 'style', 'button'}:
  38             self.skip = max(0, self.skip - 1)
  39         if self.skip:
  40             return
  41         if tag == self.heading:
  42             self.heading = None
  43         if self.current and tag in {'p', 'li', 'tr', 'td', 'th', 'text', 'h1', 'h2', 'h3', 'h4'}:
  44             self.current['parts'].append('\n')
  45 
  46     def handle_data(self, data):
  47         if self.skip or self.current is None:
  48             return
  49         self.current['parts'].append(data)
  50         if self.heading:
  51             self.current['title'] += data
  52 
  53     def entries(self, chapter_title):
  54         for block in self.blocks:
  55             text = re.sub(r'\s+', ' ', ''.join(block['parts'])).strip()
  56             yield {'kind': 'chapter', 'title': block['title'].strip(),
  57                    'chapter': chapter_title, 'path': 'index.html',
  58                    'href': block['href'], 'text': text}
  59 
  60 
  61 def text_files(files):
  62     """Выбирает весь UTF-8-текст, кроме дубликатов HTML и служебных seed-копий.
  63 
  64     PNG/WAV/MP3 и другие бинарные данные не являются словарём. Подписи встроенных
  65     схем уже входят в главы. Manifest создаётся после индекса и проверяет его.
  66     """
  67     for name, data in sorted(files.items()):
  68         if name.startswith('tooling/inputs/') or name in {
  69             '.voice-textbook-bundle', 'BUNDLE-MANIFEST.json', 'tooling/chapters.html',
  70             'AUDIO-README.md', 'index.html', 'audio-index.html',
  71         } or PurePosixPath(name).suffix == '.html':
  72             continue
  73         try:
  74             text = data.decode('utf-8')
  75         except UnicodeDecodeError:
  76             continue
  77         if '\0' in text or not text.strip():
  78             continue
  79         yield name, text
  80 
  81 
  82 def build_index(body, files):
  83     # Главы индексируются по подразделам; файл кода - целиком, чтобы поиск мог
  84     # сопоставить имя функции, параметры и комментарии из разных строк файла.
  85     entries = []
  86     chapter_count = 0
  87     for match in re.finditer(r'<section id="([^"]+)" class="chapter"[^>]*>(.*?)</section>', body, re.S):
  88         parser = ChapterText(match[1])
  89         parser.feed(match[2])
  90         chapter_count += 1
  91         chapter_title = parser.blocks[0]['title'].strip() if parser.blocks else match[1]
  92         entries.extend(parser.entries(chapter_title))
  93     file_count = 0
  94     for name, text in text_files(files):
  95         file_count += 1
  96         entries.append({'kind': 'file', 'title': PurePosixPath(name).name,
  97                         'path': name, 'href': name + '.html', 'text': text})
  98     return {'format': 1, 'chapters': chapter_count, 'files': file_count, 'entries': entries}
  99 
 100 
 101 def inline_json(value):
 102     # Даже пример с закрывающим тегом script остаётся данными, не HTML-кодом.
 103     return json.dumps(value, ensure_ascii=False, separators=(',', ':')).replace(
 104         '&', '\\u0026').replace('<', '\\u003c').replace('>', '\\u003e').replace(
 105         '\u2028', '\\u2028').replace('\u2029', '\\u2029')