Skip to content

Commit 38df013

Browse files
support firefox curls
1 parent 62e9048 commit 38df013

4 files changed

Lines changed: 260 additions & 12 deletions

File tree

curl_example.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
Replace all contents of this file with the following:
2-
1. Go to x.com (using Chrome as browser, otherwise the cURL will be a different format)
2+
1. Go to x.com
33
2. Login
44
3. On the home timeline (click on following) press F12, this will open devtools in Chrome
55
4. Go to the Network section
66
5. Locate HomeTimeline or HomeLatestTimeline (when using Following tab)
7-
6. Right click on HomeTimeline, copy, copy as cURL (bash)
7+
6. Right click on HomeTimeline, copy
8+
Chrome: copy as cURL (bash)
9+
Firefox: copy as cURL (posix)
810
7. Paste the contents in this .txt and rename to curl.txt

src/tweet.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import logging
24
from dataclasses import asdict, dataclass
35

@@ -60,6 +62,7 @@ class Tweet:
6062
replies: int = 0
6163
views: int = 0
6264
is_update: bool = False
65+
quoted_tweet: Tweet | None = None
6366

6467
def to_dict(self) -> dict:
6568
"""Serialize to a plain dict safe for JSON."""

src/xclient.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,26 @@ def _parse_cookie_string(cookie_blob: str) -> dict[str, str]:
122122
return out
123123

124124

125+
def _collapse_curl(raw_curl: str) -> str:
126+
"""Collapse backslash-newline continuations into a single line."""
127+
return re.sub(r"\\\s*\n\s*", " ", raw_curl)
128+
129+
125130
def _extract_cookies_from_curl(raw_curl: str) -> dict[str, str]:
126-
collapsed = re.sub(r"\\\s*\n", " ", raw_curl)
131+
collapsed = _collapse_curl(raw_curl)
127132
out: dict[str, str] = {}
128133
patterns = [
134+
# -b / --cookie flags (Chrome/curl format)
129135
r"(?:^|\s)-b\s+'([^']+)'",
130136
r'(?:^|\s)-b\s+"([^"]+)"',
131137
r"(?:^|\s)--cookie\s+'([^']+)'",
132138
r'(?:^|\s)--cookie\s+"([^"]+)"',
139+
# -H 'Cookie: ...' header (Firefox format)
140+
r"-H\s+'Cookie:\s*([^']+)'",
141+
r'-H\s+"Cookie:\s*([^"]+)"',
133142
]
134143
for pat in patterns:
135-
for m in re.finditer(pat, collapsed):
144+
for m in re.finditer(pat, collapsed, re.IGNORECASE):
136145
out.update(_parse_cookie_string(m.group(1)))
137146
return out
138147

@@ -195,15 +204,19 @@ def _load_curl(self) -> None:
195204
"""Parse the cURL file into url/headers/cookies/json payload."""
196205
try:
197206
raw = self.curl_path.read_text(encoding="utf-8")
198-
ctx = uncurl.parse_context(
199-
"".join(line.strip() for line in raw.splitlines())
200-
)
201-
parsed_cookies = dict(ctx.cookies) if ctx.cookies else {}
207+
ctx = uncurl.parse_context(_collapse_curl(raw))
208+
# Always prefer the regex extractor: it handles both Chrome (-b) and
209+
# Firefox (-H 'Cookie: ...') formats and reconstructs the full cookie
210+
# string even when uncurl's parser truncates long values.
211+
parsed_cookies = _extract_cookies_from_curl(raw)
202212
if not parsed_cookies:
203-
parsed_cookies = _extract_cookies_from_curl(raw)
213+
parsed_cookies = dict(ctx.cookies) if ctx.cookies else {}
214+
headers = dict(ctx.headers) if ctx.headers else {}
215+
# Remove Cookie header from headers — aiohttp sends cookies separately
216+
headers = {k: v for k, v in headers.items() if k.lower() != "cookie"}
204217
self._req = {
205218
"url": ctx.url,
206-
"headers": dict(ctx.headers) if ctx.headers else {},
219+
"headers": headers,
207220
"cookies": parsed_cookies,
208221
"json": json.loads(ctx.data) if ctx.data else None,
209222
"method": ctx.method.upper(),
@@ -600,9 +613,11 @@ def _parse_nested(n: dict) -> Tweet | None:
600613
return self._parse_single_tweet(inner)
601614

602615
nested: Tweet | None = None
616+
quoted_tweet: Tweet | None = None
603617
if quoted:
604618
nested = _parse_nested(quoted)
605619
if nested:
620+
quoted_tweet = nested
606621
title = f"{user_name} quote tweeted {nested.user_name}"
607622
q_text = "\n".join("> " + line for line in nested.text.splitlines())
608623
text = f"{text}\n\n> [@{nested.user_screen_name}](https://twitter.com/{nested.user_screen_name}):\n{q_text}"
@@ -651,6 +666,7 @@ def _parse_nested(n: dict) -> Tweet | None:
651666
retweets=retweets,
652667
replies=replies,
653668
views=views,
669+
quoted_tweet=quoted_tweet,
654670
)
655671

