birdclaw 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
package/src/lib/tweet-render.ts
CHANGED
|
@@ -57,6 +57,77 @@ export function displayUrlForLink(url: string) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function asRecord(value: unknown) {
|
|
61
|
+
return value && typeof value === "object"
|
|
62
|
+
? (value as Record<string, unknown>)
|
|
63
|
+
: {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
67
|
+
const entities = asRecord(raw);
|
|
68
|
+
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
69
|
+
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
70
|
+
const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...(rawMentions.length
|
|
74
|
+
? {
|
|
75
|
+
mentions: rawMentions.map((mention) => {
|
|
76
|
+
const value = asRecord(mention);
|
|
77
|
+
return {
|
|
78
|
+
username: String(value.username ?? ""),
|
|
79
|
+
id: typeof value.id === "string" ? String(value.id) : undefined,
|
|
80
|
+
start: Number(value.start ?? 0),
|
|
81
|
+
end: Number(value.end ?? 0),
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
...(rawUrls.length
|
|
87
|
+
? {
|
|
88
|
+
urls: rawUrls.map((url) => {
|
|
89
|
+
const value = asRecord(url);
|
|
90
|
+
const expandedUrl = String(
|
|
91
|
+
value.expandedUrl ?? value.expanded_url ?? value.url ?? "",
|
|
92
|
+
);
|
|
93
|
+
return {
|
|
94
|
+
url: String(value.url ?? ""),
|
|
95
|
+
expandedUrl,
|
|
96
|
+
displayUrl: String(
|
|
97
|
+
value.displayUrl ??
|
|
98
|
+
value.display_url ??
|
|
99
|
+
expandedUrl ??
|
|
100
|
+
value.url ??
|
|
101
|
+
"",
|
|
102
|
+
),
|
|
103
|
+
start: Number(value.start ?? 0),
|
|
104
|
+
end: Number(value.end ?? 0),
|
|
105
|
+
};
|
|
106
|
+
}),
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
109
|
+
...(rawHashtags.length
|
|
110
|
+
? {
|
|
111
|
+
hashtags: rawHashtags.map((hashtag) => {
|
|
112
|
+
const value = asRecord(hashtag);
|
|
113
|
+
return {
|
|
114
|
+
tag: String(value.tag ?? value.text ?? ""),
|
|
115
|
+
start: Number(value.start ?? 0),
|
|
116
|
+
end: Number(value.end ?? 0),
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function profileDescriptionEntitiesFromXurl(
|
|
125
|
+
raw: unknown,
|
|
126
|
+
): TweetEntities {
|
|
127
|
+
const entities = asRecord(raw);
|
|
128
|
+
return tweetEntitiesFromXurl(entities.description);
|
|
129
|
+
}
|
|
130
|
+
|
|
60
131
|
function spansOverlap(
|
|
61
132
|
leftStart: number,
|
|
62
133
|
leftEnd: number,
|
|
@@ -66,6 +137,66 @@ function spansOverlap(
|
|
|
66
137
|
return leftStart < rightEnd && rightStart < leftEnd;
|
|
67
138
|
}
|
|
68
139
|
|
|
140
|
+
function stringIndexFromCodePointIndex(text: string, index: number) {
|
|
141
|
+
if (index <= 0) return 0;
|
|
142
|
+
let stringIndex = 0;
|
|
143
|
+
let codePointIndex = 0;
|
|
144
|
+
for (const character of text) {
|
|
145
|
+
if (codePointIndex >= index) break;
|
|
146
|
+
stringIndex += character.length;
|
|
147
|
+
codePointIndex += 1;
|
|
148
|
+
}
|
|
149
|
+
return stringIndex;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function segmentTextMatches(
|
|
153
|
+
text: string,
|
|
154
|
+
segment: TweetSegment,
|
|
155
|
+
start: number,
|
|
156
|
+
end: number,
|
|
157
|
+
) {
|
|
158
|
+
const slice = text.slice(start, end);
|
|
159
|
+
if (segment.kind === "mention") {
|
|
160
|
+
return slice.toLowerCase() === `@${segment.username}`.toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
if (segment.kind === "hashtag") {
|
|
163
|
+
return slice.toLowerCase() === `#${segment.tag}`.toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
return slice === segment.url;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeSegmentTextRange(text: string, segment: TweetSegment) {
|
|
169
|
+
if (
|
|
170
|
+
segment.start >= 0 &&
|
|
171
|
+
segment.end > segment.start &&
|
|
172
|
+
segment.end <= text.length &&
|
|
173
|
+
segmentTextMatches(text, segment, segment.start, segment.end)
|
|
174
|
+
) {
|
|
175
|
+
return segment;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const start = stringIndexFromCodePointIndex(text, segment.start);
|
|
179
|
+
const end = stringIndexFromCodePointIndex(text, segment.end);
|
|
180
|
+
if (
|
|
181
|
+
start >= 0 &&
|
|
182
|
+
end > start &&
|
|
183
|
+
end <= text.length &&
|
|
184
|
+
segmentTextMatches(text, segment, start, end)
|
|
185
|
+
) {
|
|
186
|
+
return { ...segment, start, end };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return segment;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function normalizeTweetUrlEntityRangeForText(
|
|
193
|
+
text: string,
|
|
194
|
+
entry: TweetUrlEntity,
|
|
195
|
+
) {
|
|
196
|
+
const normalized = normalizeSegmentTextRange(text, { ...entry, kind: "url" });
|
|
197
|
+
return { start: normalized.start, end: normalized.end };
|
|
198
|
+
}
|
|
199
|
+
|
|
69
200
|
export function enrichFallbackUrlEntities(
|
|
70
201
|
text: string,
|
|
71
202
|
entities: TweetEntities,
|
|
@@ -150,12 +281,21 @@ export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
|
|
|
150
281
|
].sort((left, right) => left.start - right.start);
|
|
151
282
|
}
|
|
152
283
|
|
|
284
|
+
export function collectTweetSegmentsForText(
|
|
285
|
+
text: string,
|
|
286
|
+
entities: TweetEntities,
|
|
287
|
+
) {
|
|
288
|
+
return collectTweetSegments(entities)
|
|
289
|
+
.map((segment) => normalizeSegmentTextRange(text, segment))
|
|
290
|
+
.sort((left, right) => left.start - right.start);
|
|
291
|
+
}
|
|
292
|
+
|
|
153
293
|
function renderTweetText(
|
|
154
294
|
text: string,
|
|
155
295
|
entities: TweetEntities,
|
|
156
296
|
renderSegment: (segment: TweetSegment, fallback: string) => string,
|
|
157
297
|
) {
|
|
158
|
-
const segments =
|
|
298
|
+
const segments = collectTweetSegmentsForText(text, entities);
|
|
159
299
|
let cursor = 0;
|
|
160
300
|
let output = "";
|
|
161
301
|
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { searchTweetsViaBirdEffect } from "./bird";
|
|
3
|
+
import type { Database } from "./sqlite";
|
|
4
|
+
import { getNativeDb } from "./db";
|
|
5
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
6
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
|
|
9
|
+
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
10
|
+
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
11
|
+
import { searchRecentTweetsEffect } from "./xurl";
|
|
12
|
+
|
|
13
|
+
export type TweetSearchMode = "auto" | "bird" | "xurl" | "local";
|
|
14
|
+
|
|
15
|
+
export interface SyncTweetSearchOptions {
|
|
16
|
+
query: string;
|
|
17
|
+
account?: string;
|
|
18
|
+
mode?: TweetSearchMode;
|
|
19
|
+
limit?: number;
|
|
20
|
+
maxPages?: number;
|
|
21
|
+
since?: string;
|
|
22
|
+
until?: string;
|
|
23
|
+
refresh?: boolean;
|
|
24
|
+
cacheTtlMs?: number;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SyncTweetSearchResult =
|
|
29
|
+
| {
|
|
30
|
+
ok: true;
|
|
31
|
+
source: "bird" | "xurl" | "bird+xurl" | "cache";
|
|
32
|
+
accountId: string;
|
|
33
|
+
query: string;
|
|
34
|
+
count: number;
|
|
35
|
+
pageCount: number;
|
|
36
|
+
tweetIds: string[];
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
ok: false;
|
|
40
|
+
source: "bird" | "xurl" | "auto";
|
|
41
|
+
accountId: string;
|
|
42
|
+
query: string;
|
|
43
|
+
error: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SEARCH_LIMIT = 20_000;
|
|
47
|
+
const DEFAULT_MAX_PAGES = 200;
|
|
48
|
+
const DEFAULT_CACHE_TTL_MS = 2 * 60_000;
|
|
49
|
+
const XURL_PAGE_SIZE = 100;
|
|
50
|
+
|
|
51
|
+
function toError(error: unknown) {
|
|
52
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function trySync<T>(try_: () => T) {
|
|
56
|
+
return Effect.try({
|
|
57
|
+
try: try_,
|
|
58
|
+
catch: toError,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeLimit(limit: number | undefined) {
|
|
63
|
+
if (limit === undefined) return DEFAULT_SEARCH_LIMIT;
|
|
64
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
65
|
+
throw new Error("--limit must be at least 1");
|
|
66
|
+
}
|
|
67
|
+
return Math.floor(limit);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeMaxPages(maxPages: number | undefined) {
|
|
71
|
+
if (maxPages === undefined) return DEFAULT_MAX_PAGES;
|
|
72
|
+
if (!Number.isFinite(maxPages) || maxPages < 1) {
|
|
73
|
+
throw new Error("--max-pages must be at least 1");
|
|
74
|
+
}
|
|
75
|
+
return Math.floor(maxPages);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeTime(value: string | undefined, optionName: string) {
|
|
79
|
+
if (!value?.trim()) return undefined;
|
|
80
|
+
const date = new Date(value);
|
|
81
|
+
if (!Number.isFinite(date.getTime())) {
|
|
82
|
+
throw new Error(`${optionName} must be a valid date`);
|
|
83
|
+
}
|
|
84
|
+
return date.toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeCacheTtlMs(value: number | undefined) {
|
|
88
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
89
|
+
return DEFAULT_CACHE_TTL_MS;
|
|
90
|
+
}
|
|
91
|
+
return Math.floor(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveAccount(db: Database, accountId?: string) {
|
|
95
|
+
const row = accountId
|
|
96
|
+
? (db
|
|
97
|
+
.prepare("select id, handle from accounts where id = ?")
|
|
98
|
+
.get(accountId) as { id: string; handle: string } | undefined)
|
|
99
|
+
: (db
|
|
100
|
+
.prepare(
|
|
101
|
+
`
|
|
102
|
+
select id, handle
|
|
103
|
+
from accounts
|
|
104
|
+
order by is_default desc, created_at asc
|
|
105
|
+
limit 1
|
|
106
|
+
`,
|
|
107
|
+
)
|
|
108
|
+
.get() as { id: string; handle: string } | undefined);
|
|
109
|
+
|
|
110
|
+
if (!row) {
|
|
111
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
accountId: row.id,
|
|
116
|
+
username: row.handle.replace(/^@/, ""),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
121
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
122
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
123
|
+
tweetId,
|
|
124
|
+
text,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mergeTweetSearchIntoLocalStore(
|
|
129
|
+
db: Database,
|
|
130
|
+
accountId: string,
|
|
131
|
+
payload: XurlMentionsResponse,
|
|
132
|
+
source: "bird" | "xurl" | "cache",
|
|
133
|
+
) {
|
|
134
|
+
const usersById = new Map(
|
|
135
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
136
|
+
);
|
|
137
|
+
const upsertTweet = db.prepare(
|
|
138
|
+
`
|
|
139
|
+
insert into tweets (
|
|
140
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
141
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
142
|
+
entities_json, media_json, quoted_tweet_id
|
|
143
|
+
) values (?, ?, ?, 'search', ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
144
|
+
on conflict(id) do update set
|
|
145
|
+
account_id = tweets.account_id,
|
|
146
|
+
author_profile_id = excluded.author_profile_id,
|
|
147
|
+
kind = tweets.kind,
|
|
148
|
+
text = excluded.text,
|
|
149
|
+
created_at = excluded.created_at,
|
|
150
|
+
reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
|
|
151
|
+
like_count = excluded.like_count,
|
|
152
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
153
|
+
entities_json = excluded.entities_json,
|
|
154
|
+
media_json = case
|
|
155
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
156
|
+
else tweets.media_json
|
|
157
|
+
end,
|
|
158
|
+
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id),
|
|
159
|
+
bookmarked = tweets.bookmarked,
|
|
160
|
+
liked = tweets.liked
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
const tweetIds: string[] = [];
|
|
164
|
+
|
|
165
|
+
db.transaction(() => {
|
|
166
|
+
const seenAt = new Date().toISOString();
|
|
167
|
+
for (const tweet of payload.data) {
|
|
168
|
+
const author =
|
|
169
|
+
usersById.get(tweet.author_id) ??
|
|
170
|
+
({
|
|
171
|
+
id: tweet.author_id,
|
|
172
|
+
username: `user_${tweet.author_id}`,
|
|
173
|
+
name: `user_${tweet.author_id}`,
|
|
174
|
+
} as const);
|
|
175
|
+
const profile = usersById.has(tweet.author_id)
|
|
176
|
+
? upsertProfileFromXUser(db, author)
|
|
177
|
+
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
178
|
+
const replyToId =
|
|
179
|
+
tweet.referenced_tweets?.find((item) => item.type === "replied_to")
|
|
180
|
+
?.id ?? null;
|
|
181
|
+
const quotedTweetId =
|
|
182
|
+
tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
|
|
183
|
+
null;
|
|
184
|
+
upsertTweet.run(
|
|
185
|
+
tweet.id,
|
|
186
|
+
accountId,
|
|
187
|
+
profile.profile.id,
|
|
188
|
+
tweet.text,
|
|
189
|
+
tweet.created_at,
|
|
190
|
+
replyToId,
|
|
191
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
192
|
+
countTweetMedia(tweet),
|
|
193
|
+
JSON.stringify(tweet.entities ?? {}),
|
|
194
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
195
|
+
quotedTweetId,
|
|
196
|
+
);
|
|
197
|
+
upsertTweetAccountEdge(db, {
|
|
198
|
+
accountId,
|
|
199
|
+
tweetId: tweet.id,
|
|
200
|
+
kind: "search",
|
|
201
|
+
source,
|
|
202
|
+
seenAt,
|
|
203
|
+
rawJson: JSON.stringify(tweet),
|
|
204
|
+
});
|
|
205
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
206
|
+
tweetIds.push(tweet.id);
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
|
|
210
|
+
return tweetIds;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toMentionsResponse(payload: XurlTweetsResponse): XurlMentionsResponse {
|
|
214
|
+
return {
|
|
215
|
+
data: payload.data,
|
|
216
|
+
includes: payload.includes,
|
|
217
|
+
meta: payload.meta,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function mergeResponses(
|
|
222
|
+
responses: XurlMentionsResponse[],
|
|
223
|
+
): XurlMentionsResponse {
|
|
224
|
+
const seenTweetIds = new Set<string>();
|
|
225
|
+
const data = [];
|
|
226
|
+
const usersById = new Map();
|
|
227
|
+
const mediaByKey = new Map();
|
|
228
|
+
let pageCount = 0;
|
|
229
|
+
|
|
230
|
+
for (const response of responses) {
|
|
231
|
+
pageCount += Number(response.meta?.page_count ?? 1);
|
|
232
|
+
for (const user of response.includes?.users ?? []) {
|
|
233
|
+
usersById.set(user.id, user);
|
|
234
|
+
}
|
|
235
|
+
for (const media of response.includes?.media ?? []) {
|
|
236
|
+
mediaByKey.set(media.media_key, media);
|
|
237
|
+
}
|
|
238
|
+
for (const tweet of response.data) {
|
|
239
|
+
if (seenTweetIds.has(tweet.id)) continue;
|
|
240
|
+
seenTweetIds.add(tweet.id);
|
|
241
|
+
data.push(tweet);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
data,
|
|
247
|
+
includes: {
|
|
248
|
+
users: [...usersById.values()],
|
|
249
|
+
media: [...mediaByKey.values()],
|
|
250
|
+
},
|
|
251
|
+
meta: {
|
|
252
|
+
result_count: data.length,
|
|
253
|
+
page_count: pageCount,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function limitResponse(
|
|
259
|
+
response: XurlMentionsResponse,
|
|
260
|
+
limit: number,
|
|
261
|
+
): XurlMentionsResponse {
|
|
262
|
+
if (response.data.length <= limit) {
|
|
263
|
+
return response;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...response,
|
|
267
|
+
data: response.data.slice(0, limit),
|
|
268
|
+
meta: {
|
|
269
|
+
...response.meta,
|
|
270
|
+
result_count: limit,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function cacheKey({
|
|
276
|
+
query,
|
|
277
|
+
accountId,
|
|
278
|
+
mode,
|
|
279
|
+
limit,
|
|
280
|
+
maxPages,
|
|
281
|
+
since,
|
|
282
|
+
until,
|
|
283
|
+
}: {
|
|
284
|
+
query: string;
|
|
285
|
+
accountId: string;
|
|
286
|
+
mode: Exclude<TweetSearchMode, "auto" | "local">;
|
|
287
|
+
limit: number;
|
|
288
|
+
maxPages: number;
|
|
289
|
+
since?: string;
|
|
290
|
+
until?: string;
|
|
291
|
+
}) {
|
|
292
|
+
return `tweet-search:${mode}:${accountId}:${encodeURIComponent(query)}:${String(limit)}:${String(maxPages)}:${since ?? "no-since"}:${until ?? "no-until"}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function fetchBirdSearchEffect({
|
|
296
|
+
query,
|
|
297
|
+
limit,
|
|
298
|
+
maxPages,
|
|
299
|
+
}: {
|
|
300
|
+
query: string;
|
|
301
|
+
limit: number;
|
|
302
|
+
maxPages: number;
|
|
303
|
+
}) {
|
|
304
|
+
return searchTweetsViaBirdEffect(query, {
|
|
305
|
+
maxResults: Math.min(limit, XURL_PAGE_SIZE),
|
|
306
|
+
all: maxPages > 1 || limit > XURL_PAGE_SIZE,
|
|
307
|
+
maxPages,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function fetchXurlSearchEffect({
|
|
312
|
+
query,
|
|
313
|
+
limit,
|
|
314
|
+
maxPages,
|
|
315
|
+
timeoutMs,
|
|
316
|
+
since,
|
|
317
|
+
until,
|
|
318
|
+
}: {
|
|
319
|
+
query: string;
|
|
320
|
+
limit: number;
|
|
321
|
+
maxPages: number;
|
|
322
|
+
timeoutMs?: number;
|
|
323
|
+
since?: string;
|
|
324
|
+
until?: string;
|
|
325
|
+
}): Effect.Effect<XurlMentionsResponse, Error> {
|
|
326
|
+
return Effect.gen(function* () {
|
|
327
|
+
const responses: XurlMentionsResponse[] = [];
|
|
328
|
+
let nextToken: string | undefined;
|
|
329
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
330
|
+
const remaining =
|
|
331
|
+
limit -
|
|
332
|
+
responses.reduce((total, response) => total + response.data.length, 0);
|
|
333
|
+
if (remaining <= 0) break;
|
|
334
|
+
const response = yield* searchRecentTweetsEffect(query, {
|
|
335
|
+
maxResults: Math.max(10, Math.min(XURL_PAGE_SIZE, remaining)),
|
|
336
|
+
paginationToken: nextToken,
|
|
337
|
+
startTime: since,
|
|
338
|
+
endTime: until,
|
|
339
|
+
timeoutMs,
|
|
340
|
+
});
|
|
341
|
+
responses.push(toMentionsResponse(response));
|
|
342
|
+
nextToken =
|
|
343
|
+
typeof response.meta?.next_token === "string"
|
|
344
|
+
? String(response.meta.next_token)
|
|
345
|
+
: undefined;
|
|
346
|
+
if (!nextToken) break;
|
|
347
|
+
}
|
|
348
|
+
return mergeResponses(responses);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function runModeEffect(
|
|
353
|
+
mode: Exclude<TweetSearchMode, "auto" | "local">,
|
|
354
|
+
options: {
|
|
355
|
+
query: string;
|
|
356
|
+
accountId: string;
|
|
357
|
+
username: string;
|
|
358
|
+
limit: number;
|
|
359
|
+
maxPages: number;
|
|
360
|
+
since?: string;
|
|
361
|
+
until?: string;
|
|
362
|
+
refresh: boolean;
|
|
363
|
+
cacheTtlMs: number;
|
|
364
|
+
timeoutMs?: number;
|
|
365
|
+
},
|
|
366
|
+
): Effect.Effect<SyncTweetSearchResult, Error> {
|
|
367
|
+
return Effect.gen(function* () {
|
|
368
|
+
const db = getNativeDb();
|
|
369
|
+
const key = cacheKey({ ...options, mode });
|
|
370
|
+
const cached = yield* trySync(() =>
|
|
371
|
+
readSyncCache<XurlMentionsResponse>(key, db),
|
|
372
|
+
);
|
|
373
|
+
const ageMs = cached
|
|
374
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
375
|
+
: Number.POSITIVE_INFINITY;
|
|
376
|
+
const payload =
|
|
377
|
+
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
378
|
+
? cached.value
|
|
379
|
+
: yield* (
|
|
380
|
+
mode === "bird"
|
|
381
|
+
? fetchBirdSearchEffect(options).pipe(Effect.mapError(toError))
|
|
382
|
+
: fetchXurlSearchEffect(options)
|
|
383
|
+
).pipe(
|
|
384
|
+
Effect.map((response) => limitResponse(response, options.limit)),
|
|
385
|
+
);
|
|
386
|
+
if (!cached || options.refresh || ageMs > options.cacheTtlMs) {
|
|
387
|
+
yield* trySync(() => writeSyncCache(key, payload, db));
|
|
388
|
+
}
|
|
389
|
+
const tweetIds = yield* trySync(() =>
|
|
390
|
+
mergeTweetSearchIntoLocalStore(
|
|
391
|
+
db,
|
|
392
|
+
options.accountId,
|
|
393
|
+
payload,
|
|
394
|
+
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
395
|
+
? "cache"
|
|
396
|
+
: mode,
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
source:
|
|
403
|
+
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
404
|
+
? "cache"
|
|
405
|
+
: mode,
|
|
406
|
+
accountId: options.accountId,
|
|
407
|
+
query: options.query,
|
|
408
|
+
count: tweetIds.length,
|
|
409
|
+
pageCount: Number(payload.meta?.page_count ?? 1),
|
|
410
|
+
tweetIds,
|
|
411
|
+
} as const;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function combineTweetSearchResults(
|
|
416
|
+
left: SyncTweetSearchResult,
|
|
417
|
+
right: SyncTweetSearchResult,
|
|
418
|
+
limit: number,
|
|
419
|
+
): SyncTweetSearchResult {
|
|
420
|
+
if (left.ok && right.ok) {
|
|
421
|
+
const tweetIds = [...new Set([...left.tweetIds, ...right.tweetIds])].slice(
|
|
422
|
+
0,
|
|
423
|
+
limit,
|
|
424
|
+
);
|
|
425
|
+
const liveSources = new Set(
|
|
426
|
+
[left.source, right.source].filter((source) => source !== "cache"),
|
|
427
|
+
);
|
|
428
|
+
return {
|
|
429
|
+
ok: true,
|
|
430
|
+
source:
|
|
431
|
+
liveSources.has("bird") && liveSources.has("xurl")
|
|
432
|
+
? "bird+xurl"
|
|
433
|
+
: liveSources.has("bird")
|
|
434
|
+
? "bird"
|
|
435
|
+
: liveSources.has("xurl")
|
|
436
|
+
? "xurl"
|
|
437
|
+
: "cache",
|
|
438
|
+
accountId: left.accountId,
|
|
439
|
+
query: left.query,
|
|
440
|
+
count: tweetIds.length,
|
|
441
|
+
pageCount: left.pageCount + right.pageCount,
|
|
442
|
+
tweetIds,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (left.ok) return left;
|
|
446
|
+
if (right.ok) return right;
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
source: "auto",
|
|
450
|
+
accountId: left.accountId,
|
|
451
|
+
query: left.query,
|
|
452
|
+
error: `${left.error}; ${right.error}`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function syncTweetSearchEffect({
|
|
457
|
+
query,
|
|
458
|
+
account,
|
|
459
|
+
mode = "auto",
|
|
460
|
+
limit,
|
|
461
|
+
maxPages,
|
|
462
|
+
since,
|
|
463
|
+
until,
|
|
464
|
+
refresh = false,
|
|
465
|
+
cacheTtlMs,
|
|
466
|
+
timeoutMs,
|
|
467
|
+
}: SyncTweetSearchOptions): Effect.Effect<SyncTweetSearchResult, Error> {
|
|
468
|
+
return Effect.gen(function* () {
|
|
469
|
+
const normalizedQuery = query.trim();
|
|
470
|
+
if (!normalizedQuery) {
|
|
471
|
+
return yield* Effect.fail(new Error("Search query is required"));
|
|
472
|
+
}
|
|
473
|
+
const normalizedLimit = normalizeLimit(limit);
|
|
474
|
+
const normalizedMaxPages = normalizeMaxPages(maxPages);
|
|
475
|
+
const normalizedSince = yield* trySync(() =>
|
|
476
|
+
normalizeTime(since, "--since"),
|
|
477
|
+
);
|
|
478
|
+
const normalizedUntil = yield* trySync(() =>
|
|
479
|
+
normalizeTime(until, "--until"),
|
|
480
|
+
);
|
|
481
|
+
const ttlMs = normalizeCacheTtlMs(cacheTtlMs);
|
|
482
|
+
const db = getNativeDb();
|
|
483
|
+
const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
|
|
484
|
+
const accountId = resolvedAccount.accountId;
|
|
485
|
+
if (mode === "local") {
|
|
486
|
+
return {
|
|
487
|
+
ok: true,
|
|
488
|
+
source: "cache",
|
|
489
|
+
accountId,
|
|
490
|
+
query: normalizedQuery,
|
|
491
|
+
count: 0,
|
|
492
|
+
pageCount: 0,
|
|
493
|
+
tweetIds: [],
|
|
494
|
+
} as const;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const runOptions = {
|
|
498
|
+
query: normalizedQuery,
|
|
499
|
+
accountId,
|
|
500
|
+
username: resolvedAccount.username,
|
|
501
|
+
limit: normalizedLimit,
|
|
502
|
+
maxPages: normalizedMaxPages,
|
|
503
|
+
since: normalizedSince,
|
|
504
|
+
until: normalizedUntil,
|
|
505
|
+
refresh,
|
|
506
|
+
cacheTtlMs: ttlMs,
|
|
507
|
+
timeoutMs,
|
|
508
|
+
};
|
|
509
|
+
if (mode === "bird" || mode === "xurl") {
|
|
510
|
+
return yield* runModeEffect(mode, runOptions).pipe(
|
|
511
|
+
Effect.catchAll((error) =>
|
|
512
|
+
Effect.succeed({
|
|
513
|
+
ok: false,
|
|
514
|
+
source: mode,
|
|
515
|
+
accountId,
|
|
516
|
+
query: normalizedQuery,
|
|
517
|
+
error: error.message,
|
|
518
|
+
} as const),
|
|
519
|
+
),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (normalizedSince || normalizedUntil) {
|
|
524
|
+
return yield* runModeEffect("xurl", runOptions).pipe(
|
|
525
|
+
Effect.catchAll((error) =>
|
|
526
|
+
Effect.succeed({
|
|
527
|
+
ok: false,
|
|
528
|
+
source: "auto",
|
|
529
|
+
accountId,
|
|
530
|
+
query: normalizedQuery,
|
|
531
|
+
error: error.message,
|
|
532
|
+
} as const),
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const birdResult = yield* runModeEffect("bird", runOptions).pipe(
|
|
538
|
+
Effect.catchAll((error) =>
|
|
539
|
+
Effect.succeed({
|
|
540
|
+
ok: false,
|
|
541
|
+
source: "bird",
|
|
542
|
+
accountId,
|
|
543
|
+
query: normalizedQuery,
|
|
544
|
+
error: error.message,
|
|
545
|
+
} as const),
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
const xurlResult = yield* runModeEffect("xurl", runOptions).pipe(
|
|
549
|
+
Effect.catchAll((error) =>
|
|
550
|
+
Effect.succeed({
|
|
551
|
+
ok: false,
|
|
552
|
+
source: "xurl",
|
|
553
|
+
accountId,
|
|
554
|
+
query: normalizedQuery,
|
|
555
|
+
error: error.message,
|
|
556
|
+
} as const),
|
|
557
|
+
),
|
|
558
|
+
);
|
|
559
|
+
return combineTweetSearchResults(birdResult, xurlResult, normalizedLimit);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function syncTweetSearch(options: SyncTweetSearchOptions) {
|
|
564
|
+
return runEffectPromise(syncTweetSearchEffect(options));
|
|
565
|
+
}
|