Readability extraction no longer takes minutes on image-heavy pages (#838)

* Readability extraction no longer takes minutes on image-heavy pages

SingleFile inlines every image as a base64 data URI, and readability-extractor
(jsdom) slows to minutes on a page carrying megabytes of them, so these pages
hit the readability timeout and were saved without readability text.

Replace each base64 payload with a short placeholder before running
readability-extractor, then restore the payloads in its output.  Applied in
both the archive container and the native (Raspberry Pi/Debian) path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Data URI placeholders cannot be forged by page text

The placeholder was a fixed string, so a page containing placeholder-like
text could index past the payloads (IndexError, failing the archive on the
native path) or repeat a placeholder to copy a large image into the output
many times.  Each extraction now uses a random token, and text that is not a
known placeholder is left as-is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Data URI placeholders survive Readability's lazy-image cleanup

Readability's _fixLazyImages removes an <img> src whose base64 payload is
shorter than 133 characters when another attribute names an image file
(alt="cat.png", title, a leftover data-src), treating it as a lazy-load
placeholder.  The ~50 character placeholder triggered this, so those images
lost their inline data.  The token is now 160 hex characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-15 18:50:16 -06:00 • committed by Roland
parent 06e35e1095
commit 1f07b5f944
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 148 additions and 4 deletions

View file

@ -11,6 +11,8 @@ import logging
import os
import os.path
import pathlib
import re
import secrets
import signal
import subprocess
import sys
@ -187,6 +189,37 @@ async def call_single_file(url, compress: bool = False) -> (bytes, bytes):
return singlefile, page_html
# The prefix and payload of a base64 data URI. Keep in sync with modules/archive/lib.py.
BASE64_DATA_URI = re.compile(rb'(data:[^,;\s"\'()]*(?:;[^,;\s"\'()]*)*;base64,)([A-Za-z0-9+/]+=*)')
DATA_URI_PLACEHOLDER = re.compile(r'WROLPIDATAURI[0-9a-f]{160}N\d+X')
def strip_data_uris(html: bytes) -> (bytes, dict):
"""Replace each base64 data URI payload with a placeholder; readability-extractor takes minutes on a
page of large inline images (SingleFile inlines every image). Returns the HTML and the payloads by
placeholder. The placeholder holds a random token, so text in the page cannot be mistaken for one."""
# At least 133 characters: Readability drops a shorter base64 `src` as a lazy-load placeholder when another
# attribute names an image file (e.g. alt="cat.png").
token = secrets.token_hex(80)
payloads = dict()
def replace(match):
placeholder = f'WROLPIDATAURI{token}N{len(payloads)}X'
payloads[placeholder] = match.group(2)
return match.group(1) + placeholder.encode()
return BASE64_DATA_URI.sub(replace, html), payloads
def restore_data_uris(readability: dict, payloads: dict) -> dict:
"""Put the payloads removed by `strip_data_uris` back into the readability output."""
for key, value in readability.items():
if isinstance(value, str):
readability[key] = DATA_URI_PLACEHOLDER.sub(
lambda m: payloads[m.group(0)].decode() if m.group(0) in payloads else m.group(0), value)
return readability
async def extract_readability(path: str, url: str) -> dict:
"""
Call the CLI command for readability-extractor.
@ -194,6 +227,9 @@ async def extract_readability(path: str, url: str) -> dict:
See https://github.com/ArchiveBox/readability-extractor
"""
logger.info(f'readability for {url}')
html, data_uri_payloads = strip_data_uris(pathlib.Path(path).read_bytes())
path = pathlib.Path(path).with_name('readability-input.html')
path.write_bytes(html)
cmd = f'readability-extractor {path} "{url}"'
logger.debug(f'readability cmd: {cmd}')
stdout, stderr, return_code = await check_output(cmd, timeout=3 * 60)
@ -203,7 +239,7 @@ async def extract_readability(path: str, url: str) -> dict:
stderr = stderr.decode() if stderr else None
raise RuntimeError(f'Failed to extract readability. {stderr}') from e
logger.debug(f'done readability for {url}')
return output
return restore_data_uris(output, data_uri_payloads)
async def take_screenshot(url: str) -> bytes:

View file

@ -5,13 +5,14 @@ import json
import os
import pathlib
import re
import secrets
import shlex
import tempfile
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from json import JSONDecodeError
from typing import Optional, Tuple, List, Union
from typing import Optional, Tuple, List, Union, Dict
from urllib.parse import urlparse
import pytz
@ -1558,10 +1559,42 @@ async def search_domains_by_name(session: Session, name: str, limit: int = 5) ->
]
# The prefix and payload of a base64 data URI. Keep in sync with docker/archive/main.py.
BASE64_DATA_URI = re.compile(rb'(data:[^,;\s"\'()]*(?:;[^,;\s"\'()]*)*;base64,)([A-Za-z0-9+/]+=*)')
DATA_URI_PLACEHOLDER = re.compile(r'WROLPIDATAURI[0-9a-f]{160}N\d+X')
def strip_data_uris(html: bytes) -> Tuple[bytes, Dict[str, bytes]]:
"""Replace each base64 data URI payload with a placeholder; readability-extractor takes minutes on a
page of large inline images (SingleFile inlines every image). Returns the HTML and the payloads by
placeholder. The placeholder holds a random token, so text in the page cannot be mistaken for one."""
# At least 133 characters: Readability drops a shorter base64 `src` as a lazy-load placeholder when another
# attribute names an image file (e.g. alt="cat.png").
token = secrets.token_hex(80)
payloads = dict()
def replace(match):
placeholder = f'WROLPIDATAURI{token}N{len(payloads)}X'
payloads[placeholder] = match.group(2)
return match.group(1) + placeholder.encode()
return BASE64_DATA_URI.sub(replace, html), payloads
def restore_data_uris(readability: dict, payloads: Dict[str, bytes]) -> dict:
"""Put the payloads removed by `strip_data_uris` back into the readability output."""
for key, value in readability.items():
if isinstance(value, str):
readability[key] = DATA_URI_PLACEHOLDER.sub(
lambda m: payloads[m.group(0)].decode() if m.group(0) in payloads else m.group(0), value)
return readability
async def html_to_readability(html: str | bytes, url: str, timeout: int = 120):
"""Extract the readability dict from the provided HTML."""
html, data_uri_payloads = strip_data_uris(html.encode() if isinstance(html, str) else html)
with tempfile.NamedTemporaryFile('wb', suffix='.html') as singlefile_file:
singlefile_file.write(html.encode() if isinstance(html, str) else html)
singlefile_file.write(html)
singlefile_file.flush()
os.fsync(singlefile_file.fileno())
@ -1585,7 +1618,7 @@ async def html_to_readability(html: str | bytes, url: str, timeout: int = 120):
e = ChildProcessError(stderr)
raise RuntimeError(f'Failed to extract readability from {url}') from e
logger.debug(f'done readability for {url}')
return readability
return restore_data_uris(readability, data_uri_payloads)
else:
logger.error(f'Failed to extract readability for {url}')
e = ChildProcessError(stdout if stdout else 'No stdout')

