From 06c7822f791b9abf22bba607c9094ab9b713b394 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Sat, 10 Oct 2026 15:34:44 +0200 Subject: [PATCH] Skip listing containers whose entries hold user cards or quotes A thread without DiscussionForumPosting markup, where every poster writes once, still passed the teaser test: each post opens with a linked name that no other post repeats. On katzen-forum.net with the JSON-LD removed and one post per poster, recall took the whole container (3,573 chars, seven user cards instead of one). Teasers hold neither a description list nor a quote, while forum posts keep the poster's user card in one and quote each other, so an entry holding either now rules the container out. --- tests/unit_tests.py | 29 +++++++++++++++++++---------- trafilatura/main_extractor.py | 5 ++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/tests/unit_tests.py b/tests/unit_tests.py index 8ebabe1..4aae421 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -2963,13 +2963,18 @@ def test_listing_page_with_links_keeps_every_entry_heading(): assert text in result -def _forum_thread(posters, linked=True, forum_markup=False): +def _forum_thread(posters, linked=True, forum_markup=False, card_in_dl=False, quoting=False): "Thread markup: sibling
posts, each opening with a user card that names its poster in a heading." name_markup = '{0}' if linked else "{0}" + card = ( + "
Joined
March 2015
Messages
1,447
" + if card_in_dl + else '
Joined March 2015, 1,447 messages
' + ) posts = "".join( - f'

{name_markup.format(name)}

' - "
Joined
March 2015
Messages
1,447
" - f"

Reply {i} from {name} carries enough prose to count as a post, running over a couple " + f'

{name_markup.format(name)}

{card}
' + + (f"

{posters[i - 1]} wrote: see my reply above.

" if quoting and i else "") + + f"

Reply {i} from {name} carries enough prose to count as a post, running over a couple " "of clauses so the thread reads as a real discussion between people.

" for i, name in enumerate(posters) ) @@ -2986,20 +2991,24 @@ def _forum_thread(posters, linked=True, forum_markup=False): @pytest.mark.parametrize( - "posters,linked,forum_markup", + "posters,markup", [ # the same poster comes back along the thread - (["alice", "bob", "alice", "carol", "bob", "alice"], True, False), + (["alice", "bob", "alice", "carol", "bob", "alice"], {}), # poster names without a link - (["alice", "bob", "carol", "dave", "erin", "frank"], False, False), + (["alice", "bob", "carol", "dave", "erin", "frank"], {"linked": False}), # every poster different, but the page declares itself a forum thread - (["alice", "bob", "carol", "dave", "erin", "frank"], True, True), + (["alice", "bob", "carol", "dave", "erin", "frank"], {"forum_markup": True}), + # every poster different and no forum markup, but the user card is a description list + (["alice", "bob", "carol", "dave", "erin", "frank"], {"card_in_dl": True}), + # every poster different and no forum markup, but the replies quote the post before them + (["alice", "bob", "carol", "dave", "erin", "frank"], {"quoting": True}), ], ) @pytest.mark.parametrize("settings", [{"favor_recall": True}, {"favor_recall": True, "fast": True}]) -def test_forum_thread_is_not_a_listing(posters, linked, forum_markup, settings): +def test_forum_thread_is_not_a_listing(posters, markup, settings): "forum posts line up like listing entries, but taking their container brings every user card along" - doc = _forum_thread(posters, linked, forum_markup) + doc = _forum_thread(posters, **markup) with _without_listing_expression(): expected = extract(doc, output_format="markdown", config=use_config(), **settings) result = extract(doc, output_format="markdown", config=use_config(), **settings) or "" diff --git a/trafilatura/main_extractor.py b/trafilatura/main_extractor.py index 9f230ac..3cc4498 100644 --- a/trafilatura/main_extractor.py +++ b/trafilatura/main_extractor.py @@ -665,9 +665,12 @@ def _holds_most_of_the_text(tree: HtmlElement, expr: XPath) -> bool: def _entries_link_out(container: _Element) -> bool: """Whether every entry of the container is a teaser: its first heading links to a page of its own, which no other entry links to. Forum posts share the shape but open with the poster's - name, which comes back along the thread or carries no link at all.""" + name, which comes back along the thread or carries no link at all, and they hold user cards + (description lists) and quotes, which teasers do not.""" targets: set[str] = set() for entry in container.iterchildren("article"): + if entry.find(".//list[@rend='dl']") is not None or entry.find(".//quote") is not None: + return False heading = entry.find(".//head") link = heading.find(".//ref") if heading is not None else None if link is None: