Reading a page: omitted end tags (li, p, td, dd, option) nest each element in the last, and past ~1000 every source crashes #587
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#587
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading the page tree builder and running the built-in sources over pages that leave out optional end tags.Checked again on 2026-10-01 against
mainat0a5cad587: still present.plugins/builtin/has not changed; the 422 answer is now atapi/api.py:620._TreeBuilderopens every non-void start tag and closes an element only when its own end tag arrives. HTML lets a page leave out</li>,</p>,</dt>,</dd>,</td>,</th>,</tr>and</option>, and a browser closes them for it. Here each such element becomes a child of the one before, so a list of N items written without</li>is a chain N levels deep. Every walk over the tree is recursive, so a deep enough chain raisesRecursionErrorin all three built-in sources and in the remembered-places reader; a shallower one still has the wrong shape, and label places such as<dt>Local<dd>Lisboaare not found.plugins/builtin/htmlutil.py:140-148(_TreeBuilder.handle_starttag:if tag not in VOID_TAGS: self._open.append(element), with no implied end tags) and:158-162(handle_endtag); the recursive walksElement.iter(htmlutil.py:106-111),text_of'swalk(:223-235),_text_length(:282-287),_read_item(:476-493) andhints._elements(plugins/builtin/hints.py:171-180); the sibling tests inhints._next_element(:183-191) andhints._labelled(:231-240).<li>(or<option>, or<p>) items with the end tags left out, which is valid HTML, is captured. With 1,200 items,registry.parse_pagelogsCapture source 'schema.org' failed ... RecursionErrorand the same forpage-metadata, and returnsNone, even when the page's<head>carries a valid JSON-LDJobPosting, becauseextract_jsonldfinds the script throughroot.iter(). The capture answers 422 "Nothing resembling a job posting was found there." (api/api.py:618,jobs/slow.py:66).registry.page_placesalready fails at 990 items, so nothing is learned from the page either.hints.placeson<dl><dt>Local</dt><dd>Lisboa</dd><dt>Vencimento</dt><dd>1000</dd></dl>gives the label placeslocalandvencimento; on the same markup with the end tags left out it gives none, because each<dd>is a child of its<dt>rather than its next sibling. A table written<tr><td>Local<td>Lisboaloses its label places the same way.htmlutil.py:11-14) promises that the browser extension, which reads the same page through a real DOM (postulo-chromiumsrc/lib/parse.js), walks the same tree; for these pages it does not, so a page read in the browser and the same page fetched by the server give different results._TreeBuilderthe implied end tags that matter: a start tag oflicloses an openliup to the nearestul/ol;dt/ddclose an opendt/ddup to the nearestdl; a block start tag (p,div,ul,ol,table, the headings and the other block tags) closes an openp;td/thclose an opentd/th,trcloses an opentr(and its cell), andoption/optgroupclose an openoption. Separately, makeElement.iter,text_of,_text_length,_read_itemandhints._elementsiterative over an explicit stack, so that no page shape can exhaust the recursion limit.Done when
tests/test_source_corpus.py(or a newtests/test_htmlutil.py) parses<ul><li>a<li>b</ul>,<dl><dt>x<dd>y</dl>,<table><tr><td>x<td>y<tr><td>z</table>and<p>a<p>binto the sibling shape a browser builds, and asserts it.tests/test_capture.pyfeedsSchemaOrgSourcea page with a JSON-LD posting and 5,000 unclosed<li>and gets the posting back; the same page throughregistry.page_placesreturns places instead of logging an error.tests/test_remembered_places.pyfinds thelocallabel place in<dl><dt>Local<dd>Lisboa</dl>.