You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
X ingest: follow the whole thread + ingest links found across the thread
Source: human feature request (yuanhao). "X 帖子:不只抓单条,会自动追踪整个帖子串,外链文章一并摄取."
Scope: single-file change — workers/x-ingest/index.ts only. No changes to the ingest endpoint, types, or deployment config.
Context
Currently the x-ingest cron worker processes each mention in isolation: it finds the direct parent tweet (referenced_tweets.type === "replied_to"), extracts its article text and external links, and ingests those. If the parent is tweet #5 in a 10-tweet thread, we only see tweet #5.
The fix: use the X API v2 conversation_id field (available in recent-search on all paid tiers) to retrieve the full thread, concatenate the original author's tweets into one document, and aggregate external links from all thread tweets.
Implementation Plan (step-by-step)
Step 1: Add conversation_id to the XTweet type and search fields
File:workers/x-ingest/index.ts, near existing constants (after line 39)
/** Max tweets to retrieve from a thread. Bounds API cost + ingest size. */constMAX_THREAD_TWEETS=50;/** Max external links to ingest per thread. Prevents runaway link-heavy threads. */constMAX_THREAD_LINKS=20;
Step 3: Add fetchThreadTweets() function
File:workers/x-ingest/index.ts, after searchUrl() (after line 174)
This function takes a parent tweet, fetches all tweets in its conversation by the same author, and returns them ordered by ID (chronological). It reuses searchUrl() for the API call.
/** * Fetch tweets in the same conversation by the same author, ordered oldest-newest. * Returns [parent] on failure (graceful degradation to single-tweet behavior). * * Uses: GET /2/tweets/search/recent?query=conversation_id:<id> from:<author> * This only covers the last 7 days (recent-search limitation), which is fine * because our since_id cursor + 48h window means we only process recent mentions. */asyncfunctionfetchThreadTweets(parent: XTweet,parentAuthor: string|undefined,xBearer: string,): Promise<XTweet[]>{constconvId=parent.conversation_id;if(!convId||!parentAuthor)return[parent];constquery=`conversation_id:${convId} from:${parentAuthor}`;constbound=`&max_results=${Math.min(MAX_THREAD_TWEETS,100)}`;consturl=searchUrl(query,bound);try{constres=awaitfetch(url,{headers: {Authorization: `Bearer ${xBearer}`},});if(!res.ok){console.warn(`Thread fetch failed (${res.status}) for conversation ${convId} — using parent only`);return[parent];}constpayload: XSearchPayload=awaitres.json();consttweets=payload.data??[];if(tweets.length===0)return[parent];// Sort by ID ascending (older tweets first — X IDs are monotonic snowflakes)tweets.sort((a,b)=>(a.id<b.id ? -1 : a.id>b.id ? 1 : 0));returntweets;}catch(err){console.warn(`Thread fetch error for conversation ${convId}:`,(errasError)?.message??err);return[parent];}}
Design decisions:
Graceful degradation: Any failure returns [parent] — single-tweet behavior preserved.
from:<author>: Only the thread author's tweets, not replies from others (avoids noise).
Sort by ID: X tweet IDs are snowflake IDs (monotonically increasing), string comparison gives chronological order.
No pagination:max_results up to 100 (API max). One API call per mention.
Step 4: Add thread aggregation helpers
File:workers/x-ingest/index.ts, after fetchThreadTweets()
/** Aggregate and dedup external links across all tweets in a thread. */functionaggregateThreadLinks(threadTweets: XTweet[]): string[]{constseen=newSet<string>();constlinks: string[]=[];for(consttweetofthreadTweets){for(constlinkofexternalLinks(tweet)){if(!seen.has(link)){seen.add(link);links.push(link);}}}returnlinks.slice(0,MAX_THREAD_LINKS);}/** * Build concatenated thread text for ingestion (only if >1 tweet). * Returns undefined for single-tweet threads (no extra text to ingest). */functionbuildThreadText(threadTweets: XTweet[]): string|undefined{if(threadTweets.length<=1)returnundefined;consttexts=threadTweets.map((t)=>(t.text??"").trim()).filter((t)=>t.length>0);if(texts.length<=1)returnundefined;returntexts.join("\n\n---\n\n");}
Step 5: Modify the per-mention processing loop
File:workers/x-ingest/index.ts, lines 284–343
This is the core change. Three modifications inside the for (const mention of mentions) loop:
5a. Hoist parentAuthor and add thread fetch (after line 297, before current line 299):
Move parentAuthor resolution ABOVE the thread fetch (currently it's on line 302). The new order becomes:
// (after parent is resolved and validated — line 297)constparentAuthor=parent.author_id ? userById[parent.author_id] : undefined;// Fetch the full thread (author's chain only). Falls back to [parent] on failure.constthreadTweets=awaitfetchThreadTweets(parent,parentAuthor,X_BEARER);constsourceUrl=parentAuthor
? `https://x.com/${parentAuthor}/status/${parent.id}`
: `https://x.com/i/status/${parent.id}`;
Delete the old const parentAuthor line (was line 302) since it's now earlier.
5b. Add thread text job — after the article block (after ~line 324), before link extraction:
// If the thread has multiple tweets, ingest the concatenated thread text.constthreadText=buildThreadText(threadTweets);if(threadText){constthreadTitle=parentAuthor
? `Thread by @${parentAuthor}`
: `X Thread`;jobs.push({body: {text: threadText,title: threadTitle, sourceUrl },label: `thread (${threadTweets.length} tweets)`,});}
5c. Replace single-tweet link extraction with thread-wide aggregation:
X_BEARER scope:X_BEARER is declared at line 177 inside run(). Pass it as a parameter to fetchThreadTweets() (shown in plan). Do NOT access env.X_BEARER_TOKEN inside the helper.
parentAuthor hoisting: Move the parentAuthor declaration from its current position (line 302) to before the thread fetch. DELETE the original line — do not duplicate it.
Rate limits: Each mention now costs one additional API call. X API recent-search allows 60 requests/15 min. If rate-limited, fetchThreadTweets degrades gracefully. Acceptable.
Thread search returns the parent too:conversation_id:<id> from:<author> results include all of the author's tweets including the parent. So aggregateThreadLinks() already covers the parent's links. The old externalLinks(parent) must be REPLACED, not kept alongside.
Article stays on parent only: The X Article handling (lines 308–324) must NOT change. Articles are a property of the specific tweet. Thread text is a separate job.
No test harness exists for this worker. The worker is standalone (excluded from app tsconfig/eslint). The pure functions (aggregateThreadLinks, buildThreadText) are unit-testable but the worker currently has no test setup. Acceptable for this change; file a follow-up if desired.
Do NOT modifywrangler.jsonc — no new bindings needed.
Do NOT modify any files in src/ — this is purely a worker change.
Acceptance Criteria (mechanical)
XTweet interface has conversation_id?: string field
searchUrl() includes conversation_id in tweet.fields
MAX_THREAD_TWEETS constant exists (value 50)
MAX_THREAD_LINKS constant exists (value 20)
fetchThreadTweets() function exists, takes (parent, parentAuthor, xBearer), returns Promise<XTweet[]>, falls back to [parent] on any error
aggregateThreadLinks() function exists, deduplicates URLs across thread tweets, respects MAX_THREAD_LINKS
buildThreadText() function exists, returns undefined for single-tweet threads, returns joined text for multi-tweet
Per-mention loop calls fetchThreadTweets() after parent is resolved
Link extraction uses aggregateThreadLinks(threadTweets) instead of externalLinks(parent)
Thread text is pushed as a separate job when buildThreadText() returns non-undefined
Article handling (lines 308–324) is unchanged
Probe output includes parentConversationId
Single-tweet mentions behave identically to before (thread fetch returns [parent], buildThreadText returns undefined, aggregateThreadLinks([parent]) === externalLinks(parent))
X ingest: follow the whole thread + ingest links found across the thread
Source: human feature request (yuanhao). "X 帖子:不只抓单条,会自动追踪整个帖子串,外链文章一并摄取."
Scope: single-file change —
workers/x-ingest/index.tsonly. No changes to the ingest endpoint, types, or deployment config.Context
Currently the x-ingest cron worker processes each mention in isolation: it finds the direct parent tweet (
referenced_tweets.type === "replied_to"), extracts its article text and external links, and ingests those. If the parent is tweet #5 in a 10-tweet thread, we only see tweet #5.The fix: use the X API v2
conversation_idfield (available in recent-search on all paid tiers) to retrieve the full thread, concatenate the original author's tweets into one document, and aggregate external links from all thread tweets.Implementation Plan (step-by-step)
Step 1: Add
conversation_idto the XTweet type and search fieldsFile:
workers/x-ingest/index.ts1a. XTweet interface (line 82): Add
conversation_id?: string;field:1b.
searchUrl()function (line 170): Addconversation_idto thetweet.fieldsparameter:Step 2: Add thread-fetching constants
File:
workers/x-ingest/index.ts, near existing constants (after line 39)Step 3: Add
fetchThreadTweets()functionFile:
workers/x-ingest/index.ts, aftersearchUrl()(after line 174)This function takes a parent tweet, fetches all tweets in its conversation by the same author, and returns them ordered by ID (chronological). It reuses
searchUrl()for the API call.Design decisions:
[parent]— single-tweet behavior preserved.from:<author>: Only the thread author's tweets, not replies from others (avoids noise).max_resultsup to 100 (API max). One API call per mention.Step 4: Add thread aggregation helpers
File:
workers/x-ingest/index.ts, afterfetchThreadTweets()Step 5: Modify the per-mention processing loop
File:
workers/x-ingest/index.ts, lines 284–343This is the core change. Three modifications inside the
for (const mention of mentions)loop:5a. Hoist
parentAuthorand add thread fetch (after line 297, before current line 299):Move
parentAuthorresolution ABOVE the thread fetch (currently it's on line 302). The new order becomes:Delete the old
const parentAuthorline (was line 302) since it's now earlier.5b. Add thread text job — after the article block (after ~line 324), before link extraction:
5c. Replace single-tweet link extraction with thread-wide aggregation:
Replace:
With:
Step 6: Update the
probe()debug outputFile:
workers/x-ingest/index.ts, inprobe()sample output (~line 416)After the
includesMedialine, add:Gotchas for the Build Agent
X_BEARERscope:X_BEARERis declared at line 177 insiderun(). Pass it as a parameter tofetchThreadTweets()(shown in plan). Do NOT accessenv.X_BEARER_TOKENinside the helper.parentAuthorhoisting: Move theparentAuthordeclaration from its current position (line 302) to before the thread fetch. DELETE the original line — do not duplicate it.Rate limits: Each mention now costs one additional API call. X API recent-search allows 60 requests/15 min. If rate-limited,
fetchThreadTweetsdegrades gracefully. Acceptable.Thread search returns the parent too:
conversation_id:<id> from:<author>results include all of the author's tweets including the parent. SoaggregateThreadLinks()already covers the parent's links. The oldexternalLinks(parent)must be REPLACED, not kept alongside.Article stays on parent only: The X Article handling (lines 308–324) must NOT change. Articles are a property of the specific tweet. Thread text is a separate job.
No test harness exists for this worker. The worker is standalone (excluded from app tsconfig/eslint). The pure functions (
aggregateThreadLinks,buildThreadText) are unit-testable but the worker currently has no test setup. Acceptable for this change; file a follow-up if desired.Do NOT modify
wrangler.jsonc— no new bindings needed.Do NOT modify any files in
src/— this is purely a worker change.Acceptance Criteria (mechanical)
XTweetinterface hasconversation_id?: stringfieldsearchUrl()includesconversation_idintweet.fieldsMAX_THREAD_TWEETSconstant exists (value 50)MAX_THREAD_LINKSconstant exists (value 20)fetchThreadTweets()function exists, takes(parent, parentAuthor, xBearer), returnsPromise<XTweet[]>, falls back to[parent]on any erroraggregateThreadLinks()function exists, deduplicates URLs across thread tweets, respectsMAX_THREAD_LINKSbuildThreadText()function exists, returnsundefinedfor single-tweet threads, returns joined text for multi-tweetfetchThreadTweets()after parent is resolvedaggregateThreadLinks(threadTweets)instead ofexternalLinks(parent)buildThreadText()returns non-undefinedparentConversationId[parent],buildThreadTextreturnsundefined,aggregateThreadLinks([parent])===externalLinks(parent))wrangler deploy --config workers/x-ingest/wrangler.jsonc --dry-runsucceeds (TypeScript compiles)workers/x-ingest/index.ts