From 778eb35ee33cbdc80733e0d9db65b15059e8a3fa Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 03:55:23 +0100 Subject: [PATCH 1/8] Add curl-based cookie session script --- ...b_session.py => create_session_browser.py} | 11 +- tools/create_session_curl.py | 328 ++++++++++++++++++ tools/requirements.txt | 1 + 3 files changed, 333 insertions(+), 7 deletions(-) rename tools/{get_web_session.py => create_session_browser.py} (90%) create mode 100644 tools/create_session_curl.py diff --git a/tools/get_web_session.py b/tools/create_session_browser.py similarity index 90% rename from tools/get_web_session.py rename to tools/create_session_browser.py index 502c7f4..40e3dcd 100644 --- a/tools/get_web_session.py +++ b/tools/create_session_browser.py @@ -1,23 +1,20 @@ #!/usr/bin/env python3 """ -Authenticates with X.com/Twitter and extracts session cookies for use with Nitter. -Handles 2FA, extracts user info, and outputs clean JSON for sessions.jsonl. - Requirements: pip install -r tools/requirements.txt Usage: - python3 tools/get_web_session.py [totp_seed] [--append sessions.jsonl] [--headless] + python3 tools/create_session_browser.py [totp_seed] [--append sessions.jsonl] [--headless] Examples: # Output to terminal - python3 tools/get_web_session.py myusername mypassword TOTP_BASE32_SECRET + python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET # Append to sessions.jsonl - python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --append sessions.jsonl + python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET --append sessions.jsonl # Headless mode (may increase detection risk) - python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --headless + python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET --headless Output: {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} diff --git a/tools/create_session_curl.py b/tools/create_session_curl.py new file mode 100644 index 0000000..f569422 --- /dev/null +++ b/tools/create_session_curl.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Requirements: + pip install curl_cffi pyotp + +Usage: + python3 tools/create_session_curl.py [totp_seed] [--append sessions.jsonl] + +Examples: + # Output to terminal + python3 tools/create_session_curl.py myusername mypassword TOTP_SECRET + + # Append to sessions.jsonl + python3 tools/create_session_curl.py myusername mypassword TOTP_SECRET --append sessions.jsonl + +Output: + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} +""" + +import sys +import json +import pyotp +from curl_cffi import requests + +BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" +BASE_URL = "https://api.x.com/1.1/onboarding/task.json" +GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json" + +# Subtask versions required by API +SUBTASK_VERSIONS = { + "action_list": 2, "alert_dialog": 1, "app_download_cta": 1, + "check_logged_in_account": 2, "choice_selection": 3, + "contacts_live_sync_permission_prompt": 0, "cta": 7, "email_verification": 2, + "end_flow": 1, "enter_date": 1, "enter_email": 2, "enter_password": 5, + "enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5, "generic_urt": 3, + "in_app_notification": 1, "interest_picker": 3, "js_instrumentation": 1, + "menu_dialog": 1, "notifications_permission_prompt": 2, "open_account": 2, + "open_home_timeline": 1, "open_link": 1, "phone_verification": 4, + "privacy_options": 1, "security_key": 3, "select_avatar": 4, + "select_banner": 2, "settings_list": 7, "show_code": 1, "sign_up": 2, + "sign_up_review": 4, "tweet_selection_urt": 1, "update_users": 1, + "upload_media": 1, "user_recommendations_list": 4, + "user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1 +} + + +def get_base_headers(guest_token=None): + """Build base headers for API requests.""" + headers = { + "Authorization": f"Bearer {BEARER_TOKEN}", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "en-US", + "X-Twitter-Client-Language": "en-US", + "Origin": "https://x.com", + "Referer": "https://x.com/", + } + if guest_token: + headers["X-Guest-Token"] = guest_token + return headers + + +def get_cookies_dict(session): + """Extract cookies from session.""" + return session.cookies.get_dict() if hasattr(session.cookies, 'get_dict') else dict(session.cookies) + + +def make_request(session, headers, flow_token, subtask_data, print_msg): + """Generic request handler for flow steps.""" + print(f"[*] {print_msg}...", file=sys.stderr) + + payload = { + "flow_token": flow_token, + "subtask_inputs": [subtask_data] if isinstance(subtask_data, dict) else subtask_data + } + + response = session.post(BASE_URL, json=payload, headers=headers) + response.raise_for_status() + + data = response.json() + new_flow_token = data.get('flow_token') + if not new_flow_token: + raise Exception(f"Failed to get flow token: {print_msg}") + + return new_flow_token, data + + +def get_guest_token(session): + """Get guest token for unauthenticated requests.""" + print("[*] Getting guest token...", file=sys.stderr) + response = session.post(GUEST_ACTIVATE_URL, headers={"Authorization": f"Bearer {BEARER_TOKEN}"}) + response.raise_for_status() + + guest_token = response.json().get('guest_token') + if not guest_token: + raise Exception("Failed to obtain guest token") + + print(f"[*] Got guest token: {guest_token}", file=sys.stderr) + return guest_token + + +def init_flow(session, guest_token): + """Initialize the login flow.""" + print("[*] Initializing login flow...", file=sys.stderr) + + headers = get_base_headers(guest_token) + payload = { + "input_flow_data": { + "flow_context": { + "debug_overrides": {}, + "start_location": {"location": "manual_link"} + }, + "subtask_versions": SUBTASK_VERSIONS + } + } + + response = session.post(f"{BASE_URL}?flow_name=login", json=payload, headers=headers) + response.raise_for_status() + + flow_token = response.json().get('flow_token') + if not flow_token: + raise Exception("Failed to get initial flow token") + + print("[*] Got initial flow token", file=sys.stderr) + return flow_token, headers + + +def submit_username(session, flow_token, headers, guest_token, username): + """Submit username.""" + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginEnterUserIdentifierSSO", + "settings_list": { + "setting_responses": [{ + "key": "user_identifier", + "response_data": {"text_data": {"result": username}} + }], + "link": "next_link" + } + } + + flow_token, data = make_request(session, headers, flow_token, subtask, "Submitting username") + + # Check for denial (suspicious activity) + if data.get('subtasks') and 'cta' in data['subtasks'][0]: + error_msg = data['subtasks'][0]['cta'].get('primary_text', {}).get('text') + if error_msg: + raise Exception(f"Login denied: {error_msg}") + + return flow_token + + +def submit_password(session, flow_token, headers, guest_token, password): + """Submit password and detect if 2FA is needed.""" + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginEnterPassword", + "enter_password": {"password": password, "link": "next_link"} + } + + flow_token, data = make_request(session, headers, flow_token, subtask, "Submitting password") + + needs_2fa = any(s.get('subtask_id') == 'LoginTwoFactorAuthChallenge' for s in data.get('subtasks', [])) + if needs_2fa: + print("[*] 2FA required", file=sys.stderr) + + return flow_token, needs_2fa + + +def submit_2fa(session, flow_token, headers, guest_token, totp_seed): + """Submit 2FA code.""" + if not totp_seed: + raise Exception("2FA required but no TOTP seed provided") + + code = pyotp.TOTP(totp_seed).now() + print("[*] Generating 2FA code...", file=sys.stderr) + + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginTwoFactorAuthChallenge", + "enter_text": {"text": code, "link": "next_link"} + } + + flow_token, _ = make_request(session, headers, flow_token, subtask, "Submitting 2FA code") + return flow_token + + +def submit_js_instrumentation(session, flow_token, headers, guest_token): + """Submit JS instrumentation response.""" + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginJsInstrumentationSubtask", + "js_instrumentation": { + "response": '{"rf":{"a4fc506d24bb4843c48a1966940c2796bf4fb7617a2d515ad3297b7df6b459b6":121,"bff66e16f1d7ea28c04653dc32479cf416a9c8b67c80cb8ad533b2a44fee82a3":-1,"ac4008077a7e6ca03210159dbe2134dea72a616f03832178314bb9931645e4f7":-22,"c3a8a81a9b2706c6fec42c771da65a9597c537b8e4d9b39e8e58de9fe31ff239":-12},"s":"ZHYaDA9iXRxOl2J3AZ9cc23iJx-Fg5E82KIBA_fgeZFugZGYzRtf8Bl3EUeeYgsK30gLFD2jTQx9fAMsnYCw0j8ahEy4Pb5siM5zD6n7YgOeWmFFaXoTwaGY4H0o-jQnZi5yWZRAnFi4lVuCVouNz_xd2BO2sobCO7QuyOsOxQn2CWx7bjD8vPAzT5BS1mICqUWyjZDjLnRZJU6cSQG5YFIHEPBa8Kj-v1JFgkdAfAMIdVvP7C80HWoOqYivQR7IBuOAI4xCeLQEdxlGeT-JYStlP9dcU5St7jI6ExyMeQnRicOcxXLXsan8i5Joautk2M8dAJFByzBaG4wtrPhQ3QAAAZEi-_t7"}', + "link": "next_link" + } + } + + flow_token, _ = make_request(session, headers, flow_token, subtask, "Submitting JS instrumentation") + return flow_token + + +def complete_flow(session, flow_token, headers): + """Complete the login flow.""" + cookies = get_cookies_dict(session) + + headers = headers.copy() + headers["X-Twitter-Auth-Type"] = "OAuth2Session" + if cookies.get('ct0'): + headers["X-Csrf-Token"] = cookies['ct0'] + + subtask = { + "subtask_id": "AccountDuplicationCheck", + "check_logged_in_account": {"link": "AccountDuplicationCheck_false"} + } + + make_request(session, headers, flow_token, subtask, "Completing login flow") + + +def extract_user_id(cookies_dict): + """Extract user ID from twid cookie.""" + twid = cookies_dict.get('twid', '').strip('"') + + for prefix in ['u=', 'u%3D']: + if prefix in twid: + return twid.split(prefix)[1].split('&')[0].strip('"') + + return None + + +def login_and_get_cookies(username, password, totp_seed=None): + """Authenticate with X.com and extract session cookies.""" + session = requests.Session(impersonate="chrome") + + try: + guest_token = get_guest_token(session) + flow_token, headers = init_flow(session, guest_token) + flow_token = submit_js_instrumentation(session, flow_token, headers, guest_token) + flow_token = submit_username(session, flow_token, headers, guest_token, username) + flow_token, needs_2fa = submit_password(session, flow_token, headers, guest_token, password) + + if needs_2fa: + flow_token = submit_2fa(session, flow_token, headers, guest_token, totp_seed) + + complete_flow(session, flow_token, headers) + + cookies_dict = get_cookies_dict(session) + cookies_dict['username'] = username + + user_id = extract_user_id(cookies_dict) + if user_id: + cookies_dict['id'] = user_id + + print("[*] Successfully authenticated", file=sys.stderr) + return cookies_dict + + finally: + session.close() + + +def main(): + if len(sys.argv) < 3: + print('Usage: python3 create_session_curl.py username password [totp_seed] [--append sessions.jsonl]', file=sys.stderr) + sys.exit(1) + + username = sys.argv[1] + password = sys.argv[2] + totp_seed = None + append_file = None + + # Parse optional arguments + i = 3 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == '--append': + if i + 1 < len(sys.argv): + append_file = sys.argv[i + 1] + i += 2 + else: + print('[!] Error: --append requires a filename', file=sys.stderr) + sys.exit(1) + elif not arg.startswith('--'): + if totp_seed is None: + totp_seed = arg + i += 1 + else: + print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr) + i += 1 + + try: + cookies = login_and_get_cookies(username, password, totp_seed) + + session = { + 'kind': 'cookie', + 'username': cookies['username'], + 'id': cookies.get('id'), + 'auth_token': cookies['auth_token'], + 'ct0': cookies['ct0'] + } + + output = json.dumps(session) + + if append_file: + with open(append_file, 'a') as f: + f.write(output + '\n') + print(f'✓ Session appended to {append_file}', file=sys.stderr) + else: + print(output) + + sys.exit(0) + + except Exception as error: + print(f'[!] Error: {error}', file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tools/requirements.txt b/tools/requirements.txt index 4827475..2fdac24 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,2 +1,3 @@ nodriver>=0.48.0 pyotp +curl_cffi From a666c4867c9a0b4e187bae6c5d826b4aec34fd29 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 05:42:35 +0100 Subject: [PATCH 2/8] Include username in session logs if available Fixes #1310 --- src/apiutils.nim | 10 +++++----- src/auth.nim | 20 +++++++++++++++++--- src/experimental/parser/session.nim | 2 ++ src/experimental/types/session.nim | 2 +- src/types.nim | 1 + 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index ae459fa..defffd1 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -60,11 +60,11 @@ proc getAndValidateSession*(api: Api): Future[Session] {.async.} = case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: - echo "[sessions] Empty oauth token, session: ", result.id + echo "[sessions] Empty oauth token, session: ", result.pretty raise rateLimitError() of SessionKind.cookie: if result.authToken.len == 0 or result.ct0.len == 0: - echo "[sessions] Empty cookie credentials, session: ", result.id + echo "[sessions] Empty cookie credentials, session: ", result.pretty raise rateLimitError() template fetchImpl(result, fetchBody) {.dirty.} = @@ -107,7 +107,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = setLimited(session, api) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[sessions] 429 error, API: ", api, ", session: ", session.id + echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty session.apis[api].remaining = 0 # rate limit hit, resets after the 15 minute window raise rateLimitError() @@ -124,8 +124,8 @@ template fetchImpl(result, fetchBody) {.dirty.} = except OSError as e: raise e except Exception as e: - let id = if session.isNil: "null" else: $session.id - echo "error: ", e.name, ", msg: ", e.msg, ", sessionId: ", id, ", url: ", url + let s = session.pretty + echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url raise rateLimitError() finally: release(session) diff --git a/src/auth.nim b/src/auth.nim index 9f9fe8a..6c52918 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -29,6 +29,20 @@ var template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") +proc pretty*(session: Session): string = + if session.isNil: + return "" + + if session.id > 0 and session.username.len > 0: + result = $session.id & " (" & session.username & ")" + elif session.username.len > 0: + result = session.username + elif session.id > 0: + result = $session.id + else: + result = "" + result = $session.kind & " " & result + proc snowflakeToEpoch(flake: int64): int64 = int64(((flake shr 22) + 1288834974657) div 1000) @@ -130,7 +144,7 @@ proc isLimited(session: Session; api: Api): bool = if session.limited and api != Api.userTweets: if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false - log "resetting limit: ", session.id + log "resetting limit: ", session.pretty return false else: return true @@ -146,7 +160,7 @@ proc isReady(session: Session; api: Api): bool = proc invalidate*(session: var Session) = if session.isNil: return - log "invalidating: ", session.id + log "invalidating: ", session.pretty # TODO: This isn't sufficient, but it works for now let idx = sessionPool.find(session) @@ -171,7 +185,7 @@ proc getSession*(api: Api): Future[Session] {.async.} = proc setLimited*(session: Session; api: Api) = session.limited = true session.limitedAt = epochTime().int - log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", id: ", session.id + log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = # avoid undefined behavior in race conditions diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim index bb31d83..2e5a171 100644 --- a/src/experimental/parser/session.nim +++ b/src/experimental/parser/session.nim @@ -13,6 +13,7 @@ proc parseSession*(raw: string): Session = result = Session( kind: SessionKind.oauth, id: parseBiggestInt(id), + username: session.username, oauthToken: session.oauthToken, oauthSecret: session.oauthTokenSecret ) @@ -21,6 +22,7 @@ proc parseSession*(raw: string): Session = result = Session( kind: SessionKind.cookie, id: id, + username: session.username, authToken: session.authToken, ct0: session.ct0 ) diff --git a/src/experimental/types/session.nim b/src/experimental/types/session.nim index dd6be22..dfec428 100644 --- a/src/experimental/types/session.nim +++ b/src/experimental/types/session.nim @@ -1,8 +1,8 @@ type RawSession* = object kind*: string - username*: string id*: string + username*: string oauthToken*: string oauthTokenSecret*: string authToken*: string diff --git a/src/types.nim b/src/types.nim index 092d85f..55d990d 100644 --- a/src/types.nim +++ b/src/types.nim @@ -38,6 +38,7 @@ type Session* = ref object id*: int64 + username*: string pending*: int limited*: bool limitedAt*: int From 0bb0b7e78cc617544231c286a9bb641166010897 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 06:32:13 +0100 Subject: [PATCH 3/8] Support grok_share card Fixes #1306 --- src/experimental/parser/unifiedcard.nim | 15 +++++++++++++++ src/experimental/types/unifiedcard.nim | 7 +++++++ src/formatters.nim | 7 +++++-- src/sass/tweet/card.scss | 1 + 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/experimental/parser/unifiedcard.nim b/src/experimental/parser/unifiedcard.nim index a112974..de4df18 100644 --- a/src/experimental/parser/unifiedcard.nim +++ b/src/experimental/parser/unifiedcard.nim @@ -1,6 +1,7 @@ import std/[options, tables, strutils, strformat, sugar] import jsony import user, ../types/unifiedcard +import ../../formatters from ../../types import Card, CardKind, Video from ../../utils import twimg, https @@ -77,6 +78,18 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) = of model3d: result.title = "Unsupported 3D model ad" +proc parseGrokShare(data: ComponentData; card: UnifiedCard; result: var Card) = + result.kind = summaryLarge + + data.destination.parseDestination(card, result) + result.dest = "Answer by Grok" + + for msg in data.conversationPreview: + if msg.sender == "USER": + result.title = msg.message.shorten(70) + elif msg.sender == "AGENT": + result.text = msg.message.shorten(500) + proc parseUnifiedCard*(json: string): Card = let card = json.fromJson(UnifiedCard) @@ -92,6 +105,8 @@ proc parseUnifiedCard*(json: string): Card = component.parseMedia(card, result) of buttonGroup: discard + of grokShare: + component.data.parseGrokShare(card, result) of ComponentType.jobDetails: component.data.parseJobDetails(card, result) of ComponentType.hidden: diff --git a/src/experimental/types/unifiedcard.nim b/src/experimental/types/unifiedcard.nim index e540a64..cef6f44 100644 --- a/src/experimental/types/unifiedcard.nim +++ b/src/experimental/types/unifiedcard.nim @@ -22,6 +22,7 @@ type communityDetails mediaWithDetailsHorizontal hidden + grokShare unknown Component* = object @@ -42,6 +43,7 @@ type topicDetail*: tuple[title: Text] profileUser*: User shortDescriptionText*: string + conversationPreview*: seq[GrokConversation] MediaItem* = object id*: string @@ -76,6 +78,10 @@ type title*: Text category*: Text + GrokConversation* = object + message*: string + sender*: string + TypeField = Component | Destination | MediaEntity | AppStoreData converter fromText*(text: Text): string = string(text) @@ -96,6 +102,7 @@ proc enumHook*(s: string; v: var ComponentType) = of "community_details": communityDetails of "media_with_details_horizontal": mediaWithDetailsHorizontal of "commerce_drop_details": hidden + of "grok_share": grokShare else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown proc enumHook*(s: string; v: var AppType) = diff --git a/src/formatters.nim b/src/formatters.nim index 7428814..cafaa4f 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -33,10 +33,13 @@ proc getUrlPrefix*(cfg: Config): string = if cfg.useHttps: https & cfg.hostname else: "http://" & cfg.hostname -proc shortLink*(text: string; length=28): string = - result = text.replace(wwwRegex, "") +proc shorten*(text: string; length=28): string = + result = text if result.len > length: result = result[0 ..< length] & "…" + +proc shortLink*(text: string; length=28): string = + result = text.replace(wwwRegex, "").shorten(length) proc stripHtml*(text: string; shorten=false): string = var html = parseHtml(text) diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 680310c..5575191 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -42,6 +42,7 @@ .card-description { margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { From bb6eb81a20b482b6ba1dd6da3332cd292427778e Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 10:59:50 +0100 Subject: [PATCH 4/8] Add support for tweet views --- public/css/fontello.css | 44 ++++++++++++++++++------------------ public/fonts/LICENSE.txt | 18 +++++++-------- public/fonts/fontello.eot | Bin 9264 -> 9368 bytes public/fonts/fontello.svg | 32 ++++++++++++++------------ public/fonts/fontello.ttf | Bin 9096 -> 9200 bytes public/fonts/fontello.woff | Bin 5752 -> 5812 bytes public/fonts/fontello.woff2 | Bin 4772 -> 4832 bytes src/parser.nim | 6 ++++- src/types.nim | 1 + src/views/general.nim | 4 ++-- src/views/tweet.nim | 2 ++ 11 files changed, 58 insertions(+), 49 deletions(-) diff --git a/public/css/fontello.css b/public/css/fontello.css index d022bb5..2453575 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,16 +1,15 @@ @font-face { font-family: 'fontello'; - src: url('/fonts/fontello.eot?21002321'); - src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'), - url('/fonts/fontello.woff2?21002321') format('woff2'), - url('/fonts/fontello.woff?21002321') format('woff'), - url('/fonts/fontello.ttf?21002321') format('truetype'), - url('/fonts/fontello.svg?21002321#fontello') format('svg'); + src: url('/fonts/fontello.eot?61663884'); + src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), + url('/fonts/fontello.woff2?61663884') format('woff2'), + url('/fonts/fontello.woff?61663884') format('woff'), + url('/fonts/fontello.ttf?61663884') format('truetype'), + url('/fonts/fontello.svg?61663884#fontello') format('svg'); font-weight: normal; font-style: normal; } - - [class^="icon-"]:before, [class*=" icon-"]:before { +[class^="icon-"]:before, [class*=" icon-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; @@ -32,22 +31,23 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - -.icon-heart:before { content: '\2665'; } /* '♥' */ -.icon-quote:before { content: '\275e'; } /* '❞' */ -.icon-comment:before { content: '\e802'; } /* '' */ -.icon-ok:before { content: '\e803'; } /* '' */ -.icon-play:before { content: '\e804'; } /* '' */ -.icon-link:before { content: '\e805'; } /* '' */ -.icon-calendar:before { content: '\e806'; } /* '' */ -.icon-location:before { content: '\e807'; } /* '' */ + +.icon-views:before { content: '\e800'; } /* '' */ +.icon-heart:before { content: '\e801'; } /* '' */ +.icon-quote:before { content: '\e802'; } /* '' */ +.icon-comment:before { content: '\e803'; } /* '' */ +.icon-ok:before { content: '\e804'; } /* '' */ +.icon-play:before { content: '\e805'; } /* '' */ +.icon-link:before { content: '\e806'; } /* '' */ +.icon-calendar:before { content: '\e807'; } /* '' */ +.icon-location:before { content: '\e808'; } /* '' */ .icon-picture:before { content: '\e809'; } /* '' */ .icon-lock:before { content: '\e80a'; } /* '' */ .icon-down:before { content: '\e80b'; } /* '' */ -.icon-retweet:before { content: '\e80d'; } /* '' */ -.icon-search:before { content: '\e80e'; } /* '' */ -.icon-pin:before { content: '\e80f'; } /* '' */ -.icon-cog:before { content: '\e812'; } /* '' */ -.icon-rss-feed:before { content: '\e813'; } /* '' */ +.icon-retweet:before { content: '\e80c'; } /* '' */ +.icon-search:before { content: '\e80d'; } /* '' */ +.icon-pin:before { content: '\e80e'; } /* '' */ +.icon-cog:before { content: '\e80f'; } /* '' */ +.icon-rss:before { content: '\e810'; } /* '' */ .icon-info:before { content: '\f128'; } /* '' */ .icon-bird:before { content: '\f309'; } /* '' */ diff --git a/public/fonts/LICENSE.txt b/public/fonts/LICENSE.txt index c8d90ff..41f18a8 100644 --- a/public/fonts/LICENSE.txt +++ b/public/fonts/LICENSE.txt @@ -1,6 +1,15 @@ Font license info +## Modern Pictograms + + Copyright (c) 2012 by John Caserta. All rights reserved. + + Author: John Caserta + License: SIL (http://scripts.sil.org/OFL) + Homepage: http://thedesignoffice.org/project/modern-pictograms/ + + ## Entypo Copyright (C) 2012 by Daniel Bruce @@ -37,12 +46,3 @@ Font license info Homepage: http://aristeides.com/ -## Modern Pictograms - - Copyright (c) 2012 by John Caserta. All rights reserved. - - Author: John Caserta - License: SIL (http://scripts.sil.org/OFL) - Homepage: http://thedesignoffice.org/project/modern-pictograms/ - - diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index aaddd6bf3959a71b00b92cd01dd4067db5d94b53..2b2982a5711bffac0cb1aad4690f661e34e308c4 100644 GIT binary patch delta 901 zcmYL`Pe>F|9LIle-kaGO-DaKLSzJZM*3HB}5teR{$kHLKgNjIG3c2d~Cw6skm(4=l zhy~(dEf8v{T}Z(zqi)0 zbU|DS0Q_7OgG}AbZY+itQtg4>EdbID0E5G^u<_az?MJ@`Aj}RY6V({U$9o1p>Zmam z8#=Ln6MY9-=U6yyV7vtVNu1@e=#A0-kF`GlSR;UQc03XuX?h#@fGMlE-a3u}#~u0@ z{U`K+@mQj7=3FkH2hbP>x}#IW;fl4g5%j(2>to>=g9O!w=x?E~o(#t#TaPL#(XU{E zTgFs8@$7s1X8_$QWYd_A7;~Ak8GxEzzC!vaqka4jX-k=~r_i4KHMl~bzynMr$p1$` zDIyfX$^nZZ3wF+Jj9S@^I2RS-3@)LUao|3)%{}irTC&*BT!&3ac;1@lp zBu?l7FQFpOQ>ijrNCdzY6QzZ9lv#lLSyxI%hk$~!ja7tc1I4Iqz=_%hj-a*y6}1hx zP}^A7X4-%TSo|U225iH57WxcyKzNRY zxf~u?R}xI8Ug}ZcmL{mZ}!k-3Fn0u z)JHq%HuJGLmK8h29jX2BdO%OV(iB2k__jMkTGIDC7YWh%JH1vRUJ#QLk?V1u(+6C1 VIv!^elcQ5?XkvN<(oyeA^)Fc#!jb?0 delta 802 zcmXw0O-vI}5T3Vhx3p5aZI=RKW02ss3MHhG)+h&#RY)*WNic|6+HDs@VWka0f;L7% zh$nI5PmB`O3nybJ;o!lO2M@(?FkZYUr;TZ12pTbw)Oox5Hs4IXZ)V?ov-|km>x9tN z2C%d&#QB5$NOHM%sW`uwoChEm0AMJUQ}n0zKQ$s>1K=K}W(qB+4 z_yG;LvHnJ%R`pw(XEy;_E5<4rri7-9KcuH9t9vebv3d+m34JsK4j@U;FkhF?n{A_)zGL}W7e9YDeZ>$TxBQxFIinchZrB^y4x6>n6Zr*{CE}A5m zjO-s=6?=YyNUgO$mw)x)AYj=j_~KNerm^lDIB^m**nl#8BO2M}yxd)Ghc?j_y2B^< zw?c#P!r`#&Nu^G|i;ynkw6saOO5dgPgh3lwtQPng*KAD-~$Hu4A JrH_Gkp1<)Mv_b#? diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 1f30ccc..2a64343 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,26 +1,28 @@ -Copyright (C) 2020 by original authors @ fontello.com +Copyright (C) 2025 by original authors @ fontello.com - + - + - + - + - + - + - + - + + + @@ -28,19 +30,19 @@ - + - + - + - + - + - \ No newline at end of file + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index 29f1ec6d6ee124b4f25d2cad015933efaf7f982b..ef775f87308c85319ecd0d8c36791622adf5c7eb 100644 GIT binary patch delta 903 zcmYL{TSyd97{|XeXJ&WC%j{LWpt7vCiSAlhvv!e9ddP}UiB&R{9Nk^l*vsJBX5yur zB|aHL1VKx^Nkm91zJ$<&kJ&@@6huK6279Q`pdPmE8y#uR{O0`s-#KT_cfOgqwjXT^ zu9-k>a2HpfTvlFLV!h~>0GP?4^q33dc$FN&hZs?Y(9DPK|g^8y9!739eAk2rYm(H<0g)5CsRzSwzfwB9QukK^!IDq zs1vkfhDV2hfW3jfvt|H=s12Y9wE+~PHUJT|0a#EQ=$}e602^S&JAfUqLOUcdH7rDa zgIQ!=qcY=$Qfm`WIKs`E2%%)wOeoM)@>6AkyNjJg-F>?_S1wo)kddB iASJ-1N5Yp=YL~OmLPt|6b|eu=vV$X|A;_*d*TjFl`^A+2 delta 787 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 delta 3710 zcmXYz2Q=JG7shvyjb0KpN;XTZkawf5)rl6-6D84EHEOV|5`R6261)UiohZ?X5MA`> zy+rRdgsjfD`Mz(?`JK7P=%5Y6A2Gc1-~6p$xG zdcL7LzGz2t7Z3s3rL3Rler3K>D z+s53D&`Xv>SSI~Hv^EZ2)&#AHfGY?@Ntu~J{My#a+!6%34JFhF5ib5oSScokUGOI04sIep~=jp&=p9ry%Fon;k%Gzg_#gaW=c*tYv@T{g>;QqRlD(jjG}~ zc;7t!4EU0krl;R7!GV32XP@C@!Js+Ln^vMLiTRwr{X+4wllLjn53qD8(KTxk#eOZ# zg-11+izrDAA9&9rAa2!<`dd=J||N(KCW7&dpbA^@tB|X)#ozq zmn`uSkKwvwJh_FuI>H62<|$g?+>>x=|6r~5JSuL14&em5rSJK7KCENNXW;#w$5*wT zo&)C>Y4g9l$Y>=eAI`$0Z`qzm5&`$m!SHmNFWL_bNI1FbntAov>u&FNL@q9_t*)&u zE(Y`|6-&E(EJ4n(y=nw=$~z;s{dmg*3tsT2}=|9&%h zbCWg%8|j7|`=ch<{fL%-IpSOyX1a41E1f_c^H(i9y!EQ8*V_%_KYdRfvA|wPHa~uz z!;jGzsA`kWXu8iBE;>CO0E+Yu*y)6qg+c~wdW92Uillb$k#2F3GJN{wZ1%J`Ip&NY ztw19$fBZC=+1;Wh;GRC}G8-RRD8Q?=Mpu7NY~{n75eoitZ_mZn^KBRRs}3B$^8+HR zPDVttZMj1fywZZXMcxK?hm zQ8^g2SypJ(RT$L?`*t6k z3w@IEdZ5P<-lF_vdMtJbZ1|2z*k^tWD>)rDb5(DhQeAnVt}Y~WI{iCgI#(*7BE2cc z#+4KKs&gZJI$;_ZdsCgLX)ASZ5*4F>e$~6SR?T@(Lso;=pW;=GBejF4a;dM~Q6||k zZ_BL-t5|Zg@68QtHR@5JV~(}tPCW{wgx+mc3PqFLT<+%broR!V1|PBJon!f>=B_Z4 zy<3jtWFv>TH?75ywyuAOgiR_j3yGu(8txy-)qIU><;w^1j?7R|m+m3=DgJny`)D>c z$(VkFtbHljm6`}G5LElMbTn`xoQfD_+Mk>wmT^E-uKXj(fhN1oIt~xz+p=?41)bl} z_=tgou_m{K4tfda+`N#6W@bEEilL?%}l0z7npX_iASdd$WlVdBnN$NXW^)!!qju6|9C(15jiWgpFH_&- z#Vigz*sm`NRJO3OqRGG6Gxc^|i2C2k9fxYQKYL(tVG*v;k2D;(Z$iZEGP()7-+yXl ziZY0ly+Ij5;PTGDA;~QwEAn_NBe&q3u#t_`@97deD z01JL(Xl68$g5AqM8vwmS&f9S~Hp;iWl5KdDuvId0wRg1G*iV^#ik*+vyGU)2^Ngjn zvuB=5T+8%~x$%gej6bsM-Qg`%Ol%QiArOcb|9!ajS|k%0Q#Y$z79zPa`+A^=6@hHp z^7tMzONJTWb-H57o2}eC(UA7zJsK2Z{_yC1W=95DAW1gmX;8i@-wA_UVz3*z7K}JS z(^aXu44#iiq4-DnpH12Jkgf+6j=t>8>fkJ?)5iGxsd^dM(pjS7Dvn!fc&N|fCJJcA zNayExcuU?CzXvvNm_Tm)qU{I5ZdsAGJuoHxS7I;6X2i-iZ{fiQ6lZf~&nNh1WA=iY z+q(hizINuHx6e}+JJd2LS6MrvJpQD0x@kteT}V5^97vUV4{SH&RkI$iJkMfUO%2yE z?^8$T^-G;m32nYrfC~(dP3A76!z%$-dI9sU*!xZ+!}@{)yEG)6EjVa<#7jhBT`ktY z4<4QZv(eTzys&%>Gw9*^s>H2d?FU{dekSC-h!>OW?8QDv{>keA_CniRd9REgg}8m( zUiY{-xg!14V^JfRkG_)eIX%aZ7Z=?> z&p$6mtG+O1VDQa^Yx#KYtu7z6h1_VxK$tEVFB!Jfk}OreXfqmiX;(b9Yz?n!qY&AD z92L(W)V3B*vf|%wuiO=6aORx(ZC=5(g#U%!mrimbr)q(4RpHVcb_@uv^?=KOK+ow4Ue8o<8*r zu`>c~|8!k{#o5)kW?HoNJb6~1MCB&NUknF-m3eYv_5@u4Ne4a_Q-exoSL$l3y4!QM z79i2B$0AmC!u{<*6Y z)DT{gOqoB&QMz^WI|H9O$}{YSu{C{EX+wMvQY0N zYEZezd#RuRFz6CZ89{K1gux541yzB1L0e#3@E{Q_Q3TNvF*9)*@e;`slExd1H?klQ zf|VqA@Ay0P96;aG@3QntO+#lBff0d*zn&Vj2}6F;lZQk&xpNK|=h&s>{hEiv< zCV=nH)I?=&ciiaH%K5lv8ebNK;&Us-BvsVc0%Y!yWf2AIdMzF%HM_5h>pdKFXzh~> zQ%~M}>?8$bb1cJ3 zKKa2&*tG#Y&pqwT(7+CADm-c->~??6LIkV9PsOo#m0IsdIF*+i$`Q2-avCkZmiw;< z$4Mgkx!5ODcEk$1XX?4)44HG5MG6(X?)Utu?{hHKkWNfA74JLD=5!tIj9wjq!`qmw O#fW*JpmUl- diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index 551f49d02d766d86f92cf78bc80f94379da58c6e..b7541f0278434295a1178b8d5a6adff4deea570c 100644 GIT binary patch literal 4832 zcmV<65+Ch%Pew8T0RR91021H;4*&oF03+}K01}A+0RR9100000000000000000000 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 GNo@dwG#q~b literal 4772 zcmV;V5?k$ePew8T0RR9101~7C4*&oF03(P101`?70RR9100000000000000000000 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 diff --git a/src/parser.nim b/src/parser.nim index aa0f8b2..f132dea 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -231,7 +231,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = replies: js{"reply_count"}.getInt, retweets: js{"retweet_count"}.getInt, likes: js{"favorite_count"}.getInt, - quotes: js{"quote_count"}.getInt + quotes: js{"quote_count"}.getInt, + views: js{"views_count"}.getInt ) ) @@ -339,6 +340,9 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) + with count, js{"views", "count"}: + result.stats.views = count.getStr("0").parseInt + with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: result.expandNoteTweetEntities(noteTweet) diff --git a/src/types.nim b/src/types.nim index 55d990d..5a08bb7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -203,6 +203,7 @@ type retweets*: int likes*: int quotes*: int + views*: int Tweet* = ref object id*: int64 diff --git a/src/views/general.nim b/src/views/general.nim index 5ba40a3..0571aa7 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -53,7 +53,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; buildHtml(head): link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) @@ -119,7 +119,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; # this is last so images are also preloaded # if this is done earlier, Chrome only preloads one image for some reason link(rel="preload", type="font/woff2", `as`="font", - href="/fonts/fontello.woff2?21002321", crossorigin="anonymous") + href="/fonts/fontello.woff2?61663884", crossorigin="anonymous") proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 34dcd4c..6d76755 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -184,6 +184,8 @@ proc renderStats(stats: TweetStats; views: string): VNode = span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes) + if stats.views > 0: + span(class="tweet-stat"): icon "views", formatStat(stats.views) if views.len > 0: span(class="tweet-stat"): icon "play", insertSep(views, ',') From 886f2d2a4540e238c3ff71b7c2341aba159c3f8c Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 11:00:38 +0100 Subject: [PATCH 5/8] Bump API versions, use more SessionAwareUrls --- src/api.nim | 41 ++++++++++++++---- src/consts.nim | 64 ++++++++++++++++++---------- src/experimental/parser/graphql.nim | 32 +++++++++++--- src/experimental/types/graphuser.nim | 30 +++++++++++-- src/parser.nim | 36 +++++++++------- 5 files changed, 145 insertions(+), 58 deletions(-) diff --git a/src/api.nim b/src/api.nim index c0efa58..aeb0f17 100644 --- a/src/api.nim +++ b/src/api.nim @@ -7,12 +7,39 @@ import experimental/parser as newParser proc mediaUrl(id: string; cursor: string): SessionAwareUrl = let cookieVariables = userMediaVariables % [id, cursor] - oauthVariables = userTweetsVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] result = SessionAwareUrl( cookieUrl: graphUserMedia ? {"variables": cookieVariables, "features": gqlFeatures}, oauthUrl: graphUserMediaV2 ? {"variables": oauthVariables, "features": gqlFeatures} ) +proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userTweetsVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, + oauthUrl: graphUserTweetsV2 ? {"variables": oauthVariables, "features": gqlFeatures} + ) + +proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userTweetsAndRepliesVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, + oauthUrl: graphUserTweetsAndRepliesV2 ? {"variables": oauthVariables, "features": gqlFeatures} + ) + +proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = tweetDetailVariables % [id, cursor] + oauthVariables = tweetVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles}, + oauthUrl: graphTweet ? {"variables": oauthVariables, "features": gqlFeatures} + ) + proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let @@ -33,13 +60,11 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = userTweetsVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures} js = case kind of TimelineKind.tweets: - await fetch(graphUserTweets ? params, Api.userTweets) + await fetch(userTweetsUrl(id, cursor), Api.userTweets) of TimelineKind.replies: - await fetch(graphUserTweetsAndReplies ? params, Api.userTweetsAndReplies) + await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) of TimelineKind.media: await fetch(mediaUrl(id, cursor), Api.userMedia) result = parseGraphTimeline(js, after) @@ -48,7 +73,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = listTweetsVariables % [id, cursor] + variables = restIdVariables % [id, cursor] params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphListTweets ? params, Api.listTweets) result = parseGraphTimeline(js, after).tweets @@ -94,9 +119,7 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = tweetVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphTweet ? params, Api.tweetDetail) + js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) result = parseGraphConversation(js, id) proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = diff --git a/src/consts.nim b/src/consts.nim index c8ae8d2..2623484 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,16 +9,19 @@ const graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" - graphUserTweets* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" - graphUserTweetsAndReplies* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweetsV2* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" + graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMediaV2* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" + graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" - graphSearchTimeline* = gql / "KI9jCXUx3Ymt-hDKLOZb9Q/SearchTimeline" - graphListById* = gql / "oygmAig8kjn0pKsx_bUadQ/ListByRestId" - graphListBySlug* = gql / "88GTz-IPPWLn1EiU8XoNVg/ListBySlug" - graphListMembers* = gql / "kSmxeqEeelqdHSR7jMnb_w/ListMembers" + graphSearchTimeline* = gql / "7r8ibjHuK3MWUyzkzHNMYQ/SearchTimeline" + graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" + graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" + graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" gqlFeatures* = """{ @@ -96,24 +99,20 @@ const "withV2Timeline": true }""".replace(" ", "").replace("\n", "") -# oldUserTweetsVariables* = """{ -# "userId": "$1", $2 -# "count": 20, -# "includePromotedContent": false, -# "withDownvotePerspective": false, -# "withReactionsMetadata": false, -# "withReactionsPerspective": false, -# "withVoice": false, -# "withV2Timeline": true -# } -# """ + tweetDetailVariables* = """{ + "focalTweetId": "$1", + $2 + "referrer": "profile", + "with_rux_injections": false, + "rankingMode": "Relevance", + "includePromotedContent": true, + "withCommunity": true, + "withQuickPromoteEligibilityTweetFields": true, + "withBirdwatchNotes": true, + "withVoice": true +}""".replace(" ", "").replace("\n", "") - userTweetsVariables* = """{ - "rest_id": "$1", $2 - "count": 20 -}""" - - listTweetsVariables* = """{ + restIdVariables* = """{ "rest_id": "$1", $2 "count": 20 }""" @@ -126,3 +125,22 @@ const "withBirdwatchNotes": false, "withVoice": true }""".replace(" ", "").replace("\n", "") + + userTweetsVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withQuickPromoteEligibilityTweetFields": true, + "withVoice": true +}""".replace(" ", "").replace("\n", "") + + userTweetsAndRepliesVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withCommunity": true, + "withVoice": true +}""".replace(" ", "").replace("\n", "") + + fieldToggles* = """{"withArticlePlainText":false}""" + tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 69837ab..045a5d6 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,21 +1,39 @@ -import options +import options, strutils import jsony import user, ../types/[graphuser, graphlistmembers] from ../../types import User, VerifiedType, Result, Query, QueryKind +proc parseUserResult*(userResult: UserResult): User = + result = userResult.legacy + + if result.verifiedType == none and userResult.isBlueVerified: + result.verifiedType = blue + + if result.username.len == 0 and userResult.core.screenName.len > 0: + result.id = userResult.restId + result.username = userResult.core.screenName + result.fullname = userResult.core.name + result.userPic = userResult.avatar.imageUrl.replace("_normal", "") + + if userResult.verification.isSome: + let v = userResult.verification.get + if v.verifiedType != VerifiedType.none: + result.verifiedType = v.verifiedType + + if userResult.profileBio.isSome: + result.bio = userResult.profileBio.get.description + proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return let raw = json.fromJson(GraphUser) + let userResult = raw.data.userResult.result - if raw.data.userResult.result.unavailableReason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended": return User(suspended: true) - result = raw.data.userResult.result.legacy - result.id = raw.data.userResult.result.restId - if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified: - result.verifiedType = blue + result = parseUserResult(userResult) proc parseGraphListMembers*(json, cursor: string): Result[User] = result = Result[User]( @@ -31,7 +49,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] = of TimelineTimelineItem: let userResult = entry.content.itemContent.userResults.result if userResult.restId.len > 0: - result.content.add userResult.legacy + result.content.add parseUserResult(userResult) of TimelineTimelineCursor: if entry.content.cursorType == "Bottom": result.bottom = entry.content.value diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index 08100f9..d732b4e 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -1,5 +1,5 @@ -import options -from ../../types import User +import options, strutils +from ../../types import User, VerifiedType type GraphUser* = object @@ -8,8 +8,32 @@ type UserData* = object result*: UserResult - UserResult = object + UserCore* = object + name*: string + screenName*: string + createdAt*: string + + UserBio* = object + description*: string + + UserAvatar* = object + imageUrl*: string + + Verification* = object + verifiedType*: VerifiedType + + UserResult* = object legacy*: User restId*: string isBlueVerified*: bool unavailableReason*: Option[string] + core*: UserCore + avatar*: UserAvatar + profileBio*: Option[UserBio] + verification*: Option[Verification] + +proc enumHook*(s: string; v: var VerifiedType) = + v = try: + parseEnum[VerifiedType](s) + except: + VerifiedType.none diff --git a/src/parser.nim b/src/parser.nim index f132dea..c4ccab0 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -42,16 +42,16 @@ proc parseGraphUser(js: JsonNode): User = result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) # fallback to support UserMedia/recent GraphQL updates - if result.username.len == 0 and user{"core", "screen_name"}.notNull: + if result.username.len == 0: result.username = user{"core", "screen_name"}.getStr result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") if user{"is_blue_verified"}.getBool(false): result.verifiedType = blue - elif user{"verification", "verified_type"}.notNull: - let verifiedType = user{"verification", "verified_type"}.getStr("None") - result.verifiedType = parseEnum[VerifiedType](verifiedType) + + with verifiedType, user{"verification", "verified_type"}: + result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -372,10 +372,10 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet, false) -proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversation = +proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let + v2 = js{"data", "timeline_response"}.notNull rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2" contentKey = if v2: "content" else: "itemContent" resultKey = if v2: "tweetResult" else: "tweet_results" @@ -385,7 +385,8 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati return for i in instructions: - if i{"__typename"}.getStr == "TimelineAddEntries": + let instrType = i{"__typename"}.getStr(i{"type"}.getStr) + if instrType == "TimelineAddEntries": for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): @@ -421,20 +422,23 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati result.replies.bottom = e{"content", contentKey, "value"}.getStr proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = - if e{"content", "items"}.notNull: - for item in e{"content", "items"}: - with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: - var tweet = parseGraphTweet(tweetResult, false) - if not tweet.available: - tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) - result.add tweet - return + var tweetResult = e{"content", "itemContent", "tweet_results", "result"} + if tweetResult.isNull: + tweetResult = e{"content", "content", "tweetResult", "result"} - with tweetResult, e{"content", "content", "tweetResult", "result"}: + if tweetResult.notNull: var tweet = parseGraphTweet(tweetResult, false) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) result.add tweet + return + + for item in e{"content", "items"}: + with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: + var tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) From 6b655cddd803fb1efc424b70d8f7be760b6c7c60 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 11:01:20 +0100 Subject: [PATCH 6/8] Cleanup --- src/auth.nim | 17 +---------------- src/experimental/parser/user.nim | 18 ------------------ src/experimental/types/timeline.nim | 23 ----------------------- 3 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 src/experimental/types/timeline.nim diff --git a/src/auth.nim b/src/auth.nim index 6c52918..734b43e 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -7,20 +7,6 @@ import experimental/parser/session const maxConcurrentReqs = 2 hourInSeconds = 60 * 60 - apiMaxReqs: Table[Api, int] = { - Api.search: 50, - Api.tweetDetail: 500, - Api.userTweets: 500, - Api.userTweetsAndReplies: 500, - Api.userMedia: 500, - Api.userRestId: 500, - Api.userScreenName: 500, - Api.tweetResult: 500, - Api.list: 500, - Api.listTweets: 500, - Api.listMembers: 500, - Api.listBySlug: 500 - }.toTable var sessionPool: seq[Session] @@ -71,8 +57,7 @@ proc getSessionPoolHealth*(): JsonNode = for api in session.apis.keys: let apiStatus = session.apis[api] - limit = if apiStatus.limit > 0: apiStatus.limit else: apiMaxReqs.getOrDefault(api, 0) - reqs = limit - apiStatus.remaining + reqs = apiStatus.limit - apiStatus.remaining # no requests made with this session and endpoint since the limit reset if apiStatus.reset < now: diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 07e0477..498757a 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -72,21 +72,3 @@ proc parseHook*(s: string; i: var int; v: var User) = var u: RawUser parseHook(s, i, u) v = toUser u - -proc parseUser*(json: string; username=""): User = - handleErrors: - case error.code - of suspended: return User(username: username, suspended: true) - of userNotFound: return - else: echo "[error - parseUser]: ", error - - result = json.fromJson(User) - -proc parseUsers*(json: string; after=""): Result[User] = - result = Result[User](beginning: after.len == 0) - - # starting with '{' means it's an error - if json[0] == '[': - let raw = json.fromJson(seq[RawUser]) - for user in raw: - result.content.add user.toUser diff --git a/src/experimental/types/timeline.nim b/src/experimental/types/timeline.nim deleted file mode 100644 index 5ce6d9f..0000000 --- a/src/experimental/types/timeline.nim +++ /dev/null @@ -1,23 +0,0 @@ -import std/tables -from ../../types import User - -type - Search* = object - globalObjects*: GlobalObjects - timeline*: Timeline - - GlobalObjects = object - users*: Table[string, User] - - Timeline = object - instructions*: seq[Instructions] - - Instructions = object - addEntries*: tuple[entries: seq[Entry]] - - Entry = object - entryId*: string - content*: tuple[operation: Operation] - - Operation = object - cursor*: tuple[value, cursorType: string] From e8de18317ec072e47a56b42dff44f7136b20117e Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 11:21:21 +0100 Subject: [PATCH 7/8] Fix broken pinned tweet parsing --- src/parser.nim | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index c4ccab0..614ab57 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -385,7 +385,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = return for i in instructions: - let instrType = i{"__typename"}.getStr(i{"type"}.getStr) + let instrType = i{"type"}.getStr(i{"__typename"}.getStr) if instrType == "TimelineAddEntries": for e in i{"entries"}: let entryId = e{"entryId"}.getStr @@ -421,7 +421,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", contentKey, "value"}.getStr -proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = +proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = var tweetResult = e{"content", "itemContent", "tweet_results", "result"} if tweetResult.isNull: tweetResult = e{"content", "content", "tweetResult", "result"} @@ -429,7 +429,7 @@ proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = if tweetResult.notNull: var tweet = parseGraphTweet(tweetResult, false) if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + tweet.id = parseBiggestInt(e.getEntryId()) result.add tweet return @@ -469,7 +469,7 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): - for tweet in extractTweetsFromEntry(e, entryId): + for tweet in extractTweetsFromEntry(e): result.tweets.content.add tweet elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): let (thread, self) = parseGraphThread(e) @@ -477,15 +477,14 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = elif entryId.startsWith("cursor-bottom"): result.tweets.bottom = e{"content", "value"}.getStr - if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": - with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: - let tweet = parseGraphTweet(tweetResult, false) - tweet.pinned = true - if not tweet.available and tweet.tombstone.len == 0: - let entryId = i{"entry", "entryId"}.getEntryId - if entryId.len > 0: - tweet.id = parseBiggestInt(entryId) - result.pinned = some tweet + if after.len == 0: + let instrType = i{"type"}.getStr(i{"__typename"}.getStr) + if instrType == "TimelinePinEntry": + let tweets = extractTweetsFromEntry(i{"entry"}) + if tweets.len > 0: + var tweet = tweets[0] + tweet.pinned = true + result.pinned = some tweet proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = result = @[] @@ -523,7 +522,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): - for t in extractTweetsFromEntry(e, entryId): + for t in extractTweetsFromEntry(e): let photo = extractGalleryPhoto(t) if photo.url.len > 0: result.add photo From 824a7e346a730a3eeb87a945e8268b3e40867f0d Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Nov 2025 12:08:44 +0100 Subject: [PATCH 8/8] Fix rss icon tag --- src/views/general.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/general.nim b/src/views/general.nim index 0571aa7..0091c74 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -30,7 +30,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" if cfg.enableRss and rss.len > 0: - icon "rss-feed", title="RSS Feed", href=rss + icon "rss", title="RSS Feed", href=rss icon "bird", title="Open in Twitter", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about"