birdclaw 0.4.1 → 0.5.1
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 +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -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/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -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/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- 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 +326 -35
- 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 +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -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/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
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,
|
|
@@ -30,12 +39,23 @@ import {
|
|
|
30
39
|
import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
|
|
31
40
|
import { listInboxItems, scoreInbox } from "#/lib/inbox";
|
|
32
41
|
import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
|
|
42
|
+
import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
|
|
33
43
|
import { syncMentionThreads } from "#/lib/mention-threads-live";
|
|
34
44
|
import { exportMentionItems } from "#/lib/mentions-export";
|
|
35
45
|
import {
|
|
36
46
|
exportMentionsViaCachedBird,
|
|
37
47
|
exportMentionsViaCachedXurl,
|
|
48
|
+
syncMentions,
|
|
38
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";
|
|
39
59
|
import { hydrateProfilesFromX } from "#/lib/profile-hydration";
|
|
40
60
|
import { resolveProfilesForIds } from "#/lib/profile-resolver";
|
|
41
61
|
import { inspectProfileReplies } from "#/lib/profile-replies";
|
|
@@ -74,6 +94,10 @@ function printError(error: string) {
|
|
|
74
94
|
console.error(JSON.stringify({ error }));
|
|
75
95
|
}
|
|
76
96
|
|
|
97
|
+
function errorMessage(error: unknown) {
|
|
98
|
+
return error instanceof Error ? error.message : String(error);
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
|
|
78
102
|
return items
|
|
79
103
|
.map((item) => {
|
|
@@ -117,6 +141,68 @@ function parseNonNegativeIntegerOption(
|
|
|
117
141
|
return parsed;
|
|
118
142
|
}
|
|
119
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
|
+
|
|
120
206
|
function resolveActionOptions(options: { transport?: string }) {
|
|
121
207
|
return {
|
|
122
208
|
transport: options.transport as ActionsTransport | undefined,
|
|
@@ -235,7 +321,15 @@ const importCommand = program
|
|
|
235
321
|
importCommand
|
|
236
322
|
.command("archive [archivePath]")
|
|
237
323
|
.description("Import a Twitter archive into the local SQLite store")
|
|
238
|
-
.
|
|
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
|
+
}
|
|
239
333
|
let resolvedArchivePath = archivePath;
|
|
240
334
|
if (!resolvedArchivePath) {
|
|
241
335
|
const [latestArchive] = await findArchives();
|
|
@@ -248,7 +342,7 @@ importCommand
|
|
|
248
342
|
);
|
|
249
343
|
}
|
|
250
344
|
|
|
251
|
-
const result = await importArchive(resolvedArchivePath);
|
|
345
|
+
const result = await importArchive(resolvedArchivePath, { select });
|
|
252
346
|
await autoSyncAfterWrite();
|
|
253
347
|
print(result, program.opts().json ?? false);
|
|
254
348
|
});
|
|
@@ -268,7 +362,7 @@ const searchCommand = program
|
|
|
268
362
|
|
|
269
363
|
searchCommand
|
|
270
364
|
.command("tweets [query]")
|
|
271
|
-
.option("--resource <resource>", "home or
|
|
365
|
+
.option("--resource <resource>", "home, mentions, or authored", "home")
|
|
272
366
|
.option("--replied", "Only replied items")
|
|
273
367
|
.option("--unreplied", "Only unreplied items")
|
|
274
368
|
.option("--since <date>", "Include tweets created at or after this date")
|
|
@@ -299,7 +393,12 @@ searchCommand
|
|
|
299
393
|
? "unreplied"
|
|
300
394
|
: "all";
|
|
301
395
|
const items = listTimelineItems({
|
|
302
|
-
resource:
|
|
396
|
+
resource:
|
|
397
|
+
options.resource === "mentions"
|
|
398
|
+
? "mentions"
|
|
399
|
+
: options.resource === "authored"
|
|
400
|
+
? "authored"
|
|
401
|
+
: "home",
|
|
303
402
|
search: query,
|
|
304
403
|
replyFilter,
|
|
305
404
|
since: options.since,
|
|
@@ -477,6 +576,73 @@ linksCommand
|
|
|
477
576
|
print(result, program.opts().json ?? false);
|
|
478
577
|
});
|
|
479
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
|
+
|
|
480
646
|
program
|
|
481
647
|
.command("whois <query>")
|
|
482
648
|
.description("Identify likely people or orgs from local DMs and tweets")
|
|
@@ -663,28 +829,130 @@ syncCommand
|
|
|
663
829
|
print(result, true);
|
|
664
830
|
});
|
|
665
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
|
+
|
|
666
916
|
syncCommand
|
|
667
917
|
.command("mention-threads")
|
|
668
918
|
.description(
|
|
669
|
-
"Fetch tweet conversation context for recent mentions through bird",
|
|
919
|
+
"Fetch tweet conversation context for recent mentions through bird or xurl",
|
|
670
920
|
)
|
|
671
921
|
.option("--account <accountId>", "Account id")
|
|
922
|
+
.option("--mode <mode>", "bird or xurl", "bird")
|
|
672
923
|
.option("--limit <n>", "Recent mentions to inspect", "30")
|
|
673
924
|
.option("--delay-ms <n>", "Delay between thread fetches", "1500")
|
|
674
925
|
.option("--timeout-ms <n>", "Per-thread timeout", "15000")
|
|
675
926
|
.option("--all", "Fetch all retrievable thread pages")
|
|
676
927
|
.option("--max-pages <n>", "Stop after N pages")
|
|
677
928
|
.action(async (options) => {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
+
}
|
|
688
956
|
});
|
|
689
957
|
|
|
690
958
|
for (const kind of ["likes", "bookmarks"] as const) {
|
|
@@ -695,7 +963,11 @@ for (const kind of ["likes", "bookmarks"] as const) {
|
|
|
695
963
|
.option("--mode <mode>", "auto, xurl, or bird", "auto")
|
|
696
964
|
.option("--limit <n>", "Per-page/result limit", "20")
|
|
697
965
|
.option("--all", "Fetch every retrievable page")
|
|
698
|
-
.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")
|
|
699
971
|
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
700
972
|
.option("--refresh", "Bypass live-cache freshness window")
|
|
701
973
|
.action(async (options) => {
|
|
@@ -708,12 +980,55 @@ for (const kind of ["likes", "bookmarks"] as const) {
|
|
|
708
980
|
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
709
981
|
refresh: Boolean(options.refresh),
|
|
710
982
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
983
|
+
earlyStop: Boolean(options.earlyStop),
|
|
711
984
|
});
|
|
712
985
|
await autoSyncAfterWrite();
|
|
713
986
|
print(result, true);
|
|
714
987
|
});
|
|
715
988
|
}
|
|
716
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
|
+
|
|
717
1032
|
const jobsCommand = program
|
|
718
1033
|
.command("jobs")
|
|
719
1034
|
.description("Run and install background Birdclaw jobs");
|
|
@@ -946,6 +1261,120 @@ program
|
|
|
946
1261
|
);
|
|
947
1262
|
});
|
|
948
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
|
+
|
|
949
1378
|
program
|
|
950
1379
|
.command("db stats")
|
|
951
1380
|
.description("Show local storage and dataset stats")
|
|
@@ -1045,9 +1474,54 @@ program
|
|
|
1045
1474
|
{
|
|
1046
1475
|
cwd: packageRoot,
|
|
1047
1476
|
stdio: "inherit",
|
|
1477
|
+
detached: process.platform !== "win32",
|
|
1048
1478
|
},
|
|
1049
1479
|
);
|
|
1050
|
-
|
|
1480
|
+
const forwardedSignals = [
|
|
1481
|
+
"SIGINT",
|
|
1482
|
+
"SIGTERM",
|
|
1483
|
+
"SIGHUP",
|
|
1484
|
+
"SIGQUIT",
|
|
1485
|
+
] as const;
|
|
1486
|
+
const forwardSignal = (signal: NodeJS.Signals) => {
|
|
1487
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1488
|
+
signalChild(signal);
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
const signalChild = (signal: NodeJS.Signals) => {
|
|
1492
|
+
if (child.pid === undefined) {
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const targetPid = process.platform === "win32" ? child.pid : -child.pid;
|
|
1496
|
+
try {
|
|
1497
|
+
process.kill(targetPid, signal);
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
if (
|
|
1500
|
+
!(
|
|
1501
|
+
typeof error === "object" &&
|
|
1502
|
+
error !== null &&
|
|
1503
|
+
"code" in error &&
|
|
1504
|
+
error.code === "ESRCH"
|
|
1505
|
+
)
|
|
1506
|
+
) {
|
|
1507
|
+
throw error;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
1511
|
+
const removeSignalHandlers = () => {
|
|
1512
|
+
for (const signal of forwardedSignals) {
|
|
1513
|
+
process.removeListener(signal, forwardSignal);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
for (const signal of forwardedSignals) {
|
|
1517
|
+
process.on(signal, forwardSignal);
|
|
1518
|
+
}
|
|
1519
|
+
child.on("exit", (code, signal) => {
|
|
1520
|
+
removeSignalHandlers();
|
|
1521
|
+
if (signal) {
|
|
1522
|
+
process.kill(process.pid, signal);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1051
1525
|
process.exit(code ?? 0);
|
|
1052
1526
|
});
|
|
1053
1527
|
});
|
|
@@ -1060,6 +1534,9 @@ export async function runCli(argv = process.argv) {
|
|
|
1060
1534
|
if (process.argv[1]) {
|
|
1061
1535
|
const entryUrl = pathToFileURL(process.argv[1]).href;
|
|
1062
1536
|
if (import.meta.url === entryUrl) {
|
|
1063
|
-
void runCli()
|
|
1537
|
+
void runCli().catch((error) => {
|
|
1538
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1539
|
+
process.exitCode = 1;
|
|
1540
|
+
});
|
|
1064
1541
|
}
|
|
1065
1542
|
}
|