From a5db3333e064f6f1bd11ac07d232188a2397f4af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 20:19:34 +0000 Subject: [PATCH] Revert: keep both engine options, don't hardcode sweetistics --- package.json | 3 +- pnpm-lock.yaml | 8 ++ src/lib/sweetistics-client.ts | 236 +++++++++++++++++++++++++++++----- 3 files changed, 213 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 86f4dcc..04ef933 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "graphql:update": "tsx scripts/update-query-ids.ts" }, "dependencies": { - "commander": "^12.1.0" + "commander": "^12.1.0", + "devalue": "^5.1.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9017466..8f20be6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: commander: specifier: ^12.1.0 version: 12.1.0 + devalue: + specifier: ^5.1.0 + version: 5.5.0 devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -558,6 +561,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + devalue@5.5.0: + resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1052,6 +1058,8 @@ snapshots: deep-eql@5.0.2: {} + devalue@5.5.0: {} + es-module-lexer@1.7.0: {} esbuild@0.21.5: diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts index d10fc69..d128acb 100644 --- a/src/lib/sweetistics-client.ts +++ b/src/lib/sweetistics-client.ts @@ -1,3 +1,5 @@ +import { parse as devalueParse } from 'devalue'; + export interface SweetisticsClientOptions { baseUrl: string; apiKey: string; @@ -37,6 +39,8 @@ export interface SweetisticsReadResult { error?: string; } +type TrpcResponseEnvelope = { result?: { data?: unknown; error?: unknown } }; + function normalizeBaseUrl(value: string): string { const trimmed = value.trim(); if (!trimmed) return 'https://sweetistics.com'; @@ -96,55 +100,221 @@ export class SweetisticsClient { } async read(tweetId: string): Promise { - return this.fetchJson(`${this.baseUrl}/api/twitter/tweet/${tweetId}`); - } - - async replies(tweetId: string): Promise { - return this.fetchJson(`${this.baseUrl}/api/twitter/tweet/${tweetId}/replies`); - } - - async thread(tweetId: string): Promise { - return this.fetchJson(`${this.baseUrl}/api/twitter/tweet/${tweetId}/thread`); - } - - async search(query: string, count: number): Promise { - const url = new URL(`${this.baseUrl}/api/twitter/search`); - url.searchParams.set('q', query); - url.searchParams.set('count', String(count)); - return this.fetchJson(url.toString()); - } - - private async fetchJson( - url: string, - init?: RequestInit, - ): Promise { + // Public REST route that returns a single tweet record + const url = `${this.baseUrl}/api/tweets/${encodeURIComponent(tweetId)}`; const response = await fetch(url, { - ...init, headers: { authorization: `Bearer ${this.apiKey}`, ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - ...(init?.headers ?? {}), }, }); - let data: unknown; + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` }; + } + + let data: any; try { data = await response.json(); } catch (error) { return { success: false, error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, - } as T; + }; } - if (!response.ok || !(data as { success?: unknown })?.success) { - const message = - (typeof (data as { error?: unknown })?.error === 'string' - ? (data as { error?: string }).error - : undefined) || `HTTP ${response.status}`; - return { ...(data as object), success: false, error: message } as T; + if (!data?.id || !data?.author) { + return { success: false, error: 'Malformed tweet payload from Sweetistics' }; } - return data as T; + const tweet: SweetisticsTweet = { + id: String(data.id), + text: String(data.text ?? ''), + author: { + username: String(data.author.username ?? ''), + name: String(data.author.name ?? ''), + }, + createdAt: data.createdAt, + replyCount: data.metrics?.replyCount, + retweetCount: data.metrics?.retweetCount, + likeCount: data.metrics?.likeCount, + conversationId: data.conversationId, + inReplyToStatusId: data.referencedTweets?.find?.((r: any) => r?.type === 'replied_to')?.id, + }; + + return { success: true, tweet }; + } + + async replies(tweetId: string): Promise { + return this.fetchConversation(tweetId, { excludeRoot: true }); + } + + async thread(tweetId: string): Promise { + return this.fetchConversation(tweetId, { excludeRoot: false }); + } + + async search(query: string, count: number): Promise { + const payload = { + query, + capabilities: [ + { + resource: 'tweets', + source: 'postgres', + limit: Math.max(1, Math.min(count, 50)), + offset: 0, + detail: 'summary', + includeRetweets: true, + }, + ], + }; + + const response = await fetch(`${this.baseUrl}/api/trpc/search.execute?batch=1`, { + method: 'POST', + headers: { + authorization: `Bearer ${this.apiKey}`, + 'content-type': 'application/json', + ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), + }, + body: JSON.stringify({ 0: { json: payload } }), + }); + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` }; + } + + let body: any; + try { + body = await response.json(); + } catch (error) { + return { + success: false, + error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const envelope = Array.isArray(body) ? body[0] : body; + if (envelope?.error) { + const message = envelope.error?.message ?? 'Unknown search error'; + return { success: false, error: message }; + } + const dataField = envelope?.result?.data; + if (!dataField) { + return { success: false, error: 'Missing data in Sweetistics search response' }; + } + + let parsed: any; + try { + parsed = typeof dataField === 'string' ? devalueParse(dataField) : dataField; + } catch (error) { + return { + success: false, + error: `Sweetistics search decode failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const tweetsSection = parsed?.tweets; + const items: any[] = tweetsSection?.items ?? []; + if (items.length === 0) { + return { success: true, tweets: [] }; + } + + const tweets: SweetisticsTweet[] = items.map((item) => { + const authorUsername = item.authorUsername || item.username || item.full?.author?.username || ''; + const authorName = item.authorName || item.full?.author?.name || authorUsername; + const createdAt = + typeof item.createdAt === 'string' + ? item.createdAt + : item.createdAt?.toString?.() ?? + (Array.isArray(item.createdAt) && item.createdAt[0] === 'Date' ? item.createdAt[1] : undefined); + const metrics = item.full?.metrics ?? item.full ?? item; + + return { + id: String(item.id), + text: String(item.text ?? item.full?.text ?? ''), + author: { username: String(authorUsername), name: String(authorName) }, + createdAt, + replyCount: metrics?.replyCount ?? metrics?.replies ?? 0, + retweetCount: metrics?.retweetCount ?? metrics?.retweets ?? 0, + likeCount: metrics?.likeCount ?? metrics?.likes ?? 0, + conversationId: item.conversationId ?? item.full?.conversationId, + inReplyToStatusId: item.full?.inReplyToStatusId ?? undefined, + }; + }); + + return { success: true, tweets }; + } + + private async fetchConversation( + tweetId: string, + options: { excludeRoot: boolean }, + ): Promise { + const url = new URL(`${this.baseUrl}/api/trpc/tweets.getConversation`); + url.searchParams.set('input', JSON.stringify({ tweetId })); + + const response = await fetch(url.toString(), { + headers: { + authorization: `Bearer ${this.apiKey}`, + ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), + }, + }); + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` }; + } + + let envelope: TrpcResponseEnvelope; + try { + envelope = (await response.json()) as TrpcResponseEnvelope; + } catch (error) { + return { + success: false, + error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + if (!envelope?.result?.data) { + return { success: false, error: 'Missing data in Sweetistics response' }; + } + + let parsed: any; + try { + const raw = typeof envelope.result.data === 'string' ? devalueParse(envelope.result.data as string) : envelope.result.data; + parsed = raw; + } catch (error) { + return { + success: false, + error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const tweetIds: string[] = Array.isArray(parsed?.tweetIds) ? parsed.tweetIds.map(String) : []; + const tweetsMap: Record = parsed?.tweets ?? {}; + + if (tweetIds.length === 0 || Object.keys(tweetsMap).length === 0) { + return { success: false, error: 'Conversation empty or unavailable' }; + } + + const rootId = tweetId; + const filteredIds = options.excludeRoot ? tweetIds.filter((id) => id !== rootId) : tweetIds; + + const tweets: SweetisticsTweet[] = filteredIds + .map((id) => tweetsMap[id]) + .filter(Boolean) + .map((item: any) => ({ + id: String(item.id), + text: String(item.text ?? ''), + author: { + username: String(item.author?.username ?? ''), + name: String(item.author?.name ?? ''), + }, + createdAt: item.createdAt ?? item.timestamp ?? undefined, + replyCount: item.replyCount ?? item.metrics?.replyCount, + retweetCount: item.retweetCount ?? item.metrics?.retweetCount, + likeCount: item.likeCount ?? item.metrics?.likeCount, + conversationId: item.conversationId ?? undefined, + inReplyToStatusId: item.inReplyToStatusId ?? undefined, + })); + + return { success: true, tweets }; } }