classify_links не падает на битом HTML и адресах (I-1, #1)

На Python 3.9 `<![foo]>` вызывал NotImplementedError из ParserBase.error(), а адрес вида `http://[::1` — ValueError в
urljoin/urlsplit; одна такая конструкция роняла wiki_sync целиком. Теперь нестандартная секция пропускается до `>`,
битая ссылка — пропускается, остальные ссылки страницы сохраняются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 05:46:28 +04:00
parent acdd52742e
commit a51c509a87
2 changed files with 50 additions and 4 deletions

View File

@ -331,14 +331,31 @@ class EvaClient(Http):
return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit))
class _ParseError(Exception):
"""Разбор HTML споткнулся (ParserBase.error в 3.9): см. _LinkParser.error."""
class _LinkParser(HTMLParser):
"""Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки)."""
"""Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки).
На 3.9 `_markupbase` зовёт error() на `<![foo]>` и подобном, а базовый error() бросает NotImplementedError (I-1).
error() бросает _ParseError, а parse_marked_section пропускает такую конструкцию до `>`, как мусорный комментарий."""
def __init__(self):
HTMLParser.__init__(self, convert_charrefs=True)
self.items = [] # [tag, ref, hint, text]
self._open = None # запись открытой <a>, в неё копится текст
def error(self, message):
raise _ParseError(message)
def parse_marked_section(self, i, report=1):
try:
return HTMLParser.parse_marked_section(self, i, report)
except _ParseError:
j = self.rawdata.find(">", i + 3)
return -1 if j < 0 else j + 1 # -1: ждать данных; при close() goahead сам пропустит до `>`
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "img":
@ -374,12 +391,18 @@ def classify_links(html: str, base_url: str) -> Tuple[list, list]:
ref — значение атрибута после unescape (design 12). Чужой хост и static/ не передаются."""
parser = _LinkParser()
parser.feed(html or "")
parser.close()
try:
parser.feed(html or "")
parser.close()
except (_ParseError, NotImplementedError, AssertionError) as e: # запасной путь: собранное до сбоя сохраняется
log(" разбор HTML прерван (%s: %s), ссылок до сбоя: %d" % (type(e).__name__, e, len(parser.items)))
base = urllib.parse.urlsplit(base_url)
attachments, links = [], []
for _tag, ref, hint, text in parser.items:
parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip()))
try:
parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip()))
except ValueError: # `http://[::1` — ссылка битая, пропускаем (I-1)
continue
if (parts.scheme, parts.netloc) != (base.scheme, base.netloc):
continue
path = parts.path

View File

@ -78,6 +78,29 @@ class ClassifyLinksTest(unittest.TestCase):
'<a href="%s/a.bin"><img src="%s/b.png"></a><img src="%s/c.png">' % (OBJ, OBJ, OBJ), BASE)
self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"])
def test_unknown_marked_section_does_not_crash(self):
"""I-1: на 3.9 `<![foo]>` уходит в ParserBase.error() → NotImplementedError; ссылки до и после сохраняются."""
html = ('<img src="%s/a.png"><![foo]><p>текст</p><![if !supportLists]>x<![endif]>'
'<a href="/project/Document/DOC-3">c</a><img src="%s/b.png">' % (OBJ, OBJ))
att, links = agent.classify_links(html, BASE)
self.assertEqual([a["name"] for a in att], ["a.png", "b.png"])
self.assertEqual(links, [{"ref": "/project/Document/DOC-3", "code": "DOC-3"}])
def test_marked_section_bad_name_and_unclosed(self):
for html in ('<img src="%s/a.png"><![ ]><img src="%s/b.png">' % (OBJ, OBJ),
'<img src="%s/a.png"><img src="%s/b.png"><![foo' % (OBJ, OBJ)):
with self.subTest(html=html):
att, _ = agent.classify_links(html, BASE)
self.assertEqual([a["name"] for a in att], ["a.png", "b.png"])
def test_bad_url_skipped(self):
"""I-1: `http://[::1` и порт не числом дают ValueError в urljoin/urlsplit — ссылка пропускается, остальные целы."""
html = ('<img src="http://[::1"><a href="https://[zz/files/x">x</a><img src="https://eva.test:abc/files/y.png">'
'<img src="%s/b.png"><a href="/project/Document/DOC-4">d</a>' % OBJ)
att, links = agent.classify_links(html, BASE)
self.assertEqual([a["name"] for a in att], ["b.png"])
self.assertEqual([l["code"] for l in links], ["DOC-4"])
if __name__ == "__main__":
unittest.main()