View file

@ -1,5 +1,6 @@
import json
import pathlib
import re
import tempfile
from datetime import datetime, timedelta
from http import HTTPStatus
@ -19,6 +20,7 @@ from modules.archive.lib import get_or_create_domain_collection, get_new_archive
delete_archives_by_file_group_ids, model_archive_result, get_domains
from modules.archive.models import Archive
from wrolpi.api_utils import CustomJSONEncoder
from wrolpi.cmd import CommandResult
from wrolpi.collections import Collection
from wrolpi.common import get_wrolpi_config
from wrolpi.db import get_db_session
@ -1452,3 +1454,76 @@ def test_search_archives_count_is_driven_by_the_search_index(test_directory, tes
(statement, params), = counts
plan = [row[3] for row in test_session.execute(text(f'EXPLAIN QUERY PLAN {statement}'), params).fetchall()]
assert plan[0].startswith('SCAN fts VIRTUAL TABLE'), plan
@pytest.mark.asyncio
async def test_html_to_readability_data_uris(test_directory):
"""readability-extractor never receives inline base64 data, which makes it take minutes on a page of
inline images; the data is restored in the readability output."""
png = 'iVBORw0KGgo' + 'A' * 5000 + '=='
webp = 'UklGRiQAAABXRUJQ' + 'B' * 5000
html = f'<html><head><style>.x {{ background: url(data:image/png;base64,{png}) }}</style></head>' \
f'<body><article><p>Some text</p>' \
f'<img src="data:image/png;base64,{png}">' \
f'<img src=\'data:image/webp;base64,{webp}\'>' \
f'<img src="data:image/svg+xml,%3Csvg%3E%3C/svg%3E">' \
f'</article></body></html>'
received = list()
async def fake_run_command(cmd, *args, **kwargs):
# Echo the images back like readability-extractor does in `content`.
page = pathlib.Path(cmd[3]).read_text()
received.append(page)
content = ''.join(re.findall(r'<img[^>]*>', page))
stdout = json.dumps({'title': 'Title', 'content': content, 'textContent': 'Some text'}).encode()
return CommandResult(return_code=0, cancelled=False, stdout=stdout, stderr=b'', elapsed=0)
with mock.patch('modules.archive.lib.run_command', fake_run_command):
readability = await lib.html_to_readability(html, 'https://example.com')
# readability-extractor got the page without the base64 payloads.
assert png not in received[0] and webp not in received[0]
assert 'Some text' in received[0]
assert 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' in received[0], 'non-base64 data URI should be untouched'
# The images were restored in the output.
assert readability['content'] == f'<img src="data:image/png;base64,{png}">' \
f'<img src=\'data:image/webp;base64,{webp}\'>' \
f'<img src="data:image/svg+xml,%3Csvg%3E%3C/svg%3E">'
assert readability['textContent'] == 'Some text'
assert readability['title'] == 'Title'
@pytest.mark.asyncio
async def test_html_to_readability_forged_placeholder():
"""Placeholder-like text already in a page is not treated as a placeholder; it cannot index past the
payloads or duplicate an image into the output."""
png = 'iVBORw0KGgo' + 'A' * 5000
html = f'<p>WROLPIDATAURI0X WROLPIDATAURI99X</p><img src="data:image/png;base64,{png}">'
async def fake_run_command(cmd, *args, **kwargs):
page = pathlib.Path(cmd[3]).read_text()
stdout = json.dumps({'content': page, 'textContent': 'WROLPIDATAURI0X WROLPIDATAURI99X'}).encode()
return CommandResult(return_code=0, cancelled=False, stdout=stdout, stderr=b'', elapsed=0)
with mock.patch('modules.archive.lib.run_command', fake_run_command):
readability = await lib.html_to_readability(html, 'https://example.com')
assert readability['content'] == html
assert readability['content'].count(png) == 1
assert readability['textContent'] == 'WROLPIDATAURI0X WROLPIDATAURI99X'
def test_strip_data_uris_placeholder_length():
"""Readability's _fixLazyImages removes an <img> `src` whose base64 payload is shorter than 133 characters
when another attribute names an image file (e.g. alt="cat.png"), so a placeholder must be at least that
long or the image is lost before it can be restored."""
png = 'iVBORw0KGgo' + 'A' * 4000
html, payloads = lib.strip_data_uris(f'<img src="data:image/png;base64,{png}" alt="cat.png">'.encode())
(placeholder,) = payloads
assert len(placeholder) >= 133
assert html == f'<img src="data:image/png;base64,{placeholder}" alt="cat.png">'.encode()
assert lib.restore_data_uris({'content': html.decode()}, payloads)['content'] \
== f'<img src="data:image/png;base64,{png}" alt="cat.png">'