Capturing a page: without JSON-LD the whole HTML is parsed seven or eight times, and once more for its places #591
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#591
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 built-in sources and timingregistry.parse_pageon a generated 1.65 MB page.Checked again on 2026-10-01 against
mainat0a5cad587: still present.plugins/builtin/has not changed; the API's_readnow reads the page atapi/api.py:618.Every helper the built-in sources use takes the page as a string and builds its own tree with
parse_html, a pure-PythonHTMLParserwalk. A page that carries JSON-LD is parsed once, but a page without it, which is every LinkedIn page and every page the fallback reads, is parsed seven times byparse_page, eight on a board with a recipe, and once more when its places are listed. On a large page each parse costs over half a second of CPU inside a synchronous gunicorn worker, and the browser extension sends the same results page forty times in one gesture.plugins/builtin/__init__.py:355-366SchemaOrgSource.parse:extract_jsonld(html), thenextract_microdata(html)andextract_rdfa(html), each a parse.plugins/builtin/__init__.py:529-569PageMetadataSource._read:extract_meta(html),heading_title(html),main_text(html)andpage_language(html), each a parse.plugins/builtin/__init__.py:410-433BoardSource.parse: its ownparse_html(html), then_fill_from(SchemaOrgSource, …)and_fill_from(PageMetadataSource, …)with the string, which repeat all of the above.plugins/builtin/hints.py:432fillandhints.py:549places: one parse each.plugins/builtin/htmlutil.py:333main_text,:371heading_title,:423extract_jsonld,:510extract_microdata,:534extract_rdfa,:552extract_meta,:571page_language: where each parse happens.language_of(root)at:574already takes a tree.api/api.py:616(_read, synchronous inPOST /capturesand/captures/preview),jobs/slow.py:64, andjobs/remembered.py:276,:311,:330(registry.page_places).parse_htmltook 0.61 s.registry.parse_pagetook 5.58 s with 7 parses when the page had no JSON-LD (schema.org's three, then page-metadata's four), 6.48 s with 8 parses on alinkedin.comaddress (the board's own parse, then the same seven), and 0.62 s with 2 parses when it had JSON-LD.registry.page_placesadded 1.21 s with one more. Each remembered place handed to a source adds one more throughhints.fill. The extension's batch capture (#177) stores the results page once and sends it whole with each posting (postulo-chromium/src/lib/store.js:76-101,src/lib/outbox.js:55-60), so forty postings from a LinkedIn results page of that size cost about 40 × 7.7 s, five minutes of CPU, of which seven parses in eight are repeats.POST /capturesreads the page inside the request, and the image runs three synchronous gunicorn workers (docker/Dockerfile:362). The outbox sends one capture at a time, so one worker does nothing else for minutes, and a person with two browsers (or a preview and a send) takes two of three. The same repetition makes every fallback capture several times slower than it needs to be.extract_jsonld,extract_microdata,extract_rdfa,extract_meta,heading_titleandmain_textvariants that take anElement, aslanguage_ofdoes, and keep the string versions as thin wrappers for the tests that call them (tests/test_capture.py:16). HaveSchemaOrgSource.parse,PageMetadataSource.parseandBoardSource.parsebuild the tree once and hand it on (for instance through a private_parse(url, root, html, hints)), and lethints.fillandhints.placesaccept the tree. The board's_fill_fromshould call that tree-taking method rather thanparse(url, html). None of the helpers mutates the tree, which is what makes sharing it safe; a test should keep it so.Done when
tests/test_capture.pycounts calls toparse_htmland asserts thatregistry.parse_pageon a page without JSON-LD, and on a board address, parses the page once, and thathints.fillwith places handed to it adds none.Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#591).