656672
# ---------- public APIs ----------

tests/test_xclient.py

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import pytest
1111

12-
from xclient import XTimelineClient, expand_tco_urls
12+
from xclient import XTimelineClient, _collapse_curl, _extract_cookies_from_curl, expand_tco_urls
1313

1414
# ---------------------------------------------------------------------------
1515
# Helpers
@@ -68,10 +68,19 @@ def _wrap_user(name: str, screen_name: str, image_url: str = "") -> dict:
6868
"result": {
6969
"__typename": "Tweet",
7070
"rest_id": "2037910161172377806",
71-
"core": _wrap_user("TraderSZ", "trader1sz"),
71+
"core": _wrap_user(
72+
"TraderSZ",
73+
"trader1sz",
74+
"https://pbs.twimg.com/profile_images/tradersz_normal.jpg",
75+
),
76+
"views": {"count": "5000", "state": "Enabled"},
7277
"legacy": {
7378
"id_str": "2037910161172377806",
7479
"full_text": "$ETHBTC doesnt look too bad here",
80+
"created_at": "Tue Apr 01 12:00:00 +0000 2026",
81+
"favorite_count": 50,
82+
"retweet_count": 10,
83+
"reply_count": 3,
7584
"entities": {
7685
"symbols": [{"text": "ETHBTC", "indices": [0, 7]}],
7786
"hashtags": [],
@@ -597,3 +606,221 @@ def test_new_only_mode_skips_seen_tweets(self, client):
597606

598607
assert {t.id for t in results} == {1002}
599608
assert client._last_tweet_id == 1002
609+
610+
611+
# ---------------------------------------------------------------------------
612+
# Quoted tweet field
613+
# ---------------------------------------------------------------------------
614+
615+
class TestQuotedTweetField:
616+
def test_quoted_tweet_is_populated(self, client):
617+
t = _parse(client, QUOTE_TWEET)
618+
assert t.quoted_tweet is not None
619+
620+
def test_quoted_tweet_user_name(self, client):
621+
t = _parse(client, QUOTE_TWEET)
622+
assert t.quoted_tweet.user_name == "TraderSZ"
623+
624+
def test_quoted_tweet_user_screen_name(self, client):
625+
t = _parse(client, QUOTE_TWEET)
626+
assert t.quoted_tweet.user_screen_name == "trader1sz"
627+
628+
def test_quoted_tweet_user_img(self, client):
629+
t = _parse(client, QUOTE_TWEET)
630+
assert t.quoted_tweet.user_img == "https://pbs.twimg.com/profile_images/tradersz_normal.jpg"
631+
632+
def test_quoted_tweet_id(self, client):
633+
t = _parse(client, QUOTE_TWEET)
634+
assert t.quoted_tweet.id == 2037910161172377806
635+
636+
def test_quoted_tweet_url(self, client):
637+
t = _parse(client, QUOTE_TWEET)
638+
assert t.quoted_tweet.url == "https://twitter.com/user/status/2037910161172377806"
639+
640+
def test_quoted_tweet_text(self, client):
641+
t = _parse(client, QUOTE_TWEET)
642+
assert t.quoted_tweet.text == "$ETHBTC doesnt look too bad here"
643+
644+
def test_quoted_tweet_created_at(self, client):
645+
t = _parse(client, QUOTE_TWEET)
646+
assert t.quoted_tweet.created_at == "2026-04-01T12:00:00Z"
647+
648+
def test_quoted_tweet_likes(self, client):
649+
t = _parse(client, QUOTE_TWEET)
650+
assert t.quoted_tweet.likes == 50
651+
652+
def test_quoted_tweet_retweets(self, client):
653+
t = _parse(client, QUOTE_TWEET)
654+
assert t.quoted_tweet.retweets == 10
655+
656+
def test_quoted_tweet_replies(self, client):
657+
t = _parse(client, QUOTE_TWEET)
658+
assert t.quoted_tweet.replies == 3
659+
660+
def test_quoted_tweet_views(self, client):
661+
t = _parse(client, QUOTE_TWEET)
662+
assert t.quoted_tweet.views == 5000
663+
664+
def test_plain_tweet_has_no_quoted_tweet(self, client):
665+
t = _parse(client, PLAIN_TWEET)
666+
assert t.quoted_tweet is None
667+
668+
def test_retweet_has_no_quoted_tweet(self, client):
669+
t = _parse(client, RETWEET)
670+
assert t.quoted_tweet is None
671+
672+
def test_quoted_tweet_serializes_in_to_dict(self, client):
673+
t = _parse(client, QUOTE_TWEET)
674+
d = t.to_dict()
675+
assert isinstance(d["quoted_tweet"], dict)
676+
assert d["quoted_tweet"]["user_screen_name"] == "trader1sz"
677+
678+
def test_plain_tweet_quoted_tweet_is_none_in_to_dict(self, client):
679+
t = _parse(client, PLAIN_TWEET)
680+
d = t.to_dict()
681+
assert d["quoted_tweet"] is None
682+
683+
684+
# ---------------------------------------------------------------------------
685+
# TweetWithVisibilityResults (restricted reply / visibility)
686+
# ---------------------------------------------------------------------------
687+
688+
VISIBILITY_TWEET: dict = {
689+
"__typename": "TweetWithVisibilityResults",
690+
"limitedActionResults": {
691+
"limited_actions": [
692+
{
693+
"action": "Reply",
694+
"prompt": {
695+
"__typename": "CtaLimitedActionPrompt",
696+
"cta_type": "SeeConversation",
697+
"headline": {"entities": [], "text": "Who can reply?"},
698+
"subtext": {"entities": [], "text": "Only some accounts can reply."},
699+
},
700+
}
701+
]
702+
},
703+
"tweet": {
704+
"__typename": "Tweet",
705+
"rest_id": "2039500000000000001",
706+
"core": _wrap_user(
707+
"Crypto Mikey",
708+
"CryptoCX1",
709+
"https://pbs.twimg.com/profile_images/mikey_normal.jpg",
710+
),
711+
"views": {"count": "12000", "state": "Enabled"},
712+
"legacy": {
713+
"id_str": "2039500000000000001",
714+
"full_text": "$SPX big test coming up.",
715+
"created_at": "Tue Apr 01 15:00:00 +0000 2026",
716+
"favorite_count": 80,
717+
"retweet_count": 20,
718+
"reply_count": 5,
719+
"entities": {
720+
"symbols": [{"text": "SPX", "indices": [0, 4]}],
721+
"hashtags": [],
722+
},
723+
},
724+
},
725+
}
726+
727+
728+
class TestVisibilityTweet:
729+
"""TweetWithVisibilityResults — restricted replies, tweet itself is visible."""
730+
731+
def _parse_wrapped(self, client):
732+
"""Simulate how the entry parser calls normalize_tweet_result."""
733+
from xclient import normalize_tweet_result
734+
tw = normalize_tweet_result({"result": VISIBILITY_TWEET})
735+
return client._parse_single_tweet(tw)
736+
737+
def test_parsed_successfully(self, client):
738+
t = self._parse_wrapped(client)
739+
assert t is not None
740+
741+
def test_id(self, client):
742+
t = self._parse_wrapped(client)
743+
assert t.id == 2039500000000000001
744+
745+
def test_user_name(self, client):
746+
t = self._parse_wrapped(client)
747+
assert t.user_name == "Crypto Mikey"
748+
749+
def test_user_screen_name(self, client):
750+
t = self._parse_wrapped(client)
751+
assert t.user_screen_name == "CryptoCX1"
752+
753+
def test_text(self, client):
754+
t = self._parse_wrapped(client)
755+
assert "$SPX" in t.text
756+
757+
def test_likes(self, client):
758+
t = self._parse_wrapped(client)
759+
assert t.likes == 80
760+
761+
def test_views(self, client):
762+
t = self._parse_wrapped(client)
763+
assert t.views == 12000
764+
765+
def test_ticker_extracted(self, client):
766+
t = self._parse_wrapped(client)
767+
assert "SPX" in t.tickers
768+
769+
770+
# ---------------------------------------------------------------------------
771+
# Cookie extraction — Chrome and Firefox curl formats
772+
# ---------------------------------------------------------------------------
773+
774+
_CHROME_CURL = (
775+
"curl 'https://example.com' "
776+
"-b 'auth_token=abc123; ct0=def456; __cuid=xyz'"
777+
)
778+
779+
_FIREFOX_CURL = (
780+
"curl 'https://example.com' \\\n"
781+
" -H 'Authorization: Bearer token' \\\n"
782+
" -H 'Cookie: auth_token=abc123; ct0=def456; __cuid=xyz' \\\n"
783+
" --data-raw '{}'"
784+
)
785+
786+
_FIREFOX_CURL_LONG = (
787+
# Simulates a long Cookie header that wraps across a line boundary when
788+
# the browser copies it; the backslash continuation is mid-value.
789+
"curl 'https://example.com' \\\n"
790+
" -H 'Authorization: Bearer token' \\\n"
791+
" -H 'Cookie: auth_token=abc123; ct0=def456; __cuid=xyz; extra=val' \\\n"
792+
" --data-raw '{}'"
793+
)
794+
795+
796+
class TestCookieParsing:
797+
def test_chrome_format_extracts_auth_token(self):
798+
cookies = _extract_cookies_from_curl(_CHROME_CURL)
799+
assert cookies["auth_token"] == "abc123"
800+
801+
def test_chrome_format_extracts_ct0(self):
802+
cookies = _extract_cookies_from_curl(_CHROME_CURL)
803+
assert cookies["ct0"] == "def456"
804+
805+
def test_firefox_format_extracts_auth_token(self):
806+
cookies = _extract_cookies_from_curl(_FIREFOX_CURL)
807+
assert cookies["auth_token"] == "abc123"
808+
809+
def test_firefox_format_extracts_ct0(self):
810+
cookies = _extract_cookies_from_curl(_FIREFOX_CURL)
811+
assert cookies["ct0"] == "def456"
812+
813+
def test_firefox_format_extracts_cuid(self):
814+
cookies = _extract_cookies_from_curl(_FIREFOX_CURL)
815+
assert cookies["__cuid"] == "xyz"
816+
817+
def test_multiline_firefox_curl_extracts_all_cookies(self):
818+
cookies = _extract_cookies_from_curl(_FIREFOX_CURL_LONG)
819+
assert cookies["auth_token"] == "abc123"
820+
assert cookies["ct0"] == "def456"
821+
assert cookies["extra"] == "val"
822+
823+
def test_collapse_curl_joins_backslash_continuations(self):
824+
raw = "curl 'url' \\\n -H 'Foo: bar'"
825+
assert "\\\n" not in _collapse_curl(raw)
826+
assert "-H 'Foo: bar'" in _collapse_curl(raw)

0 commit comments

Comments
 (0)