birdclaw 0.4.0 → 0.5.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 +39 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/src/cli.ts +563 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1567 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +318 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/db.ts +282 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +629 -0
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +233 -7
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +219 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +33 -8
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- package/src/styles.css +106 -43
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,16 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { registerModerationCommands } from "#/cli-moderation";
|
|
9
9
|
import { findArchives } from "#/lib/archive-finder";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ARCHIVE_IMPORT_SLICES,
|
|
12
|
+
type ArchiveImportSlice,
|
|
13
|
+
importArchive,
|
|
14
|
+
} from "#/lib/archive-import";
|
|
15
|
+
import {
|
|
16
|
+
AuthoredSyncError,
|
|
17
|
+
syncAuthoredTweets,
|
|
18
|
+
type AuthoredSyncMode,
|
|
19
|
+
} from "#/lib/authored-live";
|
|
11
20
|
import {
|
|
12
21
|
exportBackup,
|
|
13
22
|
importBackup,
|
|
@@ -29,12 +38,24 @@ import {
|
|
|
29
38
|
} from "#/lib/config";
|
|
30
39
|
import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
|
|
31
40
|
import { listInboxItems, scoreInbox } from "#/lib/inbox";
|
|
41
|
+
import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
|
|
42
|
+
import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
|
|
32
43
|
import { syncMentionThreads } from "#/lib/mention-threads-live";
|
|
33
44
|
import { exportMentionItems } from "#/lib/mentions-export";
|
|
34
45
|
import {
|
|
35
46
|
exportMentionsViaCachedBird,
|
|
36
47
|
exportMentionsViaCachedXurl,
|
|
48
|
+
syncMentions,
|
|
37
49
|
} from "#/lib/mentions-live";
|
|
50
|
+
import {
|
|
51
|
+
getFollowGraphSummary,
|
|
52
|
+
listFollowEvents,
|
|
53
|
+
listMutuals,
|
|
54
|
+
listNonMutualFollowing,
|
|
55
|
+
listTopFollowers,
|
|
56
|
+
listUnfollowedSince,
|
|
57
|
+
syncFollowGraph,
|
|
58
|
+
} from "#/lib/follow-graph";
|
|
38
59
|
import { hydrateProfilesFromX } from "#/lib/profile-hydration";
|
|
39
60
|
import { resolveProfilesForIds } from "#/lib/profile-resolver";
|
|
40
61
|
import { inspectProfileReplies } from "#/lib/profile-replies";
|
|
@@ -73,6 +94,28 @@ function printError(error: string) {
|
|
|
73
94
|
console.error(JSON.stringify({ error }));
|
|
74
95
|
}
|
|
75
96
|
|
|
97
|
+
function errorMessage(error: unknown) {
|
|
98
|
+
return error instanceof Error ? error.message : String(error);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
|
|
102
|
+
return items
|
|
103
|
+
.map((item) => {
|
|
104
|
+
const linked = item.linkedTweet
|
|
105
|
+
? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
|
|
106
|
+
: ` -> ${item.expansion.finalUrl}`;
|
|
107
|
+
const source =
|
|
108
|
+
item.occurrence.sourceKind === "dm"
|
|
109
|
+
? `dm ${item.occurrence.direction ?? ""}`.trim()
|
|
110
|
+
: "tweet";
|
|
111
|
+
const participant = item.participant
|
|
112
|
+
? ` @${item.participant.handle}`
|
|
113
|
+
: "";
|
|
114
|
+
return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
|
|
115
|
+
})
|
|
116
|
+
.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
function parseNonNegativeIntegerOption(
|
|
77
120
|
value: string | undefined,
|
|
78
121
|
option: string,
|
|
@@ -98,6 +141,68 @@ function parseNonNegativeIntegerOption(
|
|
|
98
141
|
return parsed;
|
|
99
142
|
}
|
|
100
143
|
|
|
144
|
+
function parsePositiveIntegerOption(value: string | undefined, option: string) {
|
|
145
|
+
const parsed = parseNonNegativeIntegerOption(value, option);
|
|
146
|
+
if (parsed === undefined) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (parsed < 1) {
|
|
150
|
+
printError(`${option} must be at least 1`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseArchiveImportSelect(value: string | undefined) {
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const aliases: Record<string, ArchiveImportSlice> = Object.assign(
|
|
163
|
+
Object.create(null) as Record<string, ArchiveImportSlice>,
|
|
164
|
+
{
|
|
165
|
+
tweets: "tweets",
|
|
166
|
+
likes: "likes",
|
|
167
|
+
bookmarks: "bookmarks",
|
|
168
|
+
directmessages: "directMessages",
|
|
169
|
+
"direct-messages": "directMessages",
|
|
170
|
+
dms: "directMessages",
|
|
171
|
+
profiles: "profiles",
|
|
172
|
+
followers: "followers",
|
|
173
|
+
following: "following",
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
const selected: ArchiveImportSlice[] = [];
|
|
177
|
+
const seen = new Set<ArchiveImportSlice>();
|
|
178
|
+
for (const rawItem of value.split(",")) {
|
|
179
|
+
const item = rawItem.trim();
|
|
180
|
+
if (!item) continue;
|
|
181
|
+
const slice = aliases[item] ?? aliases[item.toLowerCase()];
|
|
182
|
+
if (!slice) {
|
|
183
|
+
printError(
|
|
184
|
+
`--select must be a comma-separated subset of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
|
|
185
|
+
);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (!seen.has(slice)) {
|
|
190
|
+
seen.add(slice);
|
|
191
|
+
selected.push(slice);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (selected.length === 0) {
|
|
196
|
+
printError(
|
|
197
|
+
`--select must include at least one of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
|
|
198
|
+
);
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return selected;
|
|
204
|
+
}
|
|
205
|
+
|
|
101
206
|
function resolveActionOptions(options: { transport?: string }) {
|
|
102
207
|
return {
|
|
103
208
|
transport: options.transport as ActionsTransport | undefined,
|
|
@@ -216,7 +321,15 @@ const importCommand = program
|
|
|
216
321
|
importCommand
|
|
217
322
|
.command("archive [archivePath]")
|
|
218
323
|
.description("Import a Twitter archive into the local SQLite store")
|
|
219
|
-
.
|
|
324
|
+
.option(
|
|
325
|
+
"--select <kinds>",
|
|
326
|
+
`Import only selected archive slices: ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
|
|
327
|
+
)
|
|
328
|
+
.action(async (archivePath, options: { select?: string }) => {
|
|
329
|
+
const select = parseArchiveImportSelect(options.select);
|
|
330
|
+
if (options.select !== undefined && !select) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
220
333
|
let resolvedArchivePath = archivePath;
|
|
221
334
|
if (!resolvedArchivePath) {
|
|
222
335
|
const [latestArchive] = await findArchives();
|
|
@@ -229,7 +342,7 @@ importCommand
|
|
|
229
342
|
);
|
|
230
343
|
}
|
|
231
344
|
|
|
232
|
-
const result = await importArchive(resolvedArchivePath);
|
|
345
|
+
const result = await importArchive(resolvedArchivePath, { select });
|
|
233
346
|
await autoSyncAfterWrite();
|
|
234
347
|
print(result, program.opts().json ?? false);
|
|
235
348
|
});
|
|
@@ -249,7 +362,7 @@ const searchCommand = program
|
|
|
249
362
|
|
|
250
363
|
searchCommand
|
|
251
364
|
.command("tweets [query]")
|
|
252
|
-
.option("--resource <resource>", "home or
|
|
365
|
+
.option("--resource <resource>", "home, mentions, or authored", "home")
|
|
253
366
|
.option("--replied", "Only replied items")
|
|
254
367
|
.option("--unreplied", "Only unreplied items")
|
|
255
368
|
.option("--since <date>", "Include tweets created at or after this date")
|
|
@@ -280,7 +393,12 @@ searchCommand
|
|
|
280
393
|
? "unreplied"
|
|
281
394
|
: "all";
|
|
282
395
|
const items = listTimelineItems({
|
|
283
|
-
resource:
|
|
396
|
+
resource:
|
|
397
|
+
options.resource === "mentions"
|
|
398
|
+
? "mentions"
|
|
399
|
+
: options.resource === "authored"
|
|
400
|
+
? "authored"
|
|
401
|
+
: "home",
|
|
284
402
|
search: query,
|
|
285
403
|
replyFilter,
|
|
286
404
|
since: options.since,
|
|
@@ -364,6 +482,167 @@ searchCommand
|
|
|
364
482
|
print(items, program.opts().json ?? false);
|
|
365
483
|
});
|
|
366
484
|
|
|
485
|
+
searchCommand
|
|
486
|
+
.command("links <query>")
|
|
487
|
+
.description("Search indexed short links, expansions, and linked tweets")
|
|
488
|
+
.option("--account <accountIdOrHandle>", "Account id or handle")
|
|
489
|
+
.option("--since <date>", "Include links created at or after this date")
|
|
490
|
+
.option("--until <date>", "Include links created before this date")
|
|
491
|
+
.option("--source <kind>", "dm or tweet")
|
|
492
|
+
.option("--direction <direction>", "inbound or outbound")
|
|
493
|
+
.option("--participant <value>", "DM participant handle or name")
|
|
494
|
+
.option("--media <type>", "image, video, or gif")
|
|
495
|
+
.option("--limit <n>", "Limit results", "20")
|
|
496
|
+
.action(async (query, options) => {
|
|
497
|
+
await autoUpdateBeforeRead();
|
|
498
|
+
const items = searchLinks(query, {
|
|
499
|
+
account: options.account,
|
|
500
|
+
since: options.since,
|
|
501
|
+
until: options.until,
|
|
502
|
+
source:
|
|
503
|
+
options.source === "tweet"
|
|
504
|
+
? "tweet"
|
|
505
|
+
: options.source === "dm"
|
|
506
|
+
? "dm"
|
|
507
|
+
: undefined,
|
|
508
|
+
direction:
|
|
509
|
+
options.direction === "inbound"
|
|
510
|
+
? "inbound"
|
|
511
|
+
: options.direction === "outbound"
|
|
512
|
+
? "outbound"
|
|
513
|
+
: undefined,
|
|
514
|
+
participant: options.participant,
|
|
515
|
+
mediaType:
|
|
516
|
+
options.media === "image" ||
|
|
517
|
+
options.media === "video" ||
|
|
518
|
+
options.media === "gif"
|
|
519
|
+
? options.media
|
|
520
|
+
: undefined,
|
|
521
|
+
limit: Number(options.limit),
|
|
522
|
+
});
|
|
523
|
+
if (program.opts().json) {
|
|
524
|
+
print(items, true);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
console.log(formatLinkSearchItems(items));
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const linksCommand = program
|
|
531
|
+
.command("links")
|
|
532
|
+
.description("Build and inspect the short-link index");
|
|
533
|
+
|
|
534
|
+
linksCommand
|
|
535
|
+
.command("backfill")
|
|
536
|
+
.description("Backfill indexed URL occurrences and t.co expansions")
|
|
537
|
+
.option("--all-urls", "Index all URLs, not only t.co")
|
|
538
|
+
.option("--source <kind>", "dm or tweet")
|
|
539
|
+
.option("--refresh-url-cache", "Re-expand URLs already in the index")
|
|
540
|
+
.option("--limit <n>", "Limit network/cache expansions for this run")
|
|
541
|
+
.option("--concurrency <n>", "Concurrent URL expansion workers", "12")
|
|
542
|
+
.option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
|
|
543
|
+
.action(async (options) => {
|
|
544
|
+
const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
|
|
545
|
+
if (options.limit !== undefined && limit === undefined) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const concurrency = parseNonNegativeIntegerOption(
|
|
549
|
+
options.concurrency,
|
|
550
|
+
"--concurrency",
|
|
551
|
+
);
|
|
552
|
+
if (concurrency === undefined) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const timeoutMs = parseNonNegativeIntegerOption(
|
|
556
|
+
options.timeoutMs,
|
|
557
|
+
"--timeout-ms",
|
|
558
|
+
);
|
|
559
|
+
if (timeoutMs === undefined) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const result = await backfillLinkIndex({
|
|
563
|
+
includeAllUrls: Boolean(options.allUrls),
|
|
564
|
+
refresh: Boolean(options.refreshUrlCache),
|
|
565
|
+
source:
|
|
566
|
+
options.source === "tweet"
|
|
567
|
+
? "tweet"
|
|
568
|
+
: options.source === "dm"
|
|
569
|
+
? "dm"
|
|
570
|
+
: undefined,
|
|
571
|
+
limit,
|
|
572
|
+
concurrency,
|
|
573
|
+
timeoutMs,
|
|
574
|
+
});
|
|
575
|
+
await autoSyncAfterWrite();
|
|
576
|
+
print(result, program.opts().json ?? false);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const mediaCommand = program
|
|
580
|
+
.command("media")
|
|
581
|
+
.description("Manage the local media cache");
|
|
582
|
+
|
|
583
|
+
mediaCommand
|
|
584
|
+
.command("fetch")
|
|
585
|
+
.description(
|
|
586
|
+
"Fetch missing pbs.twimg.com image media already stored in tweets",
|
|
587
|
+
)
|
|
588
|
+
.option("--account <accountId>", "Account id")
|
|
589
|
+
.option("--limit <n>", "Stop after N tweets processed")
|
|
590
|
+
.option(
|
|
591
|
+
"--kind <kind>",
|
|
592
|
+
"Tweet or collection kind, e.g. home, like, bookmark",
|
|
593
|
+
)
|
|
594
|
+
.option("--since <isoDate>", "Only tweets created at or after this date")
|
|
595
|
+
.option("--parallel <n>", "Concurrent fetch workers, capped at 5", "1")
|
|
596
|
+
.option("--pacing-ms <n>", "Delay between request starts", "250")
|
|
597
|
+
.option("--video-pacing-ms <n>", "Delay between video request starts")
|
|
598
|
+
.option("--retry-max <n>", "Retries per file after rate limiting", "3")
|
|
599
|
+
.option("--include-video", "Include video and animated GIF media", true)
|
|
600
|
+
.option("--no-include-video", "Skip video and animated GIF media")
|
|
601
|
+
.option("--max-bytes <n>", "Maximum media file size in bytes", "104857600")
|
|
602
|
+
.option("--dry-run", "List what would be fetched without downloading")
|
|
603
|
+
.option("--json", "Emit JSON output")
|
|
604
|
+
.action(async (options) => {
|
|
605
|
+
const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
|
|
606
|
+
if (options.limit !== undefined && limit === undefined) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const parallel =
|
|
610
|
+
parsePositiveIntegerOption(options.parallel, "--parallel") ?? 1;
|
|
611
|
+
const pacingMs =
|
|
612
|
+
parseNonNegativeIntegerOption(options.pacingMs, "--pacing-ms") ?? 250;
|
|
613
|
+
const retryMax =
|
|
614
|
+
parseNonNegativeIntegerOption(options.retryMax, "--retry-max") ?? 3;
|
|
615
|
+
const videoPacingMs =
|
|
616
|
+
options.videoPacingMs === undefined
|
|
617
|
+
? undefined
|
|
618
|
+
: parseNonNegativeIntegerOption(
|
|
619
|
+
options.videoPacingMs,
|
|
620
|
+
"--video-pacing-ms",
|
|
621
|
+
);
|
|
622
|
+
const maxBytes =
|
|
623
|
+
parseNonNegativeIntegerOption(options.maxBytes, "--max-bytes") ??
|
|
624
|
+
100 * 1024 * 1024;
|
|
625
|
+
if (process.exitCode) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const result = await fetchTweetMedia({
|
|
630
|
+
account: options.account,
|
|
631
|
+
limit,
|
|
632
|
+
kind: options.kind,
|
|
633
|
+
since: options.since,
|
|
634
|
+
parallel,
|
|
635
|
+
pacingMs,
|
|
636
|
+
videoPacingMs,
|
|
637
|
+
retryMax,
|
|
638
|
+
includeVideo: Boolean(options.includeVideo),
|
|
639
|
+
maxBytes,
|
|
640
|
+
dryRun: Boolean(options.dryRun),
|
|
641
|
+
});
|
|
642
|
+
const asJson = Boolean(program.opts().json || options.json);
|
|
643
|
+
print(asJson ? result : formatMediaFetchResult(result), asJson);
|
|
644
|
+
});
|
|
645
|
+
|
|
367
646
|
program
|
|
368
647
|
.command("whois <query>")
|
|
369
648
|
.description("Identify likely people or orgs from local DMs and tweets")
|
|
@@ -550,28 +829,130 @@ syncCommand
|
|
|
550
829
|
print(result, true);
|
|
551
830
|
});
|
|
552
831
|
|
|
832
|
+
syncCommand
|
|
833
|
+
.command("mentions")
|
|
834
|
+
.description("Refresh live mentions through xurl or bird")
|
|
835
|
+
.option("--account <accountId>", "Account id")
|
|
836
|
+
.option("--mode <mode>", "bird or xurl", "xurl")
|
|
837
|
+
.option("--limit <n>", "Result limit per page", "20")
|
|
838
|
+
.option("--max-pages <n>", "Stop after N pages")
|
|
839
|
+
.option("--since-id <id>", "Fetch mentions newer than this tweet id")
|
|
840
|
+
.option("--start-time <iso>", "Fetch mentions created at or after this time")
|
|
841
|
+
.option("--refresh", "Bypass live-cache freshness window")
|
|
842
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
843
|
+
.action(async (options) => {
|
|
844
|
+
try {
|
|
845
|
+
const result = await syncMentions({
|
|
846
|
+
account: options.account,
|
|
847
|
+
mode: options.mode,
|
|
848
|
+
limit: Number(options.limit),
|
|
849
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
850
|
+
sinceId: options.sinceId,
|
|
851
|
+
startTime: options.startTime,
|
|
852
|
+
refresh: Boolean(options.refresh),
|
|
853
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
854
|
+
});
|
|
855
|
+
await autoSyncAfterWrite();
|
|
856
|
+
print(result, true);
|
|
857
|
+
if (result.partial) {
|
|
858
|
+
process.exitCode = 5;
|
|
859
|
+
}
|
|
860
|
+
} catch (error) {
|
|
861
|
+
print(
|
|
862
|
+
{
|
|
863
|
+
ok: false,
|
|
864
|
+
kind: "mentions",
|
|
865
|
+
mode: options.mode ?? "xurl",
|
|
866
|
+
error: errorMessage(error),
|
|
867
|
+
},
|
|
868
|
+
true,
|
|
869
|
+
);
|
|
870
|
+
process.exitCode = 1;
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
syncCommand
|
|
875
|
+
.command("authored")
|
|
876
|
+
.description("Refresh authenticated authored tweets through xurl")
|
|
877
|
+
.option("--account <accountId>", "Account id")
|
|
878
|
+
.option("--mode <mode>", "xurl", "xurl")
|
|
879
|
+
.option("--limit <n>", "X API page size", "100")
|
|
880
|
+
.option("--max-pages <n>", "Stop after N pages and resume later")
|
|
881
|
+
.option("--since-id <tweetId>", "Override the stored since_id cursor")
|
|
882
|
+
.option(
|
|
883
|
+
"--until-id <tweetId>",
|
|
884
|
+
"Fetch tweets older than this id without moving the cursor",
|
|
885
|
+
)
|
|
886
|
+
.action(async (options) => {
|
|
887
|
+
try {
|
|
888
|
+
const result = await syncAuthoredTweets({
|
|
889
|
+
account: options.account,
|
|
890
|
+
mode: options.mode as AuthoredSyncMode,
|
|
891
|
+
limit: Number(options.limit),
|
|
892
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
893
|
+
sinceId: options.sinceId,
|
|
894
|
+
untilId: options.untilId,
|
|
895
|
+
});
|
|
896
|
+
await autoSyncAfterWrite();
|
|
897
|
+
print(result, true);
|
|
898
|
+
if (result.partial) {
|
|
899
|
+
process.exitCode = 5;
|
|
900
|
+
}
|
|
901
|
+
} catch (error) {
|
|
902
|
+
print(
|
|
903
|
+
{
|
|
904
|
+
ok: false,
|
|
905
|
+
kind: "authored",
|
|
906
|
+
source: "xurl",
|
|
907
|
+
error: errorMessage(error),
|
|
908
|
+
},
|
|
909
|
+
true,
|
|
910
|
+
);
|
|
911
|
+
process.exitCode =
|
|
912
|
+
error instanceof AuthoredSyncError ? error.exitCode : 1;
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
|
|
553
916
|
syncCommand
|
|
554
917
|
.command("mention-threads")
|
|
555
918
|
.description(
|
|
556
|
-
"Fetch tweet conversation context for recent mentions through bird",
|
|
919
|
+
"Fetch tweet conversation context for recent mentions through bird or xurl",
|
|
557
920
|
)
|
|
558
921
|
.option("--account <accountId>", "Account id")
|
|
922
|
+
.option("--mode <mode>", "bird or xurl", "bird")
|
|
559
923
|
.option("--limit <n>", "Recent mentions to inspect", "30")
|
|
560
924
|
.option("--delay-ms <n>", "Delay between thread fetches", "1500")
|
|
561
925
|
.option("--timeout-ms <n>", "Per-thread timeout", "15000")
|
|
562
926
|
.option("--all", "Fetch all retrievable thread pages")
|
|
563
927
|
.option("--max-pages <n>", "Stop after N pages")
|
|
564
928
|
.action(async (options) => {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
929
|
+
try {
|
|
930
|
+
const result = await syncMentionThreads({
|
|
931
|
+
account: options.account,
|
|
932
|
+
mode: options.mode,
|
|
933
|
+
limit: Number(options.limit),
|
|
934
|
+
delayMs: Number(options.delayMs),
|
|
935
|
+
timeoutMs: Number(options.timeoutMs),
|
|
936
|
+
all: Boolean(options.all),
|
|
937
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
938
|
+
});
|
|
939
|
+
await autoSyncAfterWrite();
|
|
940
|
+
print(result, true);
|
|
941
|
+
if (result.partial) {
|
|
942
|
+
process.exitCode = 5;
|
|
943
|
+
}
|
|
944
|
+
} catch (error) {
|
|
945
|
+
print(
|
|
946
|
+
{
|
|
947
|
+
ok: false,
|
|
948
|
+
kind: "mention-threads",
|
|
949
|
+
mode: options.mode ?? "bird",
|
|
950
|
+
error: errorMessage(error),
|
|
951
|
+
},
|
|
952
|
+
true,
|
|
953
|
+
);
|
|
954
|
+
process.exitCode = 1;
|
|
955
|
+
}
|
|
575
956
|
});
|
|
576
957
|
|
|
577
958
|
for (const kind of ["likes", "bookmarks"] as const) {
|
|
@@ -582,7 +963,11 @@ for (const kind of ["likes", "bookmarks"] as const) {
|
|
|
582
963
|
.option("--mode <mode>", "auto, xurl, or bird", "auto")
|
|
583
964
|
.option("--limit <n>", "Per-page/result limit", "20")
|
|
584
965
|
.option("--all", "Fetch every retrievable page")
|
|
585
|
-
.option(
|
|
966
|
+
.option(
|
|
967
|
+
"--max-pages <n>",
|
|
968
|
+
"Stop after N pages when using --all or --early-stop",
|
|
969
|
+
)
|
|
970
|
+
.option("--early-stop", "Stop when a fetched page is already fully local")
|
|
586
971
|
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
587
972
|
.option("--refresh", "Bypass live-cache freshness window")
|
|
588
973
|
.action(async (options) => {
|
|
@@ -595,12 +980,55 @@ for (const kind of ["likes", "bookmarks"] as const) {
|
|
|
595
980
|
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
596
981
|
refresh: Boolean(options.refresh),
|
|
597
982
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
983
|
+
earlyStop: Boolean(options.earlyStop),
|
|
598
984
|
});
|
|
599
985
|
await autoSyncAfterWrite();
|
|
600
986
|
print(result, true);
|
|
601
987
|
});
|
|
602
988
|
}
|
|
603
989
|
|
|
990
|
+
for (const direction of ["followers", "following"] as const) {
|
|
991
|
+
syncCommand
|
|
992
|
+
.command(direction)
|
|
993
|
+
.description(
|
|
994
|
+
`Dry-run or refresh live ${direction} into the local follow graph`,
|
|
995
|
+
)
|
|
996
|
+
.option("--account <accountId>", "Account id")
|
|
997
|
+
.option("--mode <mode>", "auto, bird, or xurl", "auto")
|
|
998
|
+
.option("--limit <n>", "X API users per page", "1000")
|
|
999
|
+
.option("--max-pages <n>", "Stop after N pages")
|
|
1000
|
+
.option("--max-resources <n>", "Stop after N unique users")
|
|
1001
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "86400")
|
|
1002
|
+
.option("--refresh", "Bypass the live-cache freshness window")
|
|
1003
|
+
.option("--allow-partial", "Acknowledge capped/incomplete snapshot")
|
|
1004
|
+
.option("--yes", "Confirm live sync or fresh-cache merge")
|
|
1005
|
+
.action(async (options) => {
|
|
1006
|
+
try {
|
|
1007
|
+
const result = await syncFollowGraph({
|
|
1008
|
+
direction,
|
|
1009
|
+
account: options.account,
|
|
1010
|
+
mode: options.mode,
|
|
1011
|
+
limit: Number(options.limit),
|
|
1012
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
1013
|
+
maxResources: options.maxResources
|
|
1014
|
+
? Number(options.maxResources)
|
|
1015
|
+
: undefined,
|
|
1016
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
1017
|
+
refresh: Boolean(options.refresh),
|
|
1018
|
+
allowPartial: Boolean(options.allowPartial),
|
|
1019
|
+
yes: Boolean(options.yes),
|
|
1020
|
+
});
|
|
1021
|
+
if (!result.dryRun) {
|
|
1022
|
+
await autoSyncAfterWrite();
|
|
1023
|
+
}
|
|
1024
|
+
print(result, true);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
print({ ok: false, direction, error: errorMessage(error) }, true);
|
|
1027
|
+
process.exitCode = 1;
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
604
1032
|
const jobsCommand = program
|
|
605
1033
|
.command("jobs")
|
|
606
1034
|
.description("Run and install background Birdclaw jobs");
|
|
@@ -833,6 +1261,120 @@ program
|
|
|
833
1261
|
);
|
|
834
1262
|
});
|
|
835
1263
|
|
|
1264
|
+
const graphCommand = program
|
|
1265
|
+
.command("graph")
|
|
1266
|
+
.description("Query the local cache-only follow graph");
|
|
1267
|
+
|
|
1268
|
+
graphCommand
|
|
1269
|
+
.command("summary")
|
|
1270
|
+
.description("Summarize cached followers, following, mutuals, and snapshots")
|
|
1271
|
+
.option("--account <accountId>", "Account id")
|
|
1272
|
+
.action(async (options) => {
|
|
1273
|
+
await autoUpdateBeforeRead();
|
|
1274
|
+
print(getFollowGraphSummary({ account: options.account }), true);
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
graphCommand
|
|
1278
|
+
.command("top-followers")
|
|
1279
|
+
.description("List current followers sorted by their follower count")
|
|
1280
|
+
.option("--account <accountId>", "Account id")
|
|
1281
|
+
.option("--limit <n>", "Limit results", "20")
|
|
1282
|
+
.action(async (options) => {
|
|
1283
|
+
await autoUpdateBeforeRead();
|
|
1284
|
+
print(
|
|
1285
|
+
listTopFollowers({
|
|
1286
|
+
account: options.account,
|
|
1287
|
+
limit: Number(options.limit),
|
|
1288
|
+
}),
|
|
1289
|
+
true,
|
|
1290
|
+
);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
graphCommand
|
|
1294
|
+
.command("unfollowed")
|
|
1295
|
+
.description("List cached ended follow edges since a date")
|
|
1296
|
+
.requiredOption("--date <date>", "YYYY-MM-DD or ISO timestamp")
|
|
1297
|
+
.option("--account <accountId>", "Account id")
|
|
1298
|
+
.option("--direction <direction>", "followers or following", "followers")
|
|
1299
|
+
.option("--limit <n>", "Limit results", "100")
|
|
1300
|
+
.action(async (options) => {
|
|
1301
|
+
await autoUpdateBeforeRead();
|
|
1302
|
+
print(
|
|
1303
|
+
listUnfollowedSince({
|
|
1304
|
+
account: options.account,
|
|
1305
|
+
date: options.date,
|
|
1306
|
+
direction:
|
|
1307
|
+
options.direction === "following" ? "following" : "followers",
|
|
1308
|
+
limit: Number(options.limit),
|
|
1309
|
+
}),
|
|
1310
|
+
true,
|
|
1311
|
+
);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
graphCommand
|
|
1315
|
+
.command("events")
|
|
1316
|
+
.description("List cached append-only follow graph events")
|
|
1317
|
+
.option("--account <accountId>", "Account id")
|
|
1318
|
+
.option("--direction <direction>", "followers or following")
|
|
1319
|
+
.option("--kind <kind>", "started or ended")
|
|
1320
|
+
.option("--since <date>", "YYYY-MM-DD or ISO timestamp")
|
|
1321
|
+
.option("--until <date>", "YYYY-MM-DD or ISO timestamp")
|
|
1322
|
+
.option("--limit <n>", "Limit results", "100")
|
|
1323
|
+
.action(async (options) => {
|
|
1324
|
+
await autoUpdateBeforeRead();
|
|
1325
|
+
print(
|
|
1326
|
+
listFollowEvents({
|
|
1327
|
+
account: options.account,
|
|
1328
|
+
direction:
|
|
1329
|
+
options.direction === "followers" || options.direction === "following"
|
|
1330
|
+
? options.direction
|
|
1331
|
+
: undefined,
|
|
1332
|
+
kind:
|
|
1333
|
+
options.kind === "started" || options.kind === "ended"
|
|
1334
|
+
? options.kind
|
|
1335
|
+
: undefined,
|
|
1336
|
+
since: options.since,
|
|
1337
|
+
until: options.until,
|
|
1338
|
+
limit: Number(options.limit),
|
|
1339
|
+
}),
|
|
1340
|
+
true,
|
|
1341
|
+
);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
graphCommand
|
|
1345
|
+
.command("non-mutual-following")
|
|
1346
|
+
.description("List current following who are not current followers")
|
|
1347
|
+
.option("--account <accountId>", "Account id")
|
|
1348
|
+
.option("--sort <mode>", "followers or handle", "followers")
|
|
1349
|
+
.option("--limit <n>", "Limit results", "100")
|
|
1350
|
+
.action(async (options) => {
|
|
1351
|
+
await autoUpdateBeforeRead();
|
|
1352
|
+
print(
|
|
1353
|
+
listNonMutualFollowing({
|
|
1354
|
+
account: options.account,
|
|
1355
|
+
sort: options.sort === "handle" ? "handle" : "followers",
|
|
1356
|
+
limit: Number(options.limit),
|
|
1357
|
+
}),
|
|
1358
|
+
true,
|
|
1359
|
+
);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
graphCommand
|
|
1363
|
+
.command("mutuals")
|
|
1364
|
+
.description("List profiles that are both followers and following")
|
|
1365
|
+
.option("--account <accountId>", "Account id")
|
|
1366
|
+
.option("--limit <n>", "Limit results", "100")
|
|
1367
|
+
.action(async (options) => {
|
|
1368
|
+
await autoUpdateBeforeRead();
|
|
1369
|
+
print(
|
|
1370
|
+
listMutuals({
|
|
1371
|
+
account: options.account,
|
|
1372
|
+
limit: Number(options.limit),
|
|
1373
|
+
}),
|
|
1374
|
+
true,
|
|
1375
|
+
);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
836
1378
|
program
|
|
837
1379
|
.command("db stats")
|
|
838
1380
|
.description("Show local storage and dataset stats")
|
|
@@ -947,6 +1489,9 @@ export async function runCli(argv = process.argv) {
|
|
|
947
1489
|
if (process.argv[1]) {
|
|
948
1490
|
const entryUrl = pathToFileURL(process.argv[1]).href;
|
|
949
1491
|
if (import.meta.url === entryUrl) {
|
|
950
|
-
void runCli()
|
|
1492
|
+
void runCli().catch((error) => {
|
|
1493
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1494
|
+
process.exitCode = 1;
|
|
1495
|
+
});
|
|
951
1496
|
}
|
|
952
1497
|
}
|