From c2fb556919e6398609eeaefcfdf6c68635884dab Mon Sep 17 00:00:00 2001 From: Daksh Date: Sun, 27 Sep 2026 16:35:11 +0530 Subject: [PATCH] fix(ingest): rewrite only the host when normalizing a tweet URL for oEmbed _fetch_tweet did url.replace("x.com", "twitter.com") over the whole URL, so "x.com" in the path or query was rewritten too (a ?next= link, or a handle like dropbox.com_fan becoming dropbotwitter.com_fan), and an uppercase X.COM host was missed. Match and rewrite the host only, the same way _detect_url_type classifies it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 3ec4fdcfca5f34a82d7f163807eb7108c4e5e983) --- graphify/ingest.py | 11 +++++++++-- tests/test_ingest_url_type.py | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/graphify/ingest.py b/graphify/ingest.py index c861d51a2..20962250b 100644 --- a/graphify/ingest.py +++ b/graphify/ingest.py @@ -109,8 +109,15 @@ def _html_to_markdown(html: str, url: str) -> str: def _fetch_tweet(url: str, author: str | None, contributor: str | None) -> tuple[str, str]: """Fetch a tweet URL. Returns (content, filename).""" - # Normalize to twitter.com for oEmbed - oembed_url = url.replace("x.com", "twitter.com") + # Normalize to twitter.com for oEmbed. Rewrite the host only: a plain + # text replace also rewrote "x.com" wherever it appeared in the path or query. + parts = urllib.parse.urlsplit(url) + host = (parts.hostname or "").rstrip(".") + if _host_is(host, "x.com"): + host = host[: -len("x.com")] + "twitter.com" + netloc = f"{host}:{parts.port}" if parts.port else host + parts = parts._replace(netloc=netloc) + oembed_url = urllib.parse.urlunsplit(parts) oembed_api = f"https://publish.twitter.com/oembed?url={urllib.parse.quote(oembed_url)}&omit_script=true" try: data = json.loads(safe_fetch_text(oembed_api)) diff --git a/tests/test_ingest_url_type.py b/tests/test_ingest_url_type.py index c2cb9e2ba..45d5f11c6 100644 --- a/tests/test_ingest_url_type.py +++ b/tests/test_ingest_url_type.py @@ -1,6 +1,8 @@ """Tests for how graphify.ingest classifies and routes a URL.""" from __future__ import annotations +import urllib.parse + import pytest import graphify.ingest as ingest_mod @@ -66,3 +68,38 @@ def test_a_dropbox_pdf_is_downloaded_not_saved_as_a_tweet_stub(tmp_path, monkeyp assert fetched == [url] assert out.suffix == ".pdf" assert out.read_bytes() == b"%PDF-1.7 test" + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://x.com/user/status/1", "https://twitter.com/user/status/1"), + ("https://mobile.x.com/user/status/1", "https://mobile.twitter.com/user/status/1"), + ("https://X.COM/user/status/1", "https://twitter.com/user/status/1"), + # "x.com" outside the host must survive untouched. + ( + "https://x.com/user/status/1?next=https://x.com/home", + "https://twitter.com/user/status/1?next=https://x.com/home", + ), + ( + "https://twitter.com/dropbox.com_fan/status/1", + "https://twitter.com/dropbox.com_fan/status/1", + ), + ], +) +def test_tweet_oembed_rewrites_only_the_host(url, expected, monkeypatch): + """The x.com -> twitter.com rewrite for oEmbed was a replace over the whole + URL, so "x.com" inside the path or query (e.g. a dropbox.com handle, a + ?next= link) was rewritten too, sending oEmbed a different URL.""" + requested = [] + + def fake_fetch_text(u): + requested.append(u) + return '{"html": "

hi

", "author_name": "someone"}' + + monkeypatch.setattr(ingest_mod, "safe_fetch_text", fake_fetch_text) + + ingest_mod._fetch_tweet(url, None, None) + + query = urllib.parse.parse_qs(urllib.parse.urlsplit(requested[0]).query) + assert query["url"] == [expected]