Skip to content

Commit c08bcae

Browse files
authored
fix(sources): twitter/youtube connector follow-ups (session config, rate limits, docs) (#180)
Follow-ups on the X/Twitter (#175) and YouTube (#177) connectors after running both live, plus a shared cleanup. Twitter: - Session config (cookies/proxy/credentials dir) flows through Django settings (TWITTER_COOKIES_JSON / COOKIE_AUTH_TOKEN / COOKIE_CT0 / COOKIES_FILE / CREDENTIALS_DIR / PROXY), resolved per search call so a rotated cookie export applies without a restart; the documented-but-unwired JSON/file routes now work. CREDENTIALS_DIR defaults to an absolute path. - Skip timestamp-less tweets instead of minting now() (which would poison the watermark and strand every older-but-new tweet). - Honor X's rate-limit reset with an in-cycle retry loop (mirrors Reddit's 429 loop; X's x-rate-limit-reset is an absolute epoch), ticking the poll-lease heartbeat through the wait. - Watermark filter uses strict < not <= (same-second tweets were lost). - Pin twikit to the locked rev. Shared: - Promote sleep_with_heartbeat and rate_limit_delay to connectors/base so Reddit and Twitter share one definition each. - Rename ListenerError -> TwitterError (mirrors YouTubeError; drops the 'Listener' name AGENTS.md reserves). Docs/examples: - Twitter starter (feed + watch); list twitter + youtube in the examples README. - Per-connector credentials/ directory convention (gitignored) with a guide for generating the Twitter and YouTube cookie exports. - README: product mentions in 'What it does', YouTube in the listeners + diagram, and the changelog-style section replaced by a credentials/changelog pointer. Both connectors verified live end to end (feed -> watch -> judge). Full core suite, ruff, and ty green; reviewed via /review-pr with findings addressed.
1 parent eba9c80 commit c08bcae

18 files changed

Lines changed: 591 additions & 152 deletions

File tree

README.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -240,25 +240,7 @@ Social listening is a crowded market (Brand24, Mention, Octolens, Syften, and to
240240
| Webhook methods | `POST`, `PUT`, `PATCH` |
241241
| Delivery audit | per-attempt `WatchActionDelivery` |
242242

243-
## What we've done
244-
245-
X/Twitter listening is the first connector added beyond the original Reddit / HN / RSS set. What shipped in this branch:
246-
247-
- **`twitter_search` source kind** — a twikit-based connector that runs X search queries (mode Top/Latest, count) and maps results to a schema-parity `NewTweetPayload`, registered alongside the existing kinds with the same feed/watch/webhook pipeline.
248-
- **Live-cookie auth** — the connector authenticates with an existing X session cookie JSON (`TWITTER_COOKIES_JSON``auth_token`/`ct0`, a cookies file, or a login fallback), so no API key, proxy, or vendor API is required — the same live cookies the listening kit already used keep working.
249-
- **Reliability fixes from live polling** — a per-call twikit client (multi-source polls no longer crash with "Event loop is closed") and X's transient empty-body 404 mapped retryable instead of "tweet deleted", with a regression test. 587 tests green; all CI gates pass.
250-
- **Verified live end-to-end** — a real X poll through a feed → watch → webhook chain delivered 44/44 items with HTTP 200, payload matched field-for-field against the Twenty `socialEvent` intake contract (`item.handle → actorHandle`, `author → actorName`, `content → eventText`, `occurred_at → occurredAt`, `url → sourceUrl`, `key → dedupeKey`).
251-
252-
YouTube listening followed via yt-dlp:
253-
254-
- **`youtube_search` source kind** — a yt-dlp-based connector that runs YouTube search queries and maps results to a schema-parity `NewVideoPayload`, registered alongside the existing kinds with the same feed/watch/webhook pipeline.
255-
- **No authentication required** — public YouTube search works without credentials; optional cookie file for age-restricted content.
256-
- **Error taxonomy** — 5 error codes (`video_unavailable`, `rate_limited`, `js_runtime_missing`, `network_error`, `yt_dlp_error`) with retry semantics.
257-
- **Watermark-based deduplication** — videos newer than the source's `last_event_at` are surfaced.
258-
- **Metrics extraction** — views, likes, comments mapped from YouTube metadata.
259-
- **Thumbnail media** — full thumbnail URLs attached to payloads for rich display.
260-
261-
Next up on the roadmap: **Facebook, TikTok, and Instagram connectors** (soon to be added), then Slack, LinkedIn, GitHub, Bluesky, and Mastodon.
243+
Two connectors ride an unofficial route and need a browser session cookie (X/Twitter always, YouTube only for age-restricted videos); see [apps/core/credentials/README.md](apps/core/credentials/README.md) for setup and the terms-of-service caveat. Per-release history is in the [changelog](CHANGELOG.md).
262244

263245
## Roadmap
264246

apps/core/conf/settings/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,26 @@
525525
# Google account. Empty (the default) disables it.
526526
YOUTUBE_COOKIES_FILE = os.environ.get("YOUTUBE_COOKIES_FILE", "")
527527

528+
# X (Twitter) connector session config. The connector authenticates with an
529+
# existing x.com session, resolved per poll in priority order:
530+
# TWITTER_COOKIES_JSON a full JSON dict of x.com cookies (inline env)
531+
# TWITTER_COOKIE_AUTH_TOKEN + TWITTER_COOKIE_CT0 the critical pair
532+
# TWITTER_COOKIES_FILE path to one cookie export (JSON dict or a
533+
# Get-cookies.txt-LOCALLY array)
534+
# TWITTER_CREDENTIALS_DIR dir of *.json cookie exports, each with an
535+
# optional <name>.proxy pin
536+
# Empty values fall through to the next route; all empty = guest mode (the
537+
# first search fails with a mapped `unauthorized`). See
538+
# apps/core/credentials/README.md for the on-disk convention.
539+
TWITTER_COOKIES_JSON = os.environ.get("TWITTER_COOKIES_JSON", "")
540+
TWITTER_COOKIE_AUTH_TOKEN = os.environ.get("TWITTER_COOKIE_AUTH_TOKEN", "")
541+
TWITTER_COOKIE_CT0 = os.environ.get("TWITTER_COOKIE_CT0", "")
542+
TWITTER_COOKIES_FILE = os.environ.get("TWITTER_COOKIES_FILE", "")
543+
TWITTER_CREDENTIALS_DIR = os.environ.get("TWITTER_CREDENTIALS_DIR", str(BASE_DIR / "credentials" / "twitter"))
544+
# Egress proxy for X requests (twikit passes it to httpx); a per-credential
545+
# <name>.proxy pin in TWITTER_CREDENTIALS_DIR overrides it.
546+
TWITTER_PROXY = os.environ.get("TWITTER_PROXY", "")
547+
528548
# Product telemetry (anonymous, opt-out; see apps/core/telemetry + TELEMETRY.md).
529549
# POSTHOG_API_KEY defaults to the baked-in PUBLIC, WRITE-ONLY PostHog project key
530550
# (OpenMagpie's anonymous self-hosted project, PostHog Cloud US) so a self-hoster

apps/core/credentials/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,58 @@ TWITTER_CREDENTIALS_DIR=/app/apps/core/credentials/twitter
2020
YOUTUBE_COOKIES_FILE=/app/apps/core/credentials/youtube/cookies.txt
2121
```
2222

23+
Settings are read per poll, so a refreshed export applies on the next cycle
24+
without a restart. `.env` changes themselves still need the usual
25+
`docker compose up -d --force-recreate`.
26+
27+
## X / Twitter: generating a cookie export
28+
29+
The `twitter_search` connector authenticates with an existing x.com browser
30+
session; there is no API key.
31+
32+
1. Sign in to x.com in a browser.
33+
2. The minimal route needs just two cookies. In DevTools (Application ->
34+
Cookies -> https://x.com), copy the values of `auth_token` and `ct0`
35+
and set them directly:
36+
37+
```
38+
TWITTER_COOKIE_AUTH_TOKEN=<value>
39+
TWITTER_COOKIE_CT0=<value>
40+
```
41+
42+
3. For the file route instead, export the site's cookies with a browser
43+
extension such as Cookie-Editor (export as JSON) or "Get cookies.txt
44+
LOCALLY" (JSON export). Both shapes are accepted: a plain
45+
`{name: value}` dict, or the extension's array of cookie objects. Save
46+
it as `credentials/twitter/<name>.json`; the connector picks the first
47+
usable export (sorted by filename) that carries the `auth_token`/`ct0`
48+
pair.
49+
4. Optional: pin an egress proxy for one export by writing its URL to
50+
`credentials/twitter/<name>.proxy` (same basename). `TWITTER_PROXY`
51+
sets a global one.
52+
53+
The full priority order (first configured route wins):
54+
`TWITTER_COOKIES_JSON` (inline JSON dict) -> the `auth_token`/`ct0` pair ->
55+
`TWITTER_COOKIES_FILE` (one export) -> `TWITTER_CREDENTIALS_DIR`.
56+
57+
Sessions expire when X rotates them (or you log out in that browser);
58+
re-export and the next poll picks it up.
59+
60+
## YouTube: generating cookies.txt (optional)
61+
62+
Public YouTube search needs no credentials at all — set this up only if
63+
poll logs show relevant videos skipped as age-gated ("Sign in to confirm
64+
your age").
65+
66+
1. Sign in to youtube.com, ideally in a private/incognito window (yt-dlp's
67+
recommendation: export from a private session you then close, so the
68+
browser doesn't rotate the exported cookies out from under you).
69+
2. Export the cookies in **Netscape format** with a "Get cookies.txt
70+
LOCALLY"-style extension while on youtube.com (yt-dlp requires the
71+
cookies.txt format here, not JSON).
72+
3. Save it as `credentials/youtube/cookies.txt` and set
73+
`YOUTUBE_COOKIES_FILE` as above.
74+
2375
It's recommended to use throwaway accounts for any cookies that land here:
2476
platforms flag and sometimes lock accounts whose sessions show up in
2577
automated traffic. These connectors use unofficial routes that may conflict

apps/core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ dependencies = [
2424
"python-dotenv>=1.1",
2525
"pyyaml>=6.0", # reads the examples/starters/*.yaml in seed_quickstart
2626
"trafilatura>=1.7", # HTML -> readable article text for the engine's lazy external-link fetch
27-
"twikit @ git+https://github.com/unclecode/twikit.git", # X (Twitter) unofficial route (listeningkit-verified 2026 fork of d60/twikit)
27+
"twikit @ git+https://github.com/unclecode/twikit.git@6a73ab97f4de09f79139f6308c9fb80029a9f5f7", # X (Twitter) unofficial route (listeningkit-verified 2026 fork of d60/twikit), pinned: the fork's default branch floats and this code handles auth
2828
"yt-dlp>=2026.07.04", # YouTube search connector (public API only)
2929
"ulid>=1.1",
3030
]

apps/core/sources/connectors/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,41 @@ def parse_rate_limit_wait(response: httpx.Response) -> float | None:
103103
return _as_positive_float(response.headers.get("X-RateLimit-Reset"))
104104

105105

106+
def rate_limit_delay(relative_wait: float | None, attempt: int, *, base: float, cap: float) -> float:
107+
"""Seconds to wait before retrying a rate-limited request: the caller's
108+
header-derived relative wait when usable (finite, positive), else
109+
exponential backoff `base * 2**attempt`. Capped at `cap` so a hostile or
110+
far-future value can't stall a poll worker. Each connector converts its
111+
own source shape to a relative wait first (Reddit: `parse_rate_limit_wait`'s
112+
relative seconds; Twitter: an absolute `x-rate-limit-reset` epoch minus
113+
now), keeping `base` / `cap` / retry-count as its own tunables."""
114+
delay = relative_wait if relative_wait is not None and relative_wait > 0 else base * (2**attempt)
115+
return min(delay, cap)
116+
117+
118+
# How often a backoff sleep ticks the caller's poll-lease heartbeat: long
119+
# enough not to thrash, short enough that a minute-scale wait renews the lease
120+
# several times over.
121+
HEARTBEAT_SLEEP_CHUNK_SECONDS = 15.0
122+
123+
124+
def sleep_with_heartbeat(total: float, heartbeat: Callable[[], bool] | None) -> None:
125+
"""Sleep `total` seconds, ticking `heartbeat` every chunk so the caller's
126+
poll lease renews through the wait. The return value is deliberately
127+
ignored (see the Connector.poll contract). No heartbeat (direct calls /
128+
tests) = one plain sleep. Shared by every connector that backs off inside
129+
poll() (Reddit's 429 retry, the twikit rate-limit wait)."""
130+
if heartbeat is None:
131+
time.sleep(total)
132+
return
133+
remaining = total
134+
while remaining > 0:
135+
chunk = min(remaining, HEARTBEAT_SLEEP_CHUNK_SECONDS)
136+
time.sleep(chunk)
137+
remaining -= chunk
138+
heartbeat()
139+
140+
106141
# ── SSRF-safe fetch of the open web ───────────────────────────────────────
107142
# Two callers, one block POLICY (`common.ssrf` / `common.safe_http`):
108143
# - RSS feeds (OPERATOR-chosen URLs): `validate_request_url`, an httpx request

apps/core/sources/connectors/reddit/connector.py

Lines changed: 15 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111
from sources.payload_registry import register
1212
from sources.payloads import SourcePayload
1313

14-
from ..base import BaseConnector, ConnectorParseError, parse_rate_limit_wait, read_response_capped
14+
from ..base import (
15+
BaseConnector,
16+
ConnectorParseError,
17+
parse_rate_limit_wait,
18+
rate_limit_delay,
19+
read_response_capped,
20+
sleep_with_heartbeat,
21+
)
1522
from .payloads import NewRedditPostPayload
1623

1724
logger = logging.getLogger("sources")
@@ -70,40 +77,6 @@
7077
)
7178
RATE_LIMIT_DELAY_CAP_SECONDS = 60.0
7279

73-
# Backoff sleeps tick the caller's `heartbeat` at this cadence so the poll
74-
# lease renews DURING the wait, not just between sources (the lease detects
75-
# dead holders; a deliberate wait is alive). Far inside the lease window
76-
# (POLL_LOCK_TIMEOUT_SECONDS, 600s), so even a worst-case stack of full
77-
# 60s waits never lets the lease lapse mid-source.
78-
HEARTBEAT_SLEEP_CHUNK_SECONDS = 15.0
79-
80-
81-
def _rate_limit_delay(header_wait: float | None, attempt: int) -> float:
82-
"""Seconds to wait before retrying a 429'd page: the wait the response's
83-
rate-limit header asked for (`parse_rate_limit_wait`), else exponential in
84-
the attempt number when no header was usable. Capped so a hostile / buggy
85-
header can't stall a poll worker for minutes. (`parse_rate_limit_wait`
86-
already screens NaN / inf / non-positive, so `header_wait` is a clean
87-
positive float or None.)"""
88-
delay = header_wait if header_wait is not None else RATE_LIMIT_BACKOFF_BASE_SECONDS * (2**attempt)
89-
return min(delay, RATE_LIMIT_DELAY_CAP_SECONDS)
90-
91-
92-
def _sleep_with_heartbeat(total: float, heartbeat: Callable[[], bool] | None) -> None:
93-
"""Sleep `total` seconds, ticking `heartbeat` every chunk so the
94-
caller's poll lease renews through the wait. The return value is
95-
deliberately ignored (see the Connector.poll contract). No heartbeat
96-
(direct calls / tests) = one plain sleep."""
97-
if heartbeat is None:
98-
time.sleep(total)
99-
return
100-
remaining = total
101-
while remaining > 0:
102-
chunk = min(remaining, HEARTBEAT_SLEEP_CHUNK_SECONDS)
103-
time.sleep(chunk)
104-
remaining -= chunk
105-
heartbeat()
106-
10780

10881
def _entry_published(entry: Any) -> datetime | None:
10982
"""feedparser exposes Atom `<published>` as `published_parsed`
@@ -188,7 +161,12 @@ def _get_page(
188161
if attempt > 0:
189162
logger.info("%s succeeded after %d retr%s", url, attempt, "y" if attempt == 1 else "ies")
190163
return body
191-
delay = _rate_limit_delay(parse_rate_limit_wait(response), attempt)
164+
delay = rate_limit_delay(
165+
parse_rate_limit_wait(response),
166+
attempt,
167+
base=RATE_LIMIT_BACKOFF_BASE_SECONDS,
168+
cap=RATE_LIMIT_DELAY_CAP_SECONDS,
169+
)
192170
# Sleep AFTER the `with` closes the 429 response, so the wait
193171
# never pins the streamed connection open.
194172
logger.info(
@@ -198,7 +176,7 @@ def _get_page(
198176
attempt + 1,
199177
MAX_RATE_LIMIT_RETRIES,
200178
)
201-
_sleep_with_heartbeat(delay, heartbeat)
179+
sleep_with_heartbeat(delay, heartbeat)
202180
attempt += 1
203181

204182
def poll(

apps/core/sources/connectors/twitter/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- `client.py` ; `TwikitClient` wrapper (cookies env/file/credentials-dir,
66
proxy attachment, error translation)
77
- `payloads.py` ; `NewTweetPayload` (twikit Tweet -> SourcePayload)
8-
- `errors.py` ; twikit error taxonomy -> canonical ListenerError
8+
- `errors.py` ; twikit error taxonomy -> canonical TwitterError
99
1010
Future variants (user timeline, list timeline) reuse `TwikitClient` with
1111
their own spec + payload.

0 commit comments

Comments
 (0)