birdclaw 0.2.1 → 0.4.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +54 -15
  3. package/package.json +14 -15
  4. package/playwright.config.ts +5 -2
  5. package/src/cli.ts +289 -20
  6. package/src/lib/actions-transport.ts +58 -1
  7. package/src/lib/archive-import.ts +34 -2
  8. package/src/lib/backup.ts +271 -33
  9. package/src/lib/bird-actions.ts +21 -0
  10. package/src/lib/bird.ts +332 -17
  11. package/src/lib/blocks.ts +3 -3
  12. package/src/lib/bookmark-sync-job.ts +3 -3
  13. package/src/lib/config.ts +34 -1
  14. package/src/lib/db.ts +321 -14
  15. package/src/lib/dms-live.ts +3 -3
  16. package/src/lib/identity-search-index.ts +369 -0
  17. package/src/lib/mention-threads-live.ts +267 -0
  18. package/src/lib/mentions-live.ts +18 -7
  19. package/src/lib/moderation-target.ts +7 -5
  20. package/src/lib/moderation-write.ts +3 -3
  21. package/src/lib/profile-affiliation-hydration.ts +189 -0
  22. package/src/lib/profile-affiliations.ts +262 -0
  23. package/src/lib/profile-bio-entities.ts +318 -0
  24. package/src/lib/profile-history.ts +164 -0
  25. package/src/lib/profile-hydration.ts +8 -24
  26. package/src/lib/profile-resolver.ts +428 -0
  27. package/src/lib/queries.ts +522 -68
  28. package/src/lib/research.ts +607 -0
  29. package/src/lib/seed.ts +10 -4
  30. package/src/lib/sqlite.ts +143 -0
  31. package/src/lib/timeline-collections-live.ts +22 -8
  32. package/src/lib/timeline-live.ts +182 -0
  33. package/src/lib/tweet-account-edges.ts +39 -0
  34. package/src/lib/tweet-lookup.ts +35 -0
  35. package/src/lib/types.ts +103 -1
  36. package/src/lib/url-expansion.ts +147 -0
  37. package/src/lib/whois.ts +1060 -0
  38. package/src/lib/x-profile.ts +174 -17
  39. package/src/lib/xurl.ts +41 -6
package/src/cli.ts CHANGED
@@ -29,13 +29,16 @@ import {
29
29
  } from "#/lib/config";
30
30
  import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
31
31
  import { listInboxItems, scoreInbox } from "#/lib/inbox";
32
+ import { syncMentionThreads } from "#/lib/mention-threads-live";
32
33
  import { exportMentionItems } from "#/lib/mentions-export";
33
34
  import {
34
35
  exportMentionsViaCachedBird,
35
36
  exportMentionsViaCachedXurl,
36
37
  } from "#/lib/mentions-live";
37
38
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
39
+ import { resolveProfilesForIds } from "#/lib/profile-resolver";
38
40
  import { inspectProfileReplies } from "#/lib/profile-replies";
41
+ import { runResearchMode } from "#/lib/research";
39
42
  import {
40
43
  createDmReply,
41
44
  createPost,
@@ -48,6 +51,9 @@ import {
48
51
  syncTimelineCollection,
49
52
  type TimelineCollectionMode,
50
53
  } from "#/lib/timeline-collections-live";
54
+ import { syncHomeTimeline } from "#/lib/timeline-live";
55
+ import { expandUrlsFromTexts } from "#/lib/url-expansion";
56
+ import { formatWhois, runWhois } from "#/lib/whois";
51
57
 
52
58
  const program = new Command();
53
59
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -63,12 +69,91 @@ function print(data: unknown, asJson: boolean) {
63
69
  console.log(data);
64
70
  }
65
71
 
72
+ function printError(error: string) {
73
+ console.error(JSON.stringify({ error }));
74
+ }
75
+
76
+ function parseNonNegativeIntegerOption(
77
+ value: string | undefined,
78
+ option: string,
79
+ ) {
80
+ if (value === undefined) {
81
+ return undefined;
82
+ }
83
+
84
+ const trimmed = value.trim();
85
+ if (!/^\d+$/.test(trimmed)) {
86
+ printError(`${option} must be a non-negative integer`);
87
+ process.exitCode = 1;
88
+ return undefined;
89
+ }
90
+
91
+ const parsed = Number.parseInt(trimmed, 10);
92
+ if (!Number.isSafeInteger(parsed)) {
93
+ printError(`${option} must be a non-negative integer`);
94
+ process.exitCode = 1;
95
+ return undefined;
96
+ }
97
+
98
+ return parsed;
99
+ }
100
+
66
101
  function resolveActionOptions(options: { transport?: string }) {
67
102
  return {
68
103
  transport: options.transport as ActionsTransport | undefined,
69
104
  };
70
105
  }
71
106
 
107
+ async function enrichDmItems(
108
+ query: Parameters<typeof listDmConversations>[0],
109
+ options: {
110
+ resolveProfiles?: boolean;
111
+ expandUrls?: boolean;
112
+ refreshProfileCache?: boolean;
113
+ refreshUrlCache?: boolean;
114
+ xurlFallback?: boolean;
115
+ },
116
+ ) {
117
+ let items = listDmConversations(query);
118
+ const profileResolution = options.resolveProfiles
119
+ ? await resolveProfilesForIds(
120
+ items.map((item) => item.participant.id),
121
+ {
122
+ refresh: options.refreshProfileCache,
123
+ xurlFallback: options.xurlFallback ?? true,
124
+ },
125
+ )
126
+ : undefined;
127
+ if (profileResolution) {
128
+ items = listDmConversations(query);
129
+ }
130
+
131
+ const urlExpansions = options.expandUrls
132
+ ? await expandUrlsFromTexts(
133
+ items.flatMap((item) => [
134
+ item.lastMessagePreview,
135
+ item.searchSnippet ?? "",
136
+ ...(item.matches ?? []).flatMap((match) => [
137
+ ...match.before.map((message) => message.text),
138
+ match.message.text,
139
+ ...match.after.map((message) => message.text),
140
+ ]),
141
+ ]),
142
+ { refresh: options.refreshUrlCache },
143
+ )
144
+ : undefined;
145
+
146
+ if (!profileResolution && !urlExpansions) {
147
+ return items;
148
+ }
149
+
150
+ return {
151
+ items,
152
+ ...(profileResolution ? { profileResolution } : {}),
153
+ ...(urlExpansions ? { urlExpansions } : {}),
154
+ };
155
+ }
156
+
72
157
  async function autoUpdateBeforeRead() {
73
158
  const result = await maybeAutoUpdateBackup();
74
159
  if (!result.ok) {
@@ -171,10 +256,23 @@ searchCommand
171
256
  .option("--until <date>", "Include tweets created before this date")
172
257
  .option("--originals-only", "Exclude authored replies that start with @")
173
258
  .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
259
+ .option(
260
+ "--min-likes <n>",
261
+ "Override the low-quality like threshold (default 50)",
262
+ )
263
+ .option("--quality-reason", "Include qualityReason on each row")
174
264
  .option("--liked", "Only liked tweets")
175
265
  .option("--bookmarked", "Only bookmarked tweets")
176
266
  .option("--limit <n>", "Limit results", "20")
177
267
  .action(async (query, options) => {
268
+ const minLikes = parseNonNegativeIntegerOption(
269
+ options.minLikes,
270
+ "--min-likes",
271
+ );
272
+ if (options.minLikes !== undefined && minLikes === undefined) {
273
+ return;
274
+ }
275
+
178
276
  await autoUpdateBeforeRead();
179
277
  const replyFilter = options.replied
180
278
  ? "replied"
@@ -189,6 +287,8 @@ searchCommand
189
287
  until: options.until,
190
288
  includeReplies: !options.originalsOnly,
191
289
  qualityFilter: options.hideLowQuality ? "summary" : "all",
290
+ lowQualityThreshold: minLikes,
291
+ includeQualityReason: Boolean(options.qualityReason),
192
292
  likedOnly: Boolean(options.liked),
193
293
  bookmarkedOnly: Boolean(options.bookmarked),
194
294
  limit: Number(options.limit),
@@ -204,17 +304,37 @@ searchCommand
204
304
  .option("--min-influence-score <n>", "Minimum derived influence score")
205
305
  .option("--max-influence-score <n>", "Maximum derived influence score")
206
306
  .option("--sort <mode>", "recent or influence", "recent")
307
+ .option(
308
+ "--context <n>",
309
+ "Include N messages before and after each match",
310
+ "0",
311
+ )
312
+ .option(
313
+ "--resolve-profiles",
314
+ "Resolve placeholder DM profiles through cache/bird/xurl",
315
+ )
316
+ .option("--expand-urls", "Expand URLs through the persistent URL cache")
317
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
318
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
319
+ .option(
320
+ "--no-xurl-fallback",
321
+ "Do not fall back to xurl after bird profile lookup",
322
+ )
207
323
  .option("--replied", "Only replied threads")
208
324
  .option("--unreplied", "Only unreplied threads")
209
325
  .option("--limit <n>", "Limit results", "20")
210
326
  .action(async (query, options) => {
211
327
  await autoUpdateBeforeRead();
328
+ const context = parseNonNegativeIntegerOption(options.context, "--context");
329
+ if (context === undefined) {
330
+ return;
331
+ }
212
332
  const replyFilter = options.replied
213
333
  ? "replied"
214
334
  : options.unreplied
215
335
  ? "unreplied"
216
336
  : "all";
217
- const items = listDmConversations({
337
+ const dmQuery = {
218
338
  search: query,
219
339
  participant: options.participant,
220
340
  minFollowers: options.minFollowers
@@ -231,11 +351,96 @@ searchCommand
231
351
  : undefined,
232
352
  sort: options.sort === "influence" ? "influence" : "recent",
233
353
  replyFilter,
354
+ context,
234
355
  limit: Number(options.limit),
356
+ } as const;
357
+ const items = await enrichDmItems(dmQuery, {
358
+ resolveProfiles: Boolean(options.resolveProfiles),
359
+ expandUrls: Boolean(options.expandUrls),
360
+ refreshProfileCache: Boolean(options.refreshProfileCache),
361
+ refreshUrlCache: Boolean(options.refreshUrlCache),
362
+ xurlFallback: options.xurlFallback,
235
363
  });
236
364
  print(items, program.opts().json ?? false);
237
365
  });
238
366
 
367
+ program
368
+ .command("whois <query>")
369
+ .description("Identify likely people or orgs from local DMs and tweets")
370
+ .option("--account <accountId>", "Account id")
371
+ .option("--no-dms", "Do not search DMs")
372
+ .option("--tweets", "Include local tweet search evidence")
373
+ .option("--no-resolve-profiles", "Do not resolve placeholder profiles")
374
+ .option("--no-expand-urls", "Do not expand URLs")
375
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
376
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
377
+ .option(
378
+ "--no-xurl-fallback",
379
+ "Do not fall back to xurl after bird profile lookup",
380
+ )
381
+ .option(
382
+ "--affiliation <query>",
383
+ "Require affiliation, bio, or history evidence",
384
+ )
385
+ .option(
386
+ "--current-affiliation <query>",
387
+ "Require an active affiliation badge",
388
+ )
389
+ .option(
390
+ "--exclude-domain-only",
391
+ "Drop candidates that only match domains/URLs",
392
+ )
393
+ .option("--context <n>", "DM messages before and after each match", "4")
394
+ .option("--limit <n>", "Limit candidates", "10")
395
+ .action(async (query, options) => {
396
+ await autoUpdateBeforeRead();
397
+ const context = parseNonNegativeIntegerOption(options.context, "--context");
398
+ if (context === undefined) {
399
+ return;
400
+ }
401
+ const result = await runWhois(query, {
402
+ account: options.account,
403
+ dms: options.dms,
404
+ tweets: Boolean(options.tweets),
405
+ resolveProfiles: options.resolveProfiles,
406
+ expandUrls: options.expandUrls,
407
+ refreshProfileCache: Boolean(options.refreshProfileCache),
408
+ refreshUrlCache: Boolean(options.refreshUrlCache),
409
+ xurlFallback: options.xurlFallback,
410
+ affiliation: options.affiliation,
411
+ currentAffiliation: options.currentAffiliation,
412
+ excludeDomainOnly: Boolean(options.excludeDomainOnly),
413
+ context,
414
+ limit: Number(options.limit),
415
+ });
416
+ print(
417
+ program.opts().json ? result : formatWhois(result),
418
+ program.opts().json ?? false,
419
+ );
420
+ });
421
+
422
+ program
423
+ .command("research [query]")
424
+ .description("Build a markdown research brief from bookmarked threads")
425
+ .option("--account <accountId>", "Account id")
426
+ .option("--limit <n>", "Seed bookmark limit", "20")
427
+ .option("--thread-depth <n>", "Maximum ancestor walk depth", "10")
428
+ .option("--out <path>", "Write the markdown brief to a file")
429
+ .action(async (query, options) => {
430
+ await autoUpdateBeforeRead();
431
+ const report = await runResearchMode({
432
+ account: options.account,
433
+ query,
434
+ limit: Number(options.limit),
435
+ maxThreadDepth: Number(options.threadDepth),
436
+ outPath: options.out,
437
+ });
438
+ print(
439
+ program.opts().json ? report : report.markdown,
440
+ program.opts().json ?? false,
441
+ );
442
+ });
443
+
239
444
  const mentionsCommand = program
240
445
  .command("mentions")
241
446
  .description("Export local mention tweets for scripts and agents");
@@ -325,6 +530,50 @@ const syncCommand = program
325
530
  .command("sync")
326
531
  .description("Refresh live Twitter collections into the local store");
327
532
 
533
+ syncCommand
534
+ .command("timeline")
535
+ .description("Refresh live home timeline through bird")
536
+ .option("--account <accountId>", "Account id")
537
+ .option("--limit <n>", "Result limit", "100")
538
+ .option("--for-you", 'Fetch "For You" instead of chronological Following')
539
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
540
+ .option("--refresh", "Bypass live-cache freshness window")
541
+ .action(async (options) => {
542
+ const result = await syncHomeTimeline({
543
+ account: options.account,
544
+ limit: Number(options.limit),
545
+ following: !options.forYou,
546
+ refresh: Boolean(options.refresh),
547
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
548
+ });
549
+ await autoSyncAfterWrite();
550
+ print(result, true);
551
+ });
552
+
553
+ syncCommand
554
+ .command("mention-threads")
555
+ .description(
556
+ "Fetch tweet conversation context for recent mentions through bird",
557
+ )
558
+ .option("--account <accountId>", "Account id")
559
+ .option("--limit <n>", "Recent mentions to inspect", "30")
560
+ .option("--delay-ms <n>", "Delay between thread fetches", "1500")
561
+ .option("--timeout-ms <n>", "Per-thread timeout", "15000")
562
+ .option("--all", "Fetch all retrievable thread pages")
563
+ .option("--max-pages <n>", "Stop after N pages")
564
+ .action(async (options) => {
565
+ const result = await syncMentionThreads({
566
+ account: options.account,
567
+ limit: Number(options.limit),
568
+ delayMs: Number(options.delayMs),
569
+ timeoutMs: Number(options.timeoutMs),
570
+ all: Boolean(options.all),
571
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
572
+ });
573
+ await autoSyncAfterWrite();
574
+ print(result, true);
575
+ });
576
+
328
577
  for (const kind of ["likes", "bookmarks"] as const) {
329
578
  syncCommand
330
579
  .command(kind)
@@ -434,6 +683,17 @@ dmsCommand
434
683
  .option("--min-influence-score <n>", "Minimum derived influence score")
435
684
  .option("--max-influence-score <n>", "Maximum derived influence score")
436
685
  .option("--sort <mode>", "recent or influence", "recent")
686
+ .option(
687
+ "--resolve-profiles",
688
+ "Resolve placeholder DM profiles through cache/bird/xurl",
689
+ )
690
+ .option("--expand-urls", "Expand URLs through the persistent URL cache")
691
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
692
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
693
+ .option(
694
+ "--no-xurl-fallback",
695
+ "Do not fall back to xurl after bird profile lookup",
696
+ )
437
697
  .option("--replied", "Only replied threads")
438
698
  .option("--unreplied", "Only unreplied threads")
439
699
  .option("--limit <n>", "Limit results", "20")
@@ -454,25 +714,34 @@ dmsCommand
454
714
  } else {
455
715
  await autoUpdateBeforeRead();
456
716
  }
457
- const items = listDmConversations({
458
- account: options.account,
459
- participant: options.participant,
460
- minFollowers: options.minFollowers
461
- ? Number(options.minFollowers)
462
- : undefined,
463
- maxFollowers: options.maxFollowers
464
- ? Number(options.maxFollowers)
465
- : undefined,
466
- minInfluenceScore: options.minInfluenceScore
467
- ? Number(options.minInfluenceScore)
468
- : undefined,
469
- maxInfluenceScore: options.maxInfluenceScore
470
- ? Number(options.maxInfluenceScore)
471
- : undefined,
472
- sort: options.sort === "influence" ? "influence" : "recent",
473
- replyFilter,
474
- limit: Number(options.limit),
475
- });
717
+ const items = await enrichDmItems(
718
+ {
719
+ account: options.account,
720
+ participant: options.participant,
721
+ minFollowers: options.minFollowers
722
+ ? Number(options.minFollowers)
723
+ : undefined,
724
+ maxFollowers: options.maxFollowers
725
+ ? Number(options.maxFollowers)
726
+ : undefined,
727
+ minInfluenceScore: options.minInfluenceScore
728
+ ? Number(options.minInfluenceScore)
729
+ : undefined,
730
+ maxInfluenceScore: options.maxInfluenceScore
731
+ ? Number(options.maxInfluenceScore)
732
+ : undefined,
733
+ sort: options.sort === "influence" ? "influence" : "recent",
734
+ replyFilter,
735
+ limit: Number(options.limit),
736
+ },
737
+ {
738
+ resolveProfiles: Boolean(options.resolveProfiles),
739
+ expandUrls: Boolean(options.expandUrls),
740
+ refreshProfileCache: Boolean(options.refreshProfileCache),
741
+ refreshUrlCache: Boolean(options.refreshUrlCache),
742
+ xurlFallback: options.xurlFallback,
743
+ },
744
+ );
476
745
  print(items, program.opts().json ?? false);
477
746
  });
478
747
 
@@ -9,7 +9,9 @@ import { type ActionsTransport, resolveActionsTransport } from "./config";
9
9
  import type {
10
10
  ModerationAction,
11
11
  ModerationActionTransportResult,
12
+ ModerationTransportKind,
12
13
  } from "./types";
14
+ import { blockUserViaXWeb, unblockUserViaXWeb } from "./x-web";
13
15
  import {
14
16
  blockUserViaXurl,
15
17
  lookupAuthenticatedUser,
@@ -27,7 +29,7 @@ interface RunActionParams {
27
29
  transport?: string;
28
30
  }
29
31
 
30
- function normalizeFailure(transport: "bird" | "xurl", output: string) {
32
+ function normalizeFailure(transport: ModerationTransportKind, output: string) {
31
33
  return `${transport}: ${output}`;
32
34
  }
33
35
 
@@ -133,6 +135,37 @@ async function runXurlAction(
133
135
  };
134
136
  }
135
137
 
138
+ async function runXWebAction(
139
+ action: ModerationAction,
140
+ targetUserId?: string,
141
+ ): Promise<ActionTransportResult> {
142
+ if (action !== "block" && action !== "unblock") {
143
+ return {
144
+ ok: false,
145
+ output: `x-web does not support ${action}`,
146
+ transport: "x-web",
147
+ };
148
+ }
149
+
150
+ if (!targetUserId) {
151
+ return {
152
+ ok: false,
153
+ output: "missing target user id for x-web transport",
154
+ transport: "x-web",
155
+ };
156
+ }
157
+
158
+ const result =
159
+ action === "block"
160
+ ? await blockUserViaXWeb(targetUserId)
161
+ : await unblockUserViaXWeb(targetUserId);
162
+
163
+ return {
164
+ ...result,
165
+ transport: "x-web",
166
+ };
167
+ }
168
+
136
169
  export async function runModerationAction({
137
170
  action,
138
171
  query,
@@ -160,6 +193,30 @@ export async function runModerationAction({
160
193
  };
161
194
  }
162
195
 
196
+ if (action === "block" || action === "unblock") {
197
+ const xWebResult = await runXWebAction(action, targetUserId);
198
+ if (xWebResult.ok) {
199
+ return {
200
+ ...xWebResult,
201
+ output: [
202
+ xWebResult.output,
203
+ `falling back after ${normalizeFailure("bird", birdResult.output)}`,
204
+ `falling back after ${normalizeFailure("xurl", xurlResult.output)}`,
205
+ ].join("\n"),
206
+ };
207
+ }
208
+
209
+ return {
210
+ ok: false,
211
+ output: [
212
+ normalizeFailure("bird", birdResult.output),
213
+ normalizeFailure("xurl", xurlResult.output),
214
+ normalizeFailure("x-web", xWebResult.output),
215
+ ].join("\n"),
216
+ transport: xWebResult.transport,
217
+ };
218
+ }
219
+
163
220
  return {
164
221
  ok: false,
165
222
  output: [
@@ -286,6 +286,7 @@ function clearImportedData() {
286
286
  db.exec(`
287
287
  delete from ai_scores;
288
288
  delete from tweet_actions;
289
+ delete from tweet_account_edges;
289
290
  delete from tweet_collections;
290
291
  delete from dm_fts;
291
292
  delete from tweets_fts;
@@ -476,6 +477,7 @@ export async function importArchive(
476
477
  displayName: string;
477
478
  bio: string;
478
479
  followersCount: number;
480
+ followingCount: number;
479
481
  avatarHue: number;
480
482
  createdAt: string;
481
483
  }
@@ -500,6 +502,7 @@ export async function importArchive(
500
502
  displayName: accountPayload.displayName,
501
503
  bio: accountPayload.bio,
502
504
  followersCount: 0,
505
+ followingCount: 0,
503
506
  avatarHue: 18,
504
507
  createdAt: accountPayload.createdAt,
505
508
  };
@@ -583,6 +586,7 @@ export async function importArchive(
583
586
  conversationName || `Group DM ${externalParticipantIds.length}`,
584
587
  bio: `Group DM with ${externalParticipantIds.length} participants`,
585
588
  followersCount: 0,
589
+ followingCount: 0,
586
590
  avatarHue: 220,
587
591
  createdAt: accountPayload.createdAt,
588
592
  });
@@ -598,6 +602,7 @@ export async function importArchive(
598
602
  displayName: inferred.displayName,
599
603
  bio: `Imported from archive user ${otherUserId}`,
600
604
  followersCount: 0,
605
+ followingCount: 0,
601
606
  avatarHue: 210,
602
607
  createdAt: accountPayload.createdAt,
603
608
  });
@@ -626,6 +631,7 @@ export async function importArchive(
626
631
  displayName: inferred.displayName,
627
632
  bio: `Imported from archive user ${senderId}`,
628
633
  followersCount: 0,
634
+ followingCount: 0,
629
635
  avatarHue: 240,
630
636
  createdAt: accountPayload.createdAt,
631
637
  });
@@ -745,6 +751,7 @@ export async function importArchive(
745
751
  displayName: "Unknown",
746
752
  bio: "Imported from archive collection metadata",
747
753
  followersCount: 0,
754
+ followingCount: 0,
748
755
  avatarHue: 210,
749
756
  createdAt: accountPayload.createdAt,
750
757
  });
@@ -758,8 +765,8 @@ export async function importArchive(
758
765
  values (?, ?, ?, ?, ?, 1, ?)
759
766
  `);
760
767
  const insertProfile = db.prepare(`
761
- insert into profiles (id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at)
762
- values (?, ?, ?, ?, ?, ?, ?, ?)
768
+ insert into profiles (id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at)
769
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
763
770
  `);
764
771
  const insertTweet = db.prepare(`
765
772
  insert into tweets (
@@ -770,6 +777,16 @@ export async function importArchive(
770
777
  const insertTweetFts = db.prepare(
771
778
  "insert into tweets_fts (tweet_id, text) values (?, ?)",
772
779
  );
780
+ const insertTimelineEdge = db.prepare(`
781
+ insert into tweet_account_edges (
782
+ account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
783
+ raw_json, updated_at
784
+ ) values (?, ?, ?, ?, ?, 1, 'archive', '{}', ?)
785
+ on conflict(account_id, tweet_id, kind) do update set
786
+ first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
787
+ last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
788
+ updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
789
+ `);
773
790
  const insertCollection = db.prepare(`
774
791
  insert into tweet_collections (
775
792
  account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
@@ -811,6 +828,7 @@ export async function importArchive(
811
828
  profile.displayName,
812
829
  profile.bio,
813
830
  profile.followersCount,
831
+ profile.followingCount,
814
832
  profile.avatarHue,
815
833
  null,
816
834
  profile.createdAt,
@@ -835,6 +853,16 @@ export async function importArchive(
835
853
  tweet.mediaJson,
836
854
  tweet.quotedTweetId,
837
855
  );
856
+ if (tweet.kind === "home") {
857
+ insertTimelineEdge.run(
858
+ "acct_primary",
859
+ tweet.id,
860
+ tweet.kind,
861
+ tweet.createdAt,
862
+ tweet.createdAt,
863
+ new Date().toISOString(),
864
+ );
865
+ }
838
866
  insertTweetFts.run(tweet.id, tweet.text);
839
867
  }
840
868
 
@@ -898,8 +926,11 @@ export async function importArchive(
898
926
  }
899
927
 
900
928
  export const __test__ = {
929
+ normalizeArchivePath,
901
930
  extractArchiveJson,
902
931
  parseArchiveArray,
932
+ getFirstEntry,
933
+ getMatchingEntries,
903
934
  parseTwitterDate,
904
935
  asRecord,
905
936
  asArray,
@@ -908,6 +939,7 @@ export const __test__ = {
908
939
  getTweetMediaCount,
909
940
  extractTweetEntities,
910
941
  extractTweetMedia,
942
+ extractCollectionTweet,
911
943
  buildAccountPayload,
912
944
  inferProfileFromDirectory,
913
945
  };