"""Parser tests.""" import time from lxml import html from openbb_news import parser def test_strip_html_empty(): assert parser.strip_html("") == "" def test_strip_html_plain(): assert parser.strip_html("hello world") == "hello world" def test_strip_html_tags_and_whitespace(): assert parser.strip_html("

Hello world\n!

") == "Hello world !" def test_html_to_markdown_empty(): assert parser.html_to_markdown("") == "" def test_html_to_markdown_preserves_anchor_links(): out = parser.html_to_markdown( 'NYT headline ' 'BBC headline' ) assert "[NYT headline](https://nyt.test/x)" in out assert "[BBC headline](https://bbc.test/x)" in out def test_html_to_markdown_strips_non_anchor_tags(): out = parser.html_to_markdown("

Hello bold world

") assert "bold" in out assert "<" not in out def test_html_to_markdown_anchor_without_href(): out = parser.html_to_markdown('just text') assert "just text" in out assert "[" not in out def test_html_to_markdown_anchor_without_text(): out = parser.html_to_markdown('') assert "[" not in out def test_html_to_markdown_handles_paragraphs_and_breaks(): out = parser.html_to_markdown("

Line one

Line two

") assert "Line one" in out assert "Line two" in out assert "Line one" in out.split("\n")[0] def test_html_to_markdown_handles_br(): out = parser.html_to_markdown("First
Second") assert "First" in out assert "Second" in out def test_html_to_markdown_paragraph_after_text(): out = parser.html_to_markdown("intro text

paragraph

") assert "intro text" in out assert "paragraph" in out lines = [line for line in out.split("\n") if line.strip()] assert lines.index("intro text") < lines.index("paragraph") def test_html_to_markdown_lxml_parse_failure_falls_back(monkeypatch): from lxml import html as _html def boom(*_a, **_kw): raise ValueError("synthetic parse failure") monkeypatch.setattr(_html, "fragment_fromstring", boom) out = parser.html_to_markdown('link') assert out == "link" def test_truncate_short(): assert parser.truncate("abc", limit=10) == "abc" def test_truncate_long(): text = "word " * 100 truncated = parser.truncate(text, limit=50) assert len(truncated) <= 60 assert truncated.endswith("…") def test_struct_time_to_iso_none(): iso = parser.struct_time_to_iso(None) assert "T" in iso and iso.endswith("+00:00") def test_struct_time_to_iso_value(): st = time.struct_time((2026, 5, 19, 12, 0, 0, 0, 139, 0)) assert parser.struct_time_to_iso(st) == "2026-05-19T12:00:00+00:00" def test_article_markdown_joins_with_blank_lines(sample_article_html): doc = html.fromstring(sample_article_html) article = doc.xpath("//article")[0] text = parser._article_markdown(article) assert "First paragraph of the article." in text assert text.count("\n\n") == 2 def test_article_markdown_requires_two_paragraphs(): doc = html.fromstring(b"

only one paragraph

") node = doc.xpath("//div")[0] assert parser._article_markdown(node) == "" def test_article_markdown_filters_video_chapter_timestamps(): doc = html.fromstring( b"
" b"

Real opening paragraph with content.

" b"

2:26

" b"

12:44

" b"

0:58

" b"

1:30:00

" b"

Real closing paragraph.

" b"
" ) text = parser._article_markdown(doc.xpath("//div")[0]) assert "2:26" not in text assert "12:44" not in text assert "0:58" not in text assert "1:30:00" not in text assert "Real opening paragraph" in text assert "Real closing paragraph" in text def test_article_markdown_keeps_timestamp_inside_sentence(): doc = html.fromstring( b"
" b"

The video begins at 2:26 with the host introducing.

" b"

By 12:44 the segment closes.

" b"
" ) text = parser._article_markdown(doc.xpath("//div")[0]) assert "2:26" in text assert "12:44" in text def test_article_markdown_ignores_non_paragraph_text(): doc = html.fromstring(b"
plain wrapper text here
") node = doc.xpath("//div")[0] assert parser._article_markdown(node) == "" def test_article_markdown_skips_figure_without_img(): doc = html.fromstring( b"
" b"

One.

" b"
No image here
" b"

Two.

" b"
" ) text = parser._article_markdown(doc.xpath("//article")[0]) assert "![" not in text assert "One." in text assert "Two." in text def test_article_markdown_skips_img_without_src(): doc = html.fromstring(b"

One.

Two.

") text = parser._article_markdown(doc.xpath("//article")[0]) assert "![" not in text def test_article_markdown_figure_without_caption(): doc = html.fromstring( b"
" b"

One.

" b'
' b"

Two.

" b"
" ) text = parser._article_markdown(doc.xpath("//article")[0]) assert "![](https://x.test/p.jpg)" in text def test_strip_chrome_removes_chrome_tags(): doc = html.fromstring( b"" b"
h
" b"
fm
" b"sv" b"

kept one

kept two

" b"" ) parser._strip_chrome(doc) leftover_tags = {el.tag for el in doc.iter() if isinstance(el.tag, str)} for tag in parser._CHROME_TAGS: assert tag not in leftover_tags assert "article" in leftover_tags def test_looks_clean_rejects_templates_and_empty(): assert parser._looks_clean("Some real text.") assert not parser._looks_clean("") assert not parser._looks_clean("[[ type === 'moc' ]] body text") assert not parser._looks_clean("body with {{ binding }} marker") async def test_fetch_feed_parses(stub_session_factory, sample_rss): session = stub_session_factory({"http://feed.test/rss": sample_rss}) parsed = await parser.fetch_feed(session, "http://feed.test/rss") assert len(parsed.entries) == 2 assert parsed.entries[0].title == "First Article" async def test_fetch_feed_client_error(stub_session_factory): import aiohttp session = stub_session_factory( {"http://feed.test/rss": aiohttp.ClientError("boom")} ) parsed = await parser.fetch_feed(session, "http://feed.test/rss") assert len(parsed.entries) == 0 async def test_fetch_feed_http_error(stub_session_factory): session = stub_session_factory({"http://feed.test/rss": (b"", 500)}) parsed = await parser.fetch_feed(session, "http://feed.test/rss") assert len(parsed.entries) == 0 async def test_fetch_feed_pins_headers_for_gated_host(stub_session_factory, sample_rss): url = "https://www.espn.com/espn/rss/nfl/news" session = stub_session_factory({url: sample_rss}) await parser.fetch_feed(session, url) assert session.headers_sent == [parser._HOST_HEADERS["espn.com"]] async def test_fetch_feed_no_headers_for_normal_host(stub_session_factory, sample_rss): url = "https://feeds.bbci.co.uk/news/rss.xml" session = stub_session_factory({url: sample_rss}) await parser.fetch_feed(session, url) assert session.headers_sent == [None] def test_extract_body_jsonld_top_level(sample_jsonld_html): assert parser._extract_body(sample_jsonld_html) == "JSON-LD top-level body." def test_extract_body_jsonld_graph(): payload = ( b'" ) assert parser._extract_body(payload) == "Body from graph." def test_extract_body_jsonld_list(): payload = ( b'" ) assert parser._extract_body(payload) == "Found in list." def test_extract_body_jsonld_invalid_then_article(): payload = ( b"" b'' b"

First sentence.

Second sentence.

" b"" ) assert parser._extract_body(payload) == "First sentence.\n\nSecond sentence." def test_extract_body_article_selector(sample_article_html): body = parser._extract_body(sample_article_html) assert body is not None assert "First paragraph" in body assert body.count("\n\n") == 2 def test_extract_body_picks_largest_article(): payload = ( b"" b"

Related card one.

Related card two.

" b"
" b"

Main story paragraph one with more substance.

" b"

Main story paragraph two with detail.

" b"

Main story paragraph three closing the piece.

" b"
" b"" ) body = parser._extract_body(payload) assert body is not None assert "Main story paragraph one" in body assert "Related card" not in body def test_extract_body_main_fallback_when_no_article(): payload = ( b"" b"" b"
" b"

Story paragraph one with content.

" b"

Story paragraph two with more detail.

" b"
" b"" ) assert ( parser._extract_body(payload) == "Story paragraph one with content.\n\nStory paragraph two with more detail." ) def test_extract_body_main_requires_two_paragraphs(): payload = b"

Only one paragraph here.

" assert parser._extract_body(payload) is None def test_extract_body_article_wins_over_main(): payload = ( b"" b"
" b"

Main paragraph one.

Main paragraph two.

" b"

Main paragraph three.

Main paragraph four.

" b"
" b"

Article paragraph one.

Article paragraph two.

" b"" ) assert ( parser._extract_body(payload) == "Article paragraph one.\n\nArticle paragraph two." ) def test_extract_body_rejects_unrendered_templates(): payload = ( b"" b"
" b"

[[ type === 'moc' ? 'MARKET ON CLOSE' : 'FREE WEBINAR' ]]

" b"

[[ timeLabel ]] WATCH LIVE

" b"
" b"" ) assert parser._extract_body(payload) is None def test_extract_body_strips_chrome_before_extracting(): payload = ( b"" b"" b"

banner text

banner two

" b"

Real story paragraph.

Story continues here.

" b"" b"" ) assert ( parser._extract_body(payload) == "Real story paragraph.\n\nStory continues here." ) def test_extract_body_no_container(): assert parser._extract_body(b"nothing") is None def test_extract_body_html_parse_error(): assert parser._extract_body(b"") is None def test_extract_body_jsonld_non_dict_entries(): payload = ( b"" b'' b"

Fallback one.

Fallback two.

" b"" ) assert parser._extract_body(payload) == "Fallback one.\n\nFallback two." def test_extract_body_jsonld_graph_no_body(): payload = ( b"" b'" b"

Graph fallback one.

Graph fallback two.

" b"" ) assert parser._extract_body(payload) == "Graph fallback one.\n\nGraph fallback two." def test_extract_body_jsonld_graph_non_dict_entry(): payload = ( b"" b'" ) assert parser._extract_body(payload) == "Graph article body." def test_extract_body_jsonld_template_falls_through_to_article(): payload = ( b"" b'" b"

Clean paragraph one.

Clean paragraph two.

" b"" ) assert ( parser._extract_body(payload) == "Clean paragraph one.\n\nClean paragraph two." ) def test_extract_body_preserves_utf8_smart_punctuation(): payload = ( "
" "

The Court’s ruling — issued Tuesday — was clear.

" "

Dominion’s lawyers said “we won”.

" "
" ).encode() body = parser._extract_body(payload) assert body is not None assert "Court’s" in body assert "Dominion’s" in body assert "“we won”" in body assert "—" in body for artifact in ("’", "–", " "): assert artifact not in body async def test_fetch_article_body_end_to_end(stub_session_factory, sample_article_html): session = stub_session_factory({"https://x.test/a": sample_article_html}) body = await parser.fetch_article_body(session, "https://x.test/a") assert body is not None assert "First paragraph" in body async def test_fetch_article_body_client_error(stub_session_factory): import aiohttp session = stub_session_factory({"https://x.test/a": aiohttp.ClientError("boom")}) assert await parser.fetch_article_body(session, "https://x.test/a") is None async def test_fetch_article_body_timeout(stub_session_factory): session = stub_session_factory( {"https://x.test/a": TimeoutError("simulated timeout")} ) assert await parser.fetch_article_body(session, "https://x.test/a") is None async def test_fetch_article_body_http_error(stub_session_factory): session = stub_session_factory({"https://x.test/a": (b"", 403)}) assert await parser.fetch_article_body(session, "https://x.test/a") is None async def test_fetch_article_body_no_article_returns_none(stub_session_factory): session = stub_session_factory( {"https://x.test/a": b"nothing"} ) assert await parser.fetch_article_body(session, "https://x.test/a") is None def test_extract_body_preserves_inline_img(): payload = ( b"
" b"

Opening paragraph.

" b'caption text' b"

Closing paragraph.

" b"
" ) body = parser._extract_body(payload) assert body is not None lines = body.split("\n\n") assert lines == [ "Opening paragraph.", "![caption text](https://x.test/inline.jpg)", "Closing paragraph.", ] def test_extract_body_preserves_figure_with_caption(): payload = ( b"
" b"

Lead in.

" b'
' b"
A long caption describing the photo.
" b"
" b"

After the photo.

" b"
" ) body = parser._extract_body(payload) assert body is not None assert "![A long caption describing the photo.](https://x.test/photo.jpg)" in body assert "Lead in." in body assert "After the photo." in body def test_extract_body_prepends_og_image_when_no_inline_image(): payload = ( b"" b'' b"
" b"

Paragraph one.

Paragraph two.

" b"
" ) body = parser._extract_body(payload) assert body is not None assert body.split("\n\n")[0] == "![](https://x.test/hero.jpg)" assert "Paragraph one." in body def test_extract_body_skips_hero_when_inline_image_present(): payload = ( b"" b'' b"
" b"

Lead.

" b'' b"

Body.

" b"
" ) body = parser._extract_body(payload) assert body is not None assert "https://x.test/hero.jpg" not in body assert "![](https://x.test/inline.jpg)" in body def test_extract_body_jsonld_articlebody_with_og_hero(): payload = ( b"" b'' b"" b'" ) body = parser._extract_body(payload) assert body == "![](https://x.test/hero.jpg)\n\nPure text body." def test_html_to_markdown_preserves_inline_img(): out = parser.html_to_markdown( '

Lead

alt text' ) assert "![alt text](https://x.test/inline.jpg)" in out assert "Lead" in out def test_html_to_markdown_img_tail_text(): out = parser.html_to_markdown( ' trailing text after img' ) assert "![](https://x.test/inline.jpg)" in out assert "trailing text after img" in out def test_jsonld_field_list_of_strings(): payload = ( b"" b'" ) body = parser._extract_body(payload) assert body is None or body.startswith("![](https://x.test/first.jpg)") def test_jsonld_field_list_of_image_objects(): payload = ( b"" b'" b"

One.

Two.

" b"" ) body = parser._extract_body(payload) assert body is not None assert body.startswith("![](https://x.test/o.jpg)") def test_jsonld_field_image_object_with_url(): payload = ( b"" b'" b"

One.

Two.

" b"" ) body = parser._extract_body(payload) assert body is not None assert body.startswith("![](https://x.test/obj.jpg)") DRUGS_COM_URL = "https://www.drugs.com/news/fda-clears-cholesterol-pill-130672.html" def test_host_strips_www(): assert parser._host("https://www.drugs.com/news/x.html") == "drugs.com" def test_host_without_www(): assert parser._host("https://drugs.com/news/x.html") == "drugs.com" def test_host_is_lowercased(): assert parser._host("https://WWW.Drugs.COM/news/x.html") == "drugs.com" def test_host_empty_url(): assert parser._host(None) == "" assert parser._host("") == "" def test_host_url_without_hostname(): assert parser._host("not-a-url") == "" def test_cut_tail_removes_marker_and_everything_after(): doc = html.fromstring( b"
" b"

Keep one.

" b"" b"

Keep two.

" b"

More news resources

" b"" b"

Newsletter pitch.

" b"
" ) container = doc.xpath("//div")[0] parser._cut_tail(container, parser._SITE_RULES["drugs.com"].cut) text = container.text_content() assert "Keep one." in text assert "Keep two." in text assert "More news resources" not in text assert "Newsletter pitch." not in text def test_cut_tail_without_marker_keeps_all(): doc = html.fromstring(b"

One.

Two.

") container = doc.xpath("//div")[0] parser._cut_tail(container, parser._SITE_RULES["drugs.com"].cut) assert len(container.xpath(".//p")) == 2 def test_site_body_no_container_returns_empty(): doc = html.fromstring(b"

One.

Two.

") assert parser._site_body(doc, parser._SITE_RULES["drugs.com"]) == "" def test_site_body_picks_largest_container(): doc = html.fromstring( b"" b"
" b"

Short one.

Short two.

" b"
" b"

Longer container paragraph one with much more substance.

" b"

Longer container paragraph two with additional detail.

" b"" ) body = parser._site_body(doc, parser._SITE_RULES["drugs.com"]) assert "Longer container paragraph one" in body assert "Short one." not in body def test_site_body_skips_unclean_container(): doc = html.fromstring( b"
" b"

[[ template ]] junk

{{ binding }} more junk

" b"
" ) assert parser._site_body(doc, parser._SITE_RULES["drugs.com"]) == "" def test_extract_body_drugs_com_keeps_article_text(sample_drugs_com_html): body = parser._extract_body(sample_drugs_com_html, DRUGS_COM_URL) assert body is not None assert "By Ellyn Vohnoutka HealthDay Reporter" in body assert "A new daily pill will lower cholesterol." in body assert "The FDA acted today to approve the drug for adults." in body def test_extract_body_drugs_com_drops_trailing_furniture(sample_drugs_com_html): body = parser._extract_body(sample_drugs_com_html, DRUGS_COM_URL) assert body is not None assert "Disclaimer:" not in body assert "HealthDay. All rights reserved." not in body assert "Related teaser one." not in body assert "Whatever your topic of interest" not in body def test_extract_body_drugs_com_drops_sidebar(sample_drugs_com_html): body = parser._extract_body(sample_drugs_com_html, DRUGS_COM_URL) assert body is not None assert "Sidebar drug teaser one." not in body assert "Podcast pitch paragraph." not in body assert "ddc-podcast-cover" not in body def test_extract_body_drugs_com_skips_placeholder_hero(sample_drugs_com_html): body = parser._extract_body(sample_drugs_com_html, DRUGS_COM_URL) assert body is not None assert "ddc-opengraph-logomark" not in body assert not body.startswith("![") def test_extract_body_drugs_com_keeps_real_hero(): payload = ( b"" b'' b"
" b"

Lede paragraph of the story.

Second paragraph.

" b"
" ) body = parser._extract_body(payload, DRUGS_COM_URL) assert body is not None assert body.split("\n\n")[0] == "![](https://media.test/featured.jpg)" def test_extract_body_drugs_com_falls_back_when_container_missing(): payload = ( b"
" b"

Generic paragraph one.

Generic paragraph two.

" b"
" ) body = parser._extract_body(payload, DRUGS_COM_URL) assert body == "Generic paragraph one.\n\nGeneric paragraph two." def test_extract_body_unknown_host_uses_generic_path(sample_drugs_com_html): body = parser._extract_body(sample_drugs_com_html, "https://other.test/x.html") assert body is not None assert "Whatever your topic of interest" in body def test_extract_body_without_url_uses_generic_path(sample_drugs_com_html): body = parser._extract_body(sample_drugs_com_html) assert body is not None assert "Whatever your topic of interest" in body async def test_fetch_article_body_applies_site_rule( stub_session_factory, sample_drugs_com_html ): session = stub_session_factory({DRUGS_COM_URL: sample_drugs_com_html}) body = await parser.fetch_article_body(session, DRUGS_COM_URL) assert body is not None assert "A new daily pill will lower cholesterol." in body assert "Whatever your topic of interest" not in body async def test_fetch_article_body_pins_user_agent_for_drugs_com( stub_session_factory, sample_drugs_com_html ): session = stub_session_factory({DRUGS_COM_URL: sample_drugs_com_html}) await parser.fetch_article_body(session, DRUGS_COM_URL) assert session.headers_sent == [parser._HOST_HEADERS["drugs.com"]] assert session.headers_sent[0]["User-Agent"] == parser._WEBKIT_UA def test_drugs_com_pinned_user_agent_is_not_gecko(): # drugs.com articles 403 any Gecko user agent. ua = parser._HOST_HEADERS["drugs.com"]["User-Agent"] assert "Gecko/" not in ua assert "AppleWebKit" in ua def test_host_headers_pin_safe_encoding(): # Overriding headers drops the session default Accept-Encoding; every pinned # host must exclude br, which the shared session cannot decode. for host, headers in parser._HOST_HEADERS.items(): assert headers["Accept-Encoding"] == "gzip, deflate", host assert "br" not in headers["Accept-Encoding"], host def test_host_headers_cover_expected_hosts(): assert set(parser._HOST_HEADERS) == { "drugs.com", "espn.com", "heavy.com", "bleepingcomputer.com", "pbs.org", "techmeme.com", } def test_pinned_helper_omits_user_agent_when_none(): assert "User-Agent" not in parser._pinned() assert parser._pinned("UA/1.0")["User-Agent"] == "UA/1.0" async def test_fetch_article_body_keeps_session_user_agent_for_other_hosts( stub_session_factory, sample_article_html ): session = stub_session_factory({"https://x.test/a": sample_article_html}) await parser.fetch_article_body(session, "https://x.test/a") assert session.headers_sent == [None] def test_extract_image_jsonld_fallback_path(): payload = ( b"" b'" b"

One.

Two.

" b"" ) body = parser._extract_body(payload) assert body is not None assert body.startswith("![](https://x.test/ld.jpg